Mein Lab hängt an einem selbstgehosteten Headscale — einer quelloffenen Reimplementierung der Tailscale-Control-Plane. Das funktioniert wunderbar für Menschen und für die Talos-Knoten. Sobald ich aber wollte, dass auch die Cluster-Dienste sauber im Tailnet auftauchen — die Kubernetes-API, die Talos-API, die clusterinterne Namensauflösung —, stieß ich auf eine Lücke: Der offizielle Tailscale-Kubernetes-Operator ist für die Tailscale-SaaS gebaut, nicht für Headscale. Also habe ich die Teile, die ich tatsächlich brauche, selbst nachgebaut. Das Ergebnis ist ein einziges, gut lesbares StatefulSet: das tailnet-gateway.
Was der Operator kann — und was ich davon brauche#
Der offizielle Operator ist mächtig. Er kennt API-Server-Proxies, Ingress- und Egress-Proxies, Subnet-Router, ProxyGroups und MagicDNS, alles getrieben über CRDs und Reconciler. Diese Mächtigkeit hat aber zwei Haken für meinen Fall: Sie ist eng auf die Tailscale-SaaS samt deren API zugeschnitten, und sie ist deutlich mehr Maschinerie, als ein Homelab-Cluster braucht.
Wenn ich zusammenfasse, was ich von alldem wirklich will, bleibt nur eine kurze Liste:
| Operator-Fähigkeit | Brauche ich? | Mein Ersatz |
|---|---|---|
| Cluster als Tailnet-Node | ja | tailscale-Container (containerboot) |
| Kubernetes-API über Tailnet | ja | socat → API-VIP :6443 |
| Talos-API über Tailnet | ja | socat → Talos-Endpoint :50000 |
| ClusterMesh über Tailnet | optional | socat → clustermesh-apiserver:2379 |
MagicDNS für Pods (*.tif.internal) | ja | CoreDNS-Sidecar + tailnet-dns-Service |
| Per-Service-Ingress/Egress-Proxies | nein | — |
| Subnet-Router auf jedem Knoten | nein | — |
Die untere Hälfte fällt weg. Was übrig bleibt, ist keine generische Operator-Maschine mit CRDs, sondern eine stabile Brücke pro Cluster — und genau die lässt sich als ein einzelnes, vollständig verstandenes StatefulSet ausdrücken.
Anatomie des tailnet-gateway#
Das tailnet-gateway läuft als StatefulSet mit replicas: 1 auf einem Control-Plane-Knoten (mit den passenden Tolerations und system-node-critical). In einem Pod stecken mehrere kleine, jeweils offensichtliche Container:
Der tailscale-Container ist das Herzstück. Er meldet den Cluster über
containerboot
als Tailnet-Knoten ${CLUSTER_NAME}-gateway an — entscheidend ist dabei das TS_EXTRA_ARGS-Flag, das ihn nicht zur Tailscale-SaaS, sondern zu meinem Headscale schickt:
1env:
2 - name: TS_USERSPACE
3 value: "true"
4 - name: TS_HOSTNAME
5 value: "${CLUSTER_NAME}-gateway"
6 - name: TS_KUBE_SECRET
7 value: tailnet-gateway-state
8 - name: TS_EXTRA_ARGS
9 value: "--login-server=https://ts.${EXTERNAL_DOMAIN}"
ceph-block mounten. containerboot kann seinen Tailscale-State aber direkt in einem Kubernetes-Secret (TS_KUBE_SECRET) persistieren, das in etcd liegt. Dafür genügt eine winzige Role mit get/create/update/patch auf Secrets.Die API-Server-Brücke#
Die Kernfunktion, die ich aus dem Operator nachbaue, ist sein API-Server-Proxy: die Kubernetes-API von außerhalb des LAN erreichbar machen, ohne Ports im Router aufzureißen. Beim Operator ist das ein eigener Proxy mit Auth-Logik — bei mir ist es schlicht ein socat, das auf dem Tailnet-Interface lauscht und auf die Cluster-interne API-VIP weiterreicht:
1- name: socat-k8s
2 image: alpine/socat:1.8.0.3
3 command: ["socat"]
4 args:
5 - "-d"
6 - "TCP-LISTEN:6443,fork,reuseaddr"
7 - "TCP:${KUBERNETES_API_VIP}:6443"
Weil der Pod als Tailnet-Knoten ${CLUSTER_NAME}-gateway auftritt und Headscale ihm per MagicDNS einen Namen gibt, lande ich von überall im Tailnet mit einem stabilen Ziel auf der API:
Dieselbe Mechanik trägt auch die Talos-API (:50000) und — sobald aktiviert — den ClusterMesh-Endpunkt (:2379). Drei Brücken, dreimal dasselbe simple Muster.
MagicDNS für Pods: Das Split-Horizon-Problem#
Der kniffligste Teil ist die Namensauflösung. Knoten und Menschen im Tailnet bekommen MagicDNS frei Haus über 100.100.100.100. Pods im Cluster aber nicht — und selbst wenn sie einen Namen auflösen könnten, könnten sie die zurückgelieferten 100.x-Tailnet-IPs gar nicht routen. Ein Pod, der vault.tif.internal aufrufen will, braucht also eine andere Antwort als ein Laptop im Tailnet.
Die Lösung ist Split-Horizon-DNS, aufgeteilt auf zwei CoreDNS-Instanzen. Der CoreDNS-Sidecar im Gateway-Pod ist ein reiner Forwarder auf die MagicDNS-Adresse:
1.:53 {
2 forward . 100.100.100.100 { prefer_udp }
3 cache 30
4}
Davor sitzt ein stabiler ClusterIP-Service tailnet-dns, an den das kube-system-CoreDNS die gesamte tif.internal-Zone delegiert. Für die lokalen Gateway-Hostnamen wird dabei getrickst: Statt der nicht-routbaren Tailnet-IP bekommt der Pod ein CNAME auf den clusterinternen Service:
1${TAILNET_DOMAIN}:53 {
2 template IN A ${CLUSTER_NAME}-gateway.${TAILNET_DOMAIN} {
3 answer "{{ .Name }} 30 IN CNAME tailnet-gateway.tailscale-system.svc.cluster.local."
4 }
5 forward . ${TAILNET_DNS_CLUSTERIP} { prefer_udp }
6}
Der vollständige Auflösungspfad eines Pods sieht damit so aus:
/etc/resolv.conf der Knoten nutzen? Weil Talos’ TS_ACCEPT_DNS die Knoten auf Headscale-MagicDNS zeigen lassen kann — und wenn Headscale mal wackelt, würde damit auch jede öffentliche Auflösung (cert-manager, Flux, Let’s-Encrypt-ACME) sterben. Deshalb forwardet das kube-system-CoreDNS öffentliche Namen explizit an einen festen Lab-Resolver und nur die tif.internal-Zone an den Gateway. Headscale-Ausfälle bleiben so auf das Tailnet beschränkt.Wo der gateway ins Gesamtbild passt#
Mit dem tailnet-gateway fügt sich das Tailnet sauber zusammen: Headscale ist die Control-Plane, die Talos-Knoten und Menschen treten als gewöhnliche Tailnet-Teilnehmer bei, und pro Cluster gibt es genau eine Brücke, die API, Talos, Mesh und DNS bündelt.
HTTP(S)-Anwendungen laufen übrigens nicht über diesen Pfad, sondern über ein eigenes Tailnet-Gateway der Cilium-LB-IPs
(shared-gateway-tailnet). Das tailnet-gateway ist bewusst auf die Steuerungsebene beschränkt: API, Talos, Mesh, DNS.
Fazit#
Den ganzen Operator nachzubauen wäre Unsinn gewesen — die meisten seiner Fähigkeiten brauche ich schlicht nicht. Aber die Handvoll, die ich brauche, ließ sich auf etwas erfreulich Begreifbares eindampfen: ein StatefulSet, ein tailscale-Container gegen Headscale, drei socat-Brücken und ein CoreDNS-Sidecar. Kein CRD, kein Reconciler, kein Blackbox-Verhalten — nur Bausteine, die ich vollständig lesen und im Fehlerfall in dreißig Sekunden im Kopf durchspielen kann. Manchmal ist der beste Operator der, den man nicht braucht.
Nachtrag (25. Juni 2026): Eine feste IP statt einer MagicDNS-Adresse#
Im Artikel hängt das tailnet-gateway noch an einer von Headscale vergebenen 100.64.0.x-Adresse — und genau die war der wunde Punkt: kubeconfig-tailnet, das Split-DNS-Ziel und die ClusterMesh-Peer-Referenz zeigten alle auf eine IP, die sich beim Reschedule ändern konnte. Inzwischen bekommt der Gateway-Service eine stabile Tailnet-LB-IP aus einem Cilium-IPAM-Pool. Der Service ist von ClusterIP auf LoadBalancer umgestellt und über ein Label dem Pool zugeordnet:
1spec:
2 type: LoadBalancer
3 loadBalancerIP: "${CLUSTER_TAILNET_GW_IP}" # = 10.103.<CLUSTER_ID-1>.17, fest
4 labels:
5 io.cilium/lb-pool: tailnet
Die IP wird bei der Cluster-Erstellung deterministisch aus der CLUSTER_ID abgeleitet (10.103.0.17 für hydra) — sie steht also fest, bevor der erste Pod läuft. Damit zeigen Split-DNS und kubeconfig-tailnet auf eine Konstante, nicht mehr auf ein bewegliches Ziel. Auch der DNS-Port :53 hängt jetzt an dieser LB-IP, statt nur cluster-intern zu existieren.
Mitgewandert sind die ACL-Tags. Die feingranularen tag:k8s-api, tag:talos-api, tag:dns aus dem Diagramm oben sind weg — K8s-API, Talos und DNS werden alle über die eine stabile LB-IP erreicht, nicht mehr über Knoten-Tags. Übrig bleiben zwei klar geschnittene Rollen:
tag:fabric— der Subnet-Router. Die annoncierten CIDRs (LAN192.168.100.0/26, Tailnet-LB10.103.x.16/28) werden überautoApproversautomatisch freigegeben, ohne dass ich in Headscale jede Route von Hand abnicken muss.tag:mesh— die cross-cluster-Datenebene. Ein einziger Grant erlaubt dem Gateway, Dienste auf den Tailnet-LB-IPs der Peer-Cluster zu erreichen: Postgres:5432, Garage-S3:3900/3901, Ceph:3300/6789/6800-7300.
Das ist genau die Entflechtung, die ich im HA-Plan für Headscale als „Akt 4" skizziert hatte — die stabile Tailnet-LB-IP für die Gateway-Services steht jetzt.
