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 ehrlich auflöse, was ich von alldem wirklich will, bleibt 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:
env:
- name: TS_USERSPACE
value: "true"
- name: TS_HOSTNAME
value: "${CLUSTER_NAME}-gateway"
- name: TS_KUBE_SECRET
value: tailnet-gateway-state
- name: TS_EXTRA_ARGS
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:
- name: socat-k8s
image: alpine/socat:1.8.0.3
command: ["socat"]
args:
- "-d"
- "TCP-LISTEN:6443,fork,reuseaddr"
- "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:
.:53 {
forward . 100.100.100.100 { prefer_udp }
cache 30
}
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:
${TAILNET_DOMAIN}:53 {
template IN A ${CLUSTER_NAME}-gateway.${TAILNET_DOMAIN} {
answer "{{ .Name }} 30 IN CNAME tailnet-gateway.tailscale-system.svc.cluster.local."
}
forward . ${TAILNET_DNS_CLUSTERIP} { prefer_udp }
}
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.
