Optimisez vos images Docker : La méthode multi-stage

Vos images Docker sont trop lourdes et vulnérables ? Apprenez à utiliser le multi-stage building pour réduire radicalement leur taille, booster vos déploiements et renforcer votre sécurité.

Comprendre l'embonpoint de vos conteneurs et préparer l'environnement

Illustration de la réduction spectaculaire de la taille d'une image Docker grâce au multi-stage build

Chaque mégaoctet inutile qui traîne dans votre registre d'images est une vulnérabilité potentielle en attente d'exploitation et un coût de transfert réseau inutile qui ralentit vos déploiements en production. Lorsque vous débutez en ingénierie DevOps, la tentation est grande d'écrire des fichiers de configuration simples qui compilent et exécutent vos applications au même endroit, mais cette habitude génère des conteneurs obèses contenant des compilateurs, des gestionnaires de paquets et des codes sources complets qui n'ont rien à faire sur un serveur de production.

Pourquoi nos images Docker grossissent-elles sans contrôle ?

Le problème principal réside dans l'accumulation des couches de build. Pour compiler une application écrite dans un langage moderne, vous avez besoin d'un kit de développement complet contenant des outils d'analyse de code, des bibliothèques de test et des utilitaires de compilation. Si vous utilisez une seule image de base pour tout faire, tous ces outils de développement se retrouvent embarqués dans votre image finale, augmentant radicalement la surface d'attaque de votre infrastructure et allongeant le temps nécessaire pour démarrer de nouvelles instances de votre application lors d'un pic de charge.

Préparation de notre plan d'action technique

Pour illustrer ce guide pratique de production, nous allons utiliser une application écrite en langage Go. Le choix de ce langage est idéal car il permet de générer un binaire autonome après compilation, ce qui nous permettra de réduire la taille de notre image finale de manière spectaculaire en appliquant le principe du multi-stage building. Avant de commencer, assurez-vous d'avoir installé l'environnement d'exécution Docker sur votre machine locale.

docker --version

Résultat:

Docker version 26.1.3, build 26.1.3-1

La méthode naïve : l'approche à étape unique

Pour bien apprécier la puissance de l'optimisation par étapes, nous devons d'abord étudier le comportement et les limites de la configuration classique d'un Dockerfile à étape unique. Cette approche, bien que fonctionnelle dans un environnement local de développement, pose de graves problèmes de sécurité et d'efficacité lorsqu'elle est poussée sur vos clusters Kubernetes de production.

Le Dockerfile classique sans optimisation

Voici l'implémentation typique qu'un développeur junior met en place pour faire fonctionner rapidement son application. Ce fichier récupère l'image officielle de développement, y copie l'intégralité du code source, télécharge les modules externes, puis compile et exécute le binaire directement au sein du même conteneur.

FROM golang:1.22.3-alpine3.19

WORKDIR /app

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

COPY . .

RUN go build -o api-service .

EXPOSE 8080

CMD ["./api-service"]

Analysons les instructions clés de cette configuration naïve. L'instruction FROM golang:1.22.3-alpine3.19 utilise une image de base contenant l'intégralité du compilateur Go et des outils associés. La commande RUN go build -o api-service. compile notre code source pour générer un fichier exécutable nommé api-service. Malheureusement, après cette compilation, le compilateur Go et les fichiers sources restent présents dans le système de fichiers du conteneur, ce qui alourdit considérablement l'image finale.

Mesurons l'impact de cette configuration sur l'espace disque en construisant notre image à l'aide de l'outil de build de Docker.

docker build -t api-service:naive .
docker images api-service:naive

Résultat:

REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
api-service   naive     a1b2c3d4e5f6   10 seconds ago   842MB

Une image de près de 850 mégaoctets pour exécuter une simple API est inacceptable en production. C'est l'équivalent d'acheter un camion de déménagement entier simplement pour transporter une petite enveloppe scellée.

Le Multi-Stage Build : diviser pour régner

Pour résoudre ce problème de poids, nous allons utiliser le multi-stage building. Cette fonctionnalité majeure de Docker vous permet d'utiliser plusieurs instructions FROM temporaires au sein d'un unique Dockerfile, vous permettant ainsi de passer d'un environnement de compilation riche à un environnement d'exécution minimaliste.

Le principe fondamental de la séparation des responsabilités

Pensez au multi-stage build comme à un chantier de construction. Pour bâtir une maison, vous avez besoin d'échafaudages, de grues et d'outils lourds. Une fois la maison terminée, vous ne laissez pas les échafaudages à l'intérieur du salon : vous les démontez et vous ne livrez que la structure propre au client. Le multi-stage build applique exactement ce principe en vous permettant d'isoler la phase de construction de la phase de livraison.

Architecture d'un build multi-stage Docker séparant le builder du runner

