Docker pour les devs - 07 - Multi-stage builds

Comment réduire la taille de tes images Docker de 1.2 Go a 80 Mo avec les multi-stage builds.

  1. 01 Docker pour les devs - 00 - Pourquoi Docker change tout
  2. 02 Docker pour les devs - 01 - Containers vs VMs
  3. 03 Docker pour les devs - 02 - L'architecture de Docker
  4. 04 Docker pour les devs - 03 - Docker Desktop, Engine et alternatives
  5. 05 Docker pour les devs - 04 - Écrire un Dockerfile
  6. 06 Docker pour les devs - 05 - Layers et cache
  7. 07 Docker pour les devs - 06 - Le .dockerignore
  8. 08 Docker pour les devs - 07 - Multi-stage builds
  9. 09 Docker pour les devs - 08 - Choisir son image de base
  10. 10 Docker pour les devs - 09 - Docker Compose, les bases
  11. 11 Docker pour les devs - 10 - Docker Compose avance
  12. 12 Docker pour les devs - 11 - Networking Docker, les bases
  13. 13 Docker pour les devs - 12 - Networking Docker avance
  14. 14 Docker pour les devs - 13 - Volumes et persistance
  15. 15 Docker pour les devs - 14 - Variables d'environnement et secrets
  16. 16 Docker pour les devs - 15 - Permissions et utilisateurs
  17. 17 Docker pour les devs - 16 - Docker et monorepo
  18. 18 Docker pour les devs - 17 - Dev vs Prod
  19. 19 Docker pour les devs - 18 - ENTRYPOINT, CMD et scripts d'initialisation
  20. 20 Docker pour les devs - 19 - Debugger ses conteneurs
  21. 21 Docker pour les devs - 20 - Bases de donnees dans Docker
  22. 22 Docker pour les devs - 21 - Sauvegardes et restauration
  23. 23 Docker pour les devs - 22 - Conteneuriser un frontend
  24. 24 Docker pour les devs - 23 - Sécurité des conteneurs
  25. 25 Docker pour les devs - 24 - Optimisation des images
  26. 26 Docker pour les devs - 25 - Builds multi-platform
  27. 27 Docker pour les devs - 26 - Limiter les ressources de tes conteneurs
  28. 28 Docker pour les devs - 27 - Gerer les logs comme un adulte
  29. 29 Docker pour les devs - 28 - Healthchecks et restart policies
  30. 30 Docker pour les devs - 29 - Nettoyer Docker avant qu'il mange ton disque
  31. 31 Docker pour les devs - 30 - Registries et stratégie de tags
  32. 32 Docker pour les devs - 31 - Docker en CI/CD
  33. 33 Docker pour les devs - 32 - Au-dela de Compose
  34. 34 Docker pour les devs - 33 - Glossaire Docker de A a Z

07 - Multi-stage builds

Ce que tu vas apprendre

  • Pourquoi les images de production sont trop grosses
  • La syntaxe multi-stage avec FROM ... AS
  • Comment copier des artefacts entre stages
  • Un exemple réel qui fait passer une image de 1.2 Go a 80 Mo
  • Les stages nommes et le flag --target

Prerequisites


L'image de 1.2 Go en production

C'etait un vendredi soir. L'équipe devait déployer une app Next.js. Le deploy a pris 8 minutes juste pour pusher l'image sur le registry. 1.2 Go. A l'intérieur : TypeScript compiler, ESLint, Prettier, les devDependencies, le code source non compile, les fichiers de test. Tout ca pour une app qui au final servait du HTML et un bundle JS de 300 Ko.

Le lundi suivant, j'ai reecrit le Dockerfile avec un multi-stage build. L'image est passee a 80 Mo. Le deploy prenait 40 secondes.

Le problème

Pour construire une application, tu as besoin d'outils : compilateurs, linters, bundlers, devDependencies. Pour la faire tourner, tu n'as besoin que du résultat : le binaire compile, le bundle JS, les fichiers statiques.

Un Dockerfile classique inclut tout :

dockerfileFROM node:22
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/index.js"]

Cette image contient :

  • Node.js complet (pas la variante slim)
  • Toutes les dépendances (dev incluses)
  • Le code source TypeScript
  • Les fichiers de test
  • Le dossier dist/ avec le build

Tu embarques le chantier avec la maison finie.

La solution : multi-stage

Un multi-stage build utilise plusieurs FROM dans le meme Dockerfile. Chaque FROM démarré un nouveau stage. Tu peux copier des fichiers d'un stage a l'autre avec COPY --from=.

dockerfile# Stage 1 : build
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2 : production
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Le stage builder contient tout ce qu'il faut pour compiler. Le stage final ne contient que le runtime, les dépendances de production, et les fichiers compiles.

