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.
Entrypoint qui lance un unique processus
Situation
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 ?
Problématique
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 ?
Utiliser exec
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.
Entrypoint qui lance plusieurs services
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.
Est-ce-que c'est suffisant ?
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.