Waarom kube-proxy faalt voor gRPC en WebSocket
kube-proxy neemt een load-balancing-beslissing eenmaal per TCP-verbinding. In iptables-modus (standaard tot Kubernetes 1.30) en in de nieuwere nftables-modus (standaard vanaf 1.31) installeert het DNAT-regels die afgaan bij een nieuw TCP SYN-pakket. De conntrack-tabel van de kernel onthoudt de bestemmingspod, en elk volgend pakket op die verbinding volgt dezelfde conntrack-entry. kube-proxy wordt niet meer geraadpleegd.
Voor HTTP/1.1 werkt dat prima. Clients openen veel kortstondige verbindingen, en elke nieuwe verbinding triggert een verse DNAT-beslissing. Verkeer spreidt zich vanzelf over pods.
gRPC is gebouwd op HTTP/2. HTTP/2 opent bewust een enkele langdurige TCP-verbinding per client-server-paar en multiplext alle RPC's als streams over die ene verbinding. De DNAT-beslissing valt een keer, bij het opzetten van de verbinding. Elke volgende RPC gaat over diezelfde verbinding naar dezelfde pod. Schaal de backend op naar tien replica's, en negen doen niks.
WebSocket heeft hetzelfde probleem. Een WebSocket-upgrade maakt een persistente TCP-verbinding die minuten, uren of dagen open blijft. Alle berichten gaan naar de ene pod die de initiële handshake accepteerde.
IPVS-modus lost dit niet op. nftables-modus ook niet. Het probleem is structureel: L4 load balancing verdeelt verbindingen, niet verzoeken.
L4 versus L7 load balancing
Het verschil is belangrijk, want de verkeerde laag kiezen is precies de oorzaak van het probleem.
| Eigenschap | L4 (transport) | L7 (applicatie) |
|---|---|---|
| OSI-laag | TCP/UDP | HTTP, gRPC, WebSocket |
| Beslissingseenheid | Per TCP-verbinding | Per HTTP-verzoek of gRPC-stream |
| Protocolbewustheid | Geen; stuurt ruwe pakketten door | Parseert headers, methods, paden |
| TLS-afhandeling | Passthrough of terminatie | Moet termineren om te inspecteren |
| Kosten | Laag (kernel-space NAT) | Hoger (user-space parsing) |
| gRPC-gedrag | Een pod per channel | Per-RPC-verdeling over pods |
Een L7 load balancer termineert de TCP-verbinding van de client, opent afzonderlijke HTTP/2-verbindingen naar elke backend-pod en verdeelt individuele gRPC-streams over die backend-verbindingen. De client ziet een verbinding; de proxy spreidt intern. Dat is waarom L7 het probleem oplost en L4 niet.
Headless Service met client-side load balancing
De lichtste oplossing. Geen proxy, geen mesh, geen extra infrastructuur. Vereist wel controle over de gRPC-clientcode.
Een standaard ClusterIP Service resolvet naar een enkel virtueel IP. kube-proxy handelt de verdeling erachter af. Een headless Service (spec.clusterIP: None) heeft geen VIP. DNS geeft meerdere A-records terug, een per ready pod. De client krijgt alle pod-IP's en verdeelt zelf zijn verbindingen.
gRPC-clients ondersteunen een round_robin load-balancing-policy die een subchannel maakt naar elk geresolved IP en RPC's over die subchannels verdeelt:
// Go gRPC-client met DNS-gebaseerde round-robin (grpc-go 1.62+)
conn, err := grpc.Dial(
"dns:///my-grpc-service-headless.prod.svc.cluster.local:50051",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
Datadog draait dit patroon op tienduizenden pods die biljoenen datapunten per dag verwerken, zonder service mesh.
De MaxConnectionAge-truc
Headless + round-robin heeft een valkuil: gRPC-clients doen pas een nieuwe DNS-lookup als een verbinding sluit. Nieuwe pods die bijkomen na een scale-out krijgen geen verkeer totdat bestaande verbindingen opnieuw opgezet worden. Jamf Engineering loste dit op met drie regels serverconfiguratie:
// Forceer de server om verbindingen na 30 seconden te sluiten (grpc-go keepalive)
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionAge: 30 * time.Second, // harde limiet op verbindingslevensduur
MaxConnectionAgeGrace: 10 * time.Second, // respijt voor lopende RPC's
})
Gecombineerd met minReadySeconds: 30 op de Deployment (zodat oude DNS-entries verlopen voordat verkeer verschuift) zorgt dit voor periodieke DNS-re-resolutie en gelijkmatige verdeling over alle pods, inclusief nieuwe.
Valkuilen
pick_firstals standaard. gRPC-clients gebruiken standaardpick_first, dat verbindt met het eerste DNS-resultaat en de rest negeert. Overschrijf dit altijd naarround_robinbij headless Services.- Java DNS-caching. Java cachet DNS standaard 30 seconden (of oneindig met een security manager). Go heeft geen DNS-cache. Controleer het gedrag van jouw taal-SDK.
- IP-recycling. Kubernetes kan het IP van een verwijderde pod binnen seconden toewijzen aan een andere Service. Datadog documenteerde gevallen waarin gRPC-clients na een rollout stilletjes naar de verkeerde backend verbonden. Verhelp dit met TLS-serveridentiteitsverificatie en
MaxConnectionAge. - Conntrack-uitputting. Agressieve reconnectieparameters (Datadog had 300ms-intervallen bij 900 clients) kunnen genoeg SYN-verkeer genereren om VPC connection tracking-tabellen te verzadigen, waardoor legitieme verbindingen gedropt worden.
L7-proxy aan de edge
Voor gRPC-services die buiten het cluster bereikbaar zijn, handelt een L7 Ingress-controller of Gateway API-implementatie per-RPC-verdeling af zonder aanpassingen aan applicatiecode.
NGINX Ingress Controller ondersteunt gRPC native. Het omzeilt het Kubernetes Service-VIP volledig en abonneert zich op de Endpoints API om zelf een upstream pool van pod-IP's te beheren:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grpc-ingress
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
spec:
ingressClassName: nginx
rules:
- host: grpc.jouwsite.nl
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-grpc-service
port:
number: 50051
GRPCRoute (Gateway API) is de toekomstbestendige aanpak. Het is GA sinds Gateway API v1.1.0 en biedt gRPC-native routing: match op servicenaam en method, retry op specifieke gRPC-statuscodes, en gRPC-specifieke metrics. Implementaties die het ondersteunen zijn onder andere Envoy Gateway, Istio, NGINX Gateway Fabric, Cilium en AWS Load Balancer Controller.
Deze oplossingen werken goed voor edge/ingress-verkeer. Voor service-to-service (east-west) gRPC-verkeer binnen het cluster helpt een proxy aan de edge niet. Dan heb je client-side balancing of een service mesh nodig.
Service mesh als productieoplossing
Een service mesh injecteert een proxy die al het pod-verkeer onderschept en op L7 opereert. Geen codewijzigingen nodig. De mesh-proxy opent aparte HTTP/2-verbindingen naar elke backend-pod en verdeelt individuele gRPC-streams: precies het L7-gedrag dat het probleem vereist.
Linkerd injecteert een ultralichte Rust-gebaseerde proxy per pod. Het doet automatisch request-level load balancing voor HTTP/2 en gRPC met EWMA (Exponentially Weighted Moving Average) van responsetijden, en verschuift verkeer weg van trage pods in real time. Overhead: <1ms p99 latency, <10 MiB RSS per pod. Een benchmark uit 2024 mat +33% latency onder mTLS, het laagst van de drie grote meshes.
Istio injecteert een Envoy-sidecar per pod. Configureer gRPC-balancing via een DestinationRule:
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: my-grpc-service
spec:
host: my-grpc-service
trafficPolicy:
loadBalancer:
simple: LEAST_REQUEST # aanbevolen voor gRPC
connectionPool:
http:
http2MaxRequests: 1000
maxRequestsPerConnection: 100 # forceert verbinding-recycling
Het sidecar-model voegt +166% latency toe onder mTLS. Istio Ambient-modus (per-node ztunnel + optionele L7 waypoint-proxy, geen sidecar) brengt dat terug naar +8%.
Cilium biedt een per-node Envoy-proxy (gedeeld door alle pods op de node) die je activeert met een enkele annotatie: service.cilium.io/lb-l7: enabled. gRPC L7 load balancing staat gedocumenteerd als beta. Voor teams die Cilium al als CNI draaien (GKE Dataplane V2, AKS met Azure CNI Powered by Cilium) voorkomt dit het toevoegen van een tweede mesh.
Een mesh kiezen voor gRPC
| Mesh | gRPC LB-status | mTLS-latency-overhead | Geschikt voor |
|---|---|---|---|
| Linkerd | Stabiel | +33% | Laagste overhead, simpelste operations |
| Istio Ambient | Stabiel | +8% | Geavanceerd verkeersbeleid, minder overhead dan sidecar |
| Cilium L7 | Beta | +99% | eBPF-native shops die Cilium CNI al draaien |
Proxyless gRPC met xDS
Voor teams die mesh-achtige endpoint-discovery willen zonder sidecar-overhead implementeert gRPC native een xDS-client (ondersteund in Go, Java, C++, Python sinds gRPC 1.30). Met het xds:/// URI-scheme verbindt de gRPC-client met een xDS control plane, ontvangt realtime pod-IP-updates via Endpoint Discovery Service (EDS) en doet client-side load balancing rechtstreeks.
Databricks bouwde dit: een custom EDS-server die Kubernetes EndpointSlice-objecten watcht en pod-IP's met locality-aware weights naar gRPC-clients stuurt. De xDS-agent gebruikt <25 MiB geheugen en <0,1% CPU, vergeleken met 50-100 MiB+ voor een Envoy-sidecar.
De afweging is reeel: je moet elke gRPC-client instrumenteren met xDS-bootstrapconfiguratie, en niet-gRPC-services hebben nog steeds een traditionele proxy nodig. Dit past goed bij grootschalige gRPC-zware platforms waar sidecar-overhead een materieel kostenplaatje heeft.
WebSocket: een ander probleem
WebSocket en gRPC delen dezelfde oorzaak (persistente verbinding pinned aan een pod) maar verschillen in wat een goede oplossing is.
gRPC multiplext onafhankelijke RPC's over een verbinding. De fix is RPC's verdelen over pods. WebSocket draagt een enkele stateful stream: chatsessies, geabonneerde topics, gamestate. Herverbinden met een andere pod breekt de applicatie, tenzij die pod toegang heeft tot dezelfde state.
Bij WebSocket is het doel niet "verdeel berichten over pods." Het is "plaats nieuwe verbindingen gelijkmatig en handel herverbindingen netjes af."
Ingress-timeouts en affinity-configuratie
Standaard NGINX Ingress-timeouts (60 seconden) droppen WebSocket-verbindingen stilletjes. Verhoog ze:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ws-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "WSROUTE"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
Cookie-gebaseerde session affinity routeert herverbindingen terug naar dezelfde pod. leastconn-balancing (beschikbaar in HAProxy Ingress) routeert nieuwe verbindingen naar de pod met de minste actieve verbindingen en spreidt de load bij scale-out events.
WebSocket-services schalen
Sticky sessions werken op bescheiden schaal. Op productieschaal (tienduizenden gelijktijdige verbindingen) dropt een pod-failure al zijn clients tegelijk. Het robuuste patroon is een shared-state backplane: sla sessiestatus op in Redis, NATS of Kafka. Elke pod kan elke client bedienen. Pub/sub stuurt berichten naar de juiste clients, ongeacht op welke pod ze zitten.
Standaard HPA op basis van CPU reflecteert WebSocket-load slecht (10.000 idle verbindingen gebruiken minimaal CPU). Gebruik KEDA met een custom metric: actief aantal verbindingen per pod.
Scale-down verdient aandacht. Een pod terminaten dropt elke verbinding erop. Gebruik een preStop-hook om WebSocket close frames (1001 Going Away) te sturen zodat clients weten dat ze opnieuw moeten verbinden, en zet terminationGracePeriodSeconds hoog genoeg om clients te laten draineren. In mijn ervaring werkt 60-120 seconden goed voor de meeste WebSocket-services.
Wat dit artikel niet behandelt
Dit artikel richt zich op het load-balancing-probleem en de oplossingen. Het behandelt niet gRPC health checking (zie het health probes-artikel voor grpc liveness- en readiness-probes), mutual TLS-configuratie binnen een service mesh, of graceful shutdown-patronen voor langdurige verbindingen (zie het graceful shutdown-artikel).
Wanneer welke aanpak kiezen
| Situatie | Aanbevolen aanpak |
|---|---|
| Controle over gRPC-clientcode, minimale infra gewenst | Headless Service + round_robin + MaxConnectionAge |
| Edge/ingress gRPC-routing | NGINX Ingress backend-protocol: GRPC of GRPCRoute |
| Geen codewijzigingen, laagste overhead | Linkerd |
| Geavanceerd verkeersbeleid, al op Istio | Istio Ambient-modus |
| Cilium CNI al in gebruik | Cilium L7-proxy (beta) |
| gRPC-native, geen sidecarbudget | Proxyless xDS |
| WebSocket met stateful sessies | Ingress cookie-affinity + shared-state backplane |
| WebSocket autoscaling | KEDA met connection-count metric |