TL;DR — Résumé Rapide

Les builds multi-stage Docker réduisent la taille des images et éliminent les outils de build de la production, améliorant la sécurité pour Node.js, Go et .NET.

Les images Docker de production contenant des compilateurs, des frameworks de test et des gigaoctets de dépendances de développement sont un fardeau. Elles ralentissent le démarrage des conteneurs, consomment du stockage inutile dans les registres et exposent une surface d’attaque plus large aux exploits potentiels. Les builds multi-stage Docker résolvent ce problème en vous permettant de compiler et tester dans un environnement de build complet, puis de ne copier que les artefacts finaux dans une image runtime légère et minimale — le tout depuis un seul Dockerfile. Ce guide couvre les builds multi-stage pour Node.js, Go et .NET, y compris les stratégies de cache, le renforcement de la sécurité et l’intégration avec GitHub Actions.

Prérequis

  • Docker Engine 24+ installé (docker --version)
  • Familiarité de base avec les Dockerfiles (FROM, RUN, COPY, CMD)
  • Une application fonctionnelle en Node.js, Go ou .NET (exemples fournis)
  • BuildKit Docker activé (par défaut dans Docker 23+ ; définissez DOCKER_BUILDKIT=1 pour les versions antérieures)
  • Optionnel : flux de travail GitHub Actions pour l’intégration CI/CD

Le Problème avec les Builds à Étape Unique

Un Dockerfile Node.js typique sans builds multi-stage ressemble à ceci :

FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

Cette image contient l’environnement de développement Node.js complet : npm, les chaînes de compilation natives, le répertoire node_modules incluant les devDependencies, les fichiers source et les utilitaires de test. L’image finale peut facilement dépasser 1,2 Go pour une application modeste. Chaque couche est poussée vers le registre, téléchargée à chaque déploiement et analysée pour les CVEs dans tous ces outils que vous n’exécutez jamais en production.

Comment Fonctionnent les Builds Multi-Stage

Les builds multi-stage utilisent plusieurs instructions FROM dans un seul Dockerfile. Chaque FROM démarre une nouvelle étape avec un système de fichiers propre. Vous nommez les étapes avec AS et les référencez dans les instructions COPY --from=<étape> suivantes :

# Étape 1 : environnement de build
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

# Étape 2 : runtime de production
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

Docker construit toutes les étapes séquentiellement mais ne conserve que l’étape finale dans l’image de sortie. Chaque outil, fichier temporaire et artefact de build des étapes précédentes est supprimé. Le flag --from=builder dans COPY accède au système de fichiers de l’étape nommée pour extraire des chemins spécifiques.

Build Multi-Stage pour Node.js

Voici un Dockerfile multi-stage prêt pour la production pour une application TypeScript Node.js :

# ── Étape 1 : installer toutes les dépendances ─────────────────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --frozen-lockfile

# ── Étape 2 : compiler TypeScript ──────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ── Étape 3 : runtime de production ────────────────────────────
FROM node:22-alpine AS production
ENV NODE_ENV=production
WORKDIR /app

# Créer un utilisateur non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

# Supprimer les devDependencies de node_modules
RUN npm prune --production

USER appuser
EXPOSE 8080
CMD ["node", "dist/server.js"]

Le schéma à trois étapes sépare clairement les responsabilités. L’étape deps installe tout, y compris les devDependencies. L’étape builder compile TypeScript en JavaScript. L’étape production repart de zéro, copie uniquement la sortie compilée et node_modules, supprime les devDependencies et s’exécute en tant qu’utilisateur non-root. L’image résultante pèse environ 180 Mo contre 1,2 Go pour un build à étape unique.

Build Multi-Stage pour Go

Go est un candidat idéal pour les builds multi-stage car le compilateur produit un binaire lié statiquement qui peut s’exécuter sur pratiquement n’importe quel système de fichiers Linux :

# ── Étape 1 : compiler ────────────────────────────────────────
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o /app/server ./cmd/server

# ── Étape 2 : runtime minimal ─────────────────────────────────
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

CGO_ENABLED=0 produit un binaire entièrement statique sans dépendances de bibliothèques partagées. Les -ldflags="-s -w" suppriment la table des symboles et les informations de débogage, réduisant la taille du binaire de 20 à 30%. L’image finale utilise distroless/static-debian12, qui contient uniquement le minimum pour exécuter un binaire statique — pas de shell, pas de gestionnaire de paquets, pas de /bin/sh. Le résultat est une image de 10-20 Mo contre 600 Mo pour l’étape de build.

Build Multi-Stage pour .NET

.NET sépare clairement le SDK (pour construire) du runtime (pour exécuter). L’image runtime fait environ un quart de la taille de l’image SDK :

