Docker, PID 1 et l'importance d'un programme d'init
Pourquoi le PID 1 d'un conteneur ignore les signaux et laisse les processus zombies s'accumuler, et comment dumb-init, tini ou le flag --init règlent le problème.
Ultimo aggiornamento: 9 febbraio 2026 · 10 min di lettura
Pour commencer
Déjà, un peu de contexte. Sous Linux, le processus ayant pour PID 1 est spécial, on fait souvent référence à lui comme “init process”, car c’est le premier qui est lancé au démarrage du système. Il est notamment reponsable de tous les autres processus qui naîtront et mourront jusqu’à ce que vous décidiez enfin d’éteindre votre machine pour laisser ce malheureux se reposer un peu. Une de ses responsabilités, c’est de receuillir les orphelins, mais quelle âme généreuse ! Une autre spécificité, c’est qu’il ignore les signaux (SIGTERM, SIGINT, etc.) à moins d’avoir un handler explicite pour gérer le signal en question.
docker build -t example-1 - <<'EOF'
FROM python:3.12-slim
RUN echo "import time; print('Running as PID 1', flush=True); time.sleep(30); print('Done! Still alive')" > app.py
CMD ["python3", "app.py"]
EOF
docker run --rm --name test-1 example-1
Sur un autre terminal :
container_pid=$(docker ps -qf "name=test-1")
docker kill --signal=SIGTERM $(docker ps -qf "name=test-1")
echo "Sent SIGTERM to $container_pid"
Effectivement, ce processus diabolique a survécu ! C’est dû au fait qu’aucun handler n’est défini pour le signal qui lui a été envoyé.
Programme init
Explorons maintenant ce que PID 1 implique sur une machine Linux. La commande ps -p 1 -o pid,ppid,cmd donne le résultat suivant (je suis sous Ubuntu 24) :
PID PPID CMD
1 0 /sbin/init splash
On voit que le processus au PID 1 est /sbin/init, et son PID parent étant 0, il est le processus le plus haut dans la hiérarchie.
Pour illustrer l’adoption des processus orphelins, un simple script python suffit :
import subprocess
import os
me = os.getpid()
print("My name is", me)
son = subprocess.Popen(["sleep", "60"])
print("I'm a father now!! Look at my baby:")
os.system(f"pstree -p {me}")
print(f"[please run the following command before and after you press enter] -> ps -p {son.pid} -o pid,ppid,cmd")
input("...")
print("Sorry son, I need to go buy some milk")
D’abord :
My name is 155507
I'm a father now!! Look at my baby:
python3(155507)─┬─sh(155511)───pstree(155512)
└─sleep(155510)
[please run the following command before and after you press enter] -> ps -p 155510 -o pid,ppid,cmd
...
La commande en question donne quelque chose comme ça :
ps -p 155510 -o pid,ppid,cmd
PID PPID CMD
155510 155507 sleep 60
Puis, après avoir pressé entrée dans le script python, on constate que celui-ci se termine directement. Son processus enfant cependant est toujours en cours d’exécution :
ps -p 155510 -o pid,ppid,cmd
PID PPID CMD
155510 3199 sleep 60
Il a été adopté par un autre dont le PID est 3199. Mais c’est qui lui encore ?!
ps -p 3199 -o pid,ppid,cmd
PID PPID CMD
3199 1 /usr/lib/systemd/systemd --user
Ah ok, simplement un programme init (systemd) mais qui ne gère que les programmes de l’utilisateur courant, et donc le parent est le vrai, l’originel processus PID 1. C’est une chose que je découvre en même temps que vous (ou pas, si vous êtes un érudit). En attendant, j’ai cru comprendre que ce processus a les mêmes propriétés et responsabilités que le PID 1.
Dans un conteneur Docker
Dans Docker, le point d’entrée (défini par les instructions ENTRYPOINT ou CMD dans le Dockerfile) prend automatiquement le PID 1 qui, comme on l’a vu précedemment, ne réagit pas par défaut aux signaux. De plus, bien souvent ce point d’entrée est un programme applicatifs (par exemple, une application Flask, une app Node.js, etc.) qui n’a pas été pensé pour gérer les responsabilités qu’impliquent le fait d’être le processus init, et n’ont simplement aucun moyen de collecter les processus zombies (le “reaping”).
Beaucoup d’images utilisent dumb-init, ou un autre programme du genre (comme https://github.com/krallin/tini), comme point d’entrée. Ce programme va donc devenir le PID = 1, et c’est parfait, car c’est lui qui endossera les responsabilités dont on parle.
C’est un programme très simple à installer via un gestionnaire de paquets (apt install dumb-init, apk add dumb-init, etc.), et puisqu’il s’agit d’un programme linké statiquement, il n’a en théorie aucune dépendance externe, donc il suffit que le binaire soit exécutable et placé au bon endroit, et ça devrait bien se passer.
FROM node:24-alpine3.23 AS builder
RUN apk add --no-cache dumb-init
FROM dhi.io/node:24-alpine3.23 AS runtime
# Just one file!
COPY --from=builder /usr/bin/dumb-init /usr/bin/dumb-init
# Use dumb-init as PID 1
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node"]
docker build -t dumb-init-install:latest . && docker run -it dumb-init-install:latest
Welcome to Node.js v24.13.0.
Type ".help" for more information.
> require("process").pid
7
Il faut savoir que les versions de Docker supérieures ou égales à la 1.13 intègrent tini, que j’ai mentionné plus haut. Ainsi, plus besoin d’installer manuellement dumb-init, il suffit de passer le flag --init à Docker (ou init: true dans un fichier docker-compose.yaml) :
docker run --init dhi.io/node:24-alpine3.23 node -e 'console.log(process.pid)'
7
# Sans --init
docker run dhi.io/node:24-alpine3.23 node -e 'console.log(process.pid)'
1
# Pour prouver que c'est bien tini
docker run --init dhi.io/node:24-alpine3.23 oops
[FATAL tini (7)] exec oops failed: No such file or directory
Attention, tini prend bien le PID 1, 7 correspond à son exit code.
”reaping” des processus zombies
Lorsqu’un processus en crée un autre, il devient en devient le parent. Lorsqu’un processus se termine, peu importe la raison, il est attendu que le parent collecte son statut, son résultat, via le syscall wait, Linux est fait ainsi. Cependant, si le parent ignore ses enfants, ou qu’il meurt avant que ses enfants se terminent, jamais personne ne viendra collecter le statut de ces processus, qui resteront dans un état “terminé mais en attente” : un processus zombie. Voyons cela :
zombie_factory.py :
import os
import time
print(f"Parent PID: {os.getpid()}")
while True:
pid = os.fork()
if pid == 0:
# Child: exits immediately immédiatement
print(f"Child exiting, pid={os.getpid()}")
os._exit(0)
else:
# Parent: never calls wait on its children, just keeps popping even more
time.sleep(1)
Dockerfile :
FROM python:3.13-alpine
WORKDIR /app
COPY zombie_factory.py .
CMD ["python", "zombie_factory.py"]
docker build -t zombie-factory .
docker run --rm --name zombies zombie-factory
Depuis un autre terminal :
docker exec -it zombies ps -o pid,ppid,stat,comm
PID PPID STAT COMMAND
1 0 S python
7 1 Z python
8 1 Z python
9 1 Z python
10 1 Z python
11 1 Z python
12 1 Z python
...
Les processus zombie s’accumulent ! Au bout d’un certain temps, ils vont éventuellement saturer la table des processus et le système ne fonctionnera plus comme prévu, mais dans un conteneur, cela provoquera seulement le crash de celui-ci, sans impacter le système hôte.
Et si je lance le conteneur avec --init ? Rien ne change, on m’a menti depuis le début ! En réalité, le programme d’init n’a aucune raison de reap les processus zombie dont le parent est toujours en cours d’exécution, car c’est à lui de collecter les exit codes de ses enfants, en principe.
Et si on créait un processus qui lui-même crée des zombies, avant de mourir ? Est-ce que les zombies se verront adopter par le processus parent, ou bien par le PID 1 et donc reapés instantanément ? C’est ce que nous allons voir :
multi_level_zombie.py :
import os
import time
def spawn_zombie_grandchildren(n):
for _ in range(n):
pid = os.fork()
if pid == 0:
print(f"Grandchild PID={os.getpid()} dying (becomes zombie)")
os._exit(0)
def child_process():
print(f"Child PID={os.getpid()} started, parent PID={os.getppid()}")
spawn_zombie_grandchildren(3)
print(f"Child PID={os.getpid()} sleeping 5s before exit...")
time.sleep(5)
print(f"Child PID={os.getpid()} exiting")
os._exit(0)
def parent_process():
print(f"Parent PID={os.getpid()} started")
pid = os.fork()
if pid == 0:
child_process()
else:
while True:
time.sleep(1)
if __name__ == "__main__":
parent_process()
Toujours le même Dockerfile :
FROM python:3.13-alpine
WORKDIR /app
COPY multi_level_zombie.py .
CMD ["python", "multi_level_zombie.py"]
docker build -t multizombies .
docker run --init --rm --name multizombies multizombies
docker exec -it multizombies ps -o pid,ppid,stat,comm
Des zombies sont bel et bien créés :
docker exec -it multizombies ps -o pid,ppid,stat,comm
PID PPID STAT COMMAND
1 0 S docker-init
7 1 S python
8 7 S python
9 8 Z python
10 8 Z python
11 8 Z python
36 0 R ps
Puis, après 5 secondes, ils se font tous reap à la mort du PID 8 (celui qui crée les zombies) par le programme d’init (ici donc, tini) :
docker exec -it multizombies ps -o pid,ppid,stat,comm
PID PPID STAT COMMAND
1 0 S docker-init
7 1 S python
8 7 Z python
42 0 R ps
Le PID 8 devient à son tour un zombie indéfiniment, car le processus principal se fiche de collecter son exit code, il est coincé dans sa boucle infinie.
Si on enlève --init aux paramètres de docker run, aucun reaping des processus zombies, et le process docker-init est absent :
docker run --rm --name multizombies multizombies
docker exec -it multizombies ps -o pid,ppid,stat,comm
PID PPID STAT COMMAND
1 0 S python
7 1 Z python
8 1 Z python
9 1 Z python
10 1 Z python
65 0 R ps
Hors conteneur, le processus init empêche que les zombies ne s’accumulent, car un programme qui crash et qui ne peut plus s’occuper de ses enfants, ça arrive en permanence.
D’ailleurs vous aurez peut-être remarqué que j’utilise la notation CMD ["python", "multi_level_zombie.py"] et non CMD python multi_level_zombie.py dans les Dockerfiles, c’est pour spécifier le programme et ses paramètres directement, et non pas passer par un shell (qui prendrait alors le PID 1) et fausserait nos tests.
Gestion des signaux
Comme dit plus tôt, le PID 1 est spécial, il ne réagit par défaut pas aux signaux type SIGTERM, SIGINT, SIGKILL (je n’ai pas la liste exhaustive alors je vais me contenter de dire “etc.”). Autrement, un handler par défaut existe pour chacun de ces signaux, dans le cas où le processus n’en définit pas :
Par exemple, si on lance un programme qui ne définit pas de handler, en tant que PID 1, par exemple multizombies (docker run --rm --name multizombies multizombies), on constate que la commande docker stop prend exactement 10 secondes :
time docker stop multizombies
multizombies
real 0m10,257s
user 0m0,015s
sys 0m0,021s
Pourquoi ? Car Docker envoie d’abord un signal SIGTERM, puis après un timeout (configurable, 10 secondes par défaut), un signal SIGKILL. Donc, docker kill (qui envoie SIGKILL) est instantané :
time docker kill multizombies
multizombies
real 0m0,199s
user 0m0,014s
sys 0m0,029s
Et si cette fois on utilise --init, docker stop réagit au quart de tour :
docker run --init --rm --name multizombies multizombies
time docker stop multizombies
multizombies
real 0m0,203s
user 0m0,007s
sys 0m0,022s
Comment expliquer cela ? Eh bien cette fois, tini est le programme d’init, il est PID 1, et il prend son rôle très à coeur. Il va entre-autres propager tous les signaux à ses enfants, ce qui permet aux applications de bénéficier des handlers par défaut. D’après cette discussion :
By default, SIGTERM terminates a Python process abruptly, with no cleanup, as if you had called
os._exit().
C’est plutôt clair. Ensuite, tini voyant qu’il n’a plus aucun processus enfant en cours d’exécution, se termine également, et c’est la fin d’instance du conteneur.
Conclusion
Alors voilà, utiliser un programme d’init dans nos conteneurs, ça ne coûte pas grand chose et ça permet d’avoir un filet de sécurité, dans le cas cas où l’application exécutée ne gère pas correctement les signaux, ou ses processus enfants. Même en voulant faire les choses bien, un bug est vite arrivé. Alors, voici une vieille recette de grand-mère à impérativement préparer pour chacun de vos conteneurs :
- utilisez
dumb-init,tiniou équivalent, ou simplement--init(init: truedans compose) si vous le pouvez - écrivez vos instructions
CMD/ENTRYPOINTau format tableau, type["python", "main.py"]pour éviter de passer par un shell inutilement - gérez vos signaux comme si votre vie en dépendait (car celle de votre processus en dépend pour de vrai)
- et enfin, ne négligez jamais vos enfants !
Je n’ai fait que survoler la surface de ces concepts, en les découvrant au fur à mesure, mais j’espère vous avoir convaincu qu’un petit init dans vos conteneurs est bien plus qu’un détail !