Image

Kubernetes mit Terraform 1 – Aufbau und Deployment

15. Dezember 2022

Share on:

In dieser Blogserie tasten wir uns systematisch an das Thema Kubernetes mit Terraform an, indem wir Schritt für Schritt ein Übungsprojekt aufsetzen. Damit demonstrieren wir, dass es mit vergleichsweise wenig Aufwand möglich ist, Kubernetes-Umgebungen praxistauglich aufzusetzen.
Terraform ist ein deklaratives Deployment-Tool, welches verwendet werden kann, um Systeme und Services zu provisionieren. Es unterscheidet sich grundlegend von Ansible, da dieses den Fokus auf Konfiguration legt, wohingegen Terraform viel eindeutiger auf Provisionierung ausgerichtet ist. Dies prädestiniert Terraform für den Einsatz bei Cloud-Services und Kubernetes, um deskriptiv Service Architekturen zu beschreiben.
Als Umgebung für die Umsetzung des Projektes benutzt dieses Projekt die HETZNER CLOUD, da diese einen eigenen Terraform-Provider mitbringt. Das Projekt ist aber unproblematisch auf andere Provider übertragbar.

Vorbereitung

Um das Projekt zu beginnen, müssen mehrere Schritte durchlaufen werden:
Zuerst muss ein Projekt in der HETZNER CLOUD angelegt werden. Eine weitere Konfiguration ist an dieser Stelle nicht nötig, denn alle Ressourcen werden später durch den Terraform Provider erzeugt.

Screenshot: Projektanlage in der HETZNER CLOUD.

Wir legen ein Projekt an.

 

Für den Zugriff auf die Ressourcen braucht der Terraform Provider zwei Dinge. Einen SSH-Schlüssel, sowie einen API-Token. Beide können im Reiter „Sicherheit“ konfiguriert werden. Zuerst muss ein SSH-Schlüsselpaar mit “’ssh-keygen“‘ lokal erzeugt werden. Danach kann der Public Key im Projekt hinterlegt werden. Alternativ kann der SSH-Schlüssel auch von Terraform erzeugt werden.

Screenshot: Hinzufügen eines SSH-Schlüssels.

Hinzufügen eines SSH-Schlüssels.

Der API-Token ist wichtig, um Ressourcen anzulegen und dient dazu, den Terraform-Provider zu autorisieren. Nach dem Erzeugen muss der API-Token in das Terraform Projekt integriert werden. Am besten speichert man den Token für die weitere Verwendung in einem Passwort Manager ab.

Screenshot: Hinzufügen eines API-Token.

Hinzufügen eines API-Token.

 

Ist dies geschehen, kann als nächstes das Git Repo mit dem Beispiel heruntergeladen werden. Das Projekt enthält drei Module:

  1. cluster: Dies provisioniert die virtuellen Maschinen, auf welchen der Cluster installiert wird
  2. firewall: Dies konfiguriert Firewall-Regeln welche den Zugriff auf die Maschinen festlegen
  3. kubernetes: Dies installiert die Komponenten, die zum Betrieb des Clusters notwendig sind

Wichtig: Um das Projekt zu provisionieren, muss natürlich Terraform installiert sein. Hierfür gibt es glücklicherweise ebenfalls eine Anleitung.

Installations-Beispiel für Fedora Linux:

$ sudo dnf install -y dnf-plugins-core
$ sudo dnf config-manager --add-repo
https://rpm.releases.hashicorp.com/fedora/hashicorp.repo
$ sudo dnf -y install terraform

Ein weiteres nützliches Tool ist tfk8s. Tfk8s übersetzt Kubernetes-Manifeste von YAML in HCL, die von Terraform verwendete Markup-Sprache. Dies ist insbesondere nützlich, da jede einzelne Ressource auf diese Weise im Terraform-State enthalten ist und sich Änderungen feingranularer deployen lassen. Dies wurde unter anderem verwendet, um das Modul für die Installation des nginx Ingress-Controller zu erzeugen.

Als letztes muss noch kubectl, ein Kommandozeilen Interface für Kubernetes, installiert werden. Es wird benötigt, um direkt von der Kommandozeile auf den Cluster zuzugreifen.

Kurze Checkliste.

Um weiterzumachen müssen jetzt folgende Dinge vorhanden sein:

  • ein HETZNER CLOUD Projekt
  • ein zum Projekt gehörender API-Token
  • ein SSH-Schlüsselpaar
  • die Terraform Binärdatei
  • eine lokale Kopie des Git-Repos

Deployment des Kubernetes Clusters

