Kubernetes graceful shutdown: SIGTERM en pod-terminatie afhandelen

Als Kubernetes een pod stopt, heeft je applicatie een beperkt tijdsvenster om verbindingen te draineren, lopende requests af te ronden en resources op te ruimen voordat het proces geforceerd wordt gekilld. Dit verkeerd configureren is de meest voorkomende oorzaak van 502-fouten tijdens deployments. Dit artikel behandelt de pod-terminatiecyclus, de race condition bij endpoint-verwijdering, preStop hooks, signaalafhandeling in Go, Node.js, Java en Python, en hoe je test of je shutdown daadwerkelijk graceful is.

De pod-terminatiecyclus

Wanneer een pod-delete-verzoek binnenkomt (rolling update, scale-in, node eviction, kubectl delete), doorloopt Kubernetes een specifieke reeks stappen. Het kritieke detail: twee parallelle tracks starten tegelijk, en hun interactie is de bron van de meeste shutdown-gerelateerde fouten.

Track A (kubelet):

  1. De API-server zet deletionTimestamp op de pod. De pod gaat naar Terminating.
  2. De kubelet op de node van de pod voert de preStop hook uit (als die geconfigureerd is).
  3. Na de preStop hook stuurt de kubelet SIGTERM naar PID 1 in elke container.
  4. Als de container niet binnen terminationGracePeriodSeconds stopt, stuurt de kubelet SIGKILL.

Track B (netwerk):

  1. De endpoint controller verwijdert de pod uit EndpointSlices.
  2. De API-server propageert de wijziging naar alle kube-proxy-instanties.
  3. Elke kube-proxy werkt zijn iptables/ipvs-regels bij om verkeer niet meer naar de pod te routeren.
  4. Ingress controllers vernieuwen hun upstream-lijsten.

Beide tracks starten op hetzelfde moment. Geen van beide wacht op de ander. Die parallelliteit is het probleem.

De endpoint removal race condition

Track A en Track B racen tegen elkaar. Als je applicatie stopt (Track A) voordat alle kube-proxy-instanties hun routeregels hebben bijgewerkt (Track B), komen requests nog steeds aan bij een pod die niet meer luistert. Het resultaat: 502 Bad Gateway-fouten tijdens rolling updates.

In kleine clusters kan endpoint-propagatie in minder dan een seconde klaar zijn. In grote clusters met 100+ nodes, of met ingress controllers die polling gebruiken in plaats van watches, kan het 10 tot 30 seconden duren. Gedurende dat venster blijft er verkeer aankomen bij een pod die al aan het afsluiten is.

Sinds Kubernetes v1.28 is KEP-1669 (ProxyTerminatingEndpoints) stabiel. kube-proxy valt terug op terminerende pods die nog serving zijn wanneer er geen ready endpoints bestaan. Dit vermindert verkeer-blackholes tijdens rolling updates, maar het betekent ook dat Kubernetes legitiem verkeer kan blijven sturen naar een afsluitende pod. Correcte shutdown-afhandeling in je applicatie is daardoor juist belangrijker.

Stap 1: voeg een preStop hook toe

De preStop hook draait voordat SIGTERM wordt verstuurd. Een korte sleep erin geeft de endpoint-propagatiemachinerie tijd om de pod uit alle routetabellen te verwijderen.

Voor Kubernetes 1.30+ (native sleep-actie, geen shell-binary nodig):

lifecycle:
  preStop:
    sleep:
      seconds: 15   # stel SIGTERM uit totdat endpoints bijgewerkt zijn

Voor Kubernetes < 1.30 (vereist het sleep-commando in de container):

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 15"]

De sleep draineert zelf geen verbindingen. Het vertraagt het moment waarop SIGTERM aankomt, zodat kube-proxy en ingress controllers tijd hebben om nieuw verkeer niet meer naar de pod te sturen.

Aanbevolen sleep-waarden:

Clusterprofiel Slaapduur
Klein cluster (< 50 nodes) 5 tot 10 seconden
Middelgroot cluster (50 tot 100 nodes) 10 tot 15 seconden
Groot cluster (100+ nodes) of externe load balancers 15 tot 30 seconden

Er is geen universeel juiste waarde. Meet de endpoint-propagatielatentie in je cluster tijdens het testen.

Stap 2: stel terminationGracePeriodSeconds in

De grace period is een gedeeld budget. Het begint te tellen op het moment dat de pod Terminating wordt, en het dekt zowel de preStop hook als de shutdowntijd van je applicatie. Als het verloopt, stuurt de kubelet SIGKILL.

De formule:

terminationGracePeriodSeconds >= preStop_duur + app_shutdown_duur + veiligheidsmarge

Voor een stateless HTTP-service met een preStop sleep van 15 seconden en een drain-window van 20 seconden is dat 15 + 20 + 10 = 45 seconden minimum:

