Retour aux articles

1567 processus zombies dans un conteneur

En cherchant une fuite de mémoire, je suis tombé sur autre chose : un conteneur qui hébergeait 1567 processus zombies. Ils étaient là depuis seize jours. Personne ne les avait jamais vus.

Voici pourquoi, et comment ça se corrige en un mot.

Un zombie, c'est quoi

Quand un programme se termine sous Linux, le noyau libère tout de suite sa mémoire. Mais il garde une petite fiche à son nom, avec son code de sortie, au cas où le programme qui l'a lancé veuille savoir comment ça s'est passé.

Le parent vient lire la fiche, et elle disparaît. Ça prend quelques microsecondes.

Un zombie, c'est simplement une fiche que personne n'est venu lire. Il n'y a plus de programme derrière, plus de mémoire, plus rien qui tourne. Juste une ligne qui reste.

  • PID : le numéro unique que le noyau attribue à chaque processus. Quand je parle du « processus numéro 1 » dans cet article, c'est du PID 1 qu'il s'agit. Le numéro du parent, lui, s'appelle le PPID.
  • fork : un processus se duplique pour en créer un nouveau. Le créateur est le parent, la copie est l'enfant. C'est le mécanisme de base pour créer un processus sous Linux : l'appel réellement utilisé aujourd'hui s'appelle clone, et fork n'en est qu'un cas particulier.
  • exec : la copie remplace son programme par un autre, sans changer de numéro. Un fork suivi d'un exec, c'est ce que fait votre shell à chaque commande que vous tapez.
  • prefork : créer ses enfants d'avance au démarrage, plutôt qu'un par tâche. L'intérêt est double. Créer un processus coûte du temps, donc on paie cette facture une fois au démarrage et les enfants n'ont plus qu'à attendre du travail. Et si l'un d'eux plante ou se met à fuir, le parent le remplace sans que le service s'arrête. C'est le schéma d'un serveur web ou d'un worker : un parent qui distribue, des enfants qui exécutent.
  • signal : un message d'un seul mot envoyé à un processus, par le noyau ou par un autre processus. Il ne transporte aucune donnée, juste son nom. Trois comptent ici :
    • SIGTERM : « termine-toi, s'il te plaît ». Le processus peut l'intercepter pour finir ce qu'il a commencé et fermer proprement ses fichiers avant de partir. C'est ce que docker stop envoie en premier.
    • SIGKILL : « tu meurs maintenant ». Impossible à intercepter ou à ignorer, c'est le noyau qui l'exécute. Aucun ménage n'est possible, d'où les fichiers temporaires et les écritures à moitié faites qu'on retrouve après.
    • SIGCHLD, sans le I : « un de tes enfants vient de se terminer ». Le noyau l'envoie au parent, et c'est ce signal qui l'avertit qu'il a une fiche à aller lire. Un parent qui ne va jamais la lire fabrique un zombie. Attention au piège de vocabulaire : « ignorer » SIGCHLD au sens strict, c'est-à-dire l'armer explicitement sur SIG_IGN, produit l'effet inverse — le noyau récolte alors les enfants tout seul et aucun zombie n'apparaît. Le zombie vient de l'absence d'appel à wait(), pas du signal.
  • reaping : le parent lit le code de sortie de son enfant terminé, avec l'appel wait(), et la fiche disparaît. En français, moissonner.
  • orphelin : un enfant dont le parent est mort. Le noyau le confie au processus numéro 1, à qui revient donc le reaping.

Pourquoi ça s'accumule dans un conteneur

Sous Linux, quand un programme meurt en laissant des enfants derrière lui, ces enfants sont confiés au processus numéro 1. C'est à lui de lire leurs fiches.

Sur un serveur normal, le numéro 1 c'est systemd, et c'est précisément son métier.

Dans un conteneur, il n'y a pas de systemd. Le numéro 1, c'est votre application, tout simplement parce qu'elle est le premier programme lancé.

Et votre application n'a aucune idée qu'elle a hérité de cette corvée. Personne ne l'a écrite pour ça. Elle ignore donc les fiches, et elles s'empilent.

Dans mon cas, l'application ouvrait un navigateur en arrière-plan pour chaque tâche. Chaque fermeture laissait deux fiches. Seize jours plus tard, 1567.

Pourquoi personne ne les voit

Parce qu'un zombie ne consomme presque rien. Soyons précis : la fiche est une vraie structure du noyau, la task_struct, et elle pèse environ 7 Ko — 7104 octets sur mon noyau, chiffre lisible dans /proc/slabinfo. Sur mes 1567 zombies, ça représente 11 Mo, et c'est un plancher : le noyau garde aussi quelques structures annexes autour de chaque fiche. C'est réel, mais c'est noyé dans le bruit.

Il faut distinguer deux choses. D'un côté la mémoire que le programme utilisait pour travailler : ses données, ses fichiers ouverts. Le noyau l'a rendue au système à l'instant où le programme s'est terminé. De l'autre la fiche, qui reste, et que le noyau garde de son côté.

Vos outils de surveillance mesurent la première. Ils affichent, pour chaque processus, la quantité de mémoire qu'il occupe. Pour un zombie, ce chiffre vaut zéro, parce qu'il n'y a plus rien à mesurer.

