Graceful shutdowns: vastgelopen of zombie containers in productie oplossen
Productieomgevingen met containers draaien vaak maanden zonder problemen—totdat je een deploy doet, een node reboot, een autoscaler ingrijpt of er een incident is waardoor processen moeten stoppen. Dan blijkt ineens dat “stoppen” niet hetzelfde is als “afsluiten”. Een container die niet netjes afsluit kan:
- requests afkappen (klanten zien fouten),
- locks of tijdelijke bestanden achterlaten,
- database-transacties half open laten,
- message queues dubbel consumeren,
- of eindigen als zombie container: ogenschijnlijk “weg”, maar met processen die blijven hangen, of resources die niet vrij komen.
Deze tutorial laat zien hoe je graceful shutdowns implementeert en hoe je vastgelopen/zombie containers in productie diagnosticeert en oplost. Met echte commando’s voor Docker, Kubernetes en Linux, plus concrete code- en configuratiepatronen.
1. Wat betekent “graceful shutdown” bij containers?
Een container is geen VM; het is (meestal) één hoofdproces (PID 1) met namespaces/cgroups eromheen. Stoppen gebeurt doorgaans via signalen:
- SIGTERM: “begin met netjes afsluiten”
- SIGKILL: “nu stoppen” (niet te negeren)
- soms SIGINT: vergelijkbaar met Ctrl+C (in dev)
Docker stop-semantiek
docker stop doet in feite:
- stuur SIGTERM naar PID 1 in de container
- wacht
--timeseconden (default 10) - stuur SIGKILL als het proces nog leeft
Je kunt dit zien in de praktijk:
docker stop --time 30 mycontainer
Kubernetes stop-semantiek
Bij het beëindigen van een Pod:
- Pod krijgt status
Terminating - kubelet stuurt SIGTERM naar container(s)
- eventuele
preStophook wordt uitgevoerd - kube-proxy/Endpoints verwijderen de Pod uit load balancing (niet instant)
- na
terminationGracePeriodSecondsvolgt SIGKILL
Belangrijk: graceful betekent niet alleen “SIGTERM afhandelen”, maar ook:
- geen nieuwe requests aannemen,
- lopende requests afronden (met timeouts),
- achtergrondwerk stoppen,
- state flushen,
- resources vrijgeven,
- en binnen de grace period klaar zijn.
2. Zombie containers vs zombie processen: begrippen scherp
Zombie proces (Linux)
Een zombie process is een proces dat al klaar is maar nog in de process table staat omdat de parent het exit-status nog niet heeft “ge-reaped”. Dit herken je aan STAT=Z in ps.
In containers komt dit vaak door slecht PID 1-gedrag: PID 1 moet child processes reapen. Veel applicaties doen dat niet.
“Zombie container” (operationeel)
In de praktijk bedoelen teams vaak:
- container blijft in
Stoppinghangen, - Pod blijft in
Terminatinghangen, - proces reageert niet op SIGTERM,
- of er zijn orphaned child processes die blijven draaien.
Oorzaken zijn vaak:
- PID 1 is een shell-script dat signalen niet doorgeeft
- app heeft geen signal handlers
- threads/processen blokkeren op I/O
- deadlocks in shutdown-pad
- te korte grace period
- volumes/unmounts die hangen (NFS issues)
- kernel-level stuck states (D-state)
3. Eerste hulp: snelle diagnose in productie
3.1 Docker: container die niet stopt
Bekijk status:
docker ps --filter name=mycontainer
docker inspect mycontainer --format '{{.State.Status}} {{.State.Running}} {{.State.Pid}}'
Bekijk logs:
docker logs --tail 200 mycontainer
Probeer netjes te stoppen met langere timeout:
docker stop --time 60 mycontainer
Als dat niet werkt: stuur zelf SIGTERM en inspecteer processen:
PID=$(docker inspect mycontainer --format '{{.State.Pid}}')
sudo nsenter -t "$PID" -p -m -u -i -n ps auxf
sudo nsenter -t "$PID" -p -m -u -i -n cat /proc/1/status
Als een proces in D (uninterruptible sleep) staat, helpt SIGKILL soms niet. Check:
sudo nsenter -t "$PID" -p -m -u -i -n ps -eo pid,ppid,stat,wchan,cmd
STAT met D wijst vaak op kernel/I/O blokkade (bijv. NFS).
3.2 Kubernetes: Pod hangt in Terminating
Bekijk details:
kubectl get pod -n prod mypod -o wide
kubectl describe pod -n prod mypod
Let op:
- events over
Killing,PreStopHook,FailedKillPod,UnmountVolume - welke container hangt?
Bekijk endpoints om te zien of verkeer nog naar de Pod kan gaan:
kubectl get endpoints -n prod myservice -o yaml | less
Bekijk of finalizers blokkeren (bijv. bij CRDs, maar ook soms bij Pods via controllers):
kubectl get pod -n prod mypod -o json | jq '.metadata.finalizers'
Als je echt moet forceren (laatste redmiddel):
kubectl delete pod -n prod mypod --grace-period=0 --force
Let op: dit kan lopende requests abrupt afkappen en state-corruptie veroorzaken. Gebruik alleen bij incidenten en daarna root cause fixen.
4. De kern: signal handling correct implementeren
4.1 PID 1-probleem: waarom je proces signalen “mist”
In Linux heeft PID 1 speciale semantiek: sommige signalen worden anders behandeld, en PID 1 moet zombie children reapen. Als je container een shell-script als entrypoint heeft, kan het gebeuren dat:
- SIGTERM naar de shell gaat,
- maar niet naar je echte app,
- en child processes blijven hangen.
Slecht voorbeeld (shell zonder exec)
#!/bin/sh
myserver --port 8080
Hier blijft de shell PID 1, en myserver is child. SIGTERM kan verkeerd eindigen.
Goed voorbeeld: exec
#!/bin/sh
exec myserver --port 8080
Nu wordt myserver PID 1 en ontvangt direct SIGTERM.
4.2 Gebruik een init-systeem in containers (tini)
Als je app child processes spawnt (bijv. via subprocess, cron, ffmpeg, worker pools), gebruik een minimal init zoals tini om zombies te reapen en signalen te forwarden.
Dockerfile:
FROM debian:12-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["./myapp"]
In Kubernetes kun je ook --init gebruiken bij sommige runtimes, maar expliciet tini is vaak duidelijker.
5. Graceful shutdown patroon voor HTTP services
Een HTTP service moet bij shutdown:
- stoppen met nieuwe verbindingen accepteren,
- bestaande requests afronden,
- readiness laten falen zodat load balancers stoppen met sturen,
- na een timeout hard stoppen.
5.1 Kubernetes: readiness/liveness correct inzetten
- readinessProbe: bepaalt of de Pod verkeer mag krijgen
- livenessProbe: herstart bij “hang”
- startupProbe: voorkomt te vroege liveness kills bij trage start
Voor graceful shutdown wil je vooral readiness slim gebruiken: bij SIGTERM zet je readiness op “fail”.
Een veelgebruikt patroon is een “drain file” of interne flag.
Voorbeeld Deployment snippet:
kubectl -n prod apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
terminationGracePeriodSeconds: 45
containers:
- name: web
image: nginx:1.27
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 2
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
failureThreshold: 3
EOF
In je app: /ready moet false worden zodra shutdown start.
5.2 PreStop hook: tijd kopen voor drain
Een preStop hook wordt uitgevoerd vóór SIGTERM (in de praktijk: kubelet start termination, voert hook uit, en stuurt dan SIGTERM; details kunnen per runtime verschillen, maar je moet het zien als “extra tijd om te drainen”).
Voorbeeld:
kubectl -n prod patch deploy web --type='json' -p='[
{"op":"add","path":"/spec/template/spec/containers/0/lifecycle","value":{
"preStop":{"exec":{"command":["/bin/sh","-c","echo draining; sleep 10"]}}
}}
]'
Dit geeft load balancers tijd om endpoints te updaten voordat je app echt stopt.
6. Graceful shutdown in praktijkcode (met echte signalen)
Hier zijn concrete patronen. Kies wat past bij jouw stack, maar de principes blijven gelijk.
6.1 Node.js (HTTP server)
Belangrijk: server.close() stopt met nieuwe verbindingen, maar bestaande blijven lopen. Voeg een harde timeout toe.
// server.js
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/ready') {
if (global.draining) return res.writeHead(503).end('draining');
return res.end('ok');
}
if (req.url === '/health') return res.end('ok');
// Simuleer werk
setTimeout(() => res.end('hello\n'), 200);
});
global.draining = false;
server.listen(8080, () => console.log('listening on 8080'));
function shutdown(signal) {
console.log(`received ${signal}, draining...`);
global.draining = true;
// Stop nieuwe requests
server.close(() => {
console.log('closed server, exiting');
process.exit(0);
});
// Hard stop na 30s
setTimeout(() => {
console.error('force exit after timeout');
process.exit(1);
}, 30000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Test lokaal met Docker:
docker run --rm -p 8080:8080 node:22-alpine sh -c "node server.js"
# in andere terminal:
curl -i localhost:8080/ready
Simuleer shutdown:
# vind container id en stop
docker ps
docker stop --time 40 <id>
6.2 Python (gunicorn)
Gunicorn heeft ingebouwde graceful shutdown, maar je moet timeouts goed zetten.
Voorbeeld run command:
gunicorn myapp:app \
--bind 0.0.0.0:8080 \
--workers 4 \
--graceful-timeout 30 \
--timeout 60
--graceful-timeout: tijd om workers netjes te laten stoppen--timeout: request timeout; voorkom dat requests oneindig hangen
In Kubernetes: zet terminationGracePeriodSeconds minstens graceful-timeout + marge.
6.3 Java (Spring Boot)
Spring Boot vangt SIGTERM en sluit de context, maar je moet “graceful shutdown” expliciet aanzetten (afhankelijk van versie) en server timeouts configureren.
Controleer je settings (voorbeeld):
server.shutdown=gracefulspring.lifecycle.timeout-per-shutdown-phase=30s
Daarnaast: zorg dat je readiness endpoint direct “down” gaat bij shutdown (Spring Actuator kan dit ondersteunen).
7. Waarom containers vastlopen bij shutdown (en hoe je het voorkomt)
7.1 Blokkerende I/O en D-state
Als je processen in D staan, is er meestal een kernel-level wait op I/O (disk, network filesystem). Oplossingen zijn zelden “meer SIGKILL”, maar:
- NFS/CSI issues oplossen
- storage latency/availability fixen
- timeouts in je app
- vermijden van shutdown die wacht op onbetrouwbare externe calls
Diagnose op node:
# op de node waar de container draait
ps -eo pid,stat,wchan,cmd | grep ' D '
dmesg | tail -n 50
7.2 Te korte grace period
Als terminationGracePeriodSeconds te laag is, wordt je app altijd hard gekilled. Symptomen:
- veel 499/502/503 rond deploys
- incomplete jobs
- DB locks
Fix: meet je p95/p99 requestduur en kies grace period > p99 + drain tijd.
Voorbeeld patch:
kubectl -n prod patch deploy web --type='json' -p='[
{"op":"replace","path":"/spec/template/spec/terminationGracePeriodSeconds","value":60}
]'
7.3 Readiness blijft “true” tijdens shutdown
Als readiness “ok” blijft tot het proces dood is, blijft verkeer binnenkomen. Fix:
- zet readiness op fail zodra SIGTERM ontvangen is
- of gebruik
preStopom eerst readiness te laten falen, dan pas stoppen
Praktisch patroon: readiness endpoint checkt een file:
- bij shutdown: maak
/tmp/drain - readiness faalt als file bestaat
8. Docker & Kubernetes: correcte stop-tuning
8.1 Docker: STOPSIGNAL en stop timeout
Je kunt in je Dockerfile een ander stopsignaal zetten (meestal niet nodig, maar soms handig):
STOPSIGNAL SIGTERM
En run met langere stop timeout:
docker run --stop-timeout 45 myimage
8.2 Kubernetes: terminationGracePeriodSeconds + lifecycle hooks
Combineer:
terminationGracePeriodSeconds: 60preStopsleep/drain- readiness die faalt bij drain
Voorbeeld Pod-spec fragment:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: demo-graceful
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
image: myrepo/myapp:1.0.0
ports:
- containerPort: 8080
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","echo 1 > /tmp/drain; sleep 10"]
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 2
failureThreshold: 1
EOF
9. Vastgelopen Pods/containers: gerichte interventies
9.1 “Wat draait er nog?” in een Kubernetes container
kubectl -n prod exec -it mypod -- sh -c 'ps auxf'
kubectl -n prod exec -it mypod -- sh -c 'cat /proc/1/cmdline; echo'
kubectl -n prod exec -it mypod -- sh -c 'ls -l /proc/1/fd | head'
9.2 Bekijk welke signalen je proces krijgt (strace)
Alleen doen als je tooling aanwezig is (of ephemeral debug container).
Met ephemeral container (K8s):
kubectl -n prod debug -it mypod --image=debian:12 --target=app -- bash
Installeer strace (in debug container):
apt-get update && apt-get install -y strace procps
ps auxf
strace -p 1
Je ziet dan of SIGTERM binnenkomt en waar het proces op wacht.
9.3 Forceren met kill (Docker)
Als je echt moet:
docker kill --signal=SIGTERM mycontainer
sleep 5
docker kill --signal=SIGKILL mycontainer
Als zelfs dat niet helpt, zit je vaak op node/kernel/storage niveau. Dan is node reboot soms de enige uitweg—maar behandel het als incident met postmortem.
10. Veelvoorkomende root causes en fixes (checklist)
10.1 Entrypoint en PID 1
- Gebruik
execin shell entrypoints - Gebruik
tiniof vergelijkbare init - Vermijd “bash -c …” als PID 1 zonder exec
Snelle check:
kubectl -n prod exec mypod -- ps -o pid,ppid,cmd
Als PID 1 sh/bash is, wees extra kritisch.
10.2 Timeouts overal
- HTTP server: request timeouts
- DB clients: connect/read timeouts
- Queue consumers: stop polling, commit offsets netjes
- Background jobs: cancellable contexts
10.3 Readiness-drain
- Readiness faalt direct bij shutdown start
-
preStopgeeft 5–15s om endpoints te updaten - Load balancer connection draining (cloud-specifiek) is afgestemd
10.4 Observability
- Log “shutdown start” en “shutdown complete”
- Metric: aantal actieve requests/handlers
- Traces rond deploys/terminations
11. Praktische oefening: reproduceer en fix een “hang bij shutdown”
11.1 Reproduceer: container die SIGTERM negeert door fout entrypoint
Maak entrypoint.sh:
#!/bin/sh
# FOUT: geen exec
node server.js
Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY server.js /app/server.js
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
Build en run:
docker build -t bad-shutdown .
docker run --name bad -p 8080:8080 bad-shutdown
Stop:
docker stop --time 5 bad
Je ziet vaak dat shutdown rommelig is of langer duurt, afhankelijk van hoe Node draait en of de shell signalen doorgeeft.
11.2 Fix: exec + tini
Pas entrypoint.sh aan:
#!/bin/sh
exec node server.js
En gebruik tini:
FROM node:22-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY server.js /app/server.js
ENTRYPOINT ["/sbin/tini","--","node","/app/server.js"]
Build en test opnieuw:
docker build -t good-shutdown .
docker run --name good -p 8080:8080 good-shutdown
docker stop --time 10 good
Nu is de kans veel groter dat SIGTERM correct wordt afgehandeld en dat child processes netjes worden opgeruimd.
12. Specifiek voor workers/consumers (queues, streams)
HTTP is relatief eenvoudig; workers zijn vaak de echte bron van “zombie” gedrag.
12.1 Correct shutdown patroon voor consumers
- Stop met nieuwe messages ophalen
- Laat in-flight messages afronden
- Commit offsets/acks
- Sluit connecties
- Exit binnen grace period
In Kubernetes: zet terminationGracePeriodSeconds op basis van maximale verwerkingstijd van één message plus marge.
Als je message handling soms 2 minuten duurt, maar je grace period is 30s, dan krijg je gegarandeerd duplicates of half-verwerkte state.
13. Wanneer is “force delete” acceptabel?
Soms moet je kiezen tussen:
- een node die resources lekt en clusterproblemen veroorzaakt,
- of één Pod hard killen.
Richtlijnen:
- Acceptabeler voor stateless web pods (mits je retries hebt)
- Riskant voor stateful workloads (DB, queues, exactly-once consumers)
- Altijd follow-up: root cause fixen en grace period/handlers verbeteren
Force delete in K8s:
kubectl delete pod -n prod mypod --force --grace-period=0
Daarna: controleer of de ReplicaSet/Deployment netjes vervangt:
kubectl -n prod get pods -l app=web -o wide
kubectl -n prod rollout status deploy/web
14. Samenvatting: het “productie-proof” shutdown recept
- Zorg dat PID 1 klopt:
execoftini. - Vang SIGTERM af en start een drain-modus:
- readiness faalt direct,
- stop nieuwe requests/messages,
- rond in-flight werk af.
- Gebruik timeouts zodat shutdown nooit oneindig wacht.
- Stem Kubernetes settings af:
terminationGracePeriodSecondsrealistisch,preStopvoor endpoint drain,- probes correct (readiness ≠ liveness).
- Diagnosticeer hangs met
kubectl describe,ps,nsenter,straceen let opD-state. - Forceer alleen als laatste redmiddel, en behandel het als signaal dat je shutdown-pad niet productie-waardig is.
15. Handige commandoverzameling (copy/paste)
Docker
docker stop --time 60 mycontainer
docker logs --tail 200 mycontainer
docker inspect mycontainer --format '{{.State.Status}} pid={{.State.Pid}}'
docker kill --signal=SIGTERM mycontainer
docker kill --signal=SIGKILL mycontainer
Kubernetes
kubectl -n prod get pod mypod -o wide
kubectl -n prod describe pod mypod
kubectl -n prod logs mypod --tail=200
kubectl -n prod exec -it mypod -- sh -c 'ps auxf'
kubectl -n prod delete pod mypod --grace-period=0 --force
kubectl -n prod rollout status deploy/web
Node-level (voor Docker/containerd scenario’s)
PID=$(docker inspect mycontainer --format '{{.State.Pid}}')
sudo nsenter -t "$PID" -p -m -u -i -n ps auxf
sudo nsenter -t "$PID" -p -m -u -i -n ps -eo pid,ppid,stat,wchan,cmd
Als je wilt, kan ik dit uitbreiden met een sectie per platform (EKS/GKE/AKS) over load balancer connection draining, of met een concreet “incident runbook” (stappenplan + beslisboom) voor vastgelopen Pods in Terminating.