Cilium ClusterMesh durchs Tailnet — und warum ich es wieder rausgerissen habe

Zwei Talos-Cluster mit Cilium ClusterMesh über das Headscale-Tailnet verbinden — eine Sammlung der Fallstricke, der Erkenntnis, die die Datenebene zu retten schien, und des Outages, der mich am Ende zur Föderation ohne Node-Mesh zurückbrachte.
Table of contents

Im vorigen Beitrag habe ich das tailnet-gateway gebaut — die kleine Brücke, die API, Talos und DNS eines Clusters ins Headscale-Tailnet bringt. Das hier ist die Fortsetzung. Mein Lab hat inzwischen einen zweiten Cluster (cosmos bei Hetzner, neben dem bare-metal hydra), und beide sollten föderiert werden: Pods und Services clusterübergreifend, mit Cilium ClusterMesh .

Die offizielle Anleitung sagt: clustermesh-apiserver per LoadBalancer exponieren, dafür sorgen, dass sich die Knoten beider Cluster direkt sehen, fertig. Genau das wollte ich nicht. Ich habe schon ein verschlüsseltes WireGuard-Fabric über Headscale — die Föderation soll da durch, nicht über öffentliche IPs. Diese eine Entscheidung hat mich durch ein halbes Dutzend Fallstricke geführt. Hier sind sie.

Zwei Ebenen#

ClusterMesh hat zwei Ebenen, und beide müssen durchs Tailnet:

  • Control plane: Jeder Cilium-Agent liest den clustermesh-apiserver (ein etcd) des Peer-Clusters, um dessen Identities, Endpoints und Services zu lernen. Ein paar TCP-Verbindungen — die kriegt man über eine Brücke.
  • Data plane: Der eigentliche Pod-zu-Pod-Verkehr. Cilium läuft im tunnel-Modus mit VXLAN, kapselt also jedes Paket in UDP :8472 an die InternalIP des Ziel-Knotens. Das ist Linienverkehr zwischen allen Knoten — das braucht echte Routen.

Die Control plane war schnell erledigt, hatte aber zwei Fallstricke, die nichts mit Cilium zu tun hatten. Die Data plane sah lange aus wie eine Sackgasse.

Control plane: zwei Fallen, beide nicht von Cilium#

Den Mesh-Endpunkt exponiere ich über das tailnet-gateway: ein weiterer socat-Container reicht :2379 an den clustermesh-apiserver weiter, genau wie für API und Talos.

Falle 1: Flux frisst ${target}. Mein socat-Argument enthielt eine Shell-Variable ${target}. Diese Manifeste laufen aber durch Flux’ postBuild.substituteFrom, und Flux ersetzt ${...} vor der Shell. Aus TCP:${target} wurde TCP: — und socat quittierte mit dem herrlich nichtssagenden wrong number of parameters. Fix: doppeltes Dollar, damit Flux das Literal stehen lässt.

1args:
2  - "TCP-LISTEN:2379,fork,reuseaddr"
3  - "TCP:$${target}:2379"   # $$ -> Flux lässt ${target} für die Shell stehen

Falle 2: TS_ACCEPT_DNS kapert die resolv.conf. Sobald der Tailscale-Container im Gateway-Pod mit TS_ACCEPT_DNS=true lief, schrieb er die MagicDNS-Adresse 100.100.100.100 in die /etc/resolv.conf des Pods. Der socat-Sidecar daneben konnte daraufhin ...svc.cluster.local nicht mehr auflösen — Name does not resolve. Der Tailnet-Resolver kennt eben kein cluster.local.

Fix war TS_ACCEPT_DNS=false plus ein eigener CoreDNS im Gateway, der für die internen Zonen autoritativ ist. Erst danach wurde die Control plane grün:

1$ cilium clustermesh status
2✅ Cluster access information is available:
3✅ Service "clustermesh-apiserver" of type "NodePort" found
4✅ All 1 nodes are connected to all clusters [min:1 / avg:1.0 / max:1]
5🔌 Cluster Connections:
6  - cosmos: 1/1 configured, 1/1 connected

Zwischenstopp: Talos kennt kein iptables#

Bevor es zur Data plane ging, ein Talos-typischer Stolperstein. Der Tailscale-Container will per Default eine iptables-Regel setzen. Talos läuft aber auf einem nftables-only-Kernel — die Legacy-Module iptable_filter/iptable_nat gibt es schlicht nicht, also scheitert das.