Le schéma ci-dessus illustre parfaitement le flux de notre processus de construction. Dans l'étape du constructeur, nous réunissons le code source et le compilateur pour générer notre binaire. Une fois cette étape franchie, nous créons un tout nouveau conteneur léger et nous y copions uniquement le binaire compilé, laissant de côté toutes les dépendances lourdes du SDK.

Une première implémentation multi-étape fonctionnelle

Mettons en pratique ce concept en réécrivant notre configuration. Nous allons diviser notre processus en deux étapes distinctes : une étape nommée builder chargée de compiler le code, et une étape de production utilisant une image Alpine légère pour faire tourner l'application.

# Étape 1 : Compilation
FROM golang:1.22.3-alpine3.19 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api-service .

# Étape 2 : Exécution
FROM alpine:3.19.1
WORKDIR /app
COPY --from=builder /app/api-service .
EXPOSE 8080
CMD ["./api-service"]

Dans ce fichier, l'alias AS builder permet de nommer notre première étape de build. Dans la seconde étape, l'instruction COPY --from=builder /app/api-service. indique à Docker d'extraire uniquement le fichier binaire de l'étape précédente pour l'injecter dans la nouvelle image de base Alpine. Le reste du compilateur Go est définitivement abandonné.

docker build -t api-service:multistage .
docker images api-service:multistage

Résultat:

REPOSITORY    TAG          IMAGE ID       CREATED          SIZE
api-service   multistage   b2c3d4e5f6a1   5 seconds ago    23.4MB

Le résultat parle d'eux-mêmes : nous sommes passés d'une image de 842 mégaoctets à une image de seulement 23,4 mégaoctets, tout en conservant exactement les mêmes fonctionnalités applicatives.

Sécuriser et optimiser pour la production

Illustration de la sécurité d'une image de production minimaliste et sans composants superflus

Bien que notre image de 23 mégaoctets soit déjà une grande victoire, un ingénieur DevOps chevronné sait que la sécurité en production exige d'aller encore plus loin. L'image Alpine utilisée contient encore un gestionnaire de paquets et un terminal de commande shell qui pourraient être exploités par un attaquant ayant réussi à s'introduire dans votre conteneur.

Le choix de l'image de base minimale et les utilisateurs non-root

Pour atteindre le niveau de sécurité maximal, nous allons utiliser une image de base vide nommée scratch. Cette image spéciale ne contient aucun fichier, aucun outil d'administration, aucun interpréteur de commandes et aucun utilisateur par défaut. C'est l'incarnation même du principe du privilège minimal.

Le danger des droits root par défaut

Par défaut, les conteneurs Docker exécutent vos applications avec les privilèges de l'utilisateur root. Si un attaquant parvient à exploiter une faille de sécurité dans votre code, il obtiendra instantanément les droits d'administration sur l'hôte physique. Il est impératif de configurer un utilisateur non-privilégié.

Le Dockerfile de production ultime et sécurisé

Voici l'implémentation de niveau production que vous devez déployer sur vos environnements réels. Elle intègre la création d'un utilisateur système non-privilégié, l'installation sécurisée des certificats de sécurité pour les appels HTTPS extérieurs et l'utilisation du cache de montage pour accélérer vos builds répétés.

# Étape 1 : Builder optimisé
FROM golang:1.22.3-alpine3.19 AS builder

# Installation des utilitaires de sécurité et création d'un utilisateur non-privilégié
RUN apk --no-cache add ca-certificates && \
    adduser -D -g '' -u 10001 appuser

WORKDIR /app

# Utilisation des caches de montage pour optimiser le téléchargement des dépendances
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

# Compilation statique optimisée en retirant les tables de symboles de débogage
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o api-service .

# Étape 2 : Runner ultra-sécurisé sans système d'exploitation
FROM scratch

# Importation sécurisée de la configuration utilisateur et des certificats SSL
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

WORKDIR /app

# Récupération du binaire compilé statiquement
COPY --from=builder /app/api-service .

# Activation de l'utilisateur non-privilégié
USER appuser

EXPOSE 8080

ENTRYPOINT ["./api-service"]

Ce script de production regorge d'astuces avancées. L'argument de compilation -ldflags="-s -w" indique au compilateur Go de retirer toutes les informations de débogage de l'exécutable, réduisant sa taille de près de 30% supplémentaires. L'instruction USER appuser garantit que l'application s'exécute avec des privilèges extrêmement restreints, protégeant ainsi le serveur physique hôte de toute tentative de compromission.

Construisons cette version de production pour observer le résultat final de nos optimisations.

docker build -t api-service:prod .
docker images api-service:prod

Résultat:

REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
api-service   prod      c3d4e5f6a1b2   3 seconds ago    12.8MB

Nous avons réussi l'exploit de passer d'une image naïve de 842 mégaoctets à une image durcie et sécurisée de seulement 12,8 mégaoctets, prête à encaisser de fortes charges de trafic en production.

Vers des déploiements plus agiles et résilients

