Arrêts gracieux en production : corriger les conteneurs bloqués ou zombies
Les conteneurs « bloqués », « zombies » ou qui refusent de s’arrêter proprement sont un problème récurrent en production. Ils peuvent empêcher des déploiements, saturer des nœuds, bloquer des mises à jour de sécurité, ou provoquer des indisponibilités parce que l’orchestrateur (Docker, Kubernetes, systemd, Nomad, etc.) attend indéfiniment la fin d’un processus qui ne répond plus.
Ce tutoriel explique en profondeur :
- ce qui se passe réellement lors d’un arrêt de conteneur (signaux, PID 1,
init, cgroups) ; - pourquoi certains conteneurs deviennent « zombies » ou « impossibles à tuer » ;
- comment diagnostiquer avec des commandes réelles ;
- comment corriger durablement : Dockerfile, entrypoint, gestion des signaux,
tini, probes, timeouts, hooks, et bonnes pratiques Kubernetes/Docker.
1) Comprendre le problème : « bloqué », « zombie », ou « unkillable » ?
Avant d’agir, clarifions les termes (souvent mélangés).
1.1 Conteneur « bloqué » (shutdown qui n’aboutit pas)
Symptômes fréquents :
docker stopreste longtemps puis finit enSIGKILL, ou ne rend jamais la main.- Sur Kubernetes : un Pod reste en
Terminatingpendant des minutes/heures. - L’application ne ferme pas ses sockets, ne flush pas ses buffers, ou reste dans un appel système bloquant.
Causes typiques :
- L’application n’écoute pas les signaux (
SIGTERM) ou ne les propage pas. - Le processus principal n’est pas le bon (shell wrapper), ou c’est un script qui ne
execpas. - Threads bloqués (I/O, deadlock), appels réseau sans timeout, ou attente sur un verrou.
preStophook qui pend (Kubernetes) ou script d’arrêt qui ne termine pas.
1.2 Processus « zombie » (au sens Linux)
Un zombie est un processus terminé dont le parent n’a pas récolté le code de sortie (wait()), il reste visible comme Z dans ps. Dans un conteneur, c’est souvent lié au fait que le PID 1 n’effectue pas la récole des enfants.
Conséquences :
- accumulation de zombies → table des processus saturée dans le conteneur ;
- comportements bizarres et instabilité.
Cause principale :
- PID 1 est une application qui ne gère pas
SIGCHLD/wait()(pas de « reaper »).
1.3 Processus « unkillable » (état D)
Un processus en état D (uninterruptible sleep) est en attente d’un I/O kernel (souvent stockage ou NFS). Même SIGKILL ne le tue pas tant que le kernel ne sort pas de l’attente. Cela peut donner l’impression que le conteneur est « immortel ».
Diagnostic : ps montre D ; cat /proc/<pid>/stack peut indiquer où ça bloque.
2) Ce qui se passe lors d’un arrêt : signaux, PID 1 et timeouts
2.1 Docker : stop = SIGTERM puis SIGKILL
Docker envoie par défaut :
SIGTERMau processus PID 1 du conteneur- attend
--timesecondes (par défaut 10) - envoie
SIGKILLsi le conteneur n’est pas sorti
Commandes :
docker stop <container>
docker stop --time 30 <container>
docker kill --signal=SIGTERM <container>
docker kill --signal=SIGKILL <container>
Point clé : Docker ne « tue » pas tous les processus, il cible le PID 1 du conteneur. Si PID 1 est un shell qui ne propage pas, l’app peut ignorer l’arrêt.
2.2 Kubernetes : terminationGracePeriodSeconds
Kubernetes suit une séquence similaire :
- Exécute éventuellement un hook
preStop - Envoie
SIGTERMau PID 1 du conteneur - Attend
terminationGracePeriodSeconds - Envoie
SIGKILL
Exemples de paramètres (vous les verrez dans les manifests) :
spec.terminationGracePeriodSeconds: 30lifecycle.preStop.exec.command: [...]
En pratique, si l’app met 45 secondes à se fermer et que la période est 30 secondes, vous aurez des arrêts brutaux et potentiellement corruption/rollback.
3) Diagnostiquer en production (Docker)
3.1 Vérifier l’état et les logs
docker ps -a
docker logs --tail 200 <container>
docker inspect <container> | less
Dans docker inspect, cherchez :
State.StatusState.RunningState.ExitCodeState.OOMKilledState.ErrorState.Pid
Récupérer le PID sur l’hôte :
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
echo "$PID"
3.2 Voir les processus du conteneur depuis l’hôte
docker top <container>
Ou via le namespace PID (si vous avez les droits) :
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
sudo nsenter -t "$PID" -p -m -u -i -n ps auxf
-p: namespace PID-m: mount-n: network
Cherchez :
- des processus
Z(zombies) - des processus
D(unkillable) - un PID 1 qui est
sh,bash,dash,nodewrapper, etc.
3.3 Comprendre pourquoi ça ne s’arrête pas : signaux et strace
Si vous suspectez un blocage, attachez strace au PID 1 (ou au vrai processus applicatif) :
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
sudo nsenter -t "$PID" -p -m -u -i -n strace -p 1 -s 200 -tt
Ou sur un processus spécifique :
sudo nsenter -t "$PID" -p -m -u -i -n pgrep -af myapp
sudo nsenter -t "$PID" -p -m -u -i -n strace -p <pid_app> -s 200 -tt
Vous verrez si le processus est bloqué sur futex(), read(), connect(), poll(), etc.
3.4 Identifier un état D (I/O bloqué)
Depuis le namespace :
sudo nsenter -t "$PID" -p -m -u -i -n ps -eo pid,ppid,stat,comm,wchan:32 | head -n 50
Si STAT contient D, regardez la pile kernel :
sudo cat /proc/<pid>/stack
Sur l’hôte, vous pouvez aussi regarder les messages kernel :
dmesg -T | tail -n 200
journalctl -k --since "10 min ago"
Un NFS figé, un disque saturé, ou un driver peut empêcher la sortie.
4) Diagnostiquer en production (Kubernetes)
4.1 Pod bloqué en Terminating : premières commandes
kubectl get pod -n <ns>
kubectl describe pod <pod> -n <ns>
kubectl logs <pod> -n <ns> --previous
kubectl logs <pod> -n <ns> -c <container>
Dans describe, cherchez :
State: Terminated/Waiting/Running- événements :
Killing,PreStopHook, erreurs de volume terminationGracePeriodSeconds
4.2 Voir les processus dans un conteneur
Si le conteneur est encore vivant :
kubectl exec -n <ns> -it <pod> -- ps auxf
kubectl exec -n <ns> -it <pod> -- sh -c 'ps -eo pid,ppid,stat,comm,args | head'
Si l’image est minimaliste et n’a pas ps, utilisez un conteneur éphémère (K8s) :
kubectl debug -n <ns> -it <pod> --image=busybox:1.36 --target=<container> -- sh
ps auxf
4.3 Forcer la suppression (dernier recours)
Si un Pod reste en Terminating à cause d’un finalizer, d’un nœud mort, ou d’un processus bloqué :
kubectl delete pod -n <ns> <pod> --grace-period=0 --force
Attention : cela supprime l’objet API, mais si le nœud est vivant, le runtime peut encore garder des processus. Il faut parfois intervenir côté nœud (containerd/Docker).
4.4 Diagnostiquer côté nœud (containerd / crictl)
Sur le nœud :
sudo crictl ps -a | head
sudo crictl pods | head
sudo crictl inspect <container_id> | less
sudo crictl logs <container_id> | tail -n 200
Pour tenter un arrêt :
sudo crictl stop <container_id> --timeout 30
sudo crictl rm <container_id>
5) Causes classiques et corrections durables
5.1 PID 1 qui ne propage pas les signaux (shell wrapper)
Problème très fréquent : un ENTRYPOINT ou CMD démarre via un shell, par exemple :
CMD ["sh", "-c", "python app.py"]
Ici, PID 1 = sh. Quand Docker envoie SIGTERM, c’est sh qui le reçoit. Selon la façon dont le shell lance le processus, le signal peut ne pas être transmis correctement, et l’application ne s’arrête pas.
Correction : utiliser exec (ou mieux : éviter sh -c si possible).
Exemple correct :
CMD ["python", "app.py"]
Si vous avez besoin d’un script :
#!/bin/sh
set -eu
# ... préparation ...
exec python app.py
Le exec remplace le shell par le processus applicatif : l’app devient PID 1 et reçoit les signaux.
5.2 PID 1 qui ne « reap » pas les zombies : ajouter un init (tini)
Beaucoup d’applications ne gèrent pas la récole des enfants. Dans un conteneur, PID 1 a un rôle spécial : il doit souvent agir comme init minimal.
Solution : utiliser tini (ou dumb-init) comme PID 1.
Docker : option --init (utilise tini intégré selon version/config) :
docker run --init --rm myimage
Dans Dockerfile (approche explicite) :
Debian/Ubuntu :
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "app.py"]
Alpine :
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["python", "app.py"]
Pourquoi tini aide :
- il transmet correctement les signaux au processus enfant ;
- il récolte les zombies (
waitpid) ; - il gère mieux certains cas de sous-processus.
5.3 Applications qui ignorent SIGTERM : implémenter un shutdown propre
Exemple Node.js
Node gère SIGTERM, mais votre code doit fermer les serveurs et arrêter les boucles.
const http = require('http');
const server = http.createServer((req, res) => res.end('ok'));
server.listen(3000);
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`reçu ${signal}, arrêt...`);
// Arrêter d'accepter de nouvelles connexions
server.close(() => {
console.log('serveur HTTP fermé');
process.exit(0);
});
// Timeout de sécurité
setTimeout(() => {
console.error('shutdown timeout, exit(1)');
process.exit(1);
}, 25000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Points importants :
server.close()arrête l’acceptation de nouvelles connexions ;- vous fixez un timeout <
terminationGracePeriodSeconds; - vous évitez de rester pendu indéfiniment.
Exemple Python (gunicorn)
Gunicorn gère bien les signaux si c’est le processus principal. Lancez-le en PID 1 (avec exec + éventuellement tini) :
exec gunicorn -w 4 -b 0.0.0.0:8000 myapp:wsgi --graceful-timeout 25 --timeout 30
--graceful-timeout: temps pour terminer les requêtes en cours--timeout: workers bloqués
Exemple Java (Spring Boot)
Spring Boot gère SIGTERM et déclenche un shutdown, mais vous devez :
- activer l’arrêt gracieux du serveur web (selon version),
- ajuster les timeouts de fermeture,
- fermer proprement les pools (DB, Kafka, etc.).
Vérifiez aussi que le conteneur reçoit bien SIGTERM (PID 1 correct).
5.4 Hooks Kubernetes preStop qui bloquent
Un preStop mal conçu peut empêcher la terminaison (Kubernetes attend la fin du hook avant d’envoyer SIGTERM dans certains cas, et en tout cas il consomme du temps de la période de grâce).
Exemple de hook risqué :
sleep 600
Ou un script qui attend un endpoint qui ne répond plus.
Bonnes pratiques :
preStopdoit être rapide et idempotent.- Toute attente doit être bornée par un timeout.
Exemple robuste :
#!/bin/sh
set -eu
# Exemple : retirer l'instance d'un LB interne avec un timeout
timeout 5s curl -fsS -X POST http://127.0.0.1:9000/drain || true
exit 0
Dans le manifest :
kubectl patch deploy -n <ns> <deploy> --type='json' -p='[
{"op":"replace","path":"/spec/template/spec/terminationGracePeriodSeconds","value":30}
]'
5.5 Probes mal réglées : redémarrages en boucle et terminaisons chaotiques
Si vos livenessProbe sont trop agressives, Kubernetes tue le conteneur pendant des phases de charge, ce qui ressemble à des « zombies » (en réalité des redémarrages).
Vérifiez :
timeoutSecondsfailureThresholdperiodSecondsinitialDelaySeconds
Commandes utiles :
kubectl describe pod -n <ns> <pod> | sed -n '/Liveness/,/Events/p'
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -n 50
6) Ajuster les timeouts d’arrêt (sans masquer les vrais bugs)
6.1 Docker : augmenter le délai de stop
docker stop --time 60 <container>
Pour Docker Compose :
docker compose stop -t 60
Vous pouvez aussi définir STOPSIGNAL dans le Dockerfile si votre app préfère un autre signal (rare, mais utile) :
STOPSIGNAL SIGQUIT
Attention : SIGQUIT peut produire un dump (utile pour diagnostiquer), mais ce n’est pas universel.
6.2 Kubernetes : terminationGracePeriodSeconds
Si votre app a besoin de 45 secondes pour drainer, mettez 60, mais gardez une marge et mesurez.
Pour voir la valeur effective :
kubectl get pod -n <ns> <pod> -o jsonpath='{.spec.terminationGracePeriodSeconds}{"\n"}'
7) Techniques de diagnostic avancées (quand ça résiste)
7.1 Vérifier la hiérarchie de processus et les groupes
Dans un conteneur, une app peut lancer des enfants et les laisser orphelins. Inspectez :
kubectl exec -n <ns> -it <pod> -- ps -eo pid,ppid,pgid,sid,stat,comm,args --sort=ppid | head -n 80
Sur Docker via nsenter :
sudo nsenter -t "$PID" -p -m -u -i -n ps -eo pid,ppid,pgid,sid,stat,comm,args --sort=ppid | head
Si vous voyez des processus dont le parent est 1, mais qui ne se terminent pas, il faut :
- soit que PID 1 les gère (reaping + propagation),
- soit éviter de daemoniser dans le conteneur.
Règle : dans un conteneur, évitez service start, daemon, nohup, & sans gestion.
7.2 Déclencher un thread dump / stack dump avant kill
Avant de forcer un kill, essayez de récupérer un état utile.
Java (si jstack disponible) :
kubectl exec -n <ns> -it <pod> -- jstack 1 | tail -n 200
Sinon jcmd :
kubectl exec -n <ns> -it <pod> -- jcmd 1 Thread.print | head -n 200
Go : si vous avez un endpoint pprof :
kubectl exec -n <ns> -it <pod> -- sh -c 'wget -qO- http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 | head -n 80'
Node : envoyez SIGUSR2 si configuré pour un dump (selon runtime/outils).
7.3 Quand même SIGKILL ne marche pas : suspecter D-state / kernel
Si le process est en D, vous ne pourrez pas le tuer proprement. Vous devez traiter la cause :
- stockage (EBS, Ceph, NFS) en panne ;
- filesystem gelé ;
- bug driver.
Sur le nœud :
ps -eo pid,stat,comm,wchan:32,args | grep ' D ' | head
iostat -xz 1 5
vmstat 1 5
Regardez aussi les timeouts NFS, erreurs block device :
dmesg -T | egrep -i 'nfs|ext4|xfs|blk|i/o error|timeout' | tail -n 50
8) Patterns robustes pour des arrêts gracieux
8.1 Pattern « drain puis stop »
Objectif : ne plus accepter de trafic, finir les requêtes en cours, puis quitter.
En Kubernetes :
- readinessProbe passe à
failpendant le shutdown (ou endpoint /ready renvoie non prêt) - le Service retire le Pod des endpoints
- l’app attend un court délai pour laisser le LB se mettre à jour
- l’app se termine
Implémentation typique :
- endpoint
/readydépend d’un flagshuttingDown - sur
SIGTERM, on metshuttingDown=true, on ferme le listener, on attend la fin des requêtes, puis exit.
8.2 Pattern « PID 1 propre + init minimal »
tinicomme entrypointexecdu processus applicatif- pas de daemonisation
- logs sur stdout/stderr
Dockerfile exemple (Alpine + Node) :
FROM node:20-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
8.3 Pattern « timeouts cohérents »
Alignez :
- timeout applicatif de shutdown (ex: 25s)
terminationGracePeriodSeconds(ex: 30s)- timeouts upstream (Ingress/LB) (ex: 60s) selon besoin
- timeouts DB/Kafka/HTTP clients (éviter des appels bloqués sans fin)
Exemple : si votre app attend un appel HTTP externe sans timeout, le shutdown peut rester bloqué. En production, mettez toujours des timeouts :
curl --max-time 2- bibliothèques HTTP : connect timeout + read timeout
- clients DB : timeouts + circuit breaker
9) Recettes de correction rapides (checklist opérationnelle)
9.1 Pour Docker (incident en cours)
- Identifier PID et processus :
docker inspect -f '{{.State.Pid}}' <container>
docker top <container>
- Tenter
SIGTERMavec délai plus long :
docker stop --time 30 <container>
- Si bloqué, inspecter avec
nsenter:
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
sudo nsenter -t "$PID" -p -m -u -i -n ps auxf
-
Si état
D, investiguer stockage/kernel (pas un problème « Docker »). -
Après incident : corriger Dockerfile (
exec,tini, pas desh -cinutile).
9.2 Pour Kubernetes (Pod en Terminating)
- Comprendre la cause :
kubectl describe pod -n <ns> <pod>
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -n 30
- Vérifier hooks et grace period :
kubectl get pod -n <ns> <pod> -o jsonpath='{.spec.terminationGracePeriodSeconds}{"\n"}'
- Si nécessaire, debug dans le conteneur :
kubectl exec -n <ns> -it <pod> -- ps auxf
- Dernier recours :
kubectl delete pod -n <ns> <pod> --grace-period=0 --force
- Après incident : corriger
preStop, probes, gestion SIGTERM, et ajoutertini.
10) Exemples concrets d’erreurs fréquentes et solutions
10.1 Entrypoint en shell qui masque le signal
Mauvais :
ENTRYPOINT ["sh", "-c", "myserver --port 8080"]
Bon :
ENTRYPOINT ["myserver"]
CMD ["--port", "8080"]
Ou script :
#!/bin/sh
set -eu
# config...
exec myserver --port 8080
10.2 L’app fork en arrière-plan (daemonize)
Mauvais : lancer un service qui se met en background, PID 1 termine, conteneur instable.
Solution : lancer en foreground, config « no-daemon ».
Exemples :
- nginx :
nginx -g 'daemon off;' - sshd :
sshd -D - cron : préférer un scheduler externe ou
crond -f
10.3 Zombies : absence de reaper
Symptôme :
ps aux | awk '$8 ~ /Z/ {print}'
Solution : tini/dumb-init, ou implémenter wait() si vous êtes PID 1 (rarement souhaitable).
11) Mesurer et valider : tests d’arrêt gracieux
Un arrêt gracieux doit être testé comme une fonctionnalité.
11.1 Test local Docker
- Lancer :
docker run --rm -p 8080:8080 --name test myimage
- Envoyer du trafic :
curl -v http://127.0.0.1:8080/health
- Stopper et mesurer :
time docker stop --time 30 test
Vérifiez :
- temps d’arrêt cohérent
- logs indiquant la séquence de shutdown
- pas de corruption (fichiers, DB, files)
11.2 Test Kubernetes
Déployer puis supprimer :
kubectl apply -f deploy.yaml
kubectl rollout status deploy/<name> -n <ns>
time kubectl delete pod -n <ns> -l app=<name>
Pendant la suppression, observez :
kubectl get pod -n <ns> -w
Et vérifiez que le trafic est drainé (readiness qui tombe, plus de requêtes).
12) Résumé des bonnes pratiques (à appliquer systématiquement)
- PID 1 doit être sain : utilisez
tini(ou--init) etexec. - Ne lancez pas via
sh -csauf nécessité, et dans ce cas utilisezexec. - Gérez
SIGTERM: fermer listeners, drainer, flush, puisexit. - Time-boxez tout : timeouts réseau, timeouts shutdown, hooks
preStopbornés. - Alignez les timeouts : app < grace period < timeouts infra (selon stratégie).
- Surveillez les états D et les I/O : un conteneur « immortel » est souvent un symptôme kernel/stockage.
- Testez l’arrêt gracieux en CI et en staging (c’est une exigence de prod, pas un détail).
Annexes : commandes utiles (mémo)
Docker
docker ps
docker logs --tail 200 <c>
docker inspect <c> | less
docker stop --time 30 <c>
docker kill --signal=SIGTERM <c>
docker kill --signal=SIGKILL <c>
docker top <c>
PID=$(docker inspect -f '{{.State.Pid}}' <c>)
sudo nsenter -t "$PID" -p -m -u -i -n ps auxf
sudo nsenter -t "$PID" -p -m -u -i -n strace -p 1 -tt
Kubernetes
kubectl get pod -n <ns>
kubectl describe pod -n <ns> <pod>
kubectl logs -n <ns> <pod> -c <container>
kubectl exec -n <ns> -it <pod> -- ps auxf
kubectl debug -n <ns> -it <pod> --image=busybox:1.36 --target=<container> -- sh
kubectl delete pod -n <ns> <pod> --grace-period=0 --force
Nœud (containerd)
sudo crictl ps -a
sudo crictl inspect <id> | less
sudo crictl stop <id> --timeout 30
sudo crictl rm <id>
En appliquant ces principes (PID 1 correct, propagation des signaux, init minimal, timeouts cohérents, hooks bornés), vous transformez un arrêt « best effort » en un arrêt gracieux fiable, ce qui réduit drastiquement les Pods en Terminating, les conteneurs « zombies », et les incidents lors des déploiements en production.