Retour au cours

infra / docker

Multi-stage builds

Leçon 91 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi les outils de build ne devraient jamais atterrir dans l'image de production
  • Découper un Dockerfile en plusieurs stages avec FROM ... AS <nom>
  • Copier uniquement le résultat nécessaire d'un stage à l'autre avec COPY --from
  • Utiliser une image scratch pour un binaire compilé (Go) et obtenir une image quasi vide
  • Cibler un stage précis au build (--target) pour les tests ou le débogage, sans publier l'image finale

Dans quel contexte ?

Une équipe backend en Node.js/TypeScript construit son image avec un seul stage : elle installe toutes les dépendances (y compris celles de développement), compile le TypeScript, puis lance l'application — résultat, une image de 1,2 Go alors que le code compilé ne pèse que quelques mégaoctets. En passant à un multi-stage build, un premier stage compile le code avec tous les outils nécessaires, et le stage final ne récupère que le dossier dist/ compilé : l'image finale tombe à 180 Mo, sans compilateur ni code source exposé.

Le problème : l'image finale hérite de tout

Compiler ou construire une application nécessite souvent des outils lourds — compilateurs, dépendances de développement, code source complet — qui n'ont strictement aucune utilité une fois l'application prête à tourner. Sans précaution, un Dockerfile classique embarque pourtant tout cela dans l'image finale, ce qui l'alourdit inutilement et augmente sa surface d'attaque : plus il y a d'outils installés, plus il y a de failles potentielles exploitables si l'application est compromise.

StageContenuPrésent dans l'image finale ?
builderCompilateur, sources, dépendances de devNon
productionUniquement le résultat compiléOui

Astuce

Utilise --target pour construire uniquement jusqu'à un stage précis (docker build --target builder -t debug .) : c'est très utile pour déboguer une étape intermédiaire du build sans attendre que tout le Dockerfile se termine.

L'idée : séparer construction et exécution

Un multi-stage build consiste à découper le Dockerfile en plusieurs étapes successives (des "stages"), chacune démarrant sa propre image de base avec FROM ... AS <nom>. Un premier stage, souvent appelé "builder", contient tous les outils lourds nécessaires pour compiler ou préparer l'application. Un stage final, beaucoup plus léger, ne récupère du premier stage QUE le résultat fini (un binaire compilé, du code transpilé), grâce à COPY --from=<stage>. Tout le reste — compilateur, sources, dépendances de développement — reste dans le stage builder et n'apparaît jamais dans l'image livrée.

Un exemple qui parle : Go et "scratch"

Le cas le plus spectaculaire est celui d'un langage compilé comme Go : le binaire final n'a besoin d'aucun runtime pour s'exécuter, il peut donc atterrir dans une image scratch, littéralement vide — pas même un shell. L'image finale ne contient alors que l'application elle-même, ce qui la rend minuscule et extrêmement difficile à exploiter en cas de faille, faute d'outils disponibles à l'intérieur pour un attaquant.

Lien avec la leçon suivante

Cette technique est l'un des leviers les plus efficaces de l'optimisation d'images (couverte juste après), bien plus impactant que de simplement choisir une image de base légère.

Commandes & code

Multi-stage builds

dockerfile
# Problème : les outils de build (compilateurs, dépendances de dev) n'ont rien à faire
# dans l'image finale de production -> ils l'alourdissent et augmentent la surface d'attaque.

# --- Exemple Node.js : stage de build séparé du stage d'exécution ---
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                          # installe TOUTES les dépendances, y compris devDependencies
COPY . .
RUN npm run build                     # compile TypeScript -> JavaScript dans /app/dist

FROM node:20-slim AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev                 # uniquement les dépendances de production
COPY --from=builder /app/dist ./dist    # copie SEULEMENT le résultat compilé depuis le stage builder
CMD ["node", "dist/main.js"]
# L'image finale ne contient ni le code source TypeScript, ni le compilateur, ni les devDependencies
dockerfile
# --- Exemple Go : le cas le plus spectaculaire, binaire statique dans une image quasi vide ---
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 /app/serveur ./cmd/serveur

FROM scratch
# "scratch" = image totalement vide, pas même un shell -> la plus petite image possible
COPY --from=builder /app/serveur /serveur
ENTRYPOINT ["/serveur"]
dockerfile
# --- Réutiliser un stage intermédiaire pour les tests, sans le publier ---
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
RUN pytest

FROM base AS production
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
bash
# Construire un stage précis (utile en CI pour lancer les tests avant de builder la prod)
docker build --target test -t mon-api:test .
docker build --target production -t mon-api:prod .

# Construire uniquement jusqu'à un stage, pour déboguer une étape intermédiaire
docker build --target builder -t mon-api:debug-builder .
docker run -it mon-api:debug-builder bash

Résumé

  • Chaque FROM ... AS <nom> démarre un nouveau stage ; COPY --from=<stage> transfère uniquement ce qui est nécessaire.
  • Les outils de build (compilateurs, devDependencies) restent dans le stage builder, absents de l'image finale.
  • --target permet de construire un stage précis (tests, debug) sans publier l'image de production.

Exercices pratiques

1 disponible
1

Mission : réduire drastiquement une image Node.js de 1,2 Go

Objectif : Convertir un Dockerfile mono-stage en multi-stage build et exploiter --target pour déboguer une étape intermédiaire.

Contexte

L'image mon-api (Node.js/TypeScript) pèse 1,2 Go car elle installe toutes les dépendances (dev incluses) et garde le code source dans l'image finale. Tu dois la restructurer en plusieurs stages, tout en gardant la possibilité de déboguer l'étape de compilation isolément.

Résoudre l’exercice →