## Introduction

Les builds multi-étapes Docker sont la méthode standard pour produire des images de petite taille, sécurisées et prêtes pour la production. L'idée de base est simple : utiliser une étape pour compiler ou préparer l'application, puis copier uniquement les artefacts nécessaires dans une image finale. Mais une utilisation avancée va bien au-delà d'un simple Dockerfile à deux étapes.

Cet article explore les techniques qui distinguent un pipeline de qualité production d'un projet amateur : contrôle précis du cache, builds conditionnels avec BuildKit, construction d'une étape cible spécifique, partage de données entre étapes avec `--mount`, et durcissement de l'image finale. Chaque concept est accompagné d'un exemple concret que vous pouvez exécuter dès aujourd'hui.

À la fin, vous saurez comment accélérer les builds, éviter les fuites de secrets et produire des images plus faciles à sécuriser et à maintenir.

## Pourquoi les builds multi-étapes sont importants

Un Dockerfile à une seule étape pour une application Go pourrait ressembler à ceci :

```dockerfile
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o myapp .
CMD ["./myapp"]
```

L'image résultante inclut toute la chaîne d'outils Go, le code source et le cache de build, dépassant souvent 800 Mo. En production, cela signifie des téléchargements plus lents, une surface d'attaque plus grande et un gaspillage d'espace disque.

Les builds multi-étapes résolvent ce problème en séparant l'environnement de build de l'environnement d'exécution. La même application peut être construite en moins de 20 Mo en utilisant une base `scratch` ou `alpine` :

```dockerfile
# Étape 1 : build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .

# Étape 2 : exécution
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/myapp .
CMD ["./myapp"]
```

Ce modèle est bien connu. Mais pour l'utiliser efficacement dans un vrai pipeline CI/CD, vous devez en comprendre les subtilités.

## Stratégies de mise en cache avancées

### Mise en cache des calques et ordre des instructions

Docker met en cache les calques en fonction de l'instruction et de ses entrées. Modifier une instruction invalide ce calque et tous les calques suivants. L'ordre est donc crucial.

Dans l'exemple Go ci-dessus, nous copions `go.mod` et `go.sum` avant de copier le reste du code source. Ainsi, le téléchargement des dépendances est mis en cache séparément des modifications du code de l'application. Si vous ne modifiez que `main.go`, le calque `go mod download` est réutilisé, économisant du temps réseau.

Une erreur courante est de tout copier en une seule fois :

```dockerfile
COPY . .
RUN go mod download
RUN go build -o myapp .
```

Ici, toute modification d'un fichier invalide le calque de copie, ce qui entraîne la ré-exécution de `go mod download`. Séparez toujours l'installation des dépendances de la copie du code source.

### Utilisation des montages de cache BuildKit

BuildKit (activé par défaut dans Docker 23.0+ avec le pilote Docker, ou via `DOCKER_BUILDKIT=1`) introduit des montages de cache qui persistent entre les builds sans stocker de données dans l'image finale. Ils sont parfaits pour les gestionnaires de paquets, les compilateurs et les artefacts de test.

Par exemple, pour mettre en cache les modules Go pendant un build :

```dockerfile
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /app
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 \
    go build -o myapp .
```

