Das Lab wird multi-cluster — wiederholbar föderiert, ehrlich evaluiert

Aus einem handverdrahteten Cluster plus Wegwerf-Test wurde ein wiederholbares Muster: ein Cluster pro Befehl, Apps im cluster-scope, Flux-Tenants, eine aufgeräumte Headscale-Domain — und Föderation statt Node-Mesh. Inklusive der ehrlichen Vorgeschichte (warum ClusterMesh rausflog), einer nüchternen Stabilitäts-Bewertung und dem, was als Nächstes fällig ist: Monitoring, Alerting, Backup und HA für die Föderations-Wurzel.
Table of contents

Die letzten Tage im Lab waren ein Strukturschnitt. Lange gab es genau einen ernsthaften Cluster — hydra, bare-metal — und daneben einen Wegwerf-Test, von dem die ClusterMesh-Episode erzählte. Inzwischen ist daraus ein wiederholbares Muster geworden: ein Cluster pro Befehl, Anwendungen im cluster-scope, Flux-Tenants, eine aufgeräumte Tailnet-Domain — und eine Föderation, die bewusst kein Pod-Mesh ist. Dieser Beitrag fasst die Änderungen zusammen, spricht ehrlich über die Probleme dahinter und bewertet, wie produktionstauglich das Ganze wirklich ist.

Föderation, kein Mesh — die ehrliche Vorgeschichte

Bevor irgendetwas wiederholbar wurde, musste eine Grundsatzentscheidung fallen — und die fiel erst nach einem Fehlschlag. Der Versuch, Cilium ClusterMesh über das Headscale-Tailnet zu fahren, wurde zurückgerollt. Inzwischen steht das so in FEDERATION.md, ausdrücklich „damit es nicht neu aufgerollt wird". Die zwei einzigen Wege, das Mesh über das Tailnet zu zwingen, waren beide schlecht:

VarianteWarum sie scheitert
InternalIP = Tailnet-IPAller Pod-Verkehr tunnelt übers Tailnet → Cilium-VXLAN verschachtelt in Tailscale-WireGuard (1280 MTU, Fragmentierung, Instabilität). Bricht außerdem den Hetzner-CCM, der Nodes per InternalIP zuordnet.
InternalIP = LAN + Subnet-RoutesSelf-Route-Falle: ein Node innerhalb seines eigenen annoncierten LAN installiert seine LAN-Route über tailscale0 → kubelet→API schleift → Node NotReady. Das verursachte einen echten Multi-Node-Outage.

Und es gab ein drittes Problem, eine Ebene tiefer — im Dataplane selbst. Der naheliegende Weg, schon den Node-zu-Node-Verkehr zu verschlüsseln, war die Talos-Tailscale-Extension direkt auf den Nodes: jeder Node ein Tailnet-Member mit eigenem WireGuard-Transport. In der Praxis kollidierte das frontal mit Cilium. Auf demselben Node liefen damit zwei WireGuard-Datenebenen und zwei Firewall-Manager nebeneinander — Cilium und Tailscale programmieren beide iptables/nftables-Ketten und fwmark-basiertes Policy-Routing, und sie traten sich gegenseitig auf die Füße (Tailscales ts-*-Ketten und Mark-Routing gegen Ciliums Masquerade- und Routing-Regeln). Um die Verbindung überhaupt stabil zu bekommen, musste Ciliums native WireGuard-Verschlüsselung (encryption.type: wireguard) weichen — mit dem unmittelbaren Nachteil, dass der gesamte In-Cluster-Pod-zu-Pod-Verkehr (inklusive Envoy-Gateway → Backends wie Garage-S3) unverschlüsselt über die LAN lief. Defense-in-depth geopfert, nur um zwei sich bekriegende Stacks notdürftig ruhigzustellen.

Die Konsequenz war radikal und befreiend zugleich: Nodes treten dem Tailnet gar nicht erst bei. Damit ist die ganze Gefahrenklasse unmöglich. Cross-cluster läuft north-south über einen tailnet-gateway pro Cluster — ein einzelner Pod (Tags k8s-api/talos-api/dns/fabric), der als einziges Mitglied im Tailnet hängt, die LAN- und Tailnet-LB-CIDR annonciert und API (socat :6443/:50000) plus DNS bereitstellt. Weil er ein Pod ist und kein kubelet, darf er gefahrlos --accept-routes. Und der Dataplane-Konflikt löste sich gleich mit: Da keine Node mehr Tailscale fährt, ließ sich Ciliums native WireGuard-Verschlüsselung wieder einschalten — Pod-zu-Pod-Verkehr ist seither transparent verschlüsselt, Cilium passt die Pod-MTU für den Overhead automatisch an.

flowchart LR subgraph H["hydra · primary · on-prem"] HA["apps"] --> HGW["tailnet-gateway"] end subgraph C["weiterer Cluster · Cloud"] CGW["tailnet-gateway"] --> CA["apps"] end HGW <-->|"Headscale-Tailnet · north-south · kein Node-Mesh"| CGW DEV["Laptop / Pod"] -->|"app.cluster.tif.internal"| HGW

