rad issue list: b404b7a, 5f36156, 231c25c, 8b9e4e1, fa3985f). Ich schreibe das auf, bevor ich es baue — die Reihenfolge ist der eigentliche Inhalt.Im tailnet-gateway-Beitrag habe ich Headscale zur Kontrollebene des Labs gemacht: ein selbst gehostetes Tailscale-Coordination, durch das jeder Cluster ins Tailnet kommt. Die ClusterMesh-Episode hat denselben Strang weitergesponnen. Was beide stillschweigend voraussetzen: Es gibt genau eine Headscale-Instanz, auf hydra, und wenn die wegfällt, hat die Föderation kein Gehirn mehr. Genau diesen Single Point of Failure will ich auflösen — und der Weg dahin ist überraschend gestuft.
Was passiert, wenn Headscale stirbt?
Die wichtigste Erkenntnis zuerst, weil sie das ganze Vorhaben entdramatisiert: Die Datenebene überlebt den Ausfall der Kontrollebene. Headscale verteilt nur die WireGuard-Schlüssel und die Netzwerk-Map. Sind die Tunnel einmal aufgebaut, laufen sie peer-to-peer weiter — auch wenn Headscale komplett weg ist.
Kontrollebene (Headscale) Datenebene (WireGuard, peer-to-peer)
───────────────────────── ────────────────────────────────────
✗ neue Registrierungen ✓ bestehende Tunnel laufen weiter
✗ Route-Approval ✓ Subnet-Routen bleiben aktiv
✗ DNS-/ACL-Updates ✓ MagicDNS-Antworten gecached
✗ Key-Rotation ✓ kein Datenverkehr betroffen
Das verschiebt die Messlatte: Ich brauche nicht zero-downtime, sondern Durability und schnelles Reschedule. Ein paar Minuten ohne neue Registrierungen sind verkraftbar — ein verlorener Node-Registry ist es nicht.
Die SPOF-Anatomie
Drei Dinge machen Headscale heute fragiler, als es sein müsste:
# headscale/app/helmrelease.yaml (gekürzt)
image:
repository: headscale/headscale
tag: "development" # FIXME: beweglicher Tag (v29 wegen grants)
persistence:
config:
existingClaim: headscale # die SQLite-DB lebt hier
storageClass: "${BLOCK_STORAGE_CLASS}" # ceph-block, ReadWriteOnce
accessMode: ReadWriteOnce
affinity:
nodeAffinity: # ceph-block-CSI ist worker-only → nie auf einem CP-Node
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExist
- SQLite auf einer RWO-PVC. Die gesamte Node-Registry liegt in einer einzigen Datei auf einem
ceph-block-Volume. - Worker-pinned. Weil der Rook-CSI-Treiber nur auf Worker-Nodes läuft, kann der Pod nur dorthin, wo seine
ReadWriteOnce-PVC gebunden ist. Stirbt dieser Worker, rescheduled der Pod erst nach dem Fencing des Nodes — beiReadWriteOncemuss Kubernetes sicher sein, dass das Volume nirgends mehr gemountet ist. - Beweglicher Image-Tag.
tag: developmentist der schlechtmöglichste Unterbau für HA: nicht reproduzierbar, kein Renovate-Pinning, potenzieller Bruch bei jedem Pull.
Akt 1: Erst das Fundament festnageln
Bevor irgendetwas HA wird, muss der bewegliche Tag weg (b404b7a). Der Grund für development waren Headscales grants (geplant für 0.29.0). Beim Nachsehen stellte sich heraus: Die policy.jsonc nutzt nur IP-Level-Grants — und die bilden 1:1 auf die klassischen ACLs ab, die seit 0.26 stabil sind. Der instabile Tag wird also gar nicht gebraucht. Drei Optionen, von risikoarm nach abwartend:
- a)
policy.jsonc→ ACLs konvertieren und aufv0.28.0pinnen (voll reproduzierbar). - b) Einen konkreten
main-Digest pinnen (grants-Syntax behalten, Reproduzierbarkeit + Renovate-Digest-Tracking zurückgewinnen). - c) Auf stabiles 0.29.0 warten, dann pinnen.
Direkt daneben liegt ein zäher Bug (5f36156): Das Chart legt selbst dann eine (leere) ACL-ConfigMap an, wenn data leer ist — und der Kustomize-configMapGenerator überschreibt sie. Ergebnis: HelmRelease-Drift, weshalb driftDetection derzeit auskommentiert ist. Sauber wird das erst, wenn Kustomize die ACL-ConfigMap allein besitzt; dann lässt sich driftDetection: mode: warn wieder aktivieren, damit manuelle Edits an der Föderations-Policy auffallen.
Akt 2: SQLite raus, Postgres rein
Das ist der eigentliche Verfügbarkeitshebel (231c25c), kein bloßes Refactoring. Headscale auf
CloudNativePG
umziehen — der Operator ist ohnehin cluster-weit ausgerollt (common/applications/cnpg-system), es kommt kein neuer Operator dazu.
Was der Umzug kauft:
- Headscale wird quasi stateless. Keine RWO-Bindung, keine Worker-Affinität mehr → der Pod rescheduled in ~30–60 s auf irgendeinen Node, ohne aufs Node-Fencing zu warten.
- HA-Postgres. CNPG fährt drei Instanzen mit kontinuierlichem WAL-Backup.
- Voraussetzung für alles Weitere. Ein cluster-übergreifendes active/standby-Headscale ist ohne externe DB gar nicht denkbar.
Akt 3: Der öffentliche Endpunkt muss mitwandern
Hier wird es subtil — eine schöne zirkuläre Abhängigkeit (8b9e4e1). Der Registrierungs- und DERP-Endpunkt ts.${EXTERNAL_DOMAIN} wird nur vom externen Gateway auf hydra ausgeliefert. Ein Standby-Headscale auf cosmos wäre damit genau dann unerreichbar, wenn hydras Ingress unten ist — also exakt im Fehlerfall, für den man den Standby gebaut hat.
Der öffentliche Endpunkt muss also dem aktiven Headscale folgen: health-checked DNS-Failover / GSLB für ts.${EXTERNAL_DOMAIN}, sodass Registrierung und DERP-Proxy immer den Cluster treffen, der die Kontrollebene gerade fährt. Das hängt an Akt 2 (replizierbares Headscale) und an einem cluster-übergreifenden L4-Pfad für die Postgres-Streaming-Replikation — demselben Gateway-zu-Gateway-Pfad, den auch künftige Föderations-Dienste brauchen.
Akt 4: Das gebündelte Gateway entflechten
Das tailnet-gateway bündelt heute vier Rollen in einem Single-Replica-Pod: den Tailscale-Subnet-Router, drei socat-Proxies (:6443, :50000, :2379) und CoreDNS.
tailnet-gateway (replicas: 1)
┌──────────────┬───────────────┬───────────────┬──────────┐
│ tailscale │ socat-k8s │ socat-talos │ coredns │
│ subnet-router│ :6443 → VIP │ :50000 → Talos │ :53 │
└──────────────┴───────────────┴───────────────┴──────────┘
▲ advertised CIDRs ▲ alles an EINER, pro-Replica vergebenen Tailnet-IP
Tailscale könnte Subnet-Router nativ HA fahren (N Nodes annoncieren dieselben Routen, primary + failover). Aber das Bündeln koppelt DNS und Proxies an die pro-Replica von Headscale vergebene Tailnet-IP des Pods — und genau darauf zeigt heute alles:
# config-extra.yaml — split-DNS zeigt auf FESTE Gateway-Tailnet-IPs
nameservers:
split:
"hydra.tn.${INTERNAL_DOMAIN}": ["100.64.0.4"]
"cosmos.tn.${INTERNAL_DOMAIN}": ["100.64.0.7"]
Mehr Replicas würden neue Tailnet-Nodes mit neuen IPs erzeugen — die einwertigen Referenzen (split-DNS, kubeconfig-tailnet, ClusterMesh-Peer) brächen. Der geplante Schnitt trennt zwei Schichten:
- Subnet-Router-Layer — minimale tailscale-only-Pods, N Replicas mit Node-Anti-Affinity, die nur die CIDRs annoncieren. Hier entsteht die echte Route-HA.
- Gateway-Services — CoreDNS (und ggf. die Proxies) als normales Deployment hinter einer stabilen Tailnet-LB-Service-IP (
io.cilium/lb-pool: tailnet). Split-DNS zeigt dann auf diese feste IP statt auf eine pro-Replica-MagicDNS.
Die Reihenfolge ist der Plan
Nichts davon ist dringend — die Datenebene überlebt ja. Aber der Teil, der mir wirklich Sorgen macht, ist nicht das Reschedule-Tempo, sondern die Durability: die ganze Föderation in einer SQLite-Datei auf einer einzigen PVC. Akt 2 ist deshalb der Schritt, der zuerst kommt, sobald Akt 1 das Fundament festgenagelt hat. Der Rest ist Kür — aber eine, die ohne die ersten beiden Akte gar nicht baubar wäre.
