Multi-stage Builds
Une des questions les plus fréquentes quand on débute avec Docker : pourquoi mon image fait-elle 1 Go alors que mon application fait 20 Mo ?
La réponse : ton image contient tous les outils de compilation (compilateur, dépendances de build, fichiers sources…) qui ne servent à rien en production. Le multi-stage build résout ce problème.
Le problème : les images "grasses"
# Dockerfile naïf — tout dans une seule image
FROM node:20
WORKDIR /app
COPY . .
RUN npm install # installe aussi les devDependencies
RUN npm run build # compile le TypeScript
EXPOSE 3000
CMD ["node", "dist/index.js"]
Cette image contiendra :
- Node.js complet (~350 Mo)
- npm + tous les node_modules (dev ET prod) (~400 Mo)
- Les fichiers sources TypeScript
- Le compilateur TypeScript
- Résultat : ~800 Mo pour une API qui produit 5 Mo de JS compilé
La solution : multi-stage builds
L'idée est simple : utiliser plusieurs FROM dans un seul Dockerfile. Chaque FROM démarre une nouvelle étape (stage). La dernière étape est celle qui devient l'image finale — et tu peux y copier uniquement ce dont tu as besoin depuis les étapes précédentes.
# Étape 1 : "builder" — installe et compile
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # installe toutes les dépendances (dev incluses)
COPY . .
RUN npm run build # compile TypeScript → JavaScript dans /app/dist
# Étape 2 : "runner" — image finale légère
FROM node:20-alpine AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # SEULEMENT les dépendances de production
COPY --from=builder /app/dist ./dist # copie SEULEMENT le code compilé
EXPOSE 3000
CMD ["node", "dist/index.js"]
Résultat :
- Image finale : ~150 Mo (au lieu de 800 Mo)
- Pas de code source, pas de TypeScript, pas de devDependencies
- L'étape
buildern'existe que pendant le build — elle n'est pas dans l'image finale
Comment ça marche vraiment
docker build .
│
├── Stage "builder" (FROM node:20)
│ ├── COPY sources
│ ├── RUN npm ci (tout)
│ └── RUN npm run build → /app/dist/
│ (cette étape disparaît de l'image finale)
│
└── Stage "runner" (FROM node:20-alpine) ← l'image finale
├── RUN npm ci --omit=dev
└── COPY --from=builder /app/dist ./dist ← seulement le build
L'instruction clé : COPY --from=<stage> copie des fichiers d'un stage précédent.
Exemple avec une app Go
Go est parfait pour illustrer le multi-stage : le compilateur Go fait 400 Mo, mais le binaire final peut être totalement statique et tourner dans une image vide (scratch) :
# Stage 1 : compilation
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# Stage 2 : image finale minimale
FROM scratch
COPY --from=builder /app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
- Image
golang:1.22: ~800 Mo - Image finale (
scratch+ binaire) : ~10 Mo
scratch est une image Docker vide — même pas un shell. Sécurité maximale.
Si tu as besoin de certificats TLS ou de timezone data :
FROM alpine:3.19
COPY --from=builder /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
Nommer les stages et les cibler
FROM node:20 AS deps
RUN ...
FROM node:20 AS builder
COPY --from=deps ...
RUN ...
FROM node:20-alpine AS runner
COPY --from=builder ...
# Construire jusqu'à un stage spécifique (utile pour déboguer)
docker build --target builder -t myapp:debug .
# Construire l'image finale complète
docker build -t myapp:latest .
Optimiser avec le cache des layers
Les layers Docker sont cachés. Copier d'abord les fichiers de dépendances, ENSUITE le code source évite de recalculer npm install à chaque changement de code.
FROM node:20 AS builder
WORKDIR /app
# ✓ Copier package.json en premier — ce layer est caché tant que les deps ne changent pas
COPY package*.json ./
RUN npm ci
# ✓ Copier le code seulement après — recompile seulement quand le code change
COPY . .
RUN npm run build
# Mauvais ordre — npm install tourne à CHAQUE changement de code
FROM node:20 AS builder
WORKDIR /app
COPY . . # ← copier tout en premier invalide tout le cache
RUN npm install
RUN npm run build
Copier depuis une image externe
Tu peux aussi copier depuis n'importe quelle image Docker, pas seulement tes propres stages :
# Copier un binaire depuis une image officielle
FROM scratch
COPY --from=alpine:3.19 /bin/sh /bin/sh
COPY --from=myapp:builder /app /app
Arguments de build (paramétrer les stages)
ARG NODE_VERSION=20
FROM node:${NODE_VERSION} AS builder
...
FROM node:${NODE_VERSION}-alpine AS runner
...
docker build --build-arg NODE_VERSION=22 -t myapp .
Résumé
- Le multi-stage build utilise plusieurs
FROMdans un seul Dockerfile - Seule la dernière étape (ou celle ciblée par
--target) devient l'image finale COPY --from=<stage>transfère des fichiers entre stages- Pattern typique :
builder(compile) →runner(runtime minimal) - Pour Go/C : utiliser
FROM scratchpour une image ultra-légère et sécurisée - Copier les fichiers de dépendances avant le code source pour optimiser le cache