Mein erster Reflex, die Module per machine.kernel.modules nachzuladen, ging ins Leere: Man kann nicht laden, was der Kernel nicht hat. Der richtige Hebel ist eine Zeile, die man kennen muss:

1env:
2  - name: TS_DEBUG_FIREWALL_MODE
3    value: "nftables"

Data plane: die Sackgasse, die keine war#

Hier wurde es zäh. Cilium kapselt Pod-Verkehr per VXLAN an die InternalIP des Ziel-Knotens. Ich schaute nach, welche IP cosmos dafür ankündigte — es war die öffentliche Hetzner-IP. Unverschlüsselt übers Internet, und obendrein per Firewall geblockt. Das Tailnet wurde komplett umgangen.

Der naheliegende Versuch, Cilium die Tailnet-IP als Knoten-IP zu geben, stirbt an zwei Stellen gleichzeitig: Der Hetzner Cloud-Controller-Manager erwartet im LAN-Modus die private IP, und clusterintern würde der Verkehr doppelt gekapselt (VXLAN in WireGuard für Knoten, die sich längst direkt sehen). Sackgasse.

Der saubere Weg führte über cosmos auf LAN-only: private Knoten-IPs (10.210.1.0/24), erreichbar nur übers Tailnet. Jetzt war die Tunnel-Endpunkt-IP eine private — aber damit ein hydra-Knoten sie erreicht, muss er 10.210.1.0/24 über tailscale0 routen. Und genau hier hatte ich mich verrannt. Ich war überzeugt, dass sich Tailnet-Routen nicht so fein steuern lassen, dass ein Knoten nur das fremde Netz installiert und nicht sein eigenes — sonst routet er seinen eigenen LAN-Verkehr im Kreis durchs Tailnet. Ich hielt die Data plane für tot und wich auf eine reine tailnet-LoadBalancerClass für Service-Exposition aus.

Das war voreilig.

Die Erkenntnis: Headscale gibt Routen per ACL frei, nicht per Approval#

Beim Debuggen von etwas ganz anderem fiel mir auf: Meine Workstation hatte alle angekündigten Subnet-Routen in der Tabelle, ein getaggtes Peer-Gateway aber nicht — obwohl beide dieselben Routen approved hatten.

Das war der Schlüssel. In Headscale macht das Approven einer Route sie nur verfügbar. Wer sie tatsächlich installiert, entscheidet die ACL: Ein Peer zieht eine Route nur, wenn er per Grant Zugriff auf das Zielnetz hat. Meine Workstation (autogroup:member) hatte breite member -> *-Grants und zog alles. Ein getaggter Knoten mit eng gefassten Grants zieht nur, was ihm explizit erlaubt ist.

Nachprüfen lässt sich das direkt auf dem Knoten — in Tabelle 52 landen nur die Routen, die die ACL erlaubt:

1$ ip route show table 52
210.210.1.0/24 dev tailscale0

Damit war die Data plane nie tot. Ich hatte nur das falsche Werkzeug für die Granularität gesucht.

Die Lösung: Pro-Cluster-Tags und die Selbst-Route-Falle#

Drei Teile:

  1. Pro-Cluster-Knoten-Tags. Jeder Knoten trägt tag:k8s (alle Cluster) plus tag:k8s-<cluster>. Die Tags stecken im Node-Authkey:

    1$ task tailscale:gen-authkey SCOPE=nodes TAGS=tag:k8s,tag:k8s-cosmos
    
  2. Gescopte ACL-Grants. Jeder Knoten darf nur das fremde Knoten-LAN erreichen, und nur auf den VXLAN- und Health-Ports:

    1{ "src": ["tag:k8s-hydra"],  "dst": ["cosmos-net"], "ip": ["udp:8472", "tcp:4240", "icmp:*"] },
    2{ "src": ["tag:k8s-cosmos"], "dst": ["hydra-net"],  "ip": ["udp:8472", "tcp:4240", "icmp:*"] }
    
  3. --accept-routes auf den Knoten. Erst damit installiert ein Knoten die freigegebenen Routen überhaupt.

Der entscheidende Punkt ist, was nicht im Grant steht: Ein hydra-Knoten bekommt keinen Grant auf sein eigenes 192.168.100.0/26. Also installiert er die Route nicht und schickt eigenen LAN-Verkehr weiter direkt über eth0. Damit ist die Selbst-Route-Falle entschärft — würde ein Knoten sein eigenes LAN über tailscale0 routen (Tabelle 52 mit Regel 5270 vor main), liefe lokaler Verkehr im Kreis.

