Ein Lab, das sich selbst beschreibt: GitOps mit Flux

Wie aus imperativen Skripten ein deklaratives Monorepo wurde, das Soll- und Ist-Zustand zugleich abbildet — und wie Flux die Ressourcen im Cluster verdrahtet
Table of contents

Die Hardware ist nur das Fundament. Was mein Lab eigentlich ausmacht, ist ein einziges Git-Repository, das den kompletten Cluster beschreibt — und zwar nicht als Dokumentation, sondern als verbindliche Quelle der Wahrheit. Dieser Beitrag ist die kurze Geschichte, wie es dazu kam, und wie die Teile heute zusammenspielen.

Vom Skript zum Zustand

Wie vermutlich jedes Homelab begann auch meines mit einer Sammlung von Skripten: ein bisschen Bash hier, ein helm install dort, dazwischen handgepflegte YAML-Dateien, die man „mal eben“ mit kubectl apply eingespielt hat. Das funktioniert — bis zu dem Tag, an dem man nicht mehr sicher sagen kann, warum eine Ressource im Cluster so aussieht, wie sie aussieht. Imperative Befehle beschreiben einen Übergang, keinen Zustand. Und Übergänge vergisst man.

Der Bruch kam mit der Umstellung auf Flux CD und einem strikt deklarativen Ansatz. Seitdem gilt eine einfache Regel: Alles, was im Cluster läuft, muss aus dem Repository ableitbar sein. Der einzige manuelle Schritt ist das Bootstrap eines frischen Clusters.

Das Repository ist der Ist-Zustand

Der entscheidende Gedanke bei GitOps wird oft zu kurz gefasst: Das Repo beschreibt nicht nur den Soll-Zustand. Mit aktiviertem Pruning beschreibt es auch den Ist-Zustand — denn was nicht (mehr) im Repo steht, wird aus dem Cluster entfernt. Eine Ressource auskommentieren heißt: sie verschwindet beim nächsten Reconcile.

Das sieht man der zentralen Aggregations-Kustomization direkt an. Auskommentierte Einträge sind keine Notizen, sondern aktive Entscheidungen:

  # fediverse-system
  - ../../applications/fediverse-system
  - ../../applications/fediverse-system/snac
  # NOTE: Way too bloated - Keeping for reference only
  #- ../../applications/fediverse-system/mastodon

  # harbor
  # FIXME: Still no ARM64 support, waiting for merge
  # - ../../applications/harbor/harbor

snac läuft, Mastodon ist Geschichte (dazu in einem eigenen Beitrag mehr), Harbor wartet auf ARM64-Support. Das Repo erzählt damit ganz nebenbei die Evolution des Labs — der Git-Log ist gleichzeitig das Changelog der Infrastruktur.

Wie Flux die Ressourcen verdrahtet

Jeder Cluster hat eine GitRepository namens cluster, die auf dieses Repo zeigt. Darauf setzen pro Anwendung kleine Flux-Kustomization-Objekte auf — bei mir jeweils eine ks.yaml neben der App. Eine solche Kustomization ist bewusst schlank:

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: zot
  namespace: flux-system
spec:
  interval: 15m
  path: "./k8s/clusters/${CLUSTER_NAME}/applications/zot/zot/app"
  prune: true
  wait: true
  sourceRef:
    kind: GitRepository
    name: cluster
  dependsOn:
    - name: rook-ceph-cluster

Drei Details tragen das ganze Modell:

  • prune: true — der oben beschriebene Ist-Zustand. Driftet der Cluster vom Repo ab, korrigiert Flux das.
  • dependsOn — die Anwendungen bilden einen gerichteten Abhängigkeitsgraphen. Zot wartet auf Rook-Ceph, Workloads warten auf Vault und cert-manager, und so weiter. Flux rollt in dieser Reihenfolge aus.
  • ${CLUSTER_NAME} — die Pfade sind über eine cluster-config.yaml parametrisiert. Derselbe Manifest-Baum funktioniert dadurch auf jedem Cluster; cluster-spezifisches Verhalten ist Opt-in über Overlays.

Im Überblick ergibt das die klassische Reconcile-Schleife, die nie aufhört zu laufen:

flowchart LR DEV["git push"] --> REPO["GitRepository &quot;cluster&quot;"] REPO -->|"source-controller<br/>poll (interval)"| SRC["Artefakt im Cluster"] SRC -->|"kustomize-controller<br/>+ SOPS-decrypt"| RECON["Kustomizations<br/>(dependsOn-Graph)"] RECON -->|"apply"| CLUSTER["Workloads"] RECON -.->|"prune"| CLUSTER CLUSTER -->|"Drift"| RECON

Geheimnisse bleiben draußen

Damit „alles im Repo“ nicht „alle Secrets im Repo“ bedeutet, gilt eine harte Trennung: Die wenigen Bootstrap-Secrets liegen mit SOPS verschlüsselt im Git (entschlüsselt wird zur Reconcile-Zeit mit dem AGE-Key des jeweiligen Clusters), alles Übrige kommt aus Vault und wird über den External Secrets Operator als Kubernetes-Secret in die Workloads gespiegelt. Im Klartext steht im Repo nie ein Geheimnis.

Mehrere Cluster, eine Form

Weil die Manifeste über ${CLUSTER_NAME} und Overlays parametrisiert sind, hat jeder Cluster dieselbe Gestalt: gleiche CNI (Cilium), gleiche Storage-Class-Namen, gleiches Tailnet-Layout. Ein neuer Cluster entsteht aus einer Vorlage (task core:cluster-create), bekommt seine eigenen Adressen in der cluster-config.yaml und ist ansonsten ein Klon der bewährten Form. Cluster werden so von Schmuckstücken zu Vieh — und genau das ist der Punkt.

Fazit

Das Lab ist kein statisches Setup, sondern ein lebendes Repository, das sich permanent im Wandel befindet. Der Unterschied zu früher ist nicht, dass sich nichts mehr ändert — sondern dass jede Änderung nachvollziehbar, reproduzierbar und rückgängig zu machen ist. Der Cluster ist nur noch die Projektion des Git-Logs in die Realität.