Changer le thème

Build multi-étapes Docker : réduire les images Go/Java/Rust de GB à MB

Easton editorial illustration: performance tuning console

Une image de 650 Mo qui m’a fait réfléchir

Vendredi après-midi, je regardais un Pod sur le dashboard K8s qui tournait depuis cinq minutes à tirer l’image, agacé. Une simple application Spring Boot, empaquetée en image Docker : 650 Mo. Un stagiaire m’a demandé : « Pourquoi c’est si gros ? » J’ai hésité — je n’avais jamais vraiment creusé la question.

Ce soir-là, j’ai étudié le sujet. Le lendemain matin, la même application : 89 Mo. Déploiement de 5 minutes à moins d’1 minute. Le regard du stagiaire avait changé.

98%
Optimisation image Go
295 Mo → 6,47 Mo
86%
Optimisation image Java
650 Mo → 89 Mo
99,4%
Optimisation image Rust
2 Go → 11 Mo
89 Mo
Taille après optimisation
De 650 Mo à 89 Mo, déploiement de 5 min à moins d’1 min

En fait, quelques lignes modifiées dans le Dockerfile. La technique s’appelle le build multi-étapes.

Au début, je pensais que c’était inutile. Nos images Go faisaient 295 Mo, Java dépassait souvent 500 Mo — on s’était habitués. Puis j’ai vu quelqu’un compresser une image Rust de 2 Go à 11 Mo. Là, j’ai compris qu’on empaquetait peut-être mal.

Le cœur du problème : les langages compilés ont besoin de compilateurs et d’outils de build, mais le runtime n’en a pas besoin. Les Dockerfile traditionnels embarquent Maven, Gradle, le compilateur Go — comme emmener perceuse et ciment lors d’un déménagement.

Aujourd’hui, trois cas réels en Go, Java et Rust :

  • Go : de 295 Mo à 6,47 Mo (-98 %)
  • Java Spring Boot : de 650 Mo à 89 Mo (-86 %)
  • Rust : de 2 Go à 11,2 Mo (-99,4 %)

Si vous ne vous intéressez qu’à un langage, sautez au chapitre correspondant. Chaque cas est complet et copiable.

Pourquoi vos images sont-elles si volumineuses ?

Tableau comparatif de mes tests :

LangageBuild mono-étapeBuild multi-étapesRéduction
Go295 Mo6,47 Mo98 %
Java Spring Boot650 Mo89 Mo86 %
Rust2,1 Go11,2 Mo99,4 %

La première fois, j’étais sidéré. Surtout Rust : 2 Go à 11 Mo, ce n’est pas une optimisation, c’est de la magie.

En analysant la composition des images, j’ai compris. Les langages compilés ont besoin d’un compilateur :

  • Compilateur Go : ~300 Mo
  • Maven + OpenJDK : ~500 Mo
  • Toolchain Rust : ~1,5 Go

Un Dockerfile traditionnel ressemble à ça :

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

Rien d’anormal en apparence. En réalité, gros problème : l’image golang:1.21 (295 Mo) sert de base, avec compilateur, outils de build, outils de debug. Après compilation, tout reste dans l’image finale.

Comme acheter un appartement brut, faire des travaux, puis laisser ciment, perceuse, scie et la caisse à outils du maçon dans la pièce avant d’emménager. Absurde — et pourtant beaucoup de Dockerfile font pareil.

Au runtime, il ne faut que le binaire compilé. Go produit souvent quelques Mo à quelques dizaines de Mo. Java un jar un peu plus gros. Rust aussi. Le reste, ce sont des outils de build et la surcharge de l’image de base.

Cette lourdeur ne gaspille pas seulement du stockage :

  1. Pull lent : en CI/CD, chaque déploiement tire l’image. 650 Mo sur un réseau lent, c’est interminable
  2. Risque de sécurité : compilateurs, code source, outils de build en production — autant d’outils pour un attaquant
  3. Cache gaspillé : une ligne de code modifiée, toute l’image se reconstruit car compilateur et code sont couplés