Im folgenden Abschnitt soll anhand der Durchführung des Deployments erklärt werden, welche Funktionsmechanismen ein Terraform-Deployment umfasst. Dazu werden wir das Projekt zunächst konfigurieren und dann nacheinander die einzelnen Schritte durchführen.

Konfiguration des Projektes

Das Projekt wird im Wesentlichen über die in der Datei variables.tf enthaltenen Variablen gesteuert. Es lohnt sich, diese Datei anzusehen und gegebenenfalls Parameter zu ändern. Einige wenige Parameter sind noch nicht gesetzt. Entweder fragt diese Terraform beim Aufruf ab, sie werden in die Datei eingetragen, oder über Environment-Variablen definiert. Die letzte Option hat den Vorteil, dass vertrauliche Informationen nicht im Source-Code enthalten sind und man sie auch nicht bei jedem Lauf neu eingeben muss. Hierfür kann eine .env Datei verwendet werden:

export TF_VAR_hcloud_token=<API-TOKEN>
export TF_VAR_hcloud_ssh_private_key=./ssh/hetzner
export TF_VAR_cluster_name=k8s-test

Danach kann die Datei einfach mit

$ source .env

geladen werden.

terraform init und terraform plan

Jetzt kann das Projekt initialisiert werden. Das bedeutet, Terraform holt sich alle benötigten Provider und deren Abhängigkeiten. Erledigt wird das über den Befehl terraform init:

$ terraform init
Initializing modules...

Initializing the backend...

Initializing provider plugins...
[..]
Terraform has made some changes to the provider dependency selections recorded
in the .terraform.lock.hcl file. Review those changes and commit them to your version control system if they represent changes you intended to make.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see any changes that are required for your infrastructure. All Terraform commands should now work.
If you ever set or change modules or backend configuration for Terraform, rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Ist dies erledigt kann mit terraform plan die Provisionierung vorbereitet werden:

$ terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create
<= read (data resources)
Terraform will perform the following actions:
# module.cluster.hcloud_network.kubernetes_network will be created + resource "hcloud_network" "kubernetes_network" {
+ delete_protection = false
+ id = (known after apply)
+ ip_range = "10.98.0.0/16"
+ name = "k8s-test"
}
# module.cluster.hcloud_network_subnet.kubernetes_subnet will be created + resource "hcloud_network_subnet" "kubernetes_subnet" { + gateway = (known after apply)
+ id = (known after apply)
+ ip_range = "10.98.0.0/16"
+ network_id = (known after apply)
+ network_zone = "eu-central"
+ type = "server"
}
[....]
+ worker_nodes_ids = [
+ (known after apply),
+ (known after apply),
]
+ worker_nodes_ips = [
+ (known after apply),
+ (known after apply),
]
───────────────────────────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────── ──────
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.

Die letzte Zeile ist beachtenswert: der Plan zeigt die geplante Veränderung des Zustands des Systems zum Zeitpunkt der Ausführung. Sollte sich der Zustand im weiteren Verlauf ändern, ist dieser Plan nicht mehr durchführbar. Würde zu einem späteren Zeitpunkt dann terraform apply ausgeführt werden, so würde ein neuer Plan ausgeführt, der, je nachdem wie sich der Zustand verändert hat, ganz anders aussehen kann. Es empfiehlt sich daher, den Plan zu speichern, falls sich der Zustand zwischen Planung und Ausführung noch einmal ändert.

$ terraform plan -out demo-cluster.plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create
<= read (data resources)
[...]
───────────────────────────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────── ──────
Saved the plan to: demo-cluster.plan
To perform exactly these actions, run the following command to apply: terraform apply "demo-cluster.plan"

Terraform apply

Wurde der Plan erfolgreich erstellt, kann er mit terraform apply zur Ausführung gebracht werden.

$ terraform apply demo-cluster.plan
module.cluster.hcloud_network.kubernetes_network: Creating... module.cluster.hcloud_ssh_key.demo_cluster: Creating...
module.cluster.hcloud_network.kubernetes_network: Creation complete after 0s [id=1659238]
module.cluster.hcloud_ssh_key.demo_cluster: Creation complete after 0s [id=6471488]
module.kubernetes.data.template_file.access_tokens: Reading... module.cluster.hcloud_network_subnet.kubernetes_subnet: Creating... module.kubernetes.data.template_file.access_tokens: Read complete after 0s
[id=e4853efb6a64224100a80048920bbaf6c67c27c8e1de7c4a5d0df4f7f28ffc9f] module.cluster.hcloud_server.worker_node[0]: Creating...
module.cluster.hcloud_server.master_node[0]: Creating...
module.cluster.hcloud_server.worker_node[1]: Creating...
[....]
Apply complete! Resources: 38 added, 0 changed, 0 destroyed.
Outputs:
[....]
worker_nodes_ids = [
"20557324",
"20557325",
]
worker_nodes_ips = [
"167.235.68.39",
"167.235.68.41",
]
$