La directive `--mount=type=cache` indique à BuildKit de monter un répertoire de cache persistant au chemin spécifié pendant cette instruction RUN. Le cache survit entre les builds (tant que l'ID du montage est stable) et n'est pas inclus dans l'image finale. Cela peut considérablement accélérer les builds.

Pour Node.js, vous pouvez mettre en cache `node_modules` ou le cache npm :

```dockerfile
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build
```

Ici, `/root/.npm` met en cache le cache des paquets npm, de sorte que la ré-exécution de `npm ci` ne re-télécharge pas les paquets à chaque fois.

Pour les images basées sur apt, vous pouvez mettre en cache `/var/cache/apt` et `/var/lib/apt` :

```dockerfile
FROM debian:12 AS build
RUN --mount=type=cache,target=/var/cache/apt \
    --mount=type=cache,target=/var/lib/apt \
    apt-get update && apt-get install -y --no-install-recommends \
    build-essential
```

Cela évite de re-télécharger les listes de paquets à chaque build.

### Gestion des secrets pendant le build

N'intégrez jamais de secrets dans une image. Même si vous les supprimez plus tard, ils restent dans les calques intermédiaires. BuildKit fournit des montages de secrets pour rendre les secrets disponibles uniquement pendant une instruction RUN spécifique.

Exemple : utiliser une clé SSH pour cloner un dépôt privé :

```dockerfile
# syntax=docker/dockerfile:1.7
FROM alpine:3.19
RUN apk add --no-cache git openssh-client
RUN mkdir -p -m 0700 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
RUN --mount=type=ssh git clone git@github.com:myorg/private-repo.git /src
```

Construisez avec :

```bash
docker build --ssh default -t myapp .
```

Le socket de l'agent SSH est monté uniquement pour la commande `git clone`, et la clé privée ne se retrouve jamais dans l'image.

De même, vous pouvez monter un fichier secret :

```dockerfile
RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret
```

Construisez avec :

```bash
docker build --secret id=mysecret,src=./secret.txt -t myapp .
```

C'est beaucoup plus sûr que d'utiliser `ARG` pour les secrets, car les valeurs `ARG` sont visibles dans l'historique de build.

## Builds conditionnels avec BuildKit

Avec BuildKit, vous pouvez utiliser des montages RUN en ligne et des heredocs, et surtout, vous pouvez inclure conditionnellement des étapes en utilisant des arguments de build. Cela est utile pour créer des builds de débogage, sélectionner différentes architectures CPU ou activer/désactiver des fonctionnalités.

Considérons une image qui peut être construite soit comme image de développement (avec symboles de débogage et outils), soit comme image de production (minimale).

```dockerfile
# syntax=docker/dockerfile:1.7
FROM alpine:3.19 AS base
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/myapp .

FROM base AS production
CMD ["./myapp"]

FROM base AS development
RUN apk add --no-cache curl bash
CMD ["sh"]
```

Ensuite, construisez sélectivement :

```bash
docker build --target production -t myapp:prod .
docker build --target development -t myapp:dev .
```

Vous pouvez également utiliser des arguments de build pour choisir entre des alternatives dans une étape :

```dockerfile
ARG VERSION=latest
FROM base AS builder
RUN if [ "$VERSION" = "debug" ]; then \
      go build -gcflags="all=-N -l" -o myapp .; \
    else \
      go build -o myapp .; \
    fi
```

Bien que les instructions `if` fonctionnent, elles peuvent devenir lourdes pour des conditions complexes. Une approche plus propre consiste à définir des étapes séparées et à sélectionner avec `--target`.

## Construire uniquement une étape spécifique

Dans un grand Dockerfile avec de nombreuses étapes, vous voulez souvent n'en construire qu'une seule. Par exemple, vous pourriez avoir une étape `test` qui exécute des tests unitaires, et vous voulez l'exécuter séparément de la construction de l'image finale.

```dockerfile
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS base
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .

FROM base AS test
RUN go test ./...

FROM base AS build
RUN CGO_ENABLED=0 go build -o myapp .

FROM alpine:3.19 AS final
COPY --from=build /app/myapp /usr/local/bin/myapp
CMD ["myapp"]
```

Vous pouvez exécuter uniquement les tests avec :

```bash
docker build --target test .
```

Cela évite de construire l'image finale lorsque vous n'avez besoin que des résultats des tests. En CI, cela peut être utilisé dans un pipeline pour exécuter les tests avant de procéder à la construction.

Remarque : comme `test` dépend de `base`, Docker construira d'abord `base`, mais ne construira pas `build` ni `final`.

## Partager des données entre étapes avec `--mount`

Parfois, vous devez transmettre plus que quelques fichiers entre les étapes. Les montages `--mount=type=bind` et `--mount=type=cache` de BuildKit offrent plus de flexibilité que `COPY --from`.

Par exemple, supposons que votre étape de build génère plusieurs artefacts (binaires, configurations, assets statiques) que vous voulez copier dans l'image finale, mais que vous voulez aussi exécuter une étape de post-traitement. Vous pourriez copier chaque artefact individuellement avec `COPY --from`, mais cela peut être verbeux.

Une alternative est d'utiliser un contexte nommé ou un montage bind depuis une étape précédente. Cependant, `COPY --from` reste la solution la plus simple dans la plupart des cas. La puissance de `--mount` réside dans le partage d'un répertoire pendant le build sans copier de fichiers dans un calque qui pourrait devenir obsolète.

Considérons un monorepo où l'étape de build compile un frontend et un backend, et qu'ils doivent partager du code commun. Vous pouvez monter en bind le répertoire source du contexte de build dans les deux étapes :

```dockerfile
FROM node:20 AS frontend-build
WORKDIR /app
RUN --mount=type=bind,source=.,target=/app \
    npm install && npm run build:frontend

FROM golang:1.22 AS backend-build
WORKDIR /app
RUN --mount=type=bind,source=.,target=/app \
    go build -o server ./backend
```

Mais cette approche remonte tout le contexte pour chaque étape, ce qui peut ne pas être efficace. Il est généralement préférable d'utiliser `COPY` pour des fichiers spécifiques et de s'appuyer sur la mise en cache des calques.

Un cas d'utilisation spécial est `--mount=type=cache` partagé entre les étapes. Par exemple, vous pourriez vouloir réutiliser un cache de dépendances téléchargées entre plusieurs étapes de build :

```dockerfile
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o myapp .
```

Le montage de cache est partagé entre les builds répétés, et même entre différentes étapes si elles utilisent le même chemin cible et le même ID. Mais notez que les montages de cache ne sont pas partagés entre les builds concurrents par défaut.

## Aplatir les calques et réduire la taille de l'image

Bien que les builds multi-étapes produisent déjà des images plus petites, vous pouvez aller plus loin avec l'aplatissement des calques et `COPY --link`.

`COPY --link` (nécessite BuildKit) copie des fichiers d'une autre étape sans créer de dépendance sur les calques précédents. Cela peut permettre à la copie d'être effectuée en parallèle et réduit le nombre de calques dans certains cas.

```dockerfile
# syntax=docker/dockerfile:1.7
FROM alpine:3.19 AS final
COPY --link --from=build /app/myapp /usr/local/bin/myapp
```

C'est particulièrement utile lorsque vous avez de nombreux petits fichiers à copier ; au lieu d'un calque par COPY, ils peuvent être combinés.

Pour réduire davantage la taille, envisagez d'utiliser `scratch` comme base finale si votre application est liée statiquement ou n'a pas besoin d'utilitaires système. Par exemple, un binaire Go construit avec `CGO_ENABLED=0` peut s'exécuter sur `scratch` :

```dockerfile
FROM scratch
COPY --from=build /app/myapp /myapp
ENTRYPOINT ["/myapp"]
```

Cela produit une image qui ne contient que le binaire, souvent moins de 10 Mo. Cependant, sachez que `scratch` n'a pas de shell, pas de certificats CA (sauf s'ils sont copiés) et pas de données de fuseau horaire. Vous devez inclure explicitement tout fichier nécessaire.