Une fois sur Alibaba Cloud, cinq personnes déployaient en même time, chacune avec 500 Mo+ d’image — le réseau interne saturé. J’ai alors décidé d’étudier l’optimisation.

Build multi-étapes : un Dockerfile, deux environnements

Le build multi-étapes est une fonctionnalité de Docker 17.05. Idée simple : plusieurs phases dans un Dockerfile — les premières compilent, les suivantes exécutent, seuls les artefacts nécessaires passent d’une phase à l’autre.

En clair : l’équipe rénove en phase 1, puis vous n’emmenez que la maison finie en phase 2 — le ciment et la perceuse restent en phase 1.

Exemple minimal :

# Phase 1 : build
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Phase 2 : runtime
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Points clés :

  1. Premier FROM suivi de AS builder — nom de la phase
  2. Deuxième FROM — nouvelle phase avec alpine, plus léger
  3. COPY —from=builder — copie le binaire, le reste est jeté

La phase golang:1.21 fait 295 Mo, mais l’image finale = alpine (~5 Mo) + binaire (quelques Mo), soit une dizaine de Mo.

Quand j’ai compris, ça m’a frappé : « seulement le résultat, pas le processus ». Compilateur, code source, fichiers intermédiaires = processus. Le binaire = résultat. Docker jette la phase 1, ne garde que la phase 2.

Pour déboguer la phase 1 :

docker build --target builder -t myapp:debug .

--target builder construit jusqu’à la phase builder sans exécuter la phase 2.

Vous pouvez avoir trois, quatre phases ou plus :

FROM node:18 AS frontend-builder
# Build frontend

FROM golang:1.21 AS backend-builder
# Build backend

FROM nginx:alpine
# Copier les artefacts frontend et backend
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=backend-builder /app/api /usr/local/bin/api

Chaque phase a sa tâche, tout converge vers le runtime. Le Dockerfile devient très lisible.

Aujourd’hui, pour tout langage compilé, j’utilise le build multi-étapes par défaut. C’est devenu un réflexe.

Alléger au maximum une application Go

Go est mon langage préféré pour l’optimisation d’images. Le binaire Go est statiquement lié, sans dépendance système — il tourne dans une image vide.

Mauvaise pratique (ne pas imiter) :

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

Build :

docker build -t myapp:old .
docker images myapp:old
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        old    abc123def456   295MB

295 Mo pour un simple service HTTP.

Version multi-étapes optimisée :

# Phase build
FROM golang:1.21-alpine AS builder
WORKDIR /app

# Copier les dépendances et les télécharger (cache)
COPY go.mod go.sum ./
RUN go mod download

# Copier le code source et compiler
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .

# Phase runtime
FROM scratch
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

Nouveau build :

docker build -t myapp:new .
docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    def456ghi789   6.47MB

6,47 Mo ! De 295 Mo, -98 %.

Points clés :

1. CGO_ENABLED=0

Désactive CGO, binaire statique pur. Si vous utilisez des bibliothèques C (ex. SQLite), il faut une autre image de base.

2. -ldflags=“-w -s”

Optimisation compilateur :

  • -w : supprime les infos de debug
  • -s : supprime la table des symboles

Réduction de 20 à 30 % supplémentaire. En production, ces infos sont rarement utiles.

3. FROM scratch

scratch est l’image vide Docker, 0 octet. Un binaire Go statique tourne directement dedans.

4. Cache des dépendances

Copier d’abord go.mod et go.sum, puis go mod download. Tant que ces fichiers ne changent pas, Docker réutilise le cache. Modifier le code métier ne relance pas le téléchargement des dépendances.

Avec cette astuce, rebuild après modification de code : de 2 minutes à 15 secondes.

Avancé : fuseau horaire et certificats CA

scratch n’a ni fuseaux horaires ni certificats CA. Pour HTTPS ou gestion de timezone, copiez depuis builder :