Der Pfad eines Pakets sieht dann so aus: Pod auf hydra schickt an einen cosmos-Pod, Cilium kapselt VXLAN an die cosmos-InternalIP 10.210.1.101. Der hydra-Knoten routet 10.210.1.0/24 über tailscale0 zum cosmos-gateway (dem Subnet-Router), der es ins cosmos-LAN weiterreicht. Zurück symmetrisch. Pod erreicht Pod über zwei Cluster, getunnelt durch WireGuard, ohne ein Paket übers offene Internet — und ohne dass ein Knoten je seine eigene LAN-Route ans Tailnet verliert.

Nebenbei gelernt: Tailscale-ACLs kennen kein UND#

Bei der Tag-Taxonomie lief ich in eine Wand. Ich wollte ausdrücken: „dieser Knoten gehört zu cosmos und ist ein Kubernetes-Knoten". In Tailscale/Headscale geht das nicht — eine src/dst-Liste ist immer ein ODER, ein Gerät matcht, wenn es irgendeinen der Tags trägt. Tag-Intersektion gibt es nicht.

Die idiomatische Antwort ist, das UND in einen zusammengesetzten Tag zu backen. tag:k8s-cosmos ist das vorberechnete „cosmos und Knoten". Was nach umständlicher Namensgebung aussieht, ist die einzige Art, in einer ODER-only-Grammatik eine Konjunktion auszudrücken. Daraus wurde eine kleine Taxonomie: tag:k8s (alle Knoten), tag:k8s-<cluster> (Data-plane-Scope), tag:k8s-cp-<cluster> (Control-Plane), tag:k8s-api (das Gateway selbst).

Zwei DNS-Fallen am Rande#

Parallel lief ein DNS-Umbau — weg von purem MagicDNS, hin zu einer eigenen Zone tn.this-is-fine.internal pro Cluster. Zwei Fallen daraus, weil sie für Split-DNS typisch sind:

  • override_local_dns: false. Von der Workstation aus löste app.hydra.tn... erst zu NXDOMAIN auf. tailscale0 hatte nur ~. als Routing-Domain, nicht die tn.-Domains — und die breitere this-is-fine.internal-Zuordnung eines LAN-Links gewann per Longest-Suffix-Match. Erst override_local_dns: false registriert die spezifischen Domains als eigene Routing-Domains auf tailscale0.
  • autoApprovers feuern nur bei Änderung. Eine Route, die schon angekündigt war, bevor ihr Approver existierte, bleibt „available but not approved" — rückwirkend passiert nichts. Dagegen half ein kleiner, idempotenter Task, der approved == advertised über alle Gateways abgleicht.

Und zum Schluss ein Terraform-Hinterhalt#

Um cosmos die Tailscale-Extension zu geben, fügte ich siderolabs/tailscale zu den Talos-System-Extensions hinzu. Das ändert das Image-Factory-Schematic, also den Hetzner-Snapshot, also die Image-ID. Und image auf hcloud_server ist ForceNew.

Ein arglos getipptes tofu apply hätte den Server zerstört und neu gebaut. Eine System-Extension gehört nicht über Terraform auf einen laufenden Knoten, sondern per talosctl upgrade --image (in-place); die Maschinen-Config (ExtensionServiceConfig, certSANs) kommt per talosctl apply-config. Terraform zeigt danach Drift, den man mit ignore_changes = [image] behandelt — nicht mit einem Rebuild.

Fazit#

ClusterMesh komplett durchs Tailnet zu fahren ist möglich, sauber und am Ende erfreulich unspektakulär: Die Föderation ist „echt", Pod erreicht Pod, kein Paket verlässt das WireGuard-Fabric. Aber der Weg dahin bestand fast nur aus Reibung zwischen Schichten — Flux, das Shell-Variablen frisst; ein Talos-Kernel ohne iptables; ein Tailscale-Sidecar, der die resolv.conf kapert; ACLs ohne UND; Terraform, das ein Image für unveränderlich hält.

Die eine Erkenntnis, die alles zusammenhielt, kam fast beiläufig: Headscale gibt Routen per ACL frei, nicht per Approval. Was ich für eine harte Grenze des Tailnets gehalten hatte, war in Wahrheit ein präziser Hebel — fein genug, um jedem Knoten exakt die fremde Route zu geben und die eigene vorzuenthalten.

