Optimiser les images Docker avec les builds multi-étapes : un guide complet

Maîtrisez les builds multi-étapes Docker pour réduire considérablement la taille de vos images, accélérer les déploiements et renforcer la sécurité. Ce guide complet fournit des instructions étape par étape, des exemples pratiques pour Go et Node.js, ainsi que les meilleures pratiques essentielles. Apprenez à optimiser vos Dockerfiles en séparant les dépendances de construction, en veillant à ce que seuls les composants nécessaires parviennent à votre image d'exécution finale. Une lecture essentielle pour quiconque cherche à créer des applications conteneurisées efficaces et sécurisées.

Optimiser les images Docker avec les builds multi-étapes : un guide complet

Les builds multi-étapes résolvent un problème Docker très ordinaire : les outils dont vous avez besoin pour construire une application ne sont généralement pas ceux dont vous avez besoin pour l'exécuter.

Un compilateur Go, un cache de paquets Node, un dépôt Maven, un framework de test et des en-têtes de construction sont utiles lors de la construction de l'image. Ils sont un poids mort dans l'image d'exécution. Ils ralentissent les téléchargements, augmentent la quantité de logiciels à corriger et rendent plus difficile la compréhension de ce qui s'exécute réellement en production.

Avec un Dockerfile multi-étapes, vous construisez dans une étape et copiez uniquement l'artefact fini dans une étape d'exécution plus petite. L'image finale n'hérite pas de l'étape de construction à moins que vous ne copiiez explicitement des fichiers à partir de celle-ci.

Le problème des images mono-étape

Considérons une application Go typique. Vous avez besoin de la chaîne d'outils Go pour la compiler. Une fois que vous avez un binaire Linux, le compilateur n'est plus nécessaire. Une image mono-étape le conserve quand même :

FROM golang:1.21-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

Cela fonctionne, mais l'image finale contient toujours la chaîne d'outils Go et le cache de construction. Le même schéma se retrouve avec Node, Java, Rust, les paquets Python avec des extensions natives et les builds frontend.

Le coût est pratique :

  • Augmentation de la taille de l'image : Plus de couches, plus de données à télécharger et à stocker.
  • Allongement des temps de déploiement : Les images plus volumineuses prennent plus de temps à transférer.
  • Introduction de risques de sécurité : Une surface d'attaque plus grande avec des logiciels inutiles.
  • Obscurcissement de l'environnement d'exécution : Rend plus difficile la compréhension de ce qui est réellement nécessaire.

Les images plus petites ne sont pas automatiquement plus rapides à l'exécution, mais elles sont plus rapides à déplacer dans les CI, les registres et les systèmes de déploiement. Elles rendent également la revue de sécurité moins bruyante.

Ce que font les builds multi-étapes

Chaque instruction FROM démarre une nouvelle étape. Vous pouvez nommer une étape et copier des fichiers à partir de celle-ci plus tard :

FROM golang:1.21-alpine AS builder
# construire les fichiers ici

FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp

La deuxième étape repart de zéro. Elle ne contient pas /usr/local/go, les fichiers sources, les caches de paquets ou les outils de construction de la première étape, sauf si vous les copiez.

Un exemple Go propre

Voici une petite application :

package main