FROM scratch
WORKDIR /app
# Données fuseau horaire
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Certificats CA
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp .
ENV TZ=Asia/Shanghai
CMD ["./myapp"]

Ou utilisez gcr.io/distroless/static-debian11 (~2 Mo, avec timezone et CA) :

FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /app/myapp
CMD ["/app/myapp"]

Je préfère distroless — plus simple.

Alléger Java/Spring Boot proprement

Java est plus complexe que Go. Il faut un JRE, pas d’image scratch. Mais avec la bonne méthode, le volume baisse fortement.

Pratique traditionnelle (courante) :

FROM maven:3.8-openjdk-17
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/myapp.jar"]

Image finale : 650 Mo. Maven ~500 Mo + jar + caches.

Version multi-étapes :

# Phase build
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app

# Copier pom.xml, télécharger les dépendances (cache)
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Copier le code source et packager
COPY src ./src
RUN mvn package -DskipTests

# Phase runtime
FROM openjdk:17-jre-slim
WORKDIR /app

# Copier uniquement le jar
COPY --from=builder /app/target/*.jar app.jar

# Optimisation JVM
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"

EXPOSE 8080
CMD java $JAVA_OPTS -jar app.jar

Résultat :

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    xyz789abc012   89MB

De 650 Mo à 89 Mo, -86 %.

Points clés :

1. JDK vs JRE

Build avec openjdk-17 (compilateur), runtime avec openjdk-17-jre-slim (runtime seulement) :

  • OpenJDK 17 : ~500 Mo
  • OpenJDK 17 JRE : ~200 Mo
  • OpenJDK 17 JRE Slim : ~80 Mo

2. Cache Maven

Copier d’abord pom.xml, exécuter mvn dependency:go-offline. Tant que pom.xml ne change pas, la couche est en cache. Modifier le code métier ne relance pas le téléchargement.

Crucial. Avant, chaque rebuild retéléchargeait les dépendances — dépôt Maven à l’étranger, parfois 15 minutes bloqué. Maintenant, quelques secondes.

3. Paramètres JVM

-XX:+UseContainerSupport : la JVM respecte les limites mémoire du conteneur. Sans ça, elle alloue selon la machine hôte — OOM fréquent.

-Xms128m -Xmx512m : plage heap. Ajustez selon votre application, pas 1 Go par défaut.

Version Gradle

Avec Gradle :

FROM gradle:8.5-jdk17 AS builder
WORKDIR /app

# Fichiers de config Gradle
COPY build.gradle settings.gradle ./
COPY gradle ./gradle

# Télécharger les dépendances
RUN gradle dependencies --no-daemon

# Code source et build
COPY src ./src
RUN gradle bootJar --no-daemon

FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
CMD java $JAVA_OPTS -jar app.jar

Piège : build en couches Spring Boot

Spring Boot 2.3+ supporte le découpage en couches du jar pour optimiser le cache :

FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract

FROM openjdk:17-jre-slim
WORKDIR /app
# Ordre des couches — dépendances changent le moins
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
CMD ["java", "org.springframework.boot.loader.JarLauncher"]

Modifier le code métier n’invalide pas la couche dépendances — build plus rapide. Honnêtement, je n’utilise pas souvent cette variante ; la version simple suffit en général.

Déploiement minimal pour Rust

Rust offre l’optimisation la plus spectaculaire. Toolchain énorme (1,5 Go+), binaire final minuscule. Ma première fois : 2,1 Go à 11,2 Mo.

Pratique traditionnelle :

FROM rust:1.75
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]

Image : 2,1 Go. rustc, cargo, dépendances — tout embarqué.

Version multi-étapes :

# Phase build
FROM rust:1.75 AS builder
WORKDIR /app

# Copier les dépendances, compiler d'abord les deps (cache)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src

# Copier le vrai code, compiler l'application
COPY src ./src
RUN touch src/main.rs  # Mettre à jour le timestamp, forcer recompilation
RUN cargo build --release

# Phase runtime
FROM gcr.io/distroless/cc-debian11
WORKDIR /app
COPY --from=builder /app/target/release/myapp .
CMD ["./myapp"]

Résultat :

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    rst345uvw678   11.2MB

11,2 Mo ! -99,4 %.

Cache spécifique Rust

La compilation des dépendances Rust est lente — souvent 15 minutes. L’astuce : créer un faux main.rs, laisser cargo compiler les deps, supprimer le faux fichier, copier le vrai code.

Tant que Cargo.toml et Cargo.lock ne changent pas, la couche deps est en cache. Modifier le code métier ne recompile que votre code.

Sur un projet moyen : rebuild de 15 minutes à 2 minutes.

Liaison statique vs dynamique

Par défaut, le binaire Rust peut dépendre de glibc. Code Rust pur sans bibliothèque C : compilez avec musl, image scratch :

FROM rust:1.75 AS builder
WORKDIR /app

# Installer la toolchain musl
RUN rustup target add x86_64-unknown-linux-musl

COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN rm -rf src

COPY src ./src
RUN touch src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl

# Phase runtime
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]

Encore plus petit, peut-être 5-8 Mo.

En pratique, j’utilise gcr.io/distroless/cc — meilleure compatibilité, quelques Mo de plus, ça vaut le coup.

Piège : emplacement du cache Cargo

Certains articles conseillent de cacher /usr/local/cargo. À éviter. Beaucoup d’artefacts intermédiaires, couche de cache énorme, build plus lent. J’ai testé : couche de 800 Mo, attente interminable.

Techniques avancées pour un build rapide et stable

Au-delà du build multi-étapes de base :

1. Fichier .dockerignore

Essentiel et souvent ignoré. Comme .gitignore, il indique à Docker quels fichiers ne pas copier dans le contexte de build.

Sans ça, Docker envoie tout le projet (node_modules, .git, target…) au daemon. Projet de plusieurs centaines de Mo, 30 secondes juste pour envoyer le contexte.

.dockerignore sur chaque projet :

# Contrôle de version
.git
.gitignore

# Répertoires de dépendances
node_modules
target
dist
build

# IDE
.vscode
.idea
*.swp

# Tests et documentation
**/*_test.go
**/*_test.rs
*.md
docs/

# Variables d'environnement et clés
.env
.env.local
*.key
*.pem

Build 3 à 5 fois plus rapide, surtout en CI/CD.

2. Déboguer le build multi-étapes

--target en détail. Build échoue en phase builder ?

# Construire jusqu'à builder
docker build --target builder -t myapp:debug .

# Entrer dans l'image
docker run -it myapp:debug sh

# Exécuter manuellement les commandes de build

Très utile. Une fois, mon build Go échouait avec une erreur obscure. J’ai entré dans builder, lancé go build manuellement — incompatibilité de version Go et dépendances.

3. Nommer les phases

Des noms explicites, pas stage1/stage2 :

# Mauvais
FROM golang:1.21 AS stage1
FROM node:18 AS stage2
FROM nginx AS stage3

# Bon
FROM golang:1.21 AS backend-builder
FROM node:18 AS frontend-builder
FROM nginx AS runtime

Six mois plus tard, vous vous remercierez.

4. Build parallèle avec BuildKit

BuildKit : moteur de build nouvelle génération, parallélisme, meilleur cache :

export DOCKER_BUILDKIT=1
docker build .

Ou temporairement :

DOCKER_BUILDKIT=1 docker build .

Avantages :

  • Phases indépendantes en parallèle
  • Cache plus intelligent
  • Sortie plus claire

Je l’utilise partout, y compris en CI/CD avec DOCKER_BUILDKIT=1.

5. Fixer la version de l’image de base

Évitez FROM golang:latest. En production, version fixe :

# Mauvais — comportement incertain
FROM golang:latest

# Bon — version explicite
FROM golang:1.21.5-alpine3.18

# Mieux — SHA256 fixe
FROM golang@sha256:abc123...

J’ai déjà eu un déploiement qui échouait soudainement : golang:latest mis à jour, incompatible avec nos dépendances. Depuis, versions fixées.

6. Ordre des COPY pour le cache

L’ordre compte. Fichiers stables d’abord, code métier ensuite :

# Bon ordre
FROM golang:1.21-alpine AS builder
WORKDIR /app

# 1. Fichiers de dépendances (rarement modifiés)
COPY go.mod go.sum ./
RUN go mod download

# 2. Code source (souvent modifié)
COPY . .
RUN go build -o myapp

# Mauvais ordre
COPY . .  # Tout d'un coup
RUN go mod download && go build -o myapp

Première approche : modifier le code ne relance pas le téléchargement des dépendances. Deuxième : toute modification relance tout.

Principe central du cache — à retenir.

Guide de choix des images de base

Alpine, Slim, Distroless, Scratch — lequel choisir ? Tableau comparatif :

Type d’imageVolumeContenuAvantagesInconvénientsCas d’usage
scratch0 MoVideVolume minimal, surface d’attaque minimalePas de shell, pas de debug, pas de CAGo statique, Rust statique
distroless2-20 MoLibs runtime, CAPas de shell, sécurité, petit volumeDebug difficileGo, Java, Rust, Node.js
alpine5-40 Momusl libc, gestionnaire de paquetsPetit, shell disponibleProblèmes compatibilité musl, DNSApps sans dépendance glibc
slim70-120 MoDebian/Ubuntu allégéglibc complet, bonne compatibilitéUn peu plus grosApps avec dépendances C
image complète200 Mo+Système completTous les outilsGros volume, risque sécuritéDéconseillé en production

Ma stratégie :

Go

  • Préféré : gcr.io/distroless/static-debian11 (avec CA)
  • Alternative : scratch (copier CA et timezone manuellement)
  • Avec CGO : gcr.io/distroless/base-debian11 ou alpine

Java

  • Préféré : openjdk:17-jre-slim (JRE complet, bonne compatibilité)
  • Avancé : gcr.io/distroless/java17-debian11 (plus petit, debug difficile)
  • À éviter : openjdk:17-alpine (bugs JVM sur Alpine)

Rust

  • Préféré : gcr.io/distroless/cc-debian11 (runtime C)
  • Compilation statique pure : scratch
  • À éviter : image rust complète en production

Pièges Alpine

Alpine est souvent recommandé, mais attention :

  1. Compatibilité musl : musl au lieu de glibc — bugs étranges avec Java, Python
  2. DNS : résolution DNS parfois lente en Go, config spéciale nécessaire
  3. Fuseaux horaires : pas de données timezone par défaut, installer tzdata

Mon conseil : si vous ne maîtrisez pas Alpine, -slim est plus sûr. Quelques dizaines de Mo de plus, moins de pièges.

Distroless

Images minimalistes Google : pas de shell, pas de gestionnaire de paquets, seulement le runtime.

Avantages :

  • Surface d’attaque minimale (pas de shell = pas de commandes arbitraires)
  • Petit volume
  • Maintenance officielle, correctifs sécurité réguliers

Inconvénients :

  • Debug difficile, pas de docker exec -it
  • Préparer tous les fichiers en phase build

En production, j’utilise surtout Distroless. Debug via --target builder, production en Distroless.

Arbre de décision

Quel langage ?
├─ Go
│  ├─ Go pur → distroless/static ou scratch
│  └─ Avec CGO → distroless/base ou alpine
├─ Java
│  ├─ Stabilité → openjdk:jre-slim
│  └─ Volume → distroless/java
├─ Rust
│  ├─ Rust pur → distroless/cc ou scratch
│  └─ Avec libs C → distroless/cc
└─ Autre
   └─ Commencer par slim, ajuster si problème

Couvre ~90 % des cas en pratique.

En résumé : trois points essentiels

L’essence du build multi-étapes :

  1. Phase build : image complète pour compiler
  2. Phase runtime : image allégée pour exécuter
  3. COPY —from : ne passer que les artefacts nécessaires

Simple — et les résultats sont spectaculaires : -70 à -90 % de volume, build 2 à 3 fois plus rapide avec cache optimisé, sécurité nettement améliorée.

Mon habitude : nouveau projet = Dockerfile multi-étapes dès le départ. Go, Java, Rust — réflexe automatique.

Quelques actions :

Aujourd’hui :

  • Prendre un projet existant, tester le build multi-étapes
  • Comparer avec docker images avant/après
  • Si ça marche, généraliser aux autres projets

Pour approfondir :

  • Documentation officielle Docker
  • Outil dive pour analyser les couches (docker run --rm -it wagoodman/dive:latest your-image)
  • Fonctionnalités avancées BuildKit

Long terme :

  • Cache d’images dans CI/CD
  • Mises à jour régulières des images de base
  • Scan sécurité (Trivy, etc.)

Pour finir : l’optimisation d’images Docker n’est pas difficile techniquement — le vrai défi, c’est la prise de conscience. Beaucoup d’équipes ont des images vieilles de plusieurs années, jamais revues, qui grossissent jusqu’à ce que le déploiement devienne insupportable.

N’attendez pas ce moment. Testez le build multi-étapes aujourd’hui — vous verrez à quel point les images peuvent être petites et les builds rapides.

Dites-moi en commentaire : quelle taille font vos images ? Quels gains après optimisation ? Quels pièges avez-vous rencontrés ?

Flux complet d'optimisation build multi-étapes Docker

Réduire les images Go/Java/Rust de GB à MB, avec Dockerfile complets et retours d'expérience

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: Comprendre le principe du build multi-étapes

    Principe du build multi-étapes :
    • Les langages compilés ont besoin de compilateurs et d'outils de build pour transformer le code source en exécutable
    • Mais le runtime n'a pas besoin de tout ça
    • Les Dockerfile traditionnels embarquent Maven, Gradle, le compilateur Go
    • Comme emmener perceuse et ciment lors d'un déménagement — totalement inutile

    Le cœur du problème :
    • Les langages compilés ont besoin de compilateurs et d'outils de build
    • Mais le runtime n'en a pas besoin

    Étapes du build multi-étapes :
    • Phase 1 (build) : image complète avec compilateur et outils, compilation du code source
    • Phase 2 (runtime) : image minimale avec seulement le runtime, copie de l'exécutable depuis la phase 1
    • L'image finale ne contient que le runtime et l'exécutable
  2. 2

    Step 2: Build multi-étapes Go en pratique

    Build multi-étapes Go :

    Phase 1 (build) :
    • FROM golang:1.21 AS builder
    • WORKDIR /app
    • COPY go.mod go.sum ./
    • RUN go mod download
    • COPY . .
    • RUN go build -o app

    Phase 2 (runtime) :
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/app .
    • CMD ["./app"]

    Résultats :
    • Copie uniquement du binaire compilé
    • Image de 295 Mo à 6,47 Mo (-98 %)
    • Déploiement de 5 minutes à moins d'1 minute
    • Volume d'image fortement réduit
  3. 3

    Step 3: Build multi-étapes Java et Rust en pratique

    Build multi-étapes Java :

    Phase 1 (build) :
    • FROM maven:3.9 AS builder
    • WORKDIR /app
    • COPY pom.xml .
    • RUN mvn dependency:go-offline
    • COPY src ./src
    • RUN mvn clean package -DskipTests

    Phase 2 (runtime) :
    • FROM eclipse-temurin:17-jre-alpine
    • WORKDIR /app
    • COPY --from=builder /app/target/app.jar app.jar
    • CMD ["java", "-jar", "app.jar"]

    Résultat : image de 650 Mo à 89 Mo (-86 %)

    Build multi-étapes Rust :

    Phase 1 (build) :
    • FROM rust:1.75 AS builder
    • WORKDIR /app
    • COPY Cargo.toml Cargo.lock .
    • RUN cargo fetch
    • COPY src ./src
    • RUN cargo build --release

    Phase 2 (runtime) :
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/target/release/app .
    • CMD ["./app"]

    Résultat : image de 2 Go à 11 Mo (-99,4 %)
  4. 4

    Step 4: Bonnes pratiques et optimisation long terme

    Bonnes pratiques :
    1. Choisir la bonne image runtime
    • Go : distroless/static ou scratch
    • Java : openjdk:jre-slim ou distroless/java
    • Rust : distroless/cc ou scratch

    2. Utiliser .dockerignore pour exclure les fichiers inutiles

    3. Exploiter le cache de build (copier d'abord les fichiers de dépendances, puis le code source)

    4. Mettre à jour régulièrement les images de base pour corriger les failles de sécurité

    Optimisation long terme :
    • Configurer le cache d'images dans le pipeline CI/CD
    • Mettre à jour régulièrement les images de base
    • Scanner les images avec un outil de sécurité (ex. Trivy)

    L'optimisation d'images Docker n'est pas difficile techniquement — le vrai défi, c'est la prise de conscience. Beaucoup d'équipes ont des images créées il y a des années, jamais revues, qui grossissent jusqu'à ce que le déploiement devienne insupportable.

FAQ

Qu'est-ce que le build multi-étapes Docker ? Pourquoi en a-t-on besoin ?
Principe : les langages compilés ont besoin de compilateurs et d'outils de build, mais le runtime n'en a pas besoin. Les Dockerfile traditionnels embarquent Maven, Gradle, le compilateur Go — comme emmener perceuse et ciment lors d'un déménagement.

Le cœur du problème : les langages compilés ont besoin de compilateurs, mais le runtime n'en a pas besoin.

Étapes :
• Phase 1 (build) : image complète avec compilateur et outils, compilation du code source
• Phase 2 (runtime) : image minimale avec seulement le runtime, copie de l'exécutable depuis la phase 1
• L'image finale ne contient que le runtime et l'exécutable
Quels sont les gains du build multi-étapes ?
Résultats :
• Go : de 295 Mo à 6,47 Mo (-98 %)
• Java Spring Boot : de 650 Mo à 89 Mo (-86 %)
• Rust : de 2 Go à 11 Mo (-99,4 %)
• Déploiement : de 5 minutes à moins d'1 minute

Cas concret : une application Spring Boot simple, image Docker de 650 Mo, après build multi-étapes seulement 89 Mo, déploiement de 5 minutes à moins d'1 minute.
Comment implémenter un build multi-étapes Go ?
Build multi-étapes Go :

Phase 1 avec golang:1.21 :
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app

Phase 2 avec alpine:latest :
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]

Copie uniquement du binaire compilé, image de 295 Mo à 6,47 Mo (-98 %). Déploiement de 5 minutes à moins d'1 minute.
Comment implémenter un build multi-étapes Java et Rust ?
Build multi-étapes Java :

Phase 1 avec maven:3.9 :
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests

Phase 2 avec eclipse-temurin:17-jre-alpine :
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]

Image de 650 Mo à 89 Mo (-86 %)

Build multi-étapes Rust :

Phase 1 avec rust:1.75 :
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release

Phase 2 avec alpine:latest :
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]

Image de 2 Go à 11 Mo (-99,4 %)
Quelles sont les bonnes pratiques du build multi-étapes ?
Bonnes pratiques :
1) Choisir la bonne image runtime :
• Go : distroless/static ou scratch
• Java : openjdk:jre-slim ou distroless/java
• Rust : distroless/cc ou scratch

2) Utiliser .dockerignore pour exclure les fichiers inutiles

3) Exploiter le cache de build (d'abord les dépendances, puis le code source)

4) Mettre à jour régulièrement les images de base

Optimisation long terme :
• Cache d'images dans le pipeline CI/CD
• Mises à jour régulières des images de base
• Scanner avec Trivy ou équivalent

L'optimisation n'est pas difficile techniquement — le vrai défi, c'est la prise de conscience.

14 min de lecture · Publié le: 17 déc. 2025 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog