Docker Multi-Stage Builds : réduire les images sans casser l’exécution

Cet article vous a aidé ?

Rien ne gâche un déploiement propre comme une image qui se construit correctement puis s’écroule à l’exécution parce que vous avez supprimé la bibliothèque partagée dont votre binaire avait discrètement besoin. Votre coche verte CI devient un petit mensonge que vous vous racontez.

Les builds multi-stage sont l’outil approprié pour réduire les images sans transformer la production en fouille archéologique. Mais c’est aussi un outil tranchant. Bien utilisé, il enlève le superflu. Mal utilisé, il sectionne des artères.

Table des matières

Ce que fait réellement le multi-stage (et ce qu’il ne fait pas)

Les builds multi-stage sont la manière dont Docker vous permet d’utiliser une image pour construire et une autre pour exécuter — dans un seul Dockerfile. Vous compilez dans une étape « grosse » avec compilateurs et en-têtes, puis vous copiez les résultats dans une étape « mince » qui contient seulement ce dont l’application a besoin à l’exécution.

L’essentiel : les builds multi-stage ne rendent pas magiquement votre application « compatible minimaliste ». Ils facilitent simplement la séparation des dépendances au moment de la construction et des dépendances au moment de l’exécution. Si votre application a besoin de glibc à l’exécution et que vous l’expédiez dans une image Alpine basée sur musl, vous n’avez pas « optimisé ». Vous avez posé une bombe à retardement.

Pourquoi les équipes d’exploitation aiment ça

Les images plus petites se téléchargent plus vite, coûtent moins en stockage, se scannent plus rapidement et réduisent la surface d’attaque. Ce n’est pas théorique : ce sont moins d’octets sur le réseau le jour du déploiement et moins de paquets à patcher à 2 h du matin.

Ce que ça change en pratique

  • Reproductibilité de la construction : vous pouvez fixer les toolchains de build sans alourdir le runtime.
  • Posture de sécurité : moins de paquets au runtime équivaut à moins de CVE à trier.
  • Modes d’échec : vous verrez des bibliothèques manquantes, des certificats CA manquants, des données de fuseau horaire absentes, un shell manquant, des utilisateurs manquants — des choses dont vous ne réalisiez pas que vous dépendiez.

Une citation à garder collée à l’écran :

« L’espoir n’est pas une stratégie. » — Gordon R. Dickson

Les builds multi-stage sont la façon d’arrêter d’espérer que votre image d’exécution « a probablement ce dont elle a besoin ». Vous le vérifiez.

Faits intéressants et brève histoire (contexte pour éviter les incidents)

Quelques points de contexte courts et concrets qui expliquent pourquoi les builds multi-stage sont devenus une pratique standard :

  1. Docker a ajouté les multi-stage builds en 2017 (Docker 17.05). Avant cela, les équipes utilisaient des « builder containers » fragiles et des étapes de copie manuelles.
  2. Le cache des couches a façonné le style Dockerfile : ordonner les commandes pour maximiser la réutilisation du cache est devenu une compétence parce que tout reconstruire était terriblement lent.
  3. Alpine est devenu populaire parce qu’il était minuscule, pas parce qu’il était universellement compatible. Le conflit musl vs glibc continue de mordre les équipes qui expédient des binaires précompilés.
  4. Les images distroless (runtimes minimalistes sans gestionnaire de paquets ni shell) ont gagné en traction à mesure que les préoccupations sur la chaîne d’approvisionnement et la surface d’attaque ont augmenté.
  5. Le format d’image OCI a standardisé la disposition des images conteneurs à travers les runtimes, rendant les outils d’inspection et de scan plus cohérents.
  6. BuildKit a changé la donne : parallélisme amélioré, meilleur cache, montages de secrets et motifs de copie avancés ont rendu les multi-stage plus maintenables.
  7. Les SBOM sont devenus courants quand les organisations ont dû répondre à « que contient cette image ? » lors d’audits et d’analyses d’incidents.
  8. Les écosystèmes de langages ont réagi différemment : Go a adopté les binaires statiques ; Node et Python se sont orientés vers des bases plus légères ; Java a ajouté jlink/jdeps pour réduire les runtimes.

