Gérer proprement son script d'entrypoint

Une pratique commune est d’utiliser un script shell en tant qu’ENTRYPOINT, qui va effectuer des opérations quelconques (injection de secrets, initialisation de base de données…), puis démarrer le service, sous forme d’un ou de plusieurs processus.

Dans ce cas, le Dockerfile spécifie :

  • Une instruction ENTRYPOINT associée à un script shell
  • Une instruction CMD, passée en arguments de l’entrypoint

L’entrypoint est exécuté par un shell comme /bin/sh ou /bin/bash. C’est le premier processus exécuté dans le conteneur, il a donc le PID 1.

Traditionnellement, ce script lance le service désigné par CMD.

Question:

Quels sont les risques ?

Les scripts ne transmettent pas les signaux à leurs enfants (voir explications ici).

Important:

Le processus désigné par CMD ne reçoit donc jamais les signaux qui sont envoyés au conteneur. Or, ces signaux peuvent être importants : signal de terminaison, signal de rechargement de configuration… De plus, un processus tué brutalement peut créer des situations indésirables, comme la corruption d’une base de données. Il est donc très important qu’il reçoive les signaux qui lui sont destinés.

Supposons un Dockerfile du style :

FROM debian:buster-slim
...
ENTRYPOINT [ "/entrypoint.sh" ]
# Notez qu'on lance nginx en premier plan
CMD [ "nginx", "-g", "daemon-off;" ]

Avec un entrypoint du style :

snippet.bash
#!/bin/sh
 
# Injection de secrets
sed [...]
 
# Démarrage de nginx
$@

Note:

$@ désigne l’ensemble des arguments passés à l’entrypoint, c’est-à-dire le contenu du CMD du Dockerfile.

Dans ce cas, la situation ressemble à ceci :

NOM               PID
entrypoint.sh     1
  |__nginx        2

Comme nous venons de le voir, tout signal envoyé au conteneur arrivera à l’entrypoint, qui n’en fera rien, ce qui conduira Docker à envoyer un SIGKILL au bout d’un moment.

Question:

Comment faire ?

Le comportement désiré serait d’exécuter les instruction de l’entrypoint, puis de remplacer l’entrypoint par son processus enfant. C’est exactement le but de la commande exec.

On remplace l’entrypoint par :

snippet.bash
#!/bin/sh
 
# Injection de secrets
sed [...]
 
# Démarrage de nginx
exec $@

Après exécution de l’entrypoint, les processus sont comme suit :

NOM               PID
nginx             1

L’entrypoint a été remplacé par nginx. Il reçoit correctement les signaux.

Attention:

Attention, la philosophie Docker se résume généralement en : un conteneur, un processus. En réalité, c’est plutôt un conteneur, un service. Il y a donc des cas où il est légitime que plusieurs processus tournent simultanément, mais s’il s’agit de parties bien cloisonnées d’une application (e.g. serveur web et serveur applicatif), il est préférable de les faire tourner dans deux conteneurs séparés.

Un entrypoint qui lance plusieurs services pourrait ressembler à ceci :

snippet.bash
#!/bin/sh
 
# Injection de secrets
sed [...]
 
# Lancement de PHP en arrière plan
php-fpm &
 
# Lancement de nginx en premier plan
exec nginx -g 'daemon off;'

Après lancement, la situation est la suivante :

NOM               PID
nginx             1
  |__php-fpm      2

Pourquoi ? Tout simplement parce que nginx s’est substitué au shell via exec, mais il a aussi hérité des processus enfants du shell, comme php-fpm.

Ici, on se retrouve avec une problématique similaire : nginx va bien recevoir les signaux, mais ne les transmet pas à ses enfants. Et pour le coup, on ne peut pas ré-utiliser un exec, car nginx a toujours besoin de s’exécuter.

Dans ce cas précis, il va falloir utiliser des astuces plus ou moins sales.

Lien:

Docker propose une documentation sur ce sujet.

En gros, il y a deux solutions :

  • Utiliser supervisord. C’est plutôt la moyenne artillerie, mais ça permet de faire ce que l’on veut. supervisord est un processus d’initialisation, il va s’exécuter en tant que PID 1 et assumer toutes ses fonctions. C’est ce qui a été retenu pour pica-nginx.
  • Faire du cas par cas. Par exemple, si on sait qu’un processus enfant doit pouvoir recevoir un signal important, qu’on connaît à l’avance, on utilisera un trap.

Les trap sont des handlers de signaux. On ne développe pas ce point, mais c’est la solution retenue pour Mumble.

Voici un extrait de son entrypoint :

snippet.bash
python3 /exporter.py &
 
# Trap SIGUSR1 signal to reload certificates without restarting
_reload() {
  echo "Caught SIGUSR1 signal!"
  /usr/bin/pkill -USR1 murmurd
  wait
}
trap _reload SIGUSR1
 
# Run murmur
murmurd -v -ini $CONFIG_FILE
wait

Note:

Ici, python et murmurd sont lancés en arrière plan. Le shell attend ensuite que les processus terminent. Dès qu’un signal USR1 est reçu, il est capté par l’instruction trap et la fonction _reload s’exécute. Elle a pour effet de transmettre le signal à Murmur, puis on attend de nouveau. C’est un peu hackish, mais dans ce cas spécifique, c’est suffisant car on a pas réellement besoin de transmettre tous les signaux à Murmur.

Attention:

Dans ce cas, un docker stop terminera quand même en timeout puis en SIGKILL, car l’entrypoint ne fait rien du SIGTERM. On pourrait implémenter un handler pour SIGTERM qui l’envoie aux processus enfants, et ce serait même plus propre. C’est laissé à l’appréciation de chacun·e.

Dans cette page, nous avons montré comment faire en sorte que les processus d’un conteneur Docker reçoivent les signaux qui leur sont destinés.

En revanche, ca ne règle pas tous les problèmes, car les processus qui ont un PID 1 sont traités spécialement.

Attention:

En particulier, un processus avec un PID de 1 n’a pas de comportement par défaut quand il reçoit un signal qu’il ne traite pas explicitement, ce qui peut être problématique. Voir la documentation sur l'utilisation d'un système d'initialisation pour parfaire la configuration.

  • technique/docker/good_practices/multi.txt
  • de ppom