Kubernetes load balancing voor langdurige verbindingen (gRPC, WebSocket)

gRPC- en WebSocket-workloads sturen vaak al het verkeer naar een enkele pod, ook al draaien er meerdere replica's. De oorzaak is een mismatch tussen Kubernetes L4 load balancing (dat TCP-verbindingen verdeelt) en protocollen die veel verzoeken multiplexen over een enkele persistente verbinding. Dit artikel legt uit waarom het standaard kube-proxy-gedrag faalt voor deze protocollen, wat het verschil tussen L4 en L7 load balancing in de praktijk betekent, en welke oplossingen er zijn op elk niveau van infrastructuurcomplexiteit.

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_first als standaard. gRPC-clients gebruiken standaard pick_first, dat verbindt met het eerste DNS-resultaat en de rest negeert. Overschrijf dit altijd naar round_robin bij 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

Terugkerende server- of deploymentproblemen?

Ik help teams productie betrouwbaar maken met CI/CD, Kubernetes en cloud—zodat fixes blijven en deploys geen stress meer zijn.

Bekijk DevOps consultancy

Doorzoek deze site

Begin met typen om te zoeken, of blader door de kennisbank en blog.