← Terug naar tutorials

Graceful shutdowns: vastgelopen of zombie containers in productie oplossen

devopskubernetesdockergraceful-shutdowncontainerssignal-handlingproductiontroubleshootingobservability

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:

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:

Docker stop-semantiek

docker stop doet in feite:

  1. stuur SIGTERM naar PID 1 in de container
  2. wacht --time seconden (default 10)
  3. 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:

  1. Pod krijgt status Terminating
  2. kubelet stuurt SIGTERM naar container(s)
  3. eventuele preStop hook wordt uitgevoerd
  4. kube-proxy/Endpoints verwijderen de Pod uit load balancing (niet instant)
  5. na terminationGracePeriodSeconds volgt SIGKILL

Belangrijk: graceful betekent niet alleen “SIGTERM afhandelen”, maar ook:


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:

Oorzaken zijn vaak:


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:

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:

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:

  1. stoppen met nieuwe verbindingen accepteren,
  2. bestaande requests afronden,
  3. readiness laten falen zodat load balancers stoppen met sturen,
  4. na een timeout hard stoppen.

5.1 Kubernetes: readiness/liveness correct inzetten

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

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):

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:

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:

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:

Praktisch patroon: readiness endpoint checkt een file:


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:

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

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

10.3 Readiness-drain

10.4 Observability


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

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:

Richtlijnen:

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

  1. Zorg dat PID 1 klopt: exec of tini.
  2. Vang SIGTERM af en start een drain-modus:
    • readiness faalt direct,
    • stop nieuwe requests/messages,
    • rond in-flight werk af.
  3. Gebruik timeouts zodat shutdown nooit oneindig wacht.
  4. Stem Kubernetes settings af:
    • terminationGracePeriodSeconds realistisch,
    • preStop voor endpoint drain,
    • probes correct (readiness ≠ liveness).
  5. Diagnosticeer hangs met kubectl describe, ps, nsenter, strace en let op D-state.
  6. 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.