DCNA
Docker Certified Network Associate
DCNAMulti-stage Builds

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 builder n'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 FROM dans 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 scratch pour une image ultra-légère et sécurisée
  • Copier les fichiers de dépendances avant le code source pour optimiser le cache

Lab: Multi-stage Build : construire une image légère

Multi-stage Build : construire une image légère
Lab: Multi-stage Build : construire une image légère
Compare une image naïve et une image multi-stage pour une app Node.js. Tu verras concrètement la différence de taille et de contenu.
────────────────────────────────────────
$
Crée un répertoire de travail et initialise un projet Node.js minimal
Crée un Dockerfile naïf (tout dans une image) dans /tmp/multistage-lab/Dockerfile.naive
Build l'image naïve et note sa taille
Crée maintenant le Dockerfile multi-stage dans /tmp/multistage-lab/Dockerfile.multi
Build l'image multi-stage et compare les tailles
Vérifie que l'image multi-stage NE contient PAS les sources (répertoire src/)
Teste que le container multi-stage fonctionne correctement
Build uniquement jusqu'au stage 'builder' pour inspecter son contenu
Nettoie les images et le répertoire de travail