Si vous avez besoin de certificats CA, copiez-les depuis une étape de build :

```dockerfile
FROM alpine:3.19 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app/myapp /myapp
ENTRYPOINT ["/myapp"]
```

Cela garde l'image minuscule tout en permettant les appels HTTPS.

## Durcissement de la sécurité

Les builds multi-étapes aident à la sécurité en séparant les outils de build de l'environnement d'exécution. Mais il y a d'autres mesures que vous pouvez prendre :

1. **Utilisez des versions spécifiques des images de base, pas `latest`.** Épingler à `alpine:3.19` ou `debian:12-slim` garantit la reproductibilité et réduit les surprises.
2. **Créez un utilisateur non root dans l'étape finale.** Évitez d'exécuter les conteneurs en tant que root.

```dockerfile
FROM alpine:3.19
RUN addgroup -S app && adduser -S app -G app
USER app
COPY --from=build --chown=app:app /app/myapp /usr/local/bin/myapp
CMD ["myapp"]
```

3. **Supprimez les permissions setuid/setgid inutiles** et utilisez `--no-install-recommends` pour apt.
4. **Utilisez un scanner** comme Trivy ou Clair pour identifier les vulnérabilités dans l'image finale.
5. **Définissez `LABEL` pour les métadonnées** mais n'incluez pas d'informations sensibles.

Une autre technique puissante consiste à utiliser les drapeaux `--sbom` et `--provenance` de BuildKit pour générer des attestations, mais cela dépasse le cadre de cet article.

## Pièges courants et comment les éviter

1. **Ne pas ordonner les calques pour l'efficacité du cache.** Erreur : copier tout le code source avant d'installer les dépendances. Impact : le cache est invalidé à chaque modification du code. Correction : copiez d'abord les fichiers de dépendances.

