Bonnes pratiques pour les Dockerfiles

Lors de l’import d’une image dans un Dockerfile à l’aide de la directive FROM, il est important de spécifier la version de l’image importée ; sinon la dernière version (latest) est importée, ce qui peut poser des problèmes de stabilité.

Chaque élément d’une image peut être un facteur de vulnérabilité ; même un paquet en apparence inoffensif peut dépendre d’une librairie contenant une vulnérabilité. Pour cette raison, il convient de réaliser des images aussi légères que possibles en évitant d’installer des paquets superflus ou en supprimant les paquets installés pour un besoin temporaire (par exemple pour compiler un programme) une fois qu’ils sont obsolètes.

Note:

Préférez vous baser sur une image Alpine (peu de paquets, donc peu de vulnérabilités) ou Debian. Pour Debian, préférez la version stable la plus récente possible (Buster plutôt que Stretch) pour mitiger d’emblée un grand nombre de vulnérabilités.

Préférez également les versions slim, qui contiennent moins de paquets et suffisent souvent.

Pour les services basés sur un langage interprété comme Python, utilisez l’image officielle en appliquant les principes précédents.

Note:

Il peut être intéressant d’avoir recours à des multi-stage builds. L’idée est par exemple d’utiliser une image pour la compilation, et une image qui recevra uniquement l’exécutable et les dépendances nécessaires à l’exécution. Cette dernière sera utilisée en production.

L’idée derrière un build “reproductible”, c’est que si je lance le build d’un même Dockerfile à deux moments différents, l’image finale doit être la même quel que soit le temps écoulé entre les deux.

Attention:

Ce ne sont pas de vrais builds reproductibles, car la version des paquets installés peut avoir changé, par exemple. Mais l’idée est d’essayer de s’en rapprocher.

Question:

Pourquoi des builds reproductibles ?

Pour pouvoir relancer le build d’une ancienne version et la remettre en production en cas de problème. Si on a l’ancien Dockerfile, alors on devrait pouvoir avoir quasiment la même ancienne image.

Souvent, un Dockerfile va récupérer du code sur un dépôt Git, ou encore un binaire sur un site de téléchargement de releases. Il est fortement déconseillé de faire un git clone ou un wget de la dernière version (latest, master…), ce qui rend le build non reproductible et dépendant de cette dernière version.

Il est donc important de gérer la version du service en question, par exemple avec une variable ARG SERVICE_VERSION=1.0.1 qui sera ré-utilisée dans l’URL de téléchargement.

En outre il existe deux solutions pour récupérer du code existant, versionné sur un dépôt Git distant :

  • Utiliser un wget sur une release particulière (Gitlab, Github), permettant de récupérer une archive contenant le code source d’une version spécifique.
  • Installer Git dans le Dockerfile, utiliser un git clone puis un git checkout <tag> sur la version souhaitée et copier le code dans l’image.

Note:

En général, on préférera télécharger une release du code que d’utiliser Git.

Pour lancer directement le binaire d’un service au démarrage, on utilisera la directive CMD avec la forme tableau ([ "commande", "arg1", "arg2" ]). En effet, cette forme permet de démarrer le processus directement, tandis que la forme sans tableau est exécutée par /bin/sh, ce qui pose des problèmes (explications ici).

Lorsque l’on a besoin d’exécuter des instructions préalables (injection de secrets, test du contact avec la base de données, etc…), on utilisera la directive ENTRYPOINT en conjonction avec CMD, toujours sous la forme tableau.

En général, l’entrypoint sera un script shell, qui recevra en argument le tableau passé à CMD.

Pour éviter d’installer deux fois un paquet, lors de l’installation de plusieurs paquets en une instruction, il convient de les installer dans l’ordre alphabétique.

Un Dockerfile est composé de différentes couches (layers). Une couche correspond à une instruction exécutée par le Dockerfile.

Lorsqu’un conteneur est lancé, il va accéder aux commandes du conteneur en remontant les différents niveaux. À la manière de l’héritage en programmation, si un conteneur cherche a appeler une commande qu’il ne connaît pas, il va remonter à sa classe mère et ainsi de suite. Or, ces accès réduisent la vitesse d’exécution de la commande. Il faut donc veiller à réduire au maximum le nombre de couches d’un conteneur et privilégier les instructions avec plusieurs commandes.

Note:

Les couches ont pour intérêt de pouvoir être partagées entre les conteneurs et utilisent un système de cache. Ainsi, la modification d’une instruction n’influencera pas les couches précédentes, qui n’auront pas besoin d’être reconstruites. Il faut donc trouver le juste milieu entre trop de couches et pas assez de couches.

En général, une couche est une sorte d’unité sémantique. L’exemple qui suit décrit l’installation de dépendances, qui n’ont pas lieu d’être séparées en trois instructions :

snippet.bash
RUN echo "deb http://packages.dotdeb.org jessie all" > /etc/apt/sources.list.d/dotdeb.list && \
wget -O- https://www.dotdeb.org/dotdeb.gpg | apt-key add - && \
apt-get update -y && apt-get install -y php7.0 php7.0-fpm php7.0-gd php7.0-xml nginx supervisor curl tar

Note:

Dans cet exemple, il n’y a qu’une seule instruction RUN, donc une seule couche est créée. Par contre, la moindre modification de cette instruction entraînera son ré-exécution complète lors de la reconstruction de l’image.

Les HEALTHCHECK, au sens Docker, permettent de vérifier qu’un conteneur est en “bonne santé”, par exemple en lançant une commande de type curl.

Attention:

Souvent, les commandes comme curl et wget ne sont pas installées, il faut y penser.

Lors de la rédaction d’images, on peut être tenté d’exposer des volumes sur des images “temporaires”. Par exemple, une image nginx dans laquelle on va indiquer que /var/www/html doit être persistent grâce à la directive VOLUME.

Le problème de cette pratique est que si l’on veut créer une image Dokuwiki basée sur cette image nginx, il ne sera pas possible d’écrire par dessus le volume /var/www/html. Même si au sein du Dockerfile on spécifie des commandes pour remplacer le contenu de l’image, au final, le contenu du dossier restera celui de l’image mère.

On évitera donc les directives VOLUME au sein des Dockerfile, et on spécifiera les volumes au lancement du conteneur.

Dans un Dockerfile, une commande exécutée par une directive RUN est exécutée par le compte root du conteneur par défaut. Il convient d’exécuter aussi peu de commandes que possible avec le compte root ; pour se faire, il est possible d’utiliser la directive USER.

Important:

Un conteneur lancé en tant que root pose des risques de sécurité en cas de compromission, bien plus que quand il est lancé en tant que simple utilisateur. Pour certains services, c’est difficile, mais il faut essayer de s’en rapprocher le plus possible.

La dernière directive USER utilisée correspondra à l’utilisateur qui exécutera les commandes quand le conteneur sera lancé. Il est préférable de démarrer le conteneur avec un utilisateur non-privilégié. On s’assurera qu’il a bien les droits sur les fichiers qu’il doit utiliser, par exemple avec des instructions chown lors de la construction de l’image.

  • technique/docker/general/tips.txt
  • de qduchemi