Cluster-Zugriff

Der Kubernetes Cluster ist jetzt lauffähig. Um auf den Cluster zuzugreifen ist eine Konfiguration nötig, die dann für kubectl oder in Lens verwendet werden kann. Diese befindet sich im Hauptverzeichnis und trägt den Namen kubeconfig.yaml.

apiVersion: v1
clusters:
- cluster:
certificate-authority-data: <REDACTED>
server: https://167.235.67.235:6443
name: kubernetes
contexts:
- context:
cluster: kubernetes
user: kubernetes-admin
name: k8s-test
current-context: k8s-test
kind: Config
preferences: {}
users:
- name: kubernetes-admin
user:
client-certificate-data: <REDACTED>
client-key-data: <REDACTED>

Diese Konfigurationsdatei ist wichtig, weil ohne sie kein Zugriff auf den Cluster möglich ist. Damit das Tutorial fortgesetzt werden kann, muss sie in die Kubernetes Konfiguration übernommen werden. Dazu wird die Datei nach ~/.kube/config kopiert. Im Anschluß sollten sowohl kubectl als auch Lens Zugriff auf den Cluster haben:

$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
master-1 Ready control-plane,master 55m v1.23.6
worker-1 Ready <none> 48m v1.23.6
worker-2 Ready <none> 48m v1.23.6

Terraform State

Eine weitere Datei im Folder ist terraform.tfstate. Diese bildet den Zustand nach dem Anwenden eines Planes ab und dient als Grundlage für das Erstellen eines neuen Planes, um Änderungen zu provisionieren. Diese Datei dient auch als Grundlage für verschiedene Operationen am State des Systems:

$ terraform state
[...]
Subcommands:
list List resources in the state
mv Move an item in the state
pull Pull current state and output to stdout push Update remote state from a local state file replace-provider Replace provider in the state
rm Remove instances from the state
show Show a resource in the state

Eine weitere wichtige Aktion die man mit Hilfe des Statefiles machen kann, ist terraform taint. Dies erlaubt es, bereits angelegte Ressourcen als ungültig zu markieren und beim nächsten Ausführen des Plans neu anzulegen.

$ terraform taint module.kubernetes.local_sensitive_file.kubeconfig Resource instance module.kubernetes.local_sensitive_file.kubeconfig has been marked as tainted.
$ terraform plan
[...]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: -/+ destroy and then create replacement
Terraform will perform the following actions:
# module.kubernetes.local_sensitive_file.kubeconfig is tainted, so must be replaced
-/+ resource "local_sensitive_file" "kubeconfig" {
~ id = "711f9a2b30a19c98c9ecac4a8877f93fcc315989" -> (known after apply)
# (4 unchanged attributes hidden)
}
Plan: 1 to add, 0 to change, 1 to destroy.

Aufbau eines Terraform Projektes

Schauen wir uns als nächstes den Aufbau eines Terraform Projektes an. Dieser setzt sich grob gesagt aus Modulen und deren Schnittstellen zusammen:

Modules

Terraform organisiert sich durch die Verwendung von Modulen. Ein Modul umfasst die Definitionen von anzulegenden Ressourcen, wie zum Beispiel eine Virtual Machine. Sie definiert, welcher Provider zum anlegen einer Ressource verwendet wird (in unserem Fall der hcloud-Provider). Weiterhin kann ein Modul auch weitere Module referenzieren. Die Schnittstellen eines Moduls nach außen sind Variablen und Outputs, auf die ich nochmal genauer eingehen werde.

Inputs

Terraform ist aus Modulen aufgebaut. Diese Module haben definierte Interfaces, die sich wiederum aus variables für die Parametereingabe und outputs für die Parameterausgabe zusammensetzen. Alle Objekte, die nicht über einen dieser beiden Mechanismen definiert werden, haben einen lediglich auf das Modul beschränkten Scope. Ein kurzes Beispiel für Inputs ist im Firewall-Module zu sehen:

