Ce que vous coûte un mauvais Dockerfile
| |
Quatre lignes, et chacune vous coûte quelque chose. docker build relance npm install à chaque changement de code, parce que COPY . . casse le cache des couches avant même que l’étape d’installation ait une chance d’être réutilisée. L’image finale traîne toute la base Debian, l’arbre complet de node_modules avec les dépendances de développement, et tous les outils de build que npm install a récupérés pour compiler des modules natifs. Rien n’est jeté. Le conteneur tourne en root, parce que rien ne l’en empêche, et node:latest veut dire que l’image de base peut changer entre deux builds sans laisser de trace de ce que vous avez réellement livré.
Rien de tout cela ne remonte comme une erreur. L’image se construit, le conteneur démarre, l’application répond. Le coût arrive plus tard : une build CI de cinq minutes parce que chaque étape repart de zéro, et une image de 1,1 Go pour une application dont le code fait quelques centaines de kilo-octets. Chaque correctif ci-dessous est petit. Ensemble, ils changent ce qu’un Dockerfile vous coûte au quotidien.
Comment le cache des couches Docker décide quoi reconstruire
Docker construit une image instruction par instruction et met en cache le résultat de chacune sous forme de couche. À la build suivante, il relit le Dockerfile et réutilise une couche en cache tant que l’instruction est identique et que la couche au-dessus dans la chaîne n’a pas changé. Dès qu’une instruction rate le cache, toutes celles qui suivent sont rejouées aussi. Les cache hits n’étendent une chaîne que depuis le haut. Si images, couches et conteneurs restent flous, ce qu’est Docker et comment fonctionnent les conteneurs couvre les bases que cet article suppose acquises.
COPY et ADD invalident le cache selon le contenu, pas seulement selon le texte de l’instruction : si un fichier copié a changé, le cache de cette couche disparaît, et tout ce qui suit avec lui. COPY . . copie tout le contexte de build, donc il invalide au moindre changement n’importe où dans le projet, jusqu’à un commentaire reformulé dans un fichier qui n’a rien à voir avec les dépendances. Placez RUN npm install juste après ce COPY et il repart à chaque build.
Comment ordonner les instructions du Dockerfile pour maximiser le cache
Ne copiez que ce dont une instruction a besoin, dans l’ordre où les choses changent vraiment : les manifestes de dépendances rarement, le code source en permanence.
| |
package.json et package-lock.json changent quand vous ajoutez ou mettez à jour une dépendance. Le cache de RUN npm ci ne dépend désormais que de ces deux fichiers. Modifiez un fichier source et reconstruisez : Docker réutilise la couche d’installation en cache et ne rejoue que le COPY final et ce qui suit. L’étape la plus lente de la build ne tourne que quand c’est nécessaire.
npm ci plutôt que npm install est délibéré. Il installe exactement ce qu’il y a dans le lockfile et échoue si le lockfile et package.json sont en désaccord, plutôt que de résoudre silencieusement de nouvelles versions dans une build censée être reproductible.
Le même ordre s’applique en dehors de Node : un projet Python copie requirements.txt et lance pip install avant le reste du code source, un projet Go copie go.mod/go.sum et lance go mod download en premier.
Comment les multi-stage builds réduisent l’image finale
Réordonner règle le cache. Ça ne change rien à une image qui embarque toujours les dépendances de développement, les outils de build, et tout ce que npm ci a téléchargé pour compiler des modules natifs, dont rien n’est nécessaire à l’exécution de l’application. Un multi-stage build sépare ce qu’il faut pour construire l’application de ce qu’il faut pour la faire tourner.
| |
Deux lignes FROM, deux stages. Le premier, nommé build, installe toutes les dépendances (y compris celles réservées au développement, comme un bundler ou TypeScript) et produit dist/. Le second repart de zéro depuis la même image de base, installe uniquement les dépendances de production, et récupère un répertoire du premier stage avec COPY --from=build. Le compilateur TypeScript, les fichiers source .ts, le cache npm : rien de tout cela n’atteint l’image finale, parce que le stage de build est jeté une fois la build terminée.
La différence de taille est l’essentiel. node:latest, l’image complète basée sur Debian, avoisine 1,1 Go avant même d’ajouter une seule dépendance. node:24-slim reste sous 300 Mo, et node:24-alpine sous 200 Mo si vos dépendances n’ont pas besoin de glibc. Multipliez ça par chaque service que vous faites tourner et chaque runner CI qui télécharge l’image, et l’écart devient du stockage réel et des minutes réelles.
L’effet est plus marqué pour un langage compilé, où le binaire n’a besoin de rien d’autre que lui-même.
| |
Pas de shell, pas de gestionnaire de paquets, pas de toolchain Go dans l’image finale : seulement le binaire statique plus les certificats CA et les données de fuseau horaire que fournit distroless/static. Un attaquant qui obtient une exécution dans ce conteneur n’a pas de sh à lancer ni d’apt pour en installer un.
Ce qu’il faut mettre dans .dockerignore
COPY . . envoie tout le contexte de build au daemon Docker avant même que la build ne commence, et ce contexte inclut des fichiers que vous ne voulez jamais livrer : .git, un node_modules local, des fichiers .env avec de vrais identifiants, la config de l’éditeur. Un .dockerignore à côté du Dockerfile les exclut, avec la même syntaxe de pattern que .gitignore.
| |
Exclure node_modules ne concerne pas que la taille. Si une installation locale sur votre machine a des modules natifs compilés pour macOS et arm64, COPY . . embarque ces binaires dans un conteneur Linux où ils ne se chargeront pas. Laissez le conteneur installer ses propres dépendances.
Comment faire tourner un conteneur en utilisateur non-root
Rien dans un Dockerfile basique n’empêche un conteneur de tourner en root, donc par défaut c’est ce qu’il fait. Si un attaquant obtient une exécution de code à l’intérieur, via une vulnérabilité dans une dépendance ou un bug de désérialisation, il ne lui faut souvent qu’un bug kernel ou un montage mal configuré pour passer de root dans le conteneur à root sur l’hôte. La plupart des images officielles fournissent déjà un utilisateur vers lequel basculer.
| |
L’image node crée un utilisateur node, donc USER node est la seule ligne que vous ajoutez. Quand une image de base n’en fournit aucun, créez-en un avant de basculer :
| |
Le --chown compte autant que le USER. Sans lui, les fichiers appartiennent à root pendant que le processus qui les lit tourne en app, et tout ce que l’application écrit à l’exécution échoue avec une erreur de permissions que vous découvrez en production. Même chose pour un volume Docker monté dans le conteneur : son contenu doit être accessible en écriture pour l’UID vers lequel vous avez basculé, sinon la première écriture échoue au démarrage.
Comment figer le tag ou le digest de l’image de base
FROM node:latest se résout vers ce que pointe latest le jour où vous construisez. Reconstruisez le même Dockerfile le mois prochain et vous pouvez tomber sur une version majeure différente, une release Debian différente, des paquets système différents, sans aucun diff dans votre dépôt montrant ce qui a changé. Figez la version :
| |
Pour une build qui doit être reproductible bit à bit, figez aussi le digest. Ça lie la build à une seule image immuable quoi qu’il arrive au tag :
| |
Récupérez le digest avec docker pull node:24.9.0-slim suivi de docker inspect --format='{{index .RepoDigests 0}}' node:24.9.0-slim. Figer le tag couvre la plupart des projets. Figer le digest est réservé aux pipelines où « qu’avons-nous exactement livré » doit avoir une réponse des mois plus tard.
Quand regrouper plusieurs RUN dans une seule couche
Chaque RUN produit une couche, et une couche ne fait que grossir. Supprimer un fichier dans une couche ultérieure ne réduit pas l’image, ça cache le fichier derrière un whiteout marker pendant que la couche précédente porte toujours les octets. C’est pour ça que séparer installation et nettoyage sur deux RUN distincts n’économise rien :
| |
Mettez le nettoyage dans la même couche que l’installation :
| |
Ça s’applique aux installations qui laissent des fichiers derrière elles : caches de gestionnaires de paquets, archives téléchargées, artefacts de build déjà copiés ailleurs. Ce n’est pas un argument pour fusionner tous les RUN du fichier. Une poignée de couches lisibles se débogue mieux qu’une ligne de 400 caractères, et le nombre de couches à lui seul n’est pas ce qui détermine la taille de l’image.
Quand ces pratiques ne valent pas le coup
Un script ponctuel que vous lancez en local avec docker build -t scratch . && docker run --rm scratch n’a besoin ni d’un multi-stage build ni d’un digest figé. La cérémonie coûte plus cher que le risque qu’elle évite.
L’empreinte réduite d’Alpine vient de musl libc à la place de glibc, ce qui casse les modules natifs Node et Python compilés contre glibc. Les segfaults ou erreurs de symbole manquant après un passage à -alpine sont généralement ça. Utilisez -slim en cas de doute.
Et figer les versions est un choix qui a un coût : une image de base non figée récupère les correctifs de sécurité au prochain docker build --pull sans que vous fassiez quoi que ce soit. Si vous voulez ça, faites-le délibérément, en sachant qu’une build peut commencer à se comporter différemment pour des raisons absentes de votre diff. Oublier le tag par accident n’est pas la même décision.
Comment mesurer la taille de l’image et le temps de build
Construisez les deux versions de la même application et comparez :
| |
docker images montre l’écart de taille. docker history montre quelle instruction a produit quelle couche et sa taille, le moyen le plus rapide de trouver le RUN qui traîne encore quelque chose qui n’a rien à faire là. Rien de tout ça ne change si votre application est construite par Docker Compose : un service avec build: . utilise le même Dockerfile, le même cache et le même contexte de build, donc docker compose build obtient les mêmes gains.
Vous avez hérité d’un Dockerfile antérieur à tout ça ? Construisez-le une fois tel quel, lancez docker history, et corrigez la couche qui surprend le plus. Cette seule couche représente généralement l’essentiel de l’écart.