Tous les réflexes passent donc à côté. Vous triez les processus par mémoire décroissante, les zombies sont en bas de liste et sortent de l'écran. Vous regardez les graphiques de RAM, rien ne bouge. Vous redémarrez le conteneur, tout repart, et vous concluez que ce n'était rien.

Détail qui m'a fait tiquer. Ces fiches n'appartiennent pas à votre application, elles appartiennent au processus numéro 1. Et dans ce conteneur, le numéro 1 n'était pas l'application : c'était un petit superviseur qui la relance automatiquement quand le code change.

L'application pouvait donc redémarrer autant de fois qu'elle voulait, le numéro 1 restait en place, et les fiches avec lui. Quand j'ai mesuré, l'application venait de redémarrer huit minutes plus tôt. Les plus vieilles fiches avaient seize jours.

Redémarrer le conteneur, en revanche, efface tout : le numéro 1 meurt, et les fiches avec lui. C'est exactement ce qui permet au problème de passer inaperçu pendant des mois, parce qu'un redémarrage donne l'impression que tout va bien.

Si votre application est directement le numéro 1, sans superviseur au-dessus, la question ne se pose pas : la redémarrer revient à redémarrer le conteneur.

Est-ce grave

Honnêtement, dans mon cas, non. Une centaine de fiches par jour contre un plafond de 38193 : il aurait fallu à peu près un an avant que ça pose un problème. La fuite de mémoire que je cherchais au départ avait une cause complètement différente.

Ce plafond, c'est le pids.max du cgroup de mon conteneur. Vérifiez le vôtre plutôt que de reprendre mon chiffre, parce qu'il varie du tout au tout :

docker exec MON_CONTENEUR cat /sys/fs/cgroup/pids.max

Docker ne limite rien par défaut, un --pids-limit 100 se remplit en une journée, et s'il n'y a aucune limite de cgroup c'est le pid_max du noyau qui sert de butoir — partagé, lui, avec tout le reste de la machine.

Mais le plafond existe. Une fois atteint, plus aucun programme ne peut démarrer dans le conteneur, et ça vaut aussi pour les processus dont vous auriez besoin pour comprendre ce qui se passe. Autant ne pas y arriver.

Les compter

docker exec MON_CONTENEUR sh -c "grep -l '^State:.*Z' /proc/[0-9]*/status | wc -l"

Et pour savoir de quoi il s'agit :

docker exec MON_CONTENEUR sh -c "grep -h '^Name:' \$(grep -l '^State:.*Z' /proc/[0-9]*/status) | cut -f2 | sort | uniq -c | sort -rn | head"

Ce qui m'a donné :

   1499 headless_shell
     25 Socket Process
     25 RDD Process
     13 Utility Process
      5 celery

Si le nombre grimpe de jour en jour sans jamais redescendre, vous avez le même problème que moi.

Corriger

Il ne s'agit pas de modifier votre application. Il s'agit de remettre au numéro 1 un programme qui sait faire le ménage, et de laisser votre application un cran plus bas.

Docker embarque ce qu'il faut, avec un seul drapeau :

docker run --init mon-image

En Docker Compose :

services:
  mon-worker:
    init: true

C'est tout. Docker place tini au numéro 1, un petit programme dont c'est le seul métier : lancer votre application, puis lire les fiches qui arrivent.

Une limite à connaître : tini ne récolte que ce qui lui revient, autrement dit les orphelins que le noyau lui réattribue. Si c'est votre application elle-même qui lance des enfants et qui reste vivante sans jamais appeler wait(), les fiches lui restent attachées et --init n'y changera rien. Le test tient en une commande : si le PPID de vos zombies est 1, le drapeau suffit. C'était mon cas.

Là où ce drapeau n'existe pas, parce que ce n'est ni docker run ni Compose qui lance le conteneur, on installe tini directement dans l'image :

RUN apt-get update && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["mon-appli"]

Effet secondaire agréable : votre docker stop devient instantané. Il mettait dix secondes avant, et voici pourquoi : le noyau n'applique pas au processus numéro 1 les actions par défaut des signaux. Sans gestionnaire de SIGTERM, le numéro 1 laisse donc tomber la demande d'arrêt polie que Docker envoie en premier, et il faut attendre les dix secondes au bout desquelles Docker perd patience et envoie SIGKILL. Descendue d'un cran sous tini, la même application n'est plus couverte par cette exception et s'arrête tout de suite.

Ce que j'en retiens

Un conteneur peut accumuler seize jours de déchets sans qu'aucun tableau de bord ne signale quoi que ce soit, simplement parce que ces déchets ne pèsent rien.

Si vos conteneurs lancent des sous-processus, des navigateurs, des convertisseurs, des outils en ligne de commande, la question vaut d'être posée une fois pour toutes : qui est le numéro 1 chez vous ? Si la réponse est votre application, ajoutez --init et passez à autre chose.

Sources

Vous avez des questions ou d'autres infos techniques à partager ? Contactez-moi ou retrouvez-moi sur LinkedIn.

Thomas Bourimech

Thomas Bourimech

Développeur & Intégrateur IA freelance

thomasbourimech.com