En adoptant le multi-stage building, vous transformez radicalement la qualité de votre infrastructure de conteneurs. Ce changement de méthode n'est pas qu'une simple optimisation de l'espace disque ; il s'agit d'une fondation incontournable pour sécuriser vos chaînes de déploiement et réduire l'empreinte carbone de vos infrastructures cloud.

Ce qu'il faut retenir pour vos prochains déploiements

  • Séparez toujours l'environnement de développement et de compilation de l'environnement final de production.
  • Exploitez les images minimalistes comme scratch ou les images distroless pour éliminer toute surface d'attaque logicielle inutile.
  • Configurez systématiquement un utilisateur non-root au sein de vos fichiers de configuration pour interdire les privilèges d'administration en cas d'intrusion.
  • Utilisez les caches de montage pour accélérer vos pipelines d'intégration continue tout en conservant des builds propres.

Prenez le temps dès aujourd'hui d'analyser vos registres de conteneurs et d'appliquer ces bonnes pratiques sur vos projets en cours. Vos équipes de sécurité et vos administrateurs système vous remercieront pour la rapidité et la robustesse de ces nouvelles images hautement optimisées.

Espace commentaire

Écrire un commentaire

Rejoignez la discussion

Vous devez être connecté pour poster un message.

19 commentaires

Totalement. C'est même la norme pour builder en arm64 et amd64 simultanément.

25/05/2026 à 20:14

En prod, on utilise docker buildx pour le multi-architecture. Ça marche toujours avec le multi-stage ?

25/05/2026 à 10:30

Bien sûr. distroless est un bon compromis si tu as besoin de bibliothèques C dynamiques que scratch ne peut pas gérer.

25/05/2026 à 03:06
isaac92
Membre Actif
Avatar de isaac92
isaac92
Membre Actif

Est-ce qu'on peut utiliser distroless au lieu de scratch ?

24/05/2026 à 19:22
gweiss
Membre
Avatar de gweiss
gweiss
Membre

La méthode naïve faisait 800 Mo, c'est abusé. Merci pour ce guide, ça va sauver de la bande passante sur notre CI/CD.

24/05/2026 à 12:45

Il faut faire un chown sur tes dossiers de données dans le Dockerfile ou gérer les permissions au niveau de ton docker-compose.yml avec un user: "10001".

24/05/2026 à 02:53

Le USER appuser me bloque l'accès aux logs ou à certains fichiers sur le volume monté. Des conseils ?

23/05/2026 à 16:40

Oui, le principe est identique. Tu compiles dans une image rust:alpine, et tu copies le binaire final dans scratch. Rien ne change.

23/05/2026 à 07:18

Quelqu'un a déjà testé ça avec du Rust ? Est-ce que la logique reste la même ?

22/05/2026 à 19:23

J'ai testé les -ldflags="-s -w", ça gagne vraiment du poids, c'est impressionnant.

22/05/2026 à 14:10

Non, ça nécessite BuildKit. Assure-toi d'avoir DOCKER_BUILDKIT=1 activé dans tes variables d'environnement.

22/05/2026 à 07:54
mjacques
Membre Actif
Avatar de mjacques
mjacques
Membre Actif

Est-ce que le --mount=type=cache fonctionne sur les vieilles versions de Docker ?

22/05/2026 à 00:39

Il faut copier les certificats depuis l'étape builder vers ton image finale comme montré dans l'article :

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
21/05/2026 à 20:30
marine95
Membre
Avatar de marine95
marine95
Membre

J'ai un souci avec les certificats SSL dans scratch. Mes appels API HTTPS échouent. Comment on gère ça proprement ?

21/05/2026 à 11:09

Parce que scratch est vide. Pas de shell, pas de gestionnaire de paquets, pas de vecteurs d'attaque. C'est la surface d'attaque minimale.

21/05/2026 à 05:09
cdavid
Membre
Avatar de cdavid
cdavid
Membre

Question bête : pourquoi utiliser scratch plutôt qu'une image alpine très légère ?

20/05/2026 à 23:22
cecile37
Membre Actif
Avatar de cecile37
cecile37
Membre Actif

Ah bien vu, j'avais oublié le CGO_ENABLED=0. Ça tourne nickel maintenant, merci !

20/05/2026 à 18:34

C'est probablement parce que ton binaire n'est pas lié statiquement. Si tu utilises scratch, il faut impérativement CGO_ENABLED=0.

Vérifie bien ta commande de build :

RUN CGO_ENABLED=0 GOOS=linux go build -o api-service .
20/05/2026 à 10:35

Super article. J'ai essayé le multi-stage sur un projet Go, mais je me tape une erreur standard_init_linux.go:211: exec user process caused "no such file or directory" au démarrage du conteneur. Une idée ?

20/05/2026 à 04:23

Rejoindre la communauté

Recevoir les derniers articles gratuitement en créant un compte !

S'inscrire