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) :
- la forme shell :
ENTRYPOINT node index.js - la forme exec :
ENTRYPOINT ["node", "index.js"]
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 :
- Initialiser les services système. C’est lui qui démarre l’ensemble des processus requis par le système d’exploitation au lancement.
- Adopter et nettoyer les processus zombies et orphelins. Un processus orphelin est un processus dont le parent s’est terminé ; il est alors « réadopté » par le PID1. Un processus zombie est un processus qui a terminé son exécution mais dont l’entrée subsiste dans la table des processus tant que personne n’a lu son code de sortie : ses ressources restent monopolisées. Le rôle du PID1 est de « récolter » (reaping) ces zombies pour libérer ces ressources.
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 :
- les processus zombies issus de sous-processus qu’elle aurait pu lancer (un appel système, un script, un outil externe) ne seront jamais nettoyés, et s’accumuleront jusqu’à saturer la table des processus du container ;
- la sémantique des signaux peut être surprenante : le Kernel applique au PID1 un traitement par défaut différent (certains signaux non gérés explicitement sont tout simplement ignorés au lieu de provoquer la terminaison du processus).
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 :
- il transmet correctement les signaux qu’il reçoit à votre application ;
- il récolte les processus zombies pour éviter qu’ils ne s’accumulent ;
- il reste extrêmement léger, sans imposer la moindre logique applicative.
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 :
- en ligne de commande, avec le flag
docker run --init; - avec Docker Compose (depuis la v2.2), en ajoutant
init: trueà la configuration d’un service.
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 :
- la forme exec (
["...", "..."]) est utilisée, garantissant qu’aucun shell intermédiaire ne vient s’interposer ; - Tini devient le PID1 et, grâce au séparateur
--, lancenode index.jscomme processus enfant. C’est désormais Tini qui reçoit les signaux de l’orchestrateur et les relaie proprement à votre application, tout en se chargeant du nettoyage des zombies.
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.