Das ist die unspektakuläre Erkenntnis, auf der alles Folgende ruht: Ein Homelab mit einem echten Cluster braucht kein Pod-Mesh. Es braucht eine verlässliche Brücke.

Ein Cluster in einem Befehl

Auf diesem Fundament wird ein neuer Cluster zur Routine. Das Gerüst kommt aus k8s/templates/cluster, ein Task zieht es hoch:

task core:cluster-create CLUSTER_NAME=ember
# oder mit fremder LAN-Form:
task core:cluster-create CLUSTER_NAME=ember LAN_CIDR=10.50.1.0/24 LAN_GATEWAY=10.50.1.1 ...

Das Barebone besteht aus Cilium, Kyverno, tailnet-gateway und Gatus. Der eigentliche Clou steckt in der CLUSTER_ID (eindeutig 1–255, auto-vergeben): Sie setzt Ciliums cluster.id und leitet die LoadBalancer-Pools deterministisch ab —

CLUSTER_ID = n  →  10.103.<n-1>.0/28   BGP-LB-Pool      (BGP nur auf hydra)
                   10.103.<n-1>.16/28  Tailnet-LB-Pool  (übers Tailnet annonciert)
                   10.103.<n-1>.19     Tailnet-Ingress

Genauso automatisch entsteht ein eigener AGE-Schlüssel pro Cluster: cluster-create erzeugt ein frisches Keypair, schreibt den Private Key in das flux-sops-age-Secret und fügt den Public Key additiv unter dem geteilten Anchor in .sops.yaml ein — bestehende Cluster behalten ihren Zugriff, und ein geleakter In-Cluster-Key gibt nie den Master preis.

Das Gerüst ist nicht plug-and-play. Die talconfig.yaml (Default: 3 Control-Plane + 1 Worker) muss vor jedem Bootstrap an die echte Hardware/IPs angepasst werden — eine falsche talconfig.yaml ist laut Doku der häufigste Greenfield-Fehler. „Ein Befehl" scaffoldet, es ersetzt nicht das Nachdenken.

Cluster-scope und Tenants

Damit mehrere Cluster wartbar bleiben, sind die Anwendungen in den cluster-scope gewandert: gemeinsame Manifeste unter common/applications, identische StorageClass-Namen über alle Cluster, und ein schlankes overlays/apps pro Cluster entscheidet, was wo läuft. Eine App wird einmal definiert, nicht pro Cluster kopiert.

Parallel sind Flux-Tenants scharf. Das Modell ist sauber getrennt: Die App-Manifeste eines Tenants leben in seinem eigenen Git-Repo, seine Flux-Kustomization rekonziliert ausschließlich in seinen eigenen Namespace, und das Plattform-Repo liefert nur die Gateways und das Tailnet-Plumbing. Tenants bekommen eigene AGE-Keys — dieselbe Logik wie bei den Clustern, eine Ebene tiefer. Das ist die konsequente Fortsetzung der GitOps-Architektur: Wer was deployen darf, ist eine Frage von Repo-Grenzen und Namespaces, nicht von Vertrauen.

Headscale: magic domain statt /32-Listen

Die Tailnet-Seite wurde im selben Zug aufgeräumt. Die interne Domain heißt jetzt durchgängig tif.internal, und die Headscale-Policy beschreibt Cluster nicht mehr über endlose per-Node-/32-Listen, sondern über pro-Cluster-Routen: ein <name>-net (das LAN_CIDR) und optional <name>-vip-k8s (die API-VIP), freigegeben über autoApprovers.routes für tag:fabric. Der API-Pfad ist dadurch beschreibbar geworden:

client → k8s.<cluster>.tif.internal → tailnet-gateway → K8s-VIP :6443

Ein neuer Cluster braucht also nur noch zwei Policy-Zeilen plus einen Split-DNS-Eintrag auf seine Gateway-IP — kein Pflegen von Node-Listen mehr. Dass dieser Schritt noch manuell ist, ist bekannt und als Issue (4b3bcf3) getrackt; das Ziel ist, ihn beim Cluster-Create mitzuerzeugen.

Weitere Cluster — Cloud jetzt, on-prem als Nächstes

Der Wegwerf-Test aus der ClusterMesh-Zeit ist eingestampft und durch einen sauber gescaffoldeten, in der Hetzner-Cloud gehosteten Cluster ersetzt — diesmal über die Template-Strecke (inklusive Terraform-tftpl für Cloud-Nodes) statt von Hand. Damit existiert das Muster nicht nur auf dem Papier, sondern trägt einen zweiten, fremd-gehosteten Cluster.

Ehrlich ist auch: Der erste fremde Cluster brachte genau die Provider-Eigenheiten ans Licht, die man erst im Betrieb sieht — die LoadBalancer-Verdrahtung (Cilium-LB für classless Services, der Cloud-CCM, die Envoy-Gateway-Exposition) brauchte mehrere konkrete Korrekturen, bis sie saß. Das ist normal; es zeigt nur, dass „gescaffoldet" und „stabil" zwei verschiedene Dinge sind.

