Der Edge eines Clusters — die Stelle, an der externer Traffic auf die Workloads trifft — ist eine der Komponenten, die man besser einmal richtig baut. Bei mir hat dieser Punkt eine kleine Evolution hinter sich: von Traefik als klassischem Ingress-Controller über die Adoption der Gateway-API bis hin zu Envoy Gateway , das diese API heute exklusiv umsetzt. Dieser Beitrag ist eine ausführliche Tour durch den Aufbau.
Von Traefik zur Gateway-API#
Angefangen hat alles mit Traefik als Ingress-Controller — die naheliegende Wahl, charmante Dashboard-UI, schnell aufgesetzt. Mit der Zeit stieß ich aber an die konzeptionellen Grenzen der Ingress-Ressource: Sie ist ein einziges, überladenes Objekt, dessen Lücken jeder Controller mit proprietären Annotations stopft. Routing-Logik, die eigentlich deklarativ sein sollte, verkam zu einer Sammlung anbieterspezifischer Magie in metadata.annotations.
Als Traefik begann, die Gateway-API zu unterstützen, bin ich umgestiegen — und schließlich ganz zur Gateway-API als alleinigem Modell übergegangen. Der entscheidende Unterschied ist die Rollentrennung: Die API zerlegt das Ingress-Problem in saubere, getrennt verantwortbare Ressourcen.
GatewayClass, Gateway und HTTPRoute haben jeweils einen klaren Eigentümer und ein klares Schema — kein Annotation-Wildwuchs mehr, sondern typisierte Felder, die jeder Controller gleich interpretiert. Genau diese Trennung macht den Edge im GitOps-Repo lesbar und portabel.
Warum Envoy Gateway#
Envoy Gateway setzt die Gateway-API auf Basis des battle-tested Envoy-Proxy um — desselben Proxys, der unzählige Service-Meshes und Cloud-Load-Balancer antreibt. Was mich überzeugt hat, ist weniger die Datenebene (die ist über jeden Zweifel erhaben) als die Erweiterungspunkte: ClientTrafficPolicy, BackendTrafficPolicy, SecurityPolicy und EnvoyProxy heben genau die Envoy-Fähigkeiten in die Gateway-API, die man im Betrieb wirklich braucht — und an die man bei Traefik nur über Umwege kam.
Drei Welten, drei Shared-Gateways#
Mein Edge ist nicht ein Gateway, sondern drei — je eines für eine Erreichbarkeits-Domäne, jeweils mit eigener GatewayClass, eigenem EnvoyProxy (zwei Replicas) und einer eigenen LoadBalancer-IP aus dem Cilium-BGP-Pool :
| Gateway | Domäne | TLS-Issuer | LB-IP | Erreichbar von |
|---|---|---|---|---|
shared-gateway-external | *.this-is-fine.io, *.this-is-fine.social | Let’s Encrypt | …0.1 | Internet |
shared-gateway-internal | *.this-is-fine.internal | Vault-PKI | …0.2 | LAN |
shared-gateway-tailnet | *.tif.internal | Vault-PKI | …0.3 | Tailnet |
Jeder Listener terminiert TLS mit einem Zertifikat, das cert-manager passend zur Domäne ausstellt — öffentlich per ACME für das externe Gateway, aus der internen Vault-PKI für die beiden privaten. Ein angehängter Redirect-Filter zwingt jeden :80-Request per 301 auf HTTPS:
1- name: "${EXTERNAL_DOMAIN//./-}-https"
2 protocol: HTTPS
3 port: 443
4 hostname: "*.${EXTERNAL_DOMAIN}"
5 tls:
6 certificateRefs:
7 - { kind: Secret, name: "wildcard-${EXTERNAL_DOMAIN//./-}-tls" }
Wichtig ist die Abgrenzung zum gleichnamigen Nachbarn: shared-gateway-tailnet transportiert HTTP(S)-Anwendungen über das Tailnet. Die Kubernetes- und Talos-API dagegen läuft über das tailnet-gateway
— zwei verschiedene Pfade, die man nicht verwechseln sollte.
OIDC für Apps, die kein SSO können#
Das für mich wertvollste Feature ist die SecurityPolicy: Sie schaltet einem HTTPRoute ein vollständiges OIDC-Login vor, bevor der Request überhaupt das Backend erreicht. Damit bekommt selbst ein Dienst, der von Haus aus keinerlei Authentifizierung kennt, ein sauberes Pocket-ID -Login vorgesetzt — die Auth-Logik wandert aus der Anwendung heraus in den Edge:
1apiVersion: gateway.envoyproxy.io/v1alpha1
2kind: SecurityPolicy
3metadata:
4 name: grafana-oidc
5spec:
6 targetRefs:
7 - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: grafana-internal }
8 oidc:
9 provider:
10 issuer: "https://auth.${EXTERNAL_DOMAIN}"
11 clientID: "${ENVOY_GATEWAY_OIDC_CLIENT_ID}"
12 clientSecret:
13 name: grafana-oidc-secret
14 redirectURL: "https://grafana.${INTERNAL_DOMAIN}/login/generic_oauth"
15 logoutPath: "/logout"
16 cookieDomain: "${INTERNAL_DOMAIN}"
Envoy übernimmt den kompletten Authorization-Code-Flow gegen Pocket-ID, setzt das Session-Cookie und reicht erst den authentifizierten Request weiter. Für ein Homelab voller kleiner Dienste, die SSO niemals selbst implementieren würden, ist das ein enormer Hebel: Ein einziges SecurityPolicy-Manifest, und der Dienst sitzt hinter zentralem SSO.
Feintuning per Policy#
Zwei weitere Policy-Typen erlauben Eingriffe in die Datenebene, ohne den Proxy anzufassen. Eine ClientTrafficPolicy regelt das Verhalten zum Client hin — bei mir vor allem HTTP/2-Tuning und großzügige Idle-Timeouts, damit langlebige Verbindungen wie Server-Sent-Events oder Streaming nicht vorzeitig gekappt werden:
1kind: ClientTrafficPolicy
2spec:
3 targetRef: { kind: Gateway, name: shared-gateway-external }
4 timeout:
5 http:
6 idleTimeout: 3600s
7 streamIdleTimeout: 900s
8 http2:
9 maxConcurrentStreams: 100
Eine BackendTrafficPolicy wirkt umgekehrt zum Backend hin. Das schönste Beispiel ist meine Zot-Registry : Image-Layer können hunderte Megabyte groß sein und Pushes entsprechend lange dauern. Hier hebe ich gezielt das Request-Buffer-Limit und die Timeouts an, was ich für keinen anderen Backend tun möchte:
1kind: BackendTrafficPolicy
2metadata:
3 name: zot-timeouts
4spec:
5 targetRefs:
6 - { kind: HTTPRoute, name: zot }
7 requestBuffer:
8 limit: 2Gi
9 timeout:
10 http:
11 requestTimeout: 600s
12 connectionIdleTimeout: 3600s
Diese chirurgische Präzision — eine spezielle Einstellung exakt an einem Backend, der Rest unberührt — ist genau das, was die alte annotationsbasierte Ingress-Welt so mühsam machte.
Ein Dienst, zwei Welten: Headscale#
Wie weit die Rollentrennung trägt, zeigt Headscale am schönsten: ein Service, aber die öffentliche Client-API und die Operator-UI hängen an unterschiedlichen Gateways — und damit an unterschiedlichen Vertrauensgrenzen.
| Oberfläche | Hostname | Gateway | Port |
|---|---|---|---|
| Clients / API | ts.this-is-fine.io | shared-gateway-external (Let’s Encrypt) | :8080 |
| Operator-UI | ts.this-is-fine.internal | shared-gateway-internal (Vault-PKI) | :8081 |
Zwei HTTPRoutes auf dasselbe Backend, und die Admin-Oberfläche ist schlicht nicht aus dem Internet erreichbar. Die Client-API hat aber eine Eigenheit: Das Tailscale-Control-Protokoll und das DERP-Relay verlangen HTTP-Upgrades auf eigene Protokolle. Envoy lässt sich das punktgenau pro Route beibringen — wieder über eine BackendTrafficPolicy:
1kind: BackendTrafficPolicy
2metadata:
3 name: tailscale-upgrade-policy
4spec:
5 targetRefs:
6 - { kind: HTTPRoute, name: headscale-https-external }
7 httpUpgrade:
8 - type: "tailscale-control-protocol"
9 - type: "derp"
Ein Stolperstein aus der Praxis sitzt genau hier: Eine HTTPRoute bindet nicht nur an ein Gateway, sondern über sectionName an einen konkreten Listener. Greift der daneben, bleibt die Route stumm ungebunden — Accepted=False an der Route, während die App-Pods kerngesund weiterlaufen und nichts ins Log schreiben.
Fazit#
Envoy Gateway ist bei mir weit mehr als ein Ingress-Ersatz: Es ist die zentrale Richtungsentscheidung am Edge. Drei sauber getrennte Erreichbarkeits-Domänen, zentrales OIDC für Dienste, die es selbst nie könnten, und backend-genaues Feintuning — alles in typisierten, GitOps-tauglichen Ressourcen statt in Annotation-Magie. Traefik hat mir den Weg in die Gateway-API geebnet; geblieben bin ich bei der API, nicht beim Controller.
