Headscale aus dem Single Point of Failure holen

Headscale ist die Kontrollebene der ganzen Föderation — und heute bewusst eine Single-Replica auf SQLite. Der geplante Weg zu HA: das Image festnageln, SQLite gegen CloudNativePG tauschen, den öffentlichen Endpunkt failover-fähig machen und das gebündelte tailnet-gateway entflechten.
Table of contents
Stand: Design/Roadmap. Das hier ist der geplante Weg, kein Live-Setup. Heute läuft Headscale im Lab bewusst als Single-Replica; die einzelnen Schritte leben als Issues im Backlog (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
  1. SQLite auf einer RWO-PVC. Die gesamte Node-Registry liegt in einer einzigen Datei auf einem ceph-block-Volume.
  2. 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 — bei ReadWriteOnce muss Kubernetes sicher sein, dass das Volume nirgends mehr gemountet ist.
  3. Beweglicher Image-Tag. tag: development ist 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 auf v0.28.0 pinnen (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.
Postgres ist in Headscale ein Backend zweiter Reihe — auf Lab-Maßstab (Dutzende Geräte) völlig unkritisch, aber kein Hochlast-Pfad. Der Gewinn ist Verfügbarkeit, nicht Durchsatz. Das VolSync-Backup der DB bleibt zusätzlich bestehen, auch wenn CNPG die WALs sichert.

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.

flowchart TB DNS["ts.EXTERNAL_DOMAIN — external-dns, health-checked"] subgraph H["hydra"] HGW["external gateway"] --> HHS["Headscale (active)"] end subgraph C["cosmos"] CGW["external gateway"] --> CHS["Headscale (standby)"] end DNS -->|"healthy"| HGW DNS -. "failover" .-> CGW HHS <-->|"PG-Streaming-Replikation (cross-cluster L4)"| CHS

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:

  1. Subnet-Router-Layer — minimale tailscale-only-Pods, N Replicas mit Node-Anti-Affinity, die nur die CIDRs annoncieren. Hier entsteht die echte Route-HA.
  2. 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.
Per-Cluster-Realität: HA lohnt nur auf Multi-Node-Clustern (hydra). Single-Node-cosmos bleibt bei einer Replica — dort ist der ganze Node ohnehin der SPOF. Die Replica-Zahl wird also pro Cluster gated.

Die Reihenfolge ist der Plan

flowchart LR A["Akt 1<br/>Image pinnen + ACL-Drift"] --> B["Akt 2<br/>SQLite → CNPG (stateless)"] B --> C["Akt 3<br/>Endpoint-Failover (GSLB)"] B --> D["Akt 4<br/>Gateway splitten (stabile DNS-IP)"]

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.