Pourquoi une image Docker pèse dix fois plus que l’application
Un service Node avec quelques centaines de kilo-octets de code source finit dans une image de plus de 1 Go. C’est le résultat normal d’un build qui fonctionne, pas une erreur sur une ligne précise. Le poids vient d’une image de base qui traîne un système d’exploitation complet, une chaîne d’outils de build que l’application en cours d’exécution n’appelle jamais, et un cache de gestionnaire de paquets que personne n’a demandé au build de supprimer.
Vous le payez à chaque déploiement : docker pull ralentit, et un scanner de vulnérabilités a plusieurs centaines de paquets supplémentaires à signaler. Le stockage s’accumule aussi sur chaque tag jamais publié. Corriger cela veut dire réécrire le Dockerfile, pas l’application.
Quelle image de base choisir : alpine, slim ou distroless
La ligne FROM fixe le point de départ. La choisir mal, et rien d’autre dans le fichier ne rattrape la différence.
| Image de base | Taille approximative | Contenu |
|---|---|---|
node:26 | 1,77 Go | Debian complète, compilateurs, plusieurs runtimes de langages, documentation |
node:26-slim | 371 Mo | Debian allégée, sans chaîne d’outils de build |
node:26-alpine | 247 Mo | musl libc, apk, environ 50 paquets |
gcr.io/distroless/nodejs22-debian12 | 212 Mo | Runtime Node seul, pas de shell, pas de gestionnaire de paquets, environ 10 paquets |
Le schéma se répète en dehors de Node. debian:bookworm-slim tourne autour de 74 Mo contre 77 Mo pour ubuntu:22.04. alpine:latest avoisine 7 Mo. gcr.io/distroless/static-debian12, conçue pour un binaire Go ou Rust compilé statiquement qui n’a besoin de rien d’autre que des certificats CA, frôle 2 Mo.
Alpine obtient sa taille en remplaçant glibc par musl libc, et cet échange a un coût : les modules natifs compilés contre glibc (certains paquets npm, la plupart des paquets pip avec des extensions en C) plantent avec un segfault ou lèvent des erreurs de symbole manquant sous musl. Un tag -slim évite le risque et réduit quand même l’image des deux tiers. Passez à Alpine seulement après avoir vérifié que vos dépendances s’en accommodent.
Distroless va plus loin : pas de shell, pas de gestionnaire de paquets, rien qu’un attaquant puisse exécuter après avoir obtenu l’exécution de code. Mais pas de sh non plus pour vous, ce qui devient un coût réel la première fois qu’un conteneur se comporte mal en production.
L’ordre des couches, .dockerignore et les mécanismes d’un build multi-stage sont couverts dans Bonnes Pratiques Dockerfile : Builds Plus Légers et Rapides. La suite part du principe que cette séparation existe déjà et se concentre sur ce qu’un stage de build seul ne corrige pas.
Comment garder les caches du gestionnaire de paquets hors de l’image
Un build multi-stage garde le compilateur hors du stage de runtime. Il ne fait rien pour le cache propre du gestionnaire de paquets, qui atterrit dans la couche où l’installation a tourné : inoffensif dans un stage de build voué à être jeté, poids mort dans le stage final si celui-ci installe aussi quelque chose.
Les cache mounts de BuildKit règlent le problème sans étape de nettoyage :
| |
--mount=type=cache donne à ce RUN un répertoire qui persiste entre les builds et n’est jamais validé dans une couche. Le cache de npm continue d’accélérer le build suivant ; rien de tout cela n’atteint l’image. Pour Python, c’est RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, pour Go --mount=type=cache,target=/root/.cache/go-build.
Sans BuildKit, dites à l’installateur de ne pas mettre en cache du tout : pip install --no-cache-dir, ou npm ci && npm cache clean --force. Les deux moitiés vont dans le même RUN. Supprimer un fichier dans une couche ultérieure le masque derrière un whiteout marker et laisse les octets dans la couche du dessous, donc la version scindée en deux RUN livre quand même le cache. Un service construit par Docker Compose avec build: . exécute le même Dockerfile avec le même builder, donc les cache mounts s’appliquent sans changement.
Comment retirer les symboles de debug et les fichiers que l’app ne lit jamais
Les dépendances de build ne sont que la moitié du problème. Les dépendances de runtime embarquent des fichiers jamais lus en production : suites de tests, documentation .md, caches .pyc compilés, source maps.
Pour Python, sautez le cache de pip et le cache de bytecode dans la couche qui installe :
| |
Un binaire compilé porte des informations de debug que la production ne lit jamais. Un flag dans le stage de build les supprime :
| |
| |
-ldflags="-s -w" retire généralement 20 à 30 % d’un binaire Go. Rien que vous remarquiez sur une petite CLI, mais des octets bien réels sur tout ce qui a un graphe de dépendances important. Les symboles retirés ne coûtent que si vous attachez un débogueur exactement au binaire tournant en production, ce que vous ne pouvez pas faire sur un conteneur sans shell auquel vous attacher.
Comment repérer quelle couche fait grossir l’image
Docker a enregistré où les octets sont partis. Il suffit de le relire :
| |
Une ligne par couche, avec l’instruction qui l’a créée et sa taille. Ajoutez --no-trunc quand plusieurs lignes RUN se ressemblent dans la vue tronquée.
Cela nomme l’instruction coûteuse, pas les fichiers coûteux à l’intérieur. dive fait la seconde moitié :
| |
Il ouvre une interface terminal sur les couches, colore les fichiers ajoutés, modifiés et supprimés, et rapporte un score d’efficacité avec le total d’octets gaspillés : l’espace occupé par des fichiers qu’une couche ultérieure a écrasés ou supprimés mais qui ne sont jamais sortis de l’image. CI=true dive myapp:latest exécute le même contrôle de façon non interactive et sort avec un code différent de zéro quand l’image ne respecte pas les seuils d’un fichier .dive-ci :
| |
Une image qui a grossi en silence cesse d’être quelque chose qu’un collègue remarque des semaines plus tard et devient un check qui échoue sur la pull request qui l’a causé.
Quand réduire l’image ne vaut pas l’effort
Un script que vous construisez une fois et lancez sur votre propre machine n’a besoin ni d’une base distroless ni d’un binaire allégé de ses symboles. L’image ne quitte jamais votre disque, et le travail coûte plus que le gigaoctet économisé. Même chose pour l’image d’un job CI de courte durée, reconstruite de zéro à chaque run : retirer 200 Mo de quelque chose téléchargé une fois puis jeté n’apporte rien.
Le compromis mord surtout à la marge basse. Une image distroless ou scratch n’a pas de shell, donc docker exec -it myapp sh sur un conteneur qui se comporte mal échoue net. Vous déboguez à partir des logs et d’une reproduction locale, ou vous gardez une variante de debug prévue pour ça (distroless publie des tags -debug avec un shell busybox). Alpine sacrifie moins de ce confort et vous donne en échange le risque musl. Aucun de ces coûts n’est caché, et c’est pourquoi « utilisez toujours la plus petite base possible » est un conseil moins utile que de choisir la base service par service, selon ce dont chacun a réellement besoin.
Par où commencer sur une image que vous avez déjà
Lancez docker history sur ce que vous livrez aujourd’hui, avant de changer quoi que ce soit. La couche la plus grosse est généralement une correction évidente une fois qu’elle a un nom en face. Lancez ensuite dive sur la même image avec les seuils ci-dessus : un run qui échoue au premier essai vous donne un chiffre à battre et un rapport au niveau des fichiers sur où se trouve le gaspillage, ce qui vaut mieux que deviner dans le Dockerfile à partir de ce qui gonfle habituellement les images.