spec:
  terminationGracePeriodSeconds: 60
  containers:
  - name: app
    image: my-app:v2.4.1
    lifecycle:
      preStop:
        sleep:
          seconds: 15

Aanbevolen waarden per workload-type:

Workload Grace period Reden
Stateless HTTP-microservice 45 tot 60s preStop sleep + request drain + buffer
WebSocket / long-poll service 60 tot 120s Langlevende verbindingen hebben draintijd nodig
Batchworker / job 120 tot 300s Kan middenin een groot werkpakket zitten
Stateful workload (database) 60 tot 120s Writes flushen, WAL sluiten, replicatie-handoff

De standaardwaarde is 30 seconden. Voor de meeste productie-workloads met een preStop sleep is die standaardwaarde te laag.

Stap 3: handel SIGTERM af in je applicatie

Kubernetes stuurt SIGTERM naar PID 1 in de container. Je applicatie moet dat signaal opvangen, stoppen met het aannemen van nieuwe verbindingen, lopende requests afronden, resources vrijgeven en netjes afsluiten.

De PID 1-vereiste

Als je applicatie niet PID 1 is, ontvangt het geen signaal. Dit is een veelvoorkomende valkuil met Dockerfiles:

# FOUT: /bin/sh is PID 1, stuurt SIGTERM niet door op Alpine
CMD myapp --flag

# GOED: myapp is PID 1
CMD ["myapp", "--flag"]

Als je toch een shell-entrypointscript nodig hebt, vervang het shellproces dan met exec:

#!/bin/sh
# setup stappen hier
exec myapp "$@"   # exec vervangt de shell door myapp (zelfde PID)

Voor containers waar geen van beide opties werkt, gebruik tini als minimaal init-proces dat signalen correct doorstuurt:

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["myapp"]

Go

Go's signal.NotifyContext biedt een idiomatische manier om SIGTERM aan context-cancellation te koppelen. De standaardbibliotheek's http.Server.Shutdown stopt met het aannemen van nieuwe verbindingen en wacht tot lopende requests klaar zijn:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

srv := &http.Server{
    Addr:    ":8080",
    Handler: mux,
    BaseContext: func(net.Listener) context.Context { return ctx },
}

go func() {
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatalf("ListenAndServe: %v", err)
    }
}()

<-ctx.Done()
log.Println("SIGTERM ontvangen, draining...")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Printf("Shutdown fout: %v", err)
    srv.Close()  // forceer sluiten als drain te lang duurt
}

Stel de Shutdown()-timeout in op minder dan terminationGracePeriodSeconds minus de preStop-slaapduur.

Node.js

server.close() stopt met het aannemen van nieuwe verbindingen, maar sluit geen bestaande idle HTTP keep-alive-verbindingen. Load balancers houden persistente verbindingen naar je pods, en die sluiten nooit vanzelf. Je moet ze expliciet vernietigen:

const server = http.createServer(app);
let isShuttingDown = false;

// Houd verbindingen bij voor keep-alive sockets
const connections = new Set();
server.on('connection', (socket) => {
    connections.add(socket);
    socket.on('close', () => connections.delete(socket));
});

function gracefulShutdown(signal) {
    if (isShuttingDown) return;
    isShuttingDown = true;
    console.log(`${signal} ontvangen, draining...`);

    server.close(() => {
        console.log('Server gesloten');
        process.exit(0);
    });

    // Vernietig idle keep-alive verbindingen
    for (const socket of connections) {
        socket.destroy();
    }

    setTimeout(() => process.exit(1), 25000); // harde deadline
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));

Een veelgemaakte PID 1-fout in Node.js: CMD ["npm", "start"] in de Dockerfile maakt npm PID 1 in plaats van je applicatie. npm stuurt SIGTERM niet door. Gebruik direct CMD ["node", "server.js"].

Java (Spring Boot)

Spring Boot 2.3+ heeft ingebouwde graceful-shutdownondersteuning. In application.yml:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
management:
  endpoint:
    health:
      probes:
        enabled: true

Bij SIGTERM stopt Spring met het aannemen van nieuwe requests, wacht tot timeout-per-shutdown-phase voor lopende requests, en sluit dan de applicatiecontext af. Het Actuator readiness-endpoint schakelt automatisch over naar OUT_OF_SERVICE, waardoor Kubernetes stopt met het routeren van verkeer.

Python

Voor Flask/WSGI-applicaties registreer je een SIGTERM-handler die een shutdown-vlag zet:

import signal
import sys

is_shutting_down = False

def handle_sigterm(signum, frame):
    global is_shutting_down
    is_shutting_down = True
    sys.exit(0)

signal.signal(signal.SIGTERM, handle_sigterm)

@app.route('/healthz/ready')
def readiness():
    if is_shutting_down:
        return '', 503
    return '', 200