Die nächste Ausbaustufe zielt bewusst on-prem: Das Gerüst ist LAN-form-agnostisch (beliebige LAN_CIDR, Single-Node mit allowSchedulingOnControlPlanes: true wird unterstützt), also lassen sich weitere physische Cluster nach demselben Rezept dazustellen — ohne hydras Hardware oder Netz zu kopieren.

Wie stabil ist das im Betrieb?

Die unbequeme, aber wichtige Frage. Eine nüchterne Bilanz:

  • Was solide ist: Die Föderation ist langweilig stabil — gerade weil sie north-south-only ist und die Mesh-Gefahrenklasse wegdesignt wurde. hydra trägt seit Monaten die Produktion (Headscale, Vault, der Großteil der Apps). cluster.id bleibt pro Cluster die stabile Security-Identität, unabhängig von jedem Mesh.
  • Was dünn ist: Es gibt genau einen echten Produktions-Cluster. Der zweite ist frisch und noch im Einlaufen. Und die Wurzel der Föderation ist einfach ausgelegt: Headscale läuft als Single-Replica, jeder tailnet-gateway ist ein einzelner Pod. Fällt die Kontrollebene weg, überleben zwar bestehende Tunnel — aber neue Registrierungen, Route-Approval und DNS-Updates stehen. Das ist ein bewusst offener SPOF, dokumentiert im Headscale-HA-Plan.

Kurz: Das Muster ist wiederholbar, der Betrieb ist es noch nicht vollständig. Jeder Bring-up hat manuelle Schritte (talconfig, Policy), und die Hochverfügbarkeit der Föderations-Wurzel fehlt. Produktionstauglich für einen Cluster — ja. Für eine echte Multi-Site-Produktion — noch nicht.

Ausblick: was als Nächstes fällig ist

Genau daran hängt die nächste Runde. Drei Baustellen, in der Reihenfolge, in der sie wehtun:

1. Monitoring-Gaps schließen und Alerting. Jeder gescaffoldete Cluster bringt Gatus mit — aber die Tiefe fehlt. Die Talos/Prometheus-Integration hat Lücken (358eb8a), Talos-Node-Logs sollen nach Loki (bdf1a7d), Gatus meldet False-Positives auf gesunde Dienste (2d59ea5, a9d9dc0), und die Grafana-Dashboards brauchen einen Aufräumdurchgang (84ad163). Erst wenn die Beobachtbarkeit über alle Cluster trägt, lohnt sich echtes Alerting obendrauf.

2. Backup über alle Schichten. Das zerfällt sauber in drei Ebenen:

  • CNPG — kontinuierliches WAL-Backup für die Postgres-Datenbanken (auch der Hebel, der Headscale aus dem SQLite-SPOF holt).
  • Ceph / etcd — geplante etcd-Snapshots (02c9f5b, besonders für Single-Control-Plane-Cluster) und eine Evaluierung von Velero für PVC-/Cluster-Backup (bdfce2d), zusätzlich zu den bestehenden VolSync-Restic-Backups und dem Rook-Ceph-Fundament.
  • S3 / Garage — cross-cluster-Replikation bzw. Mirroring. Garage Phase 2 macht jeden Cluster zu einer Zone; ergänzend kommt Ceph-RBD-Mirror in Frage. Beides braucht den gleichen gateway-to-gateway L4-Pfad — die produzierende Gateway annonciert die spezifische Service-CIDR, die konsumierende Gateway (ein Pod!) akzeptiert sie, niemals eine CIDR mit einer Node-Adresse darin.

3. HA für die Föderations-Wurzel. Der offenste Punkt. Der tailnet-gateway soll in eine HA-Subnet-Router-Schicht plus Dienste hinter einer stabilen Tailnet-IP zerfallen (fa3985f), und Headscale von SQLite auf CNPG-Postgres umziehen, damit es stateless rescheduled und cluster-übergreifend active/standby laufen kann (231c25c, 8b9e4e1). Solange das fehlt, ist jede neue Cluster-Zahl nur so verfügbar wie der eine Pod, der das Tailnet zusammenhält.


Der ehrliche Stand: Das Lab kann jetzt wiederholbar Cluster bauen und sie über eine bewusst einfache, mesh-freie Föderation verbinden — eine Entscheidung, die erst ein Outage erzwungen hat. Was bleibt, ist der Sprung von „läuft auf einem Cluster" zu „übersteht den Ausfall eines Clusters": Beobachtbarkeit über alle Standorte, Backup auf jeder Schicht und Hochverfügbarkeit dort, wo heute noch ein einzelner Pod die Wurzel bildet. Das ist die Arbeit der nächsten Runde — und der Grund, warum der nächste Cluster on-prem entsteht, nicht in der Cloud.