Antoine's computer
Questo articolo non è disponibile in italiano. Stai leggendo la versione Francese.

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, tini ou équivalent, ou simplement --init (init: true dans compose) si vous le pouvez
  • écrivez vos instructions CMD/ENTRYPOINT au 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 !

Lectures intéressantes