FastAPI met uvicorn handelt SIGTERM native af via uvicorn's signaalafhandeling. Bij Gunicorn + uvicorn workers: controleer of SIGTERM goed doorpropageert van de Gunicorn-master naar workerprocessen in jouw specifieke setup.

Python's signaalhandlers draaien alleen in de main thread. Als je app multiprocessing of alternatieve async-frameworks (gevent, trio) gebruikt, test signaalvoortplanting dan apart.

Stap 4: verifieer het resultaat

Na het configureren van preStop, grace period en signaalafhandeling moet je testen onder load. De race condition manifesteert zich alleen wanneer er echt verkeer in-flight is tijdens een pod-restart.

Draai een loadtest en een rolling restart tegelijk:

# Terminal 1: aanhoudende load
hey -z 60s -c 10 http://my-service.default.svc.cluster.local/

# Terminal 2: trigger rolling restart terwijl load draait
kubectl rollout restart deployment/my-app

Verwacht resultaat: nul non-2xx responses in de loadtest-output. Als je 502- of connection-refused-fouten ziet, verhoog dan de preStop sleep of controleer of je applicatie SIGTERM correct afhandelt.

Om preStop-hookuitvoering te controleren:

kubectl describe pod <pod-name>
# Zoek naar "Normal  Killing" en "Warning  FailedPreStopHook" in events

Om endpoint-verwijderingstiming te controleren:

kubectl get endpointslices -w   # bekijk endpoint-updates tijdens een restart

Complete configuratie

Alles bij elkaar. Deze Deployment-configuratie handelt de endpoint race condition af, geeft de applicatie tijd om te draineren, en zorgt dat de grace period het volledige shutdownvenster dekt:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0       # verwijder nooit een pod zonder ready vervanging
      maxSurge: 1
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
      - name: app
        image: my-app:v2.4.1
        ports:
        - containerPort: 8080
        lifecycle:
          preStop:
            sleep:
              seconds: 15     # wacht op endpoint-propagatie (K8s 1.30+)
        readinessProbe:
          httpGet:
            path: /healthz/ready
            port: 8080
          periodSeconds: 5
          failureThreshold: 2
        livenessProbe:
          httpGet:
            path: /healthz/live
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 10
          failureThreshold: 3

Het tijdsbudget voor deze configuratie:

t=0s    Pod gemarkeerd als Terminating; endpoint-verwijdering start (Track B)
t=0s    preStop sleep begint (Track A)
t=15s   preStop klaar; SIGTERM gestuurd naar applicatie
t=15s   Applicatie stopt met aannemen van verbindingen, begint met draineren
t=40s   Applicatie sluit af (25s drain-venster)
t=60s   SIGKILL zou afgaan (wordt nooit bereikt als shutdown slaagt)

Veelvoorkomende problemen

preStop hook faalt stilletjes. Hooks die afhankelijk zijn van een binary die niet in de image zit (zoals sleep op distroless images) falen met een FailedPreStopHook-event. Check kubectl describe pod voor deze waarschuwing. Op Kubernetes 1.30+ kun je de native sleep:-actie gebruiken in plaats van exec.

Applicatie ontvangt SIGTERM niet. Bijna altijd een PID 1-probleem. Draai kubectl exec <pod> -- ps aux en controleer of je applicatieproces PID 1 is. Zo niet, pas de Dockerfile aan of voeg tini toe.

Grace period te kort. Als terminationGracePeriodSeconds korter is dan de preStop-duur plus de draintijd van de applicatie, stuurt de kubelet SIGKILL voordat de applicatie klaar is. Exit code 137 in de podstatus bevestigt dit.

Nginx vereist SIGQUIT. Nginx' standaard SIGTERM-handler triggert een snelle shutdown die verbindingen verbreekt. Voor graceful shutdown stuur je SIGQUIT via een preStop hook: command: ["/usr/sbin/nginx", "-s", "quit"].

Service mesh sidecar sluit eerder af. Bij Istio ontvangen je applicatie en de Envoy-sidecar tegelijk SIGTERM. Als Envoy eerder stopt, falen uitgaande calls vanuit je applicatie tijdens het draineren. Zet EXIT_ON_ZERO_ACTIVE_CONNECTIONS=true op de sidecar zodat Envoy wacht tot actieve verbindingen gesloten zijn.

Wanneer verder hulp zoeken

Als je na het doorvoeren van bovenstaande configuratie nog steeds 502-fouten ziet tijdens deployments, verzamel dan het volgende voordat je hulp vraagt:

  • Kubernetes-versie (kubectl version)
  • Clustergrootte (aantal nodes)
  • Ingress controller-type en -versie
  • kubectl describe pod-output van een beeindigde pod met events
  • kubectl get endpointslices -w-output vastgelegd tijdens een rolling restart
  • Loadtest-resultaten met het foutpercentage en de timing
  • Of je een service mesh draait en welke versie
  • Pod spec met terminationGracePeriodSeconds, preStop hook en probeconfiguratie

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.