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
- Avoir lu l'article 04 - Dockerfile et l'article 05 - Layers & cache
- Comprendre les bases du build d'une application (compilation, transpilation)
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
FROMpour séparer le build du runtime - Seul le dernier stage (ou le stage cible) est conserve dans l'image finale
COPY --from=stage_namecopie des fichiers entre stages- Les gains sont massifs : 1.2 Go vers 80 Mo est courant pour du Node.js
- Les stages nommes et
--targetpermettent 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