← Retour au blog
Outillage

L'erreur à ne pas faire en production : le rôle du PID1 dans un container

Pourquoi votre application ne doit pas être le PID1 d'un container : propagation des signaux, processus zombies/orphelins, ENTRYPOINT Docker et la solution Tini.

📅 ✍️ Antoine Coulon
dockerpid1tinicontainerssignals

Vous avez soigné la gestion des signaux de terminaison de votre application, mis en place un arrêt gracieux irréprochable, et pourtant en production votre container refuse toujours de s’éteindre proprement : il met dix secondes à mourir, puis se fait tuer de force par l’orchestrateur. Le coupable n’est pas votre code applicatif. Il se cache un cran plus bas, dans la manière dont votre processus est lancé à l’intérieur du container, et plus précisément dans le rôle si particulier du PID1.

Dans le premier volet de cette série, nous avons vu pourquoi il est essentiel de gérer correctement les signaux de terminaison (SIGTERM, SIGINT) pour réaliser un arrêt gracieux. Mais ce travail repose sur un pré-requis souvent passé sous silence : encore faut-il que votre processus applicatif reçoive effectivement ces signaux. Cela paraît évident, et c’est pourtant là que tout déraille.

Le piège de l’ENTRYPOINT en forme shell

Il existe de nombreuses façons de lancer une application dans un container, et plusieurs d’entre elles aboutissent à une mauvaise propagation des signaux. L’exemple le plus courant concerne l’instruction ENTRYPOINT d’un Dockerfile.

Docker propose deux formes pour ENTRYPOINT (et CMD) :

Lorsque la forme shell est utilisée, Docker n’exécute pas directement votre application. Il lance d’abord un shell (/bin/sh -c), et c’est ce shell qui devient le PID1 du container. Votre application, elle, est démarrée comme un processus enfant de ce shell.

Le problème est mécanique : le shell ainsi invoqué ne transmet pas les signaux qu’il reçoit à ses enfants. Quand l’orchestrateur envoie un SIGTERM au container, il l’adresse au PID1 (donc au shell) et votre application ne le voit jamais passer. Tout le code d’arrêt gracieux que vous avez écrit reste lettre morte, et après le délai de grâce le container est tué brutalement par un SIGKILL.

On serait tenté d’en conclure que la solution idéale consiste simplement à supprimer l’intermédiaire entre l’application conteneurisée et l’orchestrateur, en passant à la forme exec pour que l’application devienne elle-même le PID1. C’est un progrès, mais ce n’est pas le bon raisonnement.

Le vrai problème n’est pas l’intermédiaire

Le problème de fond n’est pas qu’il existe un intermédiaire dans le container. Il n’est pas non plus uniquement lié à la propagation des signaux. Le cœur du sujet, c’est la nature même du PID1 et les responsabilités que le Kernel lui attribue.

La gestion correcte d’une application conteneurisée en production obéit à une règle précise :

Le processus « init », c’est-à-dire le PID1, doit être en mesure d’assumer correctement les responsabilités que le Kernel lui confie.

Pour comprendre pourquoi, il faut revenir un instant aux fondamentaux d’un système d’exploitation et à l’organisation de ses processus.

Le PID1, racine de l’arbre des processus

Sur un système d’exploitation, l’ensemble des processus est organisé sous forme d’un arbre. Le nœud racine de cet arbre porte l’identifiant 1 : c’est le PID1, communément appelé « init ». Tous les autres processus en descendent, directement ou indirectement.

Contrairement à un processus lambda, le processus « init » se voit confier par le Kernel des responsabilités bien particulières :

Dans le contexte spécifique des containers s’ajoute une troisième responsabilité : la propagation correcte des signaux aux processus enfants, précisément ce qui faisait défaut avec la forme shell.

Pourquoi votre application ne doit pas être le PID1

On comprend dès lors pourquoi faire de votre application le PID1 n’est pas la bonne solution. La plupart des runtimes applicatifs (Node.js, Python, la JVM…) n’ont jamais été conçus pour assumer le rôle d’init. Ils n’implémentent pas la récolte des processus zombies, et leur gestion des signaux par défaut diffère de celle attendue d’un véritable init.

Concrètement, si votre application est PID1 :

Toutes ces responsabilités ne peuvent, et ne doivent, pas reposer sur votre application. Il faut donc un véritable processus init au sommet de l’arbre, et y rattacher votre application comme processus enfant. Non pas pour ajouter un intermédiaire de plus, mais pour confier le rôle de PID1 à un programme fait pour ça.

La solution : Tini

Plusieurs solutions existent pour fournir ce processus init minimal, mais la référence dans le domaine est Tini.

Tini a un objectif unique : mettre à disposition un processus « init » qui se comporte exactement comme on l’attend d’un PID1. Il fait trois choses, et il les fait bien :

C’est un exécutable indépendant, mais (point important) il est embarqué par défaut dans Docker depuis la version 1.13. Vous pouvez ainsi l’activer sans même l’installer :

Intégration explicite dans un Dockerfile

Si vous préférez maîtriser explicitement le processus init dans votre image (par exemple pour ne pas dépendre du flag --init au moment de l’exécution) vous pouvez installer Tini et l’utiliser directement comme ENTRYPOINT, sous la forme exec :

# Dockerfile

FROM node:18-alpine

RUN apk add --no-cache tini

# Copy app files...

ENTRYPOINT ["/sbin/tini", "--", "node", "index.js"]

Deux détails méritent d’être soulignés dans cet ENTRYPOINT :

Conclusion

Soigner l’arrêt gracieux de votre application est indispensable, mais c’est un effort vain si les signaux ne lui parviennent jamais. Or, dans un container, la responsabilité de recevoir, traiter et propager ces signaux, tout comme celle de nettoyer les processus zombies, incombe au PID1. Et votre application n’est pas faite pour ce rôle.

La bonne pratique tient en une phrase : confiez le PID1 à un véritable processus init. Avec Docker, c’est souvent à portée d’un simple --init, ou d’un ENTRYPOINT Tini en forme exec dans votre Dockerfile. Un détail d’apparence anodine, mais qui fait toute la différence entre un container qui s’éteint proprement et un autre qui se fait tuer de force à chaque déploiement.