L'image finale ne garde que le dernier stage. Les couches du stage builder sont jetees.

Anatomie du gain

Prenons un projet TypeScript avec Express :

Composant Stage builder Stage prod
Node.js (complet) 350 Mo -
Node.js (slim) - 80 Mo
devDependencies 400 Mo -
Dependencies prod - 45 Mo
Code source TS 2 Mo -
Build JS - 500 Ko
Total ~750 Mo ~125 Mo

De 750 Mo a 125 Mo. En changeant uniquement le Dockerfile.

Exemple réel : API Bun avec TypeScript

dockerfile# === Stage build ===
FROM oven/bun:1.1 AS builder

WORKDIR /app

# Dependances
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

# Code source et build
COPY tsconfig.json ./
COPY src/ ./src/
RUN bun build src/index.ts --target=bun --outdir=./dist

# === Stage production ===
FROM oven/bun:1.1-slim

WORKDIR /app

# Seulement les deps de prod
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production

# Seulement le build
COPY --from=builder /app/dist ./dist

RUN addgroup --system app && adduser --system --ingroup app app
USER app

EXPOSE 3000
CMD ["bun", "run", "dist/index.js"]

Le stage builder installe tout, compile, et disparaît. Le stage final utilise bun:1.1-slim (plus petit) et n'a que les deps de production.

Exemple : app Go (le cas extreme)

Go compile en binaire statique. Le multi-stage est encore plus spectaculaire :

dockerfile# Build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# Production
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

L'image finale ne contient que le binaire. Pas de shell, pas de librairies, pas d'OS. La taille : 10-20 Mo selon l'application.

scratch est l'image vide. C'est le cas ideal pour les langages qui compilent en binaire statique.

Stages nommes et --target

Les stages sont nommes avec AS. Tu peux cibler un stage spécifique au build :

bash# Construire seulement le stage builder
docker build --target builder -t mon-app-build .

# Construire le stage final (par defaut)
docker build -t mon-app .

C'est utile pour :

  • Le dev : construire un stage avec les outils de debug
  • Les tests : construire un stage qui lance les tests
  • La prod : construire le stage minimal
dockerfile# Stage de base
FROM node:22-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./

# Stage de dev
FROM base AS development
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]

# Stage de build
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build

# Stage de test
FROM builder AS test
RUN npm test

# Stage de production
FROM base AS production
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
bash# Dev
docker build --target development -t mon-app:dev .

# Tests en CI
docker build --target test -t mon-app:test .

# Production
docker build --target production -t mon-app:prod .

Quatre usages, un seul Dockerfile.

Copier depuis des images externes

COPY --from peut aussi copier depuis une image existante, pas seulement un stage du meme Dockerfile :

dockerfileFROM nginx:alpine

# Copier la config depuis une image custom
COPY --from=mon-registry/nginx-config:latest /etc/nginx/nginx.conf /etc/nginx/nginx.conf

# Copier un binaire utilitaire
COPY --from=busybox:latest /bin/wget /usr/local/bin/wget

C'est pratique pour injecter des outils ou des configs sans ajouter un stage de build.

Les erreurs courantes

Oublier de reinstaller les deps de prod dans le stage final :

dockerfile# Mauvais : copie node_modules du builder (avec devDeps)
COPY --from=builder /app/node_modules ./node_modules

# Bon : reinstalle uniquement les deps de prod
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

Copier trop de fichiers du builder :

dockerfile# Mauvais : copie tout
COPY --from=builder /app .

# Bon : copie uniquement ce qui est necessaire
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public

Ne pas utiliser les memes versions de base : si le builder est sur Node 22 et le runtime sur Node 20, tu risques des incompatibilites.

Impact en CI/CD

Les multi-stage builds sont particulièrement utiles en CI/CD :

  • Les images sont plus petites, donc plus rapides a pusher et a puller
  • Le cache Docker s'applique a chaque stage indépendamment
  • Les tests peuvent tourner dans un stage dédié

Sur paltemps.fr, chaque service utilise un multi-stage build. Les images de production font entre 50 et 150 Mo. Le déploiement prend moins d'une minute.

Résumé

  • Un multi-stage build utilise plusieurs FROM pour séparer le build du runtime
  • Seul le dernier stage (ou le stage cible) est conserve dans l'image finale
  • COPY --from=stage_name copie des fichiers entre stages
  • Les gains sont massifs : 1.2 Go vers 80 Mo est courant pour du Node.js
  • Les stages nommes et --target permettent d'avoir dev, test, et prod dans un seul Dockerfile
  • Pour Go avec scratch, les images font 10-20 Mo

Precedent : 06 - .dockerignore | Suivant : 08 - Images de base

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.