modules/firewall/main.tf
# firewall/main.tf
variable "connections" {
type = list(any)
}
variable "subnet_ip_range" {
type = string
}
variable "hcloud_ssh_private_key" {
type = string
}
resource "null_resource" "firewall" {
count = length(var.connections)
triggers = {
template = templatefile("${path.module}/scripts/ufw.sh",{ subnet_ip_range = var.subnet_ip_range})
}
connection {
host = element(var.connections, count.index)
user = "root"
type = "ssh"
private_key = file("${var.hcloud_ssh_private_key}")
agent = false
}
provisioner "remote-exec" {
inline = [
templatefile("${path.module}/scripts/ufw.sh",{ subnet_ip_range = var.subnet_ip_range})
]
}
}

Zuerst werden im oberen Teil des Listings die drei Variablen definiert. Beim Anlegen der Ressource werden diese Variablen dann benutzt, um Aktionen auszuführen.

Wird das Modul im Projekt verwendet, so müssen alle Variablen, die nicht über einen Default Wert verfügen, beim Aufruf definiert oder ihrerseits durch eine Referenz auf eine Variable ersetzt werden. Dies ist in der main.tf Datei im Hauptverzeichnis des Projektes zu sehen:

main.tf
module "firewall" {
source = "./modules/firewall"
hcloud_ssh_private_key = var.hcloud_ssh_private_key
connections = module.cluster.all_nodes.*.ipv4_address subnet_ip_range = var.subnet_ip_range
}

Anhand des SSH Private Keys kann man sehr gut nachvollziehen, wie dies über die Ebenen des Projekts erfolgt. Der Pfad des SSH-Schlüssels ist auf oberster Ebene über eine Environment Variable in der .env Datei definiert:

.env
export TF_VAR_hcloud_token=<REDACTED>
export TF_VAR_hcloud_ssh_private_key=./ssh/hetzner
export TF_VAR_cluster_name=k8s-test

Diese Variable wird dann benutzt, um den Wert der gleichnamigen Variable in der Datei variables.tf zu definieren:

variables.tf
variable "hcloud_ssh_private_key" {
description = "(Required) - SSH key for ssh connections onto nodes" type = string
sensitive = true
}

Wie wir weiter oben sehen, wird dieses Variable im Aufruf des Moduls referenziert und letztendlich zum Anlegen der Firewall Ressource verwendet. Die Referenzen folgen also der Kette Environment Variable → ./variables.tf → Aufruf des Modules in ./main.tf → Variablendefinition im Modul → Resource

Outputs

Ähnlich verhält es sich mit Outputs. Outputs referenzieren Attribute von Terraform Ressourcen und können in anderen Modulen verwendet werden, oder auch einfach nur in der Standardausgabe angezeigt werden. Als Beispiel wird hier gezeigt, wie dies für die Kubernetes Konfiguration funktioniert:
Auf oberster Ebene im Hauptverzeichnis ist ein Output des Projektes namens kubeconfig definiert. Dieser gibt die Datei auf der Standardausgabe aus, sobald terraform plan ausgeführt wird:

outputs.tf
output "kubeconfig" {
value = module.kubernetes.kubeconfig
description = "Kubectl config file contents for the cluster." }

Wie zu sehen ist, referenziert dieser wiederum einen Output des Modules kubeconfig:

modules/kubernetes/outputs.tf
output "kubeconfig" {
value = module.kubeconfig.stdout
}
Das Module kubeconfig wiederum führt einen shell befehl aus und schreibt die Ausgabe dieses Befehls in stddout:
modules/kubernetes/main.tf
module "kubeconfig" {
source = "matti/resource/shell"
depends_on = [null_resource.kubeadm_join]
trigger = element(var.master_nodes.*.ipv4_address, 0)
command = <<EOT
ssh -i ${var.hcloud_ssh_private_key} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
root@${local.master_ip} 'cat /root/.kube/config'
EOT
}

Es ist hier also zu sehen, wie die Kubernetes-Konfiguration über die verschiedenen Ebenen durchgereicht wird:
Resource kubeconfig in ./modules/kubernetes/main.tf → output des Moduls ./modules/kubernetes/output.tf → definition in ./output.tf

Fazit

Wir haben in diesem Kapitel gelernt, wie ein Terraform Projekt deployed wird und als Ergebnis des Deployments einen funktionsfähigen, kleinen Cluster erzeugt. Außerdem haben wir einen kurzen Überblick über den Aufbau eines Terraform-Projektes erhalten und uns Wissen über die Mechanismen, wie Terraform-Module zusammenspielen, angeeignet. Im nächsten Teil wollen wir die Funktionsfähigkeit des Clusters erweitern. Dazu werden die Anbindungen an externe Cloud Ressourcen kurz erläutert und einige Erweiterungen des Clusters vorgestellt.