import (
	"fmt"
	"log"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Bonjour depuis une image Docker optimisée !")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Serveur démarré sur :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Le Dockerfile multi-étapes :

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/myapp /app/myapp

EXPOSE 8080
CMD ["/app/myapp"]

Les fichiers go.mod et go.sum sont copiés avant l'arborescence source complète afin que Docker puisse réutiliser la couche de téléchargement des dépendances lorsque seul le code de l'application change. CGO_ENABLED=0 est utile lorsque vous voulez un binaire statique. Si votre application dépend de bibliothèques C, vous aurez peut-être besoin d'une image d'exécution qui inclut ces bibliothèques au lieu de forcer des builds statiques.

Construisez et comparez :

docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage

Ne vous fiez pas à la taille d'un exemple de blog. Vérifiez votre propre image. Les choix de dépendances, les versions des images de base, les symboles de débogage, les certificats, les données de fuseau horaire et les bibliothèques natives affectent tous le résultat.

Choix de l'image de base d'exécution

alpine est populaire car elle est petite, mais petite n'est pas toujours synonyme de compatible. Alpine utilise musl libc, tandis que de nombreuses distributions Linux courantes utilisent glibc. La plupart des binaires Go statiques fonctionnent bien. Certains paquets Python, Node, Java ou natifs se comportent différemment.

Options d'exécution courantes :

Base d'exécution Bon ajustement Compromis
alpine Images petites, binaires simples Différences de compatibilité musl
debian:bookworm-slim Large compatibilité Linux Plus grand qu'Alpine
Images Distroless Exécutions de production avec moins d'outils Plus difficile à déboguer à l'intérieur du conteneur
scratch Binaires statiques uniquement Pas de shell, de certificats CA ou de gestionnaire de paquets sauf copiés

Si l'application appelle des points de terminaison HTTPS, assurez-vous que l'image finale inclut les certificats CA. Une image scratch sans certificats peut échouer d'une manière qui ressemble à un problème réseau.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]

Builds multi-étapes pour d'autres langages/frameworks

La même idée fonctionne partout où il y a une étape de construction.

Pour un frontend Node :

FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Pour une API Node, ne copiez pas node_modules à partir d'une installation de développement si elle inclut des dépendances de développement :

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Pour Java :

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

Le cache de construction compte aussi

Les builds multi-étapes réduisent la taille finale de l'image, mais l'ordre du Dockerfile contrôle toujours le comportement du cache. Placez les fichiers de dépendances stables avant les fichiers sources volatils. Utilisez npm ci au lieu de npm install dans les builds reproductibles. Épinglez les versions des images de base au lieu de vous fier à latest en production.

Avec BuildKit, les montages de cache peuvent accélérer les téléchargements de paquets sans intégrer les caches dans l'image finale :

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine 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=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o myapp

Ce cache est destiné à la machine de construction, pas à l'image d'exécution.

Que copier, et quoi ne pas copier

Copiez le plus petit ensemble d'exécution complet. Pour un service compilé, cela peut être un binaire plus des modèles de configuration et des certificats CA. Pour un frontend, cela peut être un répertoire dist. Pour Java, cela peut être un jar plus un JRE.

Ne copiez pas le code source, les caches du gestionnaire de paquets, les fixtures de test, les fichiers .env locaux, les clés SSH ou les sorties de construction que vous n'exécutez pas. Utilisez un fichier .dockerignore afin que ces fichiers n'entrent pas dans le contexte de construction en premier lieu :

.git
node_modules
coverage
dist
*.log
.env

Le fichier .dockerignore ne remplace pas des instructions COPY soigneuses, mais il empêche le gonflement accidentel du contexte et les fuites de secrets.

Débogage des builds multi-étapes

Nommez vos étapes. Une étape nommée est plus facile à cibler :

docker build --target builder -t app-builder .
docker run --rm -it app-builder sh

Ceci est utile lorsque la construction réussit mais que l'image finale échoue parce qu'un fichier a été copié au mauvais chemin ou qu'une bibliothèque d'exécution est manquante.

Vous pouvez également inspecter les fichiers copiés dans l'image finale :

docker run --rm -it --entrypoint sh my-image

Si l'image n'a pas de shell, remplacez temporairement l'étape finale par une base adaptée au débogage pendant le diagnostic, puis remettez la base de production.

Une règle pratique

Utilisez une étape pour chaque travail distinct : dépendances, construction, test, exécution. Gardez l'étape d'exécution ennuyeuse. Si quelqu'un ouvre l'étape finale du Dockerfile, il devrait pouvoir répondre rapidement à une question : de quels fichiers ce conteneur a-t-il réellement besoin pour fonctionner ?

C'est la vraie valeur des builds multi-étapes. Les images plus petites sont agréables. Des limites d'exécution claires sont meilleures.