14 Terraform
Sangelo edited this page 2025-07-12 09:41:59 +02:00

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: logical plan

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 werden
  • 10.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
}