2. **Fuite de secrets via les arguments de build.** Erreur : `ARG mysecret` puis l'utiliser dans un RUN. Correction : utilisez des montages de secrets ou des variables d'environnement au moment de l'exécution.

3. **Utiliser des images de base `latest`.** Erreur : imprévisibilité entre les builds. Correction : épinglez à un digest ou une version spécifique.

4. **Construire des images en tant que root et les exécuter en tant que root.** Erreur : la compromission du conteneur donne le root sur l'hôte. Correction : créez un utilisateur non root.

5. **Oublier de nettoyer dans l'étape finale.** Erreur : copier des fichiers inutiles ou inclure des caches de gestionnaires de paquets. Correction : utilisez `--no-cache` et inspectez la taille de l'image.

6. **Ne pas utiliser BuildKit.** Erreur : manquer les montages de cache, les montages de secrets et les builds parallèles. Correction : assurez-vous que `DOCKER_BUILDKIT=1` ou Docker 23+ par défaut.

7. **Ignorer le nombre de calques.** Erreur : trop de calques peuvent gonfler les métadonnées de l'image et ralentir les téléchargements. Correction : combinez les commandes RUN lorsque c'est judicieux et utilisez des copies multi-étapes.

8. **Utiliser `COPY . .` sans `.dockerignore`.** Erreur : envoyer `.git`, des secrets locaux ou de gros fichiers au démon. Correction : créez un fichier `.dockerignore`.

Exemple de `.dockerignore` :

```text
.git
.env
*.md
node_modules
```

9. **Ne pas tirer parti de `--target` pour les tests.** Erreur : reconstruire toute l'image juste pour exécuter les tests. Correction : définissez une étape de test et utilisez `docker build --target test .`

10. **Supposer que le multi-étapes réduit automatiquement l'image.** Erreur : si l'étape finale inclut des paquets inutiles, elle reste volumineuse. Correction : vérifiez régulièrement `docker images` et optimisez.

## Exemple concret : frontend Node.js avec API backend

Parcourons un Dockerfile complet pour une application web typique : un frontend Node.js qui est construit en fichiers statiques, et un backend Go servant l'API. Nous voulons une image finale qui sert à la fois l'API et les fichiers statiques du frontend.

```dockerfile
# syntax=docker/dockerfile:1.7

# ---- Build frontend ----
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

# ---- Build backend ----
FROM golang:1.22-alpine AS backend-build
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 go build -o server .

# ---- Étape finale ----
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=backend-build /app/backend/server ./server
COPY --from=frontend-build /app/frontend/dist ./static
EXPOSE 8080
USER nobody
CMD ["./server"]
```

Dans cet exemple, le frontend et le backend sont construits en parallèle (BuildKit peut exécuter des étapes indépendantes simultanément), et seuls le binaire compilé et les fichiers statiques sont copiés dans l'image finale. Le résultat est une petite image (probablement moins de 30 Mo) qui s'exécute en tant qu'utilisateur non root.

Pour construire et exécuter :

```bash
docker build -t myapp .
docker run -p 8080:8080 myapp
```

Le serveur backend doit être configuré pour servir les fichiers statiques depuis `/app/static`. Pour un serveur Go, cela peut signifier intégrer les fichiers statiques ou les servir via `http.FileServer`.

## Liste de contrôle opérationnelle pour les builds multi-étapes

Avant de pousser une image en production, vérifiez les points suivants :

- [ ] Les images de base sont épinglées par digest ou version spécifique (par exemple, `alpine:3.19@sha256:...`).
- [ ] Le fichier `.dockerignore` exclut `.git`, les secrets, les journaux et les artefacts de build.
- [ ] Aucun secret n'est transmis via `--build-arg` ; des montages de secrets ou des variables d'environnement d'exécution sont utilisés à la place.
- [ ] L'image finale s'exécute en tant qu'utilisateur non root (instruction `USER` définie).
- [ ] L'image ne contient que les fichiers nécessaires (vérifiez avec `docker run --rm -it myimage sh` et `ls`).
- [ ] Les caches sont utilisés : les couches de dépendances sont séparées des modifications du code source.
- [ ] BuildKit est activé et `--mount=type=cache` est utilisé lorsque c'est approprié.
- [ ] Si vous utilisez `scratch`, les certificats CA et les données de fuseau horaire nécessaires sont inclus.
- [ ] Le nombre de calques est raisonnable (combinez les commandes RUN liées).
- [ ] La taille de l'image est mesurée et comparée aux builds précédents (`docker images myapp`).
- [ ] Un scan de sécurité a été exécuté (par exemple, `trivy image myapp`).
- [ ] Le build est reproductible : construire dans un environnement propre donne le même ID d'image (envisagez d'utiliser `--provenance` et `--sbom`).

