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:8472an 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.
args:
- "TCP-LISTEN:2379,fork,reuseaddr"
- "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:
$ cilium clustermesh status
✅ Cluster access information is available:
✅ Service "clustermesh-apiserver" of type "NodePort" found
✅ All 1 nodes are connected to all clusters [min:1 / avg:1.0 / max:1]
🔌 Cluster Connections:
- cosmos: 1/1 configured, 1/1 connected
Zwischenstopp: Talos kennt kein iptables
Bevor es zur Data plane ging, ein Talos-typischer Stein. 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:
env:
- name: TS_DEBUG_FIREWALL_MODE
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:
$ ip route show table 52
10.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:
Pro-Cluster-Knoten-Tags. Jeder Knoten trägt
tag:k8s(alle Cluster) plustag:k8s-<cluster>. Die Tags stecken im Node-Authkey:$ task tailscale:gen-authkey SCOPE=nodes TAGS=tag:k8s,tag:k8s-cosmosGescopte ACL-Grants. Jeder Knoten darf nur das fremde Knoten-LAN erreichen, und nur auf den VXLAN- und Health-Ports:
{ "src": ["tag:k8s-hydra"], "dst": ["cosmos-net"], "ip": ["udp:8472", "tcp:4240", "icmp:*"] }, { "src": ["tag:k8s-cosmos"], "dst": ["hydra-net"], "ip": ["udp:8472", "tcp:4240", "icmp:*"] }--accept-routesauf 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östeapp.hydra.tn...erst zu NXDOMAIN auf.tailscale0hatte nur~.als Routing-Domain, nicht dietn.-Domains — und die breiterethis-is-fine.internal-Zuordnung eines LAN-Links gewann per Longest-Suffix-Match. Erstoverride_local_dns: falseregistriert die spezifischen Domains als eigene Routing-Domains auftailscale0.- 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.
