Terraform
Wir benötigen Terraform zur initialisierung unserer Kubernetes-Infrastruktur auf Hetzner Cloud. Hier wird diese Infrastruktur Dokumentiert.
Infrastrukturplan
Hier ist ein grober Überblick von der deployten Infrastruktur:
Ressourcentabelle
Das ist ein genauerer Überblick von unserer Infrastruktur:
Ressource | Name | Optionen | Interne IP-Addresse |
---|---|---|---|
Networks | k8s-cluster | IPv4 Subnet | 10.0.0.0/16 + 10.0.1.0/24 |
Controller | controller-node | IPv4, IPv6 | 10.0.1.1/24 |
Workers | worker-node-1 | IPv4, IPv6 | 10.0.1.3/24 |
↪ | worker-node-2 | IPv4, IPv6 | 10.0.1.4/24 |
↪ | worker-node-3 | IPv4, IPv6 | 10.0.1.2/24 |
Load Balancers | k8s-lb | IPv4, IPv6 | 10.0.1.5/24 |
Firewall | k8s-cluster-firewall | Siehe Firewall | - |
Object Storage | m300-bucket | - | - |
Die obere Ressourcen werden deployed, wenn man im Terraform Directory tofu apply
ausführt.
Terraform Setup
Die funktionalität des Setups ist innerhalb der Files Dokumentiert. Hier gehen wir nur überflächlich über das Ganze Setup.
S3 Backend
In main.tf
definieren wir unser Backend für Terraform. Dieses speichert den aktuellen Status von unserer Infrastruktur. So können wir untereinander einfacher und sicherer den Stand der Infrastruktur teilen.
# configure s3 backend for tfstate
backend "s3" {
bucket = "m300-bucket"
endpoints = {
s3 = "https://hel1.your-objectstorage.com" # Hetzner's endpoint
}
key = "terraform.tfstate"
region = "main" # this is required, but will be skipped!
skip_credentials_validation = true # this will skip AWS related validation
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true # skips checking STS
use_path_style = true # Ceph-S3 compatibility
skip_s3_checksum = true # Ceph-S3 compatibility
}
Netzwerk
Das Netzwerk ist spezifisch in zwei verschiedenen Konfigurationen aufgeteilt:
network.tf
firewall.tf
Das Netzwerk selber ist in zwei Segmenten aufgeteilt:
10.0.0.0/16
als Gesamtnetz, da könnten mehrere Cluster mit eigenen Netzen deployed werden10.0.1.0/24
als Netz für unser (momentan einziges) Cluster
Firewall
Die Firewall wird von Hetzner bereitgestellt um den Netzwerverkehr zu filtrieren.
In unseren fall müssen einege Regeln erstellt werden um bessere Sicherheit zu gewehrleisten.
Protokoll | Port | Beschreibung | Quelle |
---|---|---|---|
TCP | 22 | SSH | ["0.0.0.0/0", "::/0"] |
TCP | 6443 | Kubernetes API Server | ["0.0.0.0/0", "::/0"] |
TCP | 2379-2380 | Kubernets etcd | ["10.0.0.0/16"] |
TCP | 10250 | Kubelet API | ["10.0.0.0/16"] |
TCP | 30000-32767 | Kubernetes NodePort services | ["0.0.0.0/0", "::/0"] |
UDP | 8472 | Flannel VXLAN | ["10.0.0.0/16"] |
ICMP | - | Allow ICMP | ["0.0.0.0/0", "::/0"] |
Controller
In der host_controller.tf
Datei wird unser K8s Controller definiert, inklusive dessen Netzwerk, SSH-Keys und Cloud-Init.
# k8s controller nodes
resource "hcloud_server" "controller-node" {
name = "controller-node"
image = "ubuntu-24.04"
server_type = "cax11"
location = "fsn1"
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
network {
network_id = hcloud_network.private_network.id
# Die vom Controller-Knoten verwendete IP-Adresse, muss statisch sein
# Die Worker-Knoten verwenden hier 10.0.1.1, um mit dem Controller-Knoten zu kommunizieren
ip = "10.0.1.1"
}
# importiere alle SSH-Schlüssel aus ../ssh/*.pub für den root-Benutzer
ssh_keys = [for key in hcloud_ssh_key.team_keys : key.id]
# Initialisierung mit cloud-init
# Wir rendern die Template-Datei und übergeben den Inhalt des öffentlichen Schlüssels
user_data = templatefile("${path.module}/init/controller-init.yaml.tftpl", {
ssh_keys = [for key in hcloud_ssh_key.team_keys : key.public_key]
network_name_b64 = base64encode(hcloud_network.private_network.name)
hcloud_token_b64 = base64encode(var.hcloud_token)
})
# Wenn wir dies nicht angeben, erstellt Terraform die Ressourcen parallel
# Wir möchten, dass dieser Knoten erst nach dem Erstellen des privaten Netzwerks angelegt wird
depends_on = [hcloud_network_subnet.private_network_subnet]
}
Die Cloud-init Datei, die den Controller deployed, sieht so aus. Diese wurde mit Kommentaren versehen, um die Funktion der Datei zu erklären.
#cloud-config
# benötigte pakete installieren
packages:
- curl
- open-iscsi
- nfs-common
users:
# cluster user erstellen, um Kommunikation zwischen Nodes zu erlauben
- name: cluster
ssh-authorized-keys:
# escaped, um syntax-highlighting für die Doku beizubehalten
\%{ for key in ssh_keys ~}
- ${key}
%{ endfor ~}
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP4IJDWOsosR7oTRPwqzsmcoJ1TL1IA0emM5EAPntsG4 m300-worker"
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
# basis-secrets + configs erstellen
write_files:
# zertifikatkonfiguration
- path: /etc/rancher/k3s/config.yaml
content: |
tls-san:
- "controller01m300.cpu.cafe"
- "worker01m300.cpu.cafe"
- "worker02m300.cpu.cafe"
- "worker03m300.cpu.cafe"
- "api01m300.cpu.cafe"
- "10.0.1.1"
- "10.0.1.2"
- "10.0.1.3"
- "10.0.1.4"
write-kubeconfig-mode: "0644"
# traefik-loadbalancer konfiguration
- path: /var/lib/rancher/k3s/server/manifests/traefik-helmconfig.yaml
content: |
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
service:
annotations:
load-balancer.hetzner.cloud/name: "k8s-lb"
load-balancer.hetzner.cloud/location: "fsn1"
load-balancer.hetzner.cloud/use-private-ip: "true"
load-balancer.hetzner.cloud/uses-proxyprotocol: "true"
# hcloud-secret erstellen
- path: /var/lib/rancher/k3s/server/manifests/hcloud-secret.yaml
content: |
apiVersion: v1
kind: Secret
metadata:
name: hcloud
namespace: kube-system
data:
network: ${network_name_b64}
token: ${hcloud_token_b64}
# hcloud-csi-secret erstellen
- path: /var/lib/rancher/k3s/server/manifests/hcloud-csi-secret.yaml
content: |
apiVersion: v1
kind: Secret
metadata:
name: hcloud-csi
namespace: kube-system
data:
token: ${hcloud_token_b64}
# fluxcd namespace erstellen
- path: /var/lib/rancher/k3s/server/manifests/fluxcd-namespace.yaml
content: |
apiVersion: v1
kind: Namespace
metadata:
name: flux-system
# fluxcd-sops private key erstellen
- path: /var/lib/rancher/k3s/server/manifests/fluxcd-sops-private-key.yaml
content: |
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: sops-gpg
namespace: flux-system
data:
sops.asc: ${sops_gpg_private_key_b64}
# k3s-installation
runcmd:
- apt-get update -y
- apt-get upgrade -y # system-upgrade
- systemctl enable --now iscsid
- systemctl enable --now open-iscsi # iscsi einschalten für longhorn
- curl https://get.k3s.io | INSTALL_K3S_EXEC="--disable-cloud-controller --kubelet-arg cloud-provider=external --node-ip ${node_ip} --node-external-ip=${node_external_ip}" sh - # k3s-installation
- curl -L "https://github.com/hetznercloud/hcloud-cloud-controller-manager/releases/latest/download/ccm-networks.yaml" -o "/var/lib/rancher/k3s/server/manifests/hcloud-ccm.yaml" # cloud controller manager von hcloud installieren
- curl -L "https://raw.githubusercontent.com/hetznercloud/csi-driver/v2.16.0/deploy/kubernetes/hcloud-csi.yml" -o "/var/lib/rancher/k3s/server/manifests/hcloud-csi.yaml" # csi controller installieren
- chown cluster:cluster /etc/rancher/k3s/k3s.yaml # k3s config-rechte bearbeiten
- chown cluster:cluster /var/lib/rancher/k3s/server/node-token # k3s node-token-rechte bearbeiten (damit worker nodes sich anbinden können)
Worker
Ähnlich wie beim Controller Konfiuguirieren wir hier unsere Worker nodes. Speziell hier ist, dass ein Counter/Loop für die Namen und IP Adressen verwendet wird, damit wir nicht für jeden Node eine eigene Terraform-Definition erstellen müssen.
# k8s worker nodes
resource "hcloud_server" "worker-nodes" {
count = 3
# Der Namenloop. Resultiert in: `worker-node-1, worker-node-2, worker-node-3` usw.
name = "worker-node-${count.index + 1}"
image = "ubuntu-24.04"
server_type = "cax11"
location = "fsn1"
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
network {
network_id = hcloud_network.private_network.id
ip = "10.0.1.${count.index + 2}"
}
# Importiere alle SSH-Schlüssel aus ../ssh/*.pub für den root-Benutzer
ssh_keys = [for key in hcloud_ssh_key.team_keys : key.id]
# Initialisierung mit cloud-init
# Wir rendern die Template-Datei und übergeben den Inhalt des öffentlichen Schlüssel
user_data = templatefile("${path.module}/init/worker-init.yaml.tftpl", {
ssh_keys = [for key in hcloud_ssh_key.team_keys : key.public_key]
worker_private_key = var.worker_private_key
})
depends_on = [
hcloud_network_subnet.private_network_subnet,
hcloud_server.controller-node # Aus Konsistenzgründen von master-node geändert
]
}
Die Cloud-Init Datei sieht beim Worker ähnlich aus wie beim Controller-Node, aber ist stattdessen viel simpler aufgebaut.
#cloud-config
packages:
- curl
- open-iscsi
- nfs-common
users:
- name: cluster
ssh-authorized-keys:
# tftpl auskommentiert, um syntax-highlighting beizubehalten
#%{ for key in ssh_keys ~}
- ${key}
#%{ endfor ~}
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
# SSH private key zur Verbindung von Worker Nodes + Controller
write_files:
- path: /root/.ssh/id_rsa
content: |
${worker_private_key}
permissions: "0600"
runcmd:
- apt-get update -y
- apt-get upgrade -y
- systemctl enable --now iscsid
- systemctl enable --now open-iscsi # longhorn-dependencies
- until curl -k https://10.0.1.1:6443; do sleep 5; done # abwarten, bis die K8s API auf dem Controller erreichbar ist
- REMOTE_TOKEN=$(ssh -o StrictHostKeyChecking=accept-new cluster@10.0.1.1 sudo cat /var/lib/rancher/k3s/server/node-token) # den Verbindungstoken vom Controller auslesen
- curl -sfL https://get.k3s.io | K3S_URL=https://10.0.1.1:6443 K3S_TOKEN=$REMOTE_TOKEN INSTALL_K3S_EXEC="--node-ip ${node_ip} --node-external-ip=${node_external_ip} --kubelet-arg cloud-provider=external" sh - # k3s im worker-modus installieren
Zertifikate
Die Kommunikation zwischen den Nodes muss verschlüsselt verlaufen, um die maximale Sicherheit zu erlauben. Würden wir das nicht tun, wären einfache MiM-Attacken möglich.
Ein Problem mit K3s' Dokumentation aber ist es, dass die richtige Zertifikatgeneration sehr schlecht dokumentiert ist. Es gibt zwei Zertifikate die wir anpassen müssen:
tls-san
: Das Zertifikat für die Kommunikation zwischen uns(eren Laptops) und der API vom Cluster.- Kubelet Zertifikat: Das Zertifikat welches für die Verschlüsselte Kommunikation zwischen den Nodes selber verläuft.
tls-san
Dieses Zertifikat enthält standardgemäss unsere Domains + internen IPs nicht. Zwar brauchen wir nicht zwingend die Domains in diesem Zertifikat, macht es die Konfiguration der Laptops viel leichter, da man nicht die IP-Adressen rauslesen muss, wenn man sich die Konfiguration herunterladet.
Für diese Konfiguration können wir einfach die Datei /etc/rancher/k3s/config.yaml
erstellen:
tls-san:
- "controller01m300.cpu.cafe"
- "worker01m300.cpu.cafe"
- "worker02m300.cpu.cafe"
- "worker03m300.cpu.cafe"
- "api01m300.cpu.cafe"
- "10.0.1.1"
- "10.0.1.2"
- "10.0.1.3"
- "10.0.1.4"
write-kubeconfig-mode: "0644"
Sobald K3s neugestartet wird (oder in unserem Fall, zum allerersten Mal gestartet wird nach der Cloud-Init Initialisierung), generiert es das Zertifikat mit den richtigen einträgen.
Auto-Deploy Pipeline
Damit wir nicht jedes mal selber unsere ganze Infrastruktur Deployen und Starten müssen nutzen wird einen git workflow der unsere Infrastruktur neu builded.
In unseren infra-init.yaml (siehe comments innerhalb des files für mehr infos!)
---
# Hier definieren wir welche Paths tatsächlich im build enthalten sein sollen.
on:
push:
paths:
- "terraform/**"
- "fluxcd/**"
# Wir defieren hier 3 Jobs, einmal terraform_apply, get_kubeconfig und fluxcd_bootstrap_apply
jobs:
# Im ersten Job wird Terraform mit OpenTofu neu aufgebaut. Dabei werden AWS-Zugangsdaten und Terraform-Variablen aus Secrets verwendet.
terraform_apply:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Set up tofu
uses: https://github.com/opentofu/setup-opentofu@v1
- name: Create terraform.tfvars from secret
run: echo "${{ secrets.TERRAFORM_TFVARS }}" > terraform/terraform.tfvars
- name: Run tofu apply
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: main
working-directory: terraform
run: tofu init && tofu apply -auto-approve -var-file=terraform.tfvars
# In diesem Job wird nach erfolgreichem Terraform-Apply die kubeconfig vom K3s-Control-Node per SSH abgeholt und angepasst.
get_kubeconfig:
runs-on: docker
needs: terraform_apply
steps:
- name: Fetch kubeconfig from control node
env:
SSH_KEY: ${{ secrets.K3S_SSH_KEY }}
CONTROL_NODE: ${{ vars.K3S_CONTROL_NODE }}
run: |
mkdir -p ~/.ssh
mkdir -p ~/.kube
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh -o StrictHostKeyChecking=no cluster@$CONTROL_NODE 'cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/config
sed -i 's|https://127.0.0.1:6443|https://138.199.157.142:6443|g' ~/.kube/config
# In diesem letzten Job wird FluxCD über Terraform/OpenTofu auf dem Kubernetes-Cluster gebootstrapped.
fluxcd_bootstrap_apply:
runs-on: docker
needs: get_kubeconfig
steps:
- uses: actions/checkout@v4
- name: Set up tofu
uses: https://github.com/opentofu/setup-opentofu@v1
- name: Create terraform.tfvars for fluxcd from secret
run: echo "${{ secrets.FLUXCD_TFVARS }}" > fluxcd/terraform.tfvars
- name: Run tofu apply (fluxcd)
working-directory: fluxcd
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: main
run: tofu init && tofu apply -auto-approve -var-file=terraform.tfvars
Kubelet
K3s erlaubt es nicht, dieses auszuschalten, also muss es richtig konfiguriert werden, damit die Kommunikation in unserem Netzwerk funktioniert.
Es ist viel schwieriger gewesen, die Dokumentation für diese Option zu finden, da sie in GitHub Issues verbuddelt war. Man muss bei der Installation von K3s zwei Argumente beigeben:
... --node-ip $NODE_IP --node-external-ip=$NODE_EXTERNAL_IP ...
Die Node IP ist die interne IP-Adresse vom Node und die External IP die öffentliche. Sobald diese zwei Argumente angegeben werden, wird das Zertifikat richtig generiert.
Beispiel eines Github Issues: https://github.com/k3s-io/k3s/discussions/9888
Ich weiss nicht mehr, wo ich die Verbindung zwischen diesen Argumenten und den Kubelet-Zertifikaten gefunden habe.
Flux
Alles was in Flux-System
drinn ist wird automatisch von Flux generiert bei der Installation und ist nicht von uns erstellt worden!
Innerhalb der Providers.tf
definieren wir als FluxCD Bootstrapper als Provider definieren, um Kubernetes und GitOps ansteuern zu können.
# configure the fluxcd bootstrapper provider
provider "flux" {
kubernetes = {
config_path = "~/.kube/config"
}
git = {
author_name = "ci-bot"
author_email = "ci-bot@lunivity.com"
url = "ssh://git@${var.forgejo_host}:${var.forgejo_port}/${var.forgejo_org}/${var.forgejo_repository}.git"
ssh = {
username = "git"
private_key = var.forgejo_key
}
}
}
# forgejo provider
provider "forgejo" {
host = "https://${var.forgejo_host}"
api_token = var.forgejo_token
}