Patrons essentiels qui fonctionnent en production

Patron 1 : Builder + runtime avec artefacts explicites

C’est le pattern canonique multi-stage : construire dans une étape, copier un binaire ou une application packagée dans une étape runtime.

cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Opinion : si vous pouvez expédier un binaire véritablement statique, faites-le. C’est l’artefact opérationnel le plus propre. Mais seulement si vous comprenez ce à quoi vous renoncez (fonctionnalités glibc, nuances DNS, outils au niveau OS). Statique n’est pas « mieux », c’est « différent ».

Patron 2 : runtime « slim » (avec shell)

Distroless est excellent pour le durcissement. C’est aussi pénible quand vous êtes de garde et que vous devez inspecter rapidement le conteneur. Parfois la bonne réponse est « Debian slim avec un shell », surtout lorsque la maturité de votre organisation implique que vous déboguerez en direct.

cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM node:22-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Si vous réduisez les images, faites-le sans supprimer votre capacité à opérer le service. Un shell n’est pas maléfique ; expédier des compilateurs et des gestionnaires de paquets dans le runtime l’est.

Patron 3 : étape « toolbox » pour le débogage (hors production)

Gardez les images de production minimales, mais ne prétendez pas que vous n’aurez jamais besoin d’outils. Utilisez une étape supplémentaire comme boîte à outils pour reproduire des problèmes localement ou dans un déploiement de debug contrôlé.

cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM debian:bookworm AS toolbox
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl ca-certificates iproute2 dnsutils procps strace \
  && rm -rf /var/lib/apt/lists/*

# other stages ...

Vous n’expédiez pas l’étape toolbox. Vous la gardez pour que vos ingénieurs puissent attacher les mêmes outils au même layout de filesystem lors du débogage.

Patron 4 : réutiliser des artefacts pour plusieurs images finales

Un bon Dockerfile multi-stage peut produire plusieurs cibles : une image runtime, une image de debug, une image de test. Même source, sorties différentes.

cr0x@server:~$ docker buildx build --target runtime -t myapp:runtime .
[+] Building 18.7s (16/16) FINISHED
 => exporting to image
 => => naming to docker.io/library/myapp:runtime

C’est ainsi que vous conservez la parité sans expédier des déchets. Même build, cible différente.

Blague #1 : Si votre Dockerfile n’a qu’une seule étape, il n’est pas « simple », il est « optimiste ».

Le contrat d’exécution : ce que vous devez conserver

L’image runtime est un contrat entre votre application et l’userland OS que vous fournissez. Les builds multi-stage rendent facile de violer ce contrat par accident. Voici ce qui a tendance à disparaître :

1) Bibliothèques partagées et chargeur dynamique

Si vous compilez avec CGO activé (courant pour le comportement DNS, les bindings SQLite, le traitement d’images, etc.), vous aurez besoin du bon libc et du chargeur dans l’étape runtime. Si vous construisez sur Debian et exécutez sur Alpine, vous pouvez obtenir le classique :

  • exec /app: no such file or directory (même si le fichier existe) parce que le chemin du chargeur dynamique n’existe pas dans le runtime.

2) Certificats CA

Votre service parle à des endpoints HTTPS. Sans certificats CA, TLS échoue. Beaucoup d’images « minimales » ne les incluent pas par défaut.

3) Données de fuseau horaire et locales

UTC uniquement, ça va jusqu’à un certain point — les rapports, les horodatages côté client et la journalisation de conformité peuvent vite devenir étranges.

4) Utilisateurs, permissions et propriété des fichiers

Les copies multi-stage conservent la propriété des fichiers à moins que vous n’indiquiez le contraire à Docker. Si vous exécutez en non-root (ce que vous devriez faire), vérifiez que les fichiers sont lisibles/exécutables.

5) Sémantique d’entrypoint

Forme shell vs forme exec : ça compte. Si vous comptez sur l’expansion du shell mais avez supprimé le shell, vous vous en rendrez compte à l’exécution, pas à la construction.

6) Attentes d’observabilité

Si votre procédure on-call suppose que curl existe dans le conteneur, distroless vous décevra. Utilisez des sidecars, des conteneurs de debug éphémères, ou des cibles de debug séparées. Décidez-en intentionnellement.

Tâches pratiques : commandes, sorties et décisions (12+)

Voici des tâches réelles que vous pouvez exécuter aujourd’hui. Chacune inclut : commande, ce que signifie la sortie, et quelle décision prendre.

Task 1: Compare image sizes and decide if optimization matters

cr0x@server:~$ docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
REPOSITORY   TAG       SIZE
myapp        fat       1.12GB
myapp        slim      142MB
myapp        distroless 34.6MB

Signification : vous avez une réduction d’ordre de grandeur disponible.

Décision : si les déploiements sont lents, le stockage du registre coûteux, ou les scanners submergés, poursuivez le multi-stage. Si votre image est déjà ~60–150MB et stable, priorisez la correction plutôt que d’économiser encore 10MB.

Task 2: Inspect layers to find what’s bloating the image

cr0x@server:~$ docker history --no-trunc myapp:fat | head -n 8
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
3f2c...        2 hours ago    /bin/sh -c npm install                          402MB
b18a...        2 hours ago    /bin/sh -c apt-get update && apt-get install   311MB
9c10...        3 hours ago    /bin/sh -c pip install -r requirements.txt     198MB
...

Signification : vous expédiez des dépendances de build et des caches de paquets.

Décision : déplacez les outils de compilation/installation dans l’étape builder ; assurez-vous que les caches ne sont pas copiés dans le runtime ; utilisez npm ci --omit=dev ou équivalent.

Task 3: Confirm which stages exist and what they’re named

cr0x@server:~$ docker buildx bake --print 2>/dev/null | sed -n '1,40p'
{
  "target": {
    "default": {
      "context": ".",
      "dockerfile": "Dockerfile"
    }
  }
}

Signification : votre configuration de build est simple ; la découverte des étapes se fait dans le Dockerfile.

Décision : nommez explicitement les étapes (AS build, AS runtime) pour que les lignes de copie ne pourrissent pas.

Task 4: Build a specific target to validate runtime stage alone

cr0x@server:~$ docker build --target runtime -t myapp:runtime .
[+] Building 21.3s (12/12) FINISHED
 => exporting to image
 => => naming to docker.io/library/myapp:runtime

Signification : l’étape runtime se construit et s’exporte. Bon départ.

Décision : intégrez CI pour construire explicitement la cible runtime, et pas seulement la cible par défaut.

Task 5: Run the container and watch for the “it builds, it doesn’t run” class of failures

cr0x@server:~$ docker run --rm myapp:runtime
standard_init_linux.go:228: exec user process caused: no such file or directory

Signification : généralement un décalage du chargeur dynamique / incompatibilité libc, pas un binaire manquant.

Décision : vérifiez si le binaire est lié dynamiquement et si la base runtime possède le chargeur correct (glibc vs musl). Corrigez le choix de base ou les flags de compilation.

Task 6: Check if a binary is dynamically linked (builder stage inspection)

cr0x@server:~$ docker run --rm --entrypoint /bin/bash myapp:build -lc "file /out/app && ldd /out/app || true"
/out/app: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
	linux-vdso.so.1 (0x00007ffd6b3d9000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9d7c3b4000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f9d7c5b6000)

Signification : il est lié dynamiquement et attend les chemins du chargeur glibc de type Debian.

Décision : exécutez sur une base compatible Debian/Ubuntu/distroless-glibc, ou reconstruisez statique (si viable), ou fournissez soigneusement les libs requises.

Task 7: Verify CA certificates exist in the runtime image

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:runtime -lc "ls -l /etc/ssl/certs/ca-certificates.crt 2>/dev/null || echo missing"
missing

Signification : les appels TLS échoueront à moins que votre runtime de langage n’embarque les certificats (beaucoup ne le font pas, ou pas complètement).

Décision : installez ca-certificates dans l’étape runtime (ou copiez-les depuis le builder), ou changez pour une base qui les inclut.

Task 8: Test outbound TLS from inside the container (when tools exist)

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "curl -fsS https://example.com | head"
<!doctype html>
<html>
<head>

Signification : les certificats et le DNS fonctionnent, la sortie réseau fonctionne, sanity runtime de base OK.

Décision : si cela échoue, ne devinez pas — vérifiez les certificats, le DNS, les proxies et les policies réseau avant de toucher au Dockerfile à nouveau.

Task 9: Confirm the runtime image contains required config files

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:runtime -lc "ls -l /app/config || true"
ls: cannot access '/app/config': No such file or directory

Signification : vous avez oublié de copier des configurations par défaut, templates, migrations ou assets statiques.

Décision : COPY explicitement ces artefacts depuis le contexte de build ou depuis la sortie du builder ; ne comptez pas sur « c’était là dans l’ancienne image ».

Task 10: Confirm user and file permissions (non-root runtime)

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "id && ls -l /app && test -x /app/app && echo executable"
uid=1000(node) gid=1000(node) groups=1000(node)
total 18240
-rwxr-xr-x 1 root root 18673664 Jan  2 12:11 app
executable

Signification : le binaire est exécutable, mais détenu par root.

Décision : décidez si la propriété importe. Pour lecture/exécution c’est acceptable ; pour écrire des logs, fichiers temporaires, caches, ça explosera. Préférez COPY --chown=node:node pour les répertoires applicatifs nécessitant des écritures.

Task 11: Measure build time and cache effectiveness

cr0x@server:~$ DOCKER_BUILDKIT=1 docker build --progress=plain -t myapp:test . | sed -n '1,35p'
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/node:22-bookworm
#3 [build 1/6] WORKDIR /app
#4 [build 2/6] COPY package*.json ./
#5 [build 3/6] RUN npm ci
#5 CACHED
#6 [build 4/6] COPY . .
#7 [build 5/6] RUN npm run build
...

Signification : l’étape d’installation des dépendances est mise en cache ; la copie du code source invalide les couches suivantes seulement.

Décision : copiez les descripteurs de dépendances avant le source, pour que de petits changements de code n’entraînent pas une reconstruction complète des dépendances.

Task 12: Verify what actually ended up in the runtime image filesystem

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "du -sh /app/* | sort -h | tail"
4.0K	/app/package.json
16M	/app/node_modules
52M	/app/dist

Signification : node_modules est plus petit que dist ; vous n’expédiez que les dépendances de production (bien).

Décision : si node_modules est énorme, vous avez probablement expédié des devDependencies ou un cache de build. Corrigez la commande d’installation et .dockerignore.

Task 13: Detect accidental inclusion of secrets or build junk

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "find /app -maxdepth 2 -name '*.pem' -o -name '.env' -o -name 'id_rsa' 2>/dev/null | head"

Signification : pas de fichiers secrets évidents trouvés (ce n’est pas un audit complet, mais c’est un contrôle de sanity rapide).

Décision : si quelque chose apparaît, stoppez la chaîne. Corrigez le contexte de build et .dockerignore, puis faites tourner les secrets si nécessaire.

Task 14: Quick CVE surface comparison via package listing

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "dpkg -l | wc -l"
196

Signification : 196 paquets installés. C’est une grande surface potentielle à patcher.

Décision : si vous pouvez passer à distroless ou une base plus fine en respectant le contrat runtime, vous réduisez le travail de patch. Sinon, au moins pinnez et patchez de manière prévisible.

Task 15: Confirm entrypoint and cmd are what you think they are

cr0x@server:~$ docker inspect myapp:runtime --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}'
["/app"] null

Signification : le conteneur utilise un entrypoint en forme exec ; aucun shell requis.

Décision : conservez cette configuration. Si vous voyez ["/bin/sh","-c",...] dans une image minimale, attendez-vous à des échecs à l’exécution quand le shell n’est pas présent.

Plan de diagnostic rapide

Quand une « optimisation » multi-stage casse la production, vous n’avez pas le temps pour de la philosophie container. Il vous faut une séquence qui identifie rapidement le goulot d’étranglement.

Premier : classer l’échec (démarrage vs service vs appels externes)

  • Le conteneur ne démarre pas : entrypoint manquant, mismatch du chargeur, permissions, mauvaise architecture.
  • Démarre puis plante : fichiers/config manquants, variables d’environnement manquantes, segfault dû à mismatch libc, mauvais répertoire de travail.
  • Démarre et sert mais fonctionnalités cassées : certificats CA manquants, fuseau horaire, polices, codecs d’images, locales, différences de comportement DNS.

Deuxième : confirmer ce que vous avez expédié (pas ce que vous vouliez expédier)

  • Inspecter entrypoint/cmd (docker inspect).
  • Lister les fichiers attendus dans le runtime (ls, du).
  • Vérifier le mode de lien du binaire (file, ldd dans l’étape builder).

Troisième : valider le contrat runtime avec une requête de sondage

  • Pour les clients HTTPS : vérifier la présence du bundle CA ; tester TLS vers un endpoint connu (dans une image de debug si nécessaire).
  • Pour les apps sensibles au DNS : vérifier la configuration du résolveur et le comportement ; confirmer /etc/resolv.conf dans le conteneur.
  • Pour les écritures filesystem : vérifier l’utilisateur, les permissions et les chemins inscriptibles (/tmp, dossiers cache de l’app).

Quatrième : décider du plan de remédiation

  • Mismatch de base image : changez la base runtime, ne colmatez pas avec des bibliothèques à la va-vite à moins d’aimer les surprises.
  • Artefacts manquants : copiez-les explicitement, ajoutez des tests qui échouent la build quand ils manquent.
  • Trop minimal pour déboguer : ajoutez une cible debug ; ne glissez pas de shells dans la production « au cas où ».

Erreurs courantes : symptômes → cause → correction

Cette section existe parce que la plupart des échecs se répètent. Les équipes changent ; la physique non.

1) « exec … no such file or directory » alors que le fichier existe

  • Symptôme : le conteneur sort immédiatement ; l’erreur mentionne « no such file or directory ».
  • Cause racine : chemin du chargeur dynamique manquant (binaire glibc sur image musl), mauvaise architecture, ou fins de ligne CRLF pour des scripts.
  • Correction : exécutez file et ldd dans l’étape builder ; alignez la base image avec la libc ; assurez le bon GOOS/GOARCH ; utilisez l’entrypoint en forme exec ; normalisez les fins de ligne.

2) Erreurs TLS : « x509: certificate signed by unknown authority »

  • Symptôme : l’app démarre mais ne peut pas appeler des dépendances HTTPS.
  • Cause racine : bundle de certificats CA manquant dans l’image runtime.
  • Correction : installez ca-certificates dans le runtime ; ou copiez le bundle de certificats depuis le builder ; vérifiez avec une sonde TLS.

3) Fonctionne en CI, échoue en production : config/templates/migrations manquants

  • Symptôme : erreurs runtime sur fichiers manquants ; endpoints renvoient 500 ; démarrage se plaint de templates.
  • Cause racine : le multi-stage n’a copié que le binaire, pas les fichiers de support.
  • Correction : définissez un répertoire d’artefacts explicite dans la sortie du builder et copiez-le en entier ; ajoutez une vérification à la build que les chemins requis existent.

4) Permission denied lors d’écritures de logs/cache

  • Symptôme : l’app plante en écriture dans /app, /var/log ou dans les répertoires de cache ; fonctionne en root.
  • Cause racine : le runtime s’exécute en non-root, mais les fichiers/dossiers copiés sont possédés par root et non inscriptibles.
  • Correction : créez des répertoires inscriptibles dans l’étape runtime ; utilisez COPY --chown ; définissez intentionnellement USER ; préférez l’écriture dans /tmp ou des volumes dédiés.

5) « sh: not found » ou « bash: not found »

  • Symptôme : le conteneur échoue parce qu’il tente d’exécuter une commande shell.
  • Cause racine : CMD/ENTRYPOINT en forme shell dans une image minimale qui n’inclut pas de shell.
  • Correction : utilisez la forme exec (tableaux JSON) ; supprimez les scripts shell ou choisissez une base appropriée ; pour une logique de démarrage complexe, envisagez un petit binaire init.

6) L’image est petite mais les builds sont maintenant terriblement lents

  • Symptôme : le temps de build CI augmente après « optimisation ».
  • Cause racine : mauvais ordre de cache, copie du repo entier avant l’installation des dépendances, ou désactivation des fonctionnalités BuildKit.
  • Correction : copiez d’abord les manifests de dépendances ; utilisez BuildKit ; utilisez .dockerignore ; envisagez des montages de cache pour les gestionnaires de paquets.

7) « Ça tourne en local, échoue sur Kubernetes » après réduction

  • Symptôme : exécution locale OK ; dans le cluster ça échoue avec DNS/timeouts/permissions.
  • Cause racine : le contexte de sécurité du cluster utilise un UID différent ; filesystem en lecture seule ; policies réseau plus strictes ; outils d’introspection manquants.
  • Correction : alignez l’utilisateur runtime ; écrivez dans des chemins autorisés ; testez avec le même contexte de sécurité ; fournissez une cible debug ou une méthode d’inspection éphémère.

Blague #2 : Distroless, c’est génial jusqu’à ce que vous réalisiez que vous avez containerisé votre capacité à paniquer discrètement.

Trois mini-récits d’entreprise issus du terrain

Mini-récit 1 : L’incident causé par une mauvaise hypothèse

Ils avaient un service Go qui « évidemment » compilait en binaire statique. C’est ce que tout le monde dit des services Go juste avant d’activer CGO pour une petite fonctionnalité et de l’oublier. L’équipe a changé la base runtime de Debian slim vers Alpine parce que la taille avait l’air fantastique sur une slide.

Le déploiement s’est déroulé en heures ouvrables. Quelques pods ont démarré, puis ont crashé immédiatement. Les logs étaient insultants : « no such file or directory ». Les gens ont vérifié l’image ; le binaire était bien présent. Quelqu’un a suggéré que le registre l’avait corrompu. Un autre a suggéré que Kubernetes « avait l’une de ses journées ». Classique.

Le vrai problème : le binaire était lié dynamiquement avec glibc et attendait /lib64/ld-linux-x86-64.so.2. Alpine ne l’avait pas. Le binaire ne pouvait même pas atteindre main(). Ce n’était pas un bug applicatif. C’était un problème de chargeur.

La correction a été ennuyeuse : remettre la base runtime compatible glibc, puis décider si CGO était vraiment nécessaire. Plus tard ils ont reconstruit avec CGO_ENABLED=0 et vérifié avec file et ldd dans la CI.

La leçon est restée : « minimal » est un contrat runtime, pas un régime. Si vous ne pouvez pas décrire les dépendances de votre binaire, vous ne pouvez pas réduire le conteneur en toute sécurité.

Mini-récit 2 : L’optimisation qui s’est retournée contre eux

Une équipe plateforme a voulu accélérer la CI en mettant tout en cache. Ils ont introduit un caching BuildKit agressif et copié le monorepo entier tôt dans le Dockerfile pour que les builds aient « du contexte ». L’image est devenue plus petite grâce au multi-stage. Les temps de build, en revanche, se sont transformés en catastrophe au ralenti.

Pourquoi ? Parce que copier tout le repo avant l’installation des dépendances invalidait le cache à presque chaque commit. Un changement de doc dans un service voisin faisait reconstruire la couche des dépendances Node. Les ingénieurs ont commencé à éviter les merges près des fenêtres de release parce que les builds étaient trop lents pour itérer sereinement.

L’équipe a ensuite ajouté une seconde « optimisation » : un RUN rm -rf de nettoyage tardif dans l’étape builder. Cela n’a pas aidé la taille runtime (le builder était déjà jeté), mais ça a augmenté le temps de build et réduit encore la réutilisation du cache, car les couches changeaient constamment.

Ils ont finalement refactorisé : contextes par service, .dockerignore strict, manifests de dépendances copiés en premier. Ils ont aussi arrêté de « nettoyer » des couches builder qui n’étaient pas expédiées.

La conclusion opérationnelle : optimisez ce que vous payez réellement. Avec les builds multi-stage, la taille runtime est souvent bon marché à corriger ; la latence de build exige discipline et structure.

Mini-récit 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise

Une équipe d’entreprise migrait une API Python vers des builds multi-stage. Tout le monde voulait distroless car la sécurité aimait la phrase « pas de shell ». Les SRE ont résisté, pas parce qu’ils adoraient les shells, mais parce qu’ils adoraient dormir.

Ils ont construit deux cibles : runtime (minimal) et debug (mêmes bits applicatifs, plus un shell et des outils réseau de base). L’image de debug n’était pas déployée par défaut. Elle n’était utilisée que pour la réponse aux incidents dans un namespace contrôlé, avec approbations explicites.

Deux mois plus tard, une dépendance a commencé à échouer les handshakes TLS de manière intermittente à cause d’une rotation de proxy MITM d’entreprise. L’image de production n’avait pas curl ni openssl, comme prévu. L’on-call a déployé la cible debug pour reproduire l’échec et a confirmé le problème de chaîne CA en quelques minutes, pas des heures.

La résolution a été simple : mettre à jour les CA de confiance et valider les paramètres proxy. Le vrai gain a été le temps de diagnostic. L’équipe n’a pas eu à reconstruire une « image de debug one-off » pendant un incident sous les yeux de tous.

La leçon : avoir une cible debug est un travail de gouvernance ennuyeux. C’est aussi la façon d’éviter de transformer les incidents en théâtre improvisé.

Listes de contrôle / plan étape par étape

Plan étape par étape : migrer vers le multi-stage sans casser l’exécution

  1. Commencez par une base connue et fonctionnelle. Conservez votre Dockerfile/image actuel comme référence. Ne le supprimez pas tant que le nouveau ne s’est pas prouvé.
  2. Nommez vos étapes. Utilisez AS build et AS runtime. Votre futur vous remerciera.
  3. Définissez un répertoire d’artefacts. Exemple : /out contient binaires, assets, migrations et configs qui doivent être expédiés.
  4. Copiez uniquement les artefacts dans le runtime. Pas le repo entier. Pas /root/.cache. Pas vos émotions.
  5. Choisissez une base runtime qui correspond à votre linkage. Si lien dynamique glibc : utilisez Debian/Ubuntu/distroless-base. Si statique : distroless-static peut être excellent.
  6. Ajoutez des vérifications du contrat runtime dans la CI. Vérifiez la présence des fichiers attendus ; vérifiez le type de binaire ; vérifiez la présence du bundle CA si nécessaire.
  7. Exécutez en non-root. Définissez USER. Corrigez la propriété des fichiers avec COPY --chown et créez explicitement les dossiers inscriptibles.
  8. Créez une cible debug. Mêmes bits applicatifs, outils en plus. Ne la déployez pas par défaut en prod, mais gardez-la buildable.
  9. Mesurez avant/après. Taille d’image, temps de pull, temps de build, temps de scan, temps de démarrage. Choisissez les optimisations qui font vraiment bouger les indicateurs opérationnels.
  10. Déployez progressivement. Canary. Surveillez les logs pour fichiers/libs manquants. Si vous êtes surpris, vos vérifications sont incomplètes.

Checklist : éléments du contrat runtime

  • Entrypoint en forme exec et existant
  • Architecture du binaire compatible avec les nœuds du cluster
  • Linkage du binaire compatible avec la libc de la base
  • Certificats CA disponibles pour HTTPS
  • Stratégie fuseau horaire/locale définie (UTC-only ou inclusion de tzdata)
  • Utilisateur non-root configuré ; répertoires inscriptibles créés
  • Tous les assets/config/migrations requis copiés
  • Healthcheck fonctionnel (ou contrôles externes prenant en compte les images minimales)

FAQ

1) Les multi-stage builds valent-ils toujours le coup ?

Non. Si votre image runtime est déjà petite et stable, et que vous reconstruisez rarement, le bénéfice peut être limité. Mais pour la plupart des services avec des déploiements fréquents, ça vaut la peine de le faire une fois correctement et de le maintenir.

2) Dois-je utiliser Alpine en runtime pour gagner de la place ?

Seulement si vous êtes sûr que vos dépendances runtime sont compatibles avec musl, ou si vous avez construit spécifiquement pour Alpine. Sinon, utilisez Debian slim ou des variantes distroless qui correspondent à votre linkage.

3) Distroless vs slim : lequel choisir ?

Distroless quand vous avez une observabilité solide et un chemin de debug clair (cible debug, conteneurs de debug éphémères). Slim quand votre organisation a encore besoin de « se connecter au conteneur » pour survivre aux incidents.

4) Pourquoi mon image a rétréci mais le démarrage est plus lent ?

Ce n’est généralement pas la taille de l’image. C’est l’absence de caches (attendu), la compilation JIT à froid, des changements de comportement DNS ou des modifications de la logique d’init. Mesurez le temps de démarrage et vérifiez ce qui a changé en dehors des bytes.

5) Comment éviter de copier des secrets dans l’étape runtime ?

Nettoyez votre contexte de build avec .dockerignore, évitez de copier le repo entier quand vous n’avez besoin que d’artefacts, et ne cuisez jamais de secrets runtime dans les images. Utilisez des mécanismes d’injection de secrets au runtime.

6) Puis-je fonctionner sans shell et quand même déboguer efficacement ?

Oui. Utilisez une image cible debug, des sidecars ou des conteneurs de debug éphémères. L’important est de planifier le débogage, pas de prétendre qu’il n’arrivera pas.

7) Quelle est la façon la plus sûre de gérer les certificats dans les images minimales ?

Préférez une base qui inclut les certificats CA, ou installez/copiez explicitement un bundle CA connu. Ensuite testez TLS dans une vérification d’intégration qui échoue bruyamment si nécessaire.

8) Comment garder des temps de build rapides avec des multi-stage builds ?

Ordonnez votre Dockerfile pour le caching : copiez d’abord les manifests de dépendances, installez les dépendances, puis copiez le code source. Utilisez BuildKit. Employez .dockerignore pour éviter d’invalider les couches avec des fichiers non pertinents.

9) Est-il acceptable d’avoir plusieurs étapes finales ?

Oui. C’est un pattern fort : runtime pour la production, debug pour les incidents, test pour la CI. Même source et artefacts, empaquetages différents.

10) Que faire si mon appli a besoin de paquets OS au runtime ?

Alors installez-les dans le runtime — intentionnellement et de manière minimale. Les multi-stage builds ne signifient pas refuser les dépendances runtime ; ils signifient refuser les dépendances accidentelles.

Conclusion : prochaines étapes rentables

Si vous retenez une chose : les builds multi-stage ne sont pas un tour pour gagner un concours du « plus petite image ». Ce sont un mécanisme pour expliciter et revoir les dépendances runtime.

Prochaines étapes réalisables cette semaine :

  1. Choisissez un service avec une image embarrassante de volumineuse et implémentez une séparation builder/runtime avec des étapes nommées.
  2. Ajoutez des vérifications CI qui valident le contrat runtime : linkage/architecture du binaire, fichiers requis présents, et certificats CA quand applicable.
  3. Créez une cible debug et documentez quand elle peut être utilisée.
  4. Mesurez la taille d’image, le temps de build et le temps de déploiement avant/après. Conservez les changements qui améliorent l’opération, pas seulement l’esthétique.
← Précédent
En-têtes d’e-mail : lire correctement « Received » — retracer où la livraison casse
Suivant →
Recherche en texte intégral MySQL vs PostgreSQL : quand l’intégration suffit et quand c’est un piège

Laisser un commentaire