# ── Étape 1 : restaurer les paquets NuGet ──────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS restore
WORKDIR /src
COPY *.sln .
COPY src/**/*.csproj ./
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore

# ── Étape 2 : build et publication ─────────────────────────────
FROM restore AS builder
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet publish src/MyApp/MyApp.csproj \
    -c Release \
    -o /app/publish \
    --no-restore

# ── Étape 3 : runtime de production ────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/publish .
USER appuser
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

Une API .NET 9 typique passe de 1,1 Go (image SDK) à environ 130 Mo (image runtime Alpine).

Stratégies de Cache

Les caches de montage BuildKit (--mount=type=cache) sont l’optimisation la plus impactante pour la vitesse de reconstruction. Contrairement au cache de couches Docker, les caches de montage persistent entre les builds sans être inclus dans l’image :

# npm
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# Go — mettre en cache les téléchargements de modules et le cache de build séparément
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build ./...

# NuGet
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore

L’ordre des couches est tout aussi important. Copiez uniquement les fichiers de manifeste de dépendances (package.json, go.mod, *.csproj) avant de copier le code source. Docker invalide le cache pour toutes les couches après la première couche modifiée :

# Correct : copier les manifestes en premier, le code source en second
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build

Comparaison de la Taille des Images

ApplicationImage Étape UniqueImage Multi-StageRéduction
Node.js TypeScript API1 240 Mo185 Mo85%
Service REST Go612 Mo12 Mo98%
API ASP.NET Core .NET 91 080 Mo130 Mo88%
App Python Flask945 Mo180 Mo81%
Java Spring Boot (JRE)880 Mo250 Mo72%

Scénario Réel

Vous construisez et déployez une API REST Node.js via GitHub Actions. Actuellement, chaque exécution CI prend 8 minutes pour construire une image de 1,2 Go. Avec les builds multi-stage et le cache de couches BuildKit, vous pouvez réduire cela à moins de 2 minutes sur les exécutions avec cache.

name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          target: production
          cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
          cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=max

Pièges et Cas Limites

Les valeurs ARG ne traversent pas les limites d’étape. Si vous définissez ARG VERSION=1.0 avant le premier FROM, il est disponible globalement. S’il est défini dans une étape, il n’existe que dans cette étape. Redéclarez ARG dans chaque étape qui en a besoin.

Le contexte de build est partagé entre toutes les étapes. Utilisez un fichier .dockerignore pour exclure node_modules, .git et les fixtures de test du contexte pour accélérer le transfert et éviter que des fichiers sensibles apparaissent dans les couches intermédiaires.

Le flag --target arrête le build à une étape nommée. Utilisez-le pour tester des étapes individuelles :

docker build --target builder -t myapp:debug .
docker run --rm -it myapp:debug sh

Dépannage

L’image finale contient encore des fichiers source — Vous avez oublié d’utiliser COPY --from=builder et utilisez un simple COPY . . dans l’étape de production. Vérifiez chaque COPY dans votre étape de production.

Le binaire Go échoue avec “exec format error” sur Alpine — Vous avez compilé avec CGO_ENABLED=1 mais utilisé une étape sans runtime C. Définissez CGO_ENABLED=0 explicitement, ou changez l’étape runtime pour alpine:3.19.

Les caches de montage ne persistent pas en CI--mount=type=cache est une fonctionnalité locale de BuildKit. En CI, utilisez plutôt la stratégie de cache de registre (cache-from/cache-to).

La taille de l’image n’a pas diminué significativement — Vérifiez que votre étape de production ne copie pas de répertoires inutiles. Exécutez docker history myimage:latest pour voir quelles couches contribuent le plus à la taille.

Résumé

  • Les builds à étape unique incluent des compilateurs, des outils de développement et du code source dans l’image finale — les builds multi-stage éliminent tout cela
  • Utilisez COPY --from=<étape> pour copier uniquement les artefacts compilés dans une image base runtime minimale
  • Les applications Node.js se réduisent de 85%, Go de 98% et .NET de 88% avec les builds multi-stage
  • Montez des caches de gestionnaire de paquets avec --mount=type=cache pour accélérer les reconstructions sans gonfler l’image
  • Copiez les manifestes de dépendances avant le code source pour maximiser la réutilisation du cache de couches Docker
  • Exécutez les conteneurs en tant qu’utilisateurs non-root et utilisez des systèmes de fichiers en lecture seule si possible
  • Dans GitHub Actions, utilisez cache-from/cache-to avec un cache de registre pour partager les couches BuildKit entre les exécutions
  • Utilisez --target pour construire et déboguer des étapes spécifiques sans exécuter le build complet

Articles Connexes