Nachtrag: der Outage, und warum ich das alles wieder rausgerissen habe#

Ein paar Tage später hat mich genau dieser Aufbau umgehauen — und am Ende habe ich ClusterMesh komplett verworfen. Der Reihe nach.

Die Selbst-Route-Falle war nicht entschärft, nur verschoben. Ich war stolz darauf, dass meine Data-plane-Grants keinem Knoten sein eigenes LAN geben. Aber --accept-routes ist „alles oder nichts" und ACL-gated: ein Knoten installiert jede freigegebene Route, auf deren Zielnetz ihn irgendein Grant lässt. Und ich hatte andere Grants übersehen — * → hydra-gw (und die API-Grants, die auf LAN-/32s der Control Plane zeigten). hydra-gw (192.168.100.1) liegt in 192.168.100.0/26. Also zogen die hydra-Knoten, kaum dass ich sie mit neuen Keys re-authentifiziert hatte, doch ihr eigenes LAN über tailscale0. kubelet→API lief im Kreis, alle Worker gingen NotReady, Headscale (das auf einem Worker lief) ging mit runter, und Ceph verlor das Quorum. Genau die Schleife, die ich für gebannt hielt.

Die Recovery war zäh — und lehrreich: ein In-place-apply-config half nicht, weil der laufende tailscaled die gecachte Route neu setzte; nur ein sauberer Reboot (per BMC) brachte einen Knoten ohne die Route hoch. Headscale musste ich währenddessen aktiv unten halten, sonst pushte es die Route sofort wieder. Mit den Knoten kamen die mons zurück, das Quorum heilte.

Dann die unbequeme Frage: ist das Mesh den Aufwand überhaupt wert? Bei der Ursachensuche habe ich nachgelesen, was ich von Anfang an hätte nachlesen sollen: Cilium ClusterMesh nimmt zwingend die Knoten-InternalIP als Tunnel-Endpunkt, und es gibt kein per-Node- oder per-Ziel-Override. Übers Tailnet bleiben damit nur zwei Formen, und beide sind genau die Sackgassen aus diesem Beitrag:

  • InternalIP = Tailnet-IP → Cilium-VXLAN in Tailscale-WireGuard genestet (1280 MTU, Fragmentierung, Instabilität) und der Hetzner-CCM bricht, weil er Knoten per InternalIP matcht.
  • InternalIP = LAN + Subnet-Routes → exakt die --accept-routes-Selbst-Route-Falle, die mich gerade umgehauen hat.

Es gibt sogar ein Praxis-Writeup mit demselben Stack (Cilium ClusterMesh + Tailscale, Multi-Subnet), das an der Nested-Overlay-Wand landete und Tailscale am Ende auf reinen Control-Plane-Verkehr beschränkte. Für ein Lab mit einem echten Cluster (hydra) und einem Wegwerf-Test (cosmos) ist clusterübergreifendes Pod-Mesh den Preis nicht wert.

Also: Föderation statt Mesh. Die Knoten kommen komplett aus dem Tailnet raus — kein Node-tailscaled, keine siderolabs/tailscale-Extension mehr im Schematic. Damit ist die ganze Hazard-Klasse nicht entschärft, sondern unmöglich: Was nicht im Tailnet ist, kann sein LAN nicht ins Tailnet routen. Pro Cluster bleibt nur das tailnet-gateway (ein Pod) als Subnet-Router und API-Proxy. Clusterübergreifend läuft alles north-south über dieses Gateway — ein Dienst auf cosmos ist von hydra aus schlicht https://app.cosmos.tn.this-is-fine.internal. Und --accept-routes darf das Gateway behalten: ein Pod ist kein kubelet, er kann kein Knoten-LAN selbst-routen.

Die Erkenntnis, dass Headscale Routen per ACL freigibt, war übrigens richtig. Sie war nur das falsche Werkzeug für das richtige Problem — präzise genug, um mich zu überzeugen, einen fundamental fragilen Mechanismus wieder scharf zu schalten. Die eigentliche Lektion ist banaler: Setz keine kubelets auf ein Fabric, auf dem sie ihr eigenes LAN routen können. Das Tailnet ist für die Kontrollebene und für north-south-Verkehr großartig. Die Pod-Datenebene gehört da nicht rein.