Alléger une image Docker Java : le build multi-étapes

Une image Java construite avec Maven pèse souvent un gigaoctet pour livrer un jar de quelques mégaoctets. Le build multi-étapes la ramène à sa taille utile.

4 min de lecturedockermavenjava

Vous venez de conteneuriser une application Java. Le Dockerfile fonctionne, le conteneur démarre, les tests passent. Puis vous tapez docker images et vous découvrez que votre application de quelques mégaoctets voyage dans une image de 900 Mo.

Ce n'est ni une erreur de configuration ni un problème de Docker. C'est la conséquence directe d'une chose simple : vous avez livré votre atelier avec le produit fini.

Ce que contient réellement l'image

Prenons le Dockerfile que presque tout le monde écrit au début :

dockerfile
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package
CMD ["java", "-jar", "target/app.jar"]

L'image de départ, maven:3.9-eclipse-temurin-21, contient le JDK complet, Maven, et les outils nécessaires à la compilation. À cela s'ajoute, pendant le mvn package, le dépôt local ~/.m2 rempli de toutes les dépendances téléchargées, plus le dossier target/ avec ses classes compilées et ses rapports de tests.

Or, pour exécuter l'application, il ne faut rien de tout ça. Il faut une JVM et un fichier .jar.

Ce qui est dans l'imageNécessaire pour compilerNécessaire pour exécuter
Compilateur Java (JDK)OuiNon
MavenOuiNon
Dépôt local ~/.m2OuiNon
Classes intermédiaires, rapports de testsOuiNon
Machine virtuelle Java (JRE)Oui
Le fichier .jarOui

Deux lignes sur six servent en production. Le reste occupe de la place, allonge chaque docker pull, et surtout élargit la surface d'attaque : un compilateur dans un conteneur exposé sur Internet est un outil offert à qui saura s'en servir.

Le principe du build multi-étapes

Un build multi-étapes découpe le Dockerfile en plusieurs images successives, dont une seule est conservée. Les précédentes servent d'échafaudage et sont jetées.

Concrètement :

dockerfile
# Étape 1 : on compile, avec tout l'outillage nécessaire
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app

# Les dépendances d'abord, séparément du code : tant que le pom.xml ne change
# pas, Docker réutilise cette couche et ne retélécharge rien.
COPY pom.xml .
RUN mvn dependency:go-offline

COPY src ./src
RUN mvn package -DskipTests

# Étape 2 : on n'emporte que le résultat
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/app.jar ./app.jar

# Ne jamais tourner en root sans raison : si le processus est compromis,
# l'attaquant hérite de ses droits.
RUN addgroup -S app && adduser -S app -G app
USER app

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

La ligne qui fait tout le travail est COPY --from=builder. Elle prend un fichier dans l'étape précédente et l'amène dans la nouvelle. Tout ce qui n'est pas explicitement copié disparaît.

Ce que ça change en pratique

Sur une application Spring Boot ordinaire, l'ordre de grandeur est le suivant :

  • image mono-étape : environ 900 Mo à 1 Go
  • image multi-étapes sur 21-jre-alpine : environ 180 à 200 Mo

Les chiffres exacts dépendent de vos dépendances, mais le rapport de quatre à cinq se vérifie presque toujours, parce qu'il vient de l'outillage retiré et non de votre code.

Pourquoi -DskipTests dans l'étape de build ?

Parce que les tests appartiennent à la chaîne d'intégration continue, pas à la construction de l'image. Si vous les lancez ici, chaque docker build les rejoue — y compris sur le poste d'un collègue qui voulait seulement démarrer l'application.

Le bon découpage est celui-ci : Jenkins ou GitHub Actions exécute mvn verify, et ne construit l'image qu'une fois les tests verts. L'image devient alors l'artéfact d'un build déjà validé, et non l'endroit où l'on découvre qu'il est cassé.

L'erreur qui annule le gain

Il reste un piège classique. Si votre Dockerfile commence par COPY . . sans .dockerignore, vous emportez dans le contexte de build le dossier target/, le dossier .git, et parfois node_modules. Le résultat : des couches inutiles, des builds lents, et des secrets qui se promènent.

Un .dockerignore minimal règle le problème :

text
target/
.git/
.gitignore
*.md
.env*

À retenir

Le build multi-étapes ne demande pas d'apprendre un nouvel outil : c'est un FROM supplémentaire et un COPY --from. En échange, l'image ne contient plus que ce qu'elle exécute — ce qui la rend plus petite, plus rapide à déployer, et sensiblement plus difficile à attaquer.

La question à se poser devant n'importe quel Dockerfile est toujours la même : est-ce que ce fichier sert à construire, ou à exécuter ? Tout ce qui répond « à construire » appartient à une étape jetable.

Une fois l'image allégée, reste à la faire tourner. C'est là que Docker s'arrête et que Kubernetes prend la suite : qui fait quoi entre les deux.

Ce sujet fait partie d’un cours complet

Développement et déploiement de solutions de données — les premiers modules sont en accès libre.

Voir le plan du cours

Continuer sur le même sujet