← Retour aux tutoriels

Arrêts gracieux en production : corriger les conteneurs bloqués ou zombies

devopsdockerkubernetesgraceful-shutdownconteneursproductionpid-1sre

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 :


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 :

Causes typiques :

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 :

Cause principale :

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 :

  1. SIGTERM au processus PID 1 du conteneur
  2. attend --time secondes (par défaut 10)
  3. envoie SIGKILL si 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 :

  1. Exécute éventuellement un hook preStop
  2. Envoie SIGTERM au PID 1 du conteneur
  3. Attend terminationGracePeriodSeconds
  4. Envoie SIGKILL

Exemples de paramètres (vous les verrez dans les manifests) :

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 :

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

Cherchez :

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 :

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 :

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 :

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

Exemple Java (Spring Boot)

Spring Boot gère SIGTERM et déclenche un shutdown, mais vous devez :

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 :

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 :

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 :

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 :

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 :

  1. readinessProbe passe à fail pendant le shutdown (ou endpoint /ready renvoie non prêt)
  2. le Service retire le Pod des endpoints
  3. l’app attend un court délai pour laisser le LB se mettre à jour
  4. l’app se termine

Implémentation typique :

8.2 Pattern « PID 1 propre + init minimal »

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 :

Exemple : si votre app attend un appel HTTP externe sans timeout, le shutdown peut rester bloqué. En production, mettez toujours des timeouts :


9) Recettes de correction rapides (checklist opérationnelle)

9.1 Pour Docker (incident en cours)

  1. Identifier PID et processus :
docker inspect -f '{{.State.Pid}}' <container>
docker top <container>
  1. Tenter SIGTERM avec délai plus long :
docker stop --time 30 <container>
  1. Si bloqué, inspecter avec nsenter :
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
sudo nsenter -t "$PID" -p -m -u -i -n ps auxf
  1. Si état D, investiguer stockage/kernel (pas un problème « Docker »).

  2. Après incident : corriger Dockerfile (exec, tini, pas de sh -c inutile).

9.2 Pour Kubernetes (Pod en Terminating)

  1. Comprendre la cause :
kubectl describe pod -n <ns> <pod>
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -n 30
  1. Vérifier hooks et grace period :
kubectl get pod -n <ns> <pod> -o jsonpath='{.spec.terminationGracePeriodSeconds}{"\n"}'
  1. Si nécessaire, debug dans le conteneur :
kubectl exec -n <ns> -it <pod> -- ps auxf
  1. Dernier recours :
kubectl delete pod -n <ns> <pod> --grace-period=0 --force
  1. Après incident : corriger preStop, probes, gestion SIGTERM, et ajouter tini.

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 :

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

  1. Lancer :
docker run --rm -p 8080:8080 --name test myimage
  1. Envoyer du trafic :
curl -v http://127.0.0.1:8080/health
  1. Stopper et mesurer :
time docker stop --time 30 test

Vérifiez :

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)


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.