**Responsabilité** : L'ingénieur DevOps (par exemple, Priya Shah, responsable de l'ingénierie) est le seul responsable de la définition du build multi-étapes et de sa revue. Le processus de build doit être revu au moins tous les trimestres ou chaque fois qu'une image de base change, qu'une nouvelle dépendance est ajoutée ou qu'un avis de sécurité affecte la pile.

## Modes de défaillance et récupération

Même avec une planification minutieuse, des problèmes surviennent. Voici les défaillances courantes et comment les récupérer :

1. **Le build échoue en raison d'un secret ou d'un fichier manquant.** Symptôme : `ERROR: failed to solve: ... not found`. Récupération : assurez-vous que le fichier secret existe et est transmis correctement (`--secret` ou `--ssh`). Vérifiez que le chemin à l'intérieur du conteneur est correct.

2. **Le cache n'est pas utilisé.** Symptôme : le build re-télécharge toujours les dépendances. Récupération : vérifiez l'ordre des instructions et que les montages de cache sont correctement définis. Utilisez `docker build --progress=plain` pour voir les hits de cache. Assurez-vous que BuildKit est actif.

3. **Taille de l'image anormalement élevée.** Symptôme : `docker images` montre une taille supérieure à celle attendue. Récupération : exécutez `docker history <image>` pour voir les tailles des calques. Identifiez les gros calques ; supprimez les fichiers inutiles ; envisagez une étape finale `scratch` ou `alpine` ; utilisez `--no-install-recommends`.

4. **Le conteneur plante avec `not found` pour les certificats CA ou le fuseau horaire.** Récupération : copiez les certificats CA depuis une étape alpine comme montré précédemment ; définissez la variable d'environnement du fuseau horaire ou copiez tzdata.

5. **Permission refusée pour l'utilisateur non root.** Symptôme : l'application ne peut pas écrire dans un répertoire. Récupération : assurez-vous que le répertoire appartient au même utilisateur/groupe en utilisant `--chown` dans COPY ou `RUN chown`.

6. **Secrets divulgués dans les calques de l'image.** Récupération : faites immédiatement pivoter le secret. Reconstruisez l'image avec des montages de secrets. Envisagez d'utiliser le multi-étapes pour garantir que les secrets ne sont pas dans les calques finaux. Utilisez `docker scan` ou `docker history` pour détecter si nécessaire.

7. **La mise en cache des calques provoque des dépendances obsolètes.** Symptôme : une ancienne version d'une dépendance est utilisée parce que le cache de calque est valide. Récupération : invalidez le cache en modifiant le fichier pertinent (par exemple, mettez à jour `go.sum` ou `package-lock.json`) ou utilisez `--no-cache`.

8. **Le montage de cache BuildKit remplit le disque.** Symptôme : l'utilisation du disque de l'hôte de build augmente avec le temps. Récupération : purgez périodiquement le cache avec `docker builder prune` et définissez des limites de cache.

## Conclusion

Les builds multi-étapes sont la pierre angulaire d'une utilisation efficace de Docker, mais leurs fonctionnalités avancées peuvent faire une différence significative en termes de vitesse de build, de sécurité et de maintenabilité. En appliquant les techniques de cet article — montages de cache, montages de secrets, ciblage sélectif d'étapes et gestion appropriée des utilisateurs — vous pouvez construire des images de production petites, rapides à construire et sécurisées.

La clé est de traiter le Dockerfile comme un élément d'infrastructure critique : versionnez-le, révisez-le et optimisez-le continuellement. Commencez par une amélioration : activez BuildKit, ajoutez un `.dockerignore`, utilisez des montages de cache pour votre gestionnaire de paquets et introduisez un utilisateur non root. Ensuite, mesurez l'impact et itérez.

Avec ces pratiques, vos conteneurs seront légers, vos builds rapides et votre surface d'attaque réduite.