Bien écrire un Dockerfile : multi-stage build pour applications Spring Boot
1. Aperçu
Un Dockerfile écrit à la hâte produit généralement une image pesant des centaines de Mo. Cette image contient le JDK, Maven, le code source et le répertoire .m2. À chaque modification d’une ligne de code, Docker doit télécharger toutes les dépendances à nouveau.
Cet article vous guide pour utiliser multi-stage build afin de séparer l’étape de build de l’étape d’exécution. Après sa lecture, vous saurez écrire un Dockerfile pour une application Spring Boot 3.x (Java 21) produisant une image légère, qui se reconstruit rapidement grâce au cache, exécute les tests dans un stage séparé et lance le processus avec un utilisateur non-root.
2. Problèmes avec un Dockerfile à un seul stage
Prenons une petite application avec un endpoint retournant le statut d’une commande :
package com.nonla.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
@RestController
class OrderController {
@GetMapping("/orders/{id}/status")
String status(@PathVariable long id) {
return id > 0 ? "CONFIRMED" : "UNKNOWN";
}
}
Les dépendances du pom.xml incluent uniquement web et test :
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Un Dockerfile « qui fonctionne » s’écrit généralement ainsi :
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]
Ce Dockerfile pose trois problèmes :
- Image lourde : l’image finale contient Maven, le JDK complet, le code source et l’intégralité du cache
.m2. - Mauvaise mise en cache :
COPY . .précèdemvn package. Modifier un seul fichier.javainvalide ce layer, forçant le retéléchargement de toutes les dépendances. - Surface d’attaque large : les outils de build et le code source restent présents dans le conteneur production.
3. Bases du multi-stage build
Le multi-stage build permet à un Dockerfile d’avoir plusieurs commandes FROM. Chaque FROM ouvre un nouveau stage. Un stage suivant peut copier des fichiers d’un stage précédent avec COPY --from=<nom du stage>. Seul le dernier stage devient l’image résultante. Les stages intermédiaires sont abandonnés.
3.1. Stage de build et stage de runtime
# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests
# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Explications :
AS buildnomme le premier stage. Vous utilisez ce nom dansCOPY --from=build.- Le stage de build utilise une image avec Maven et le JDK pour compiler et empaqueter le jar.
- Le stage de runtime utilise
eclipse-temurin:21-jre. Cette image contient uniquement le JRE, pasjavacni Maven. COPY --from=buildcopie uniquement le fichier jar vers l’image finale.ENTRYPOINTest en forme exec (tableau JSON). Ainsi,javas’exécute en tant que PID 1 et reçoit le signalSIGTERMquand le conteneur s’arrête.
3.2. Comparaison des résultats
Construisez et vérifiez la taille :
docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders
Résultats illustratifs (les chiffres réels dépendent des versions des images et des dépendances) :
REPOSITORY TAG SIZE
orders single ~560MB
orders multi ~290MB
La plupart du reste du volume provient de la JRE et du système d’exploitation de base. Vous pouvez réduire davantage en utilisant une image de base plus légère comme eclipse-temurin:21-jre-alpine. Cependant, vérifiez attentivement si votre application utilise des bibliothèques natives.
4. Exploiter le cache des layers
Chaque commande RUN et COPY crée un layer. Docker réutilise un layer si la commande et l’entrée n’ont pas changé. Quand un layer change, tous les layers suivants doivent être reconstruits. Par conséquent, le principe général est de placer ce qui change rarement en premier, et ce qui change souvent en dernier.
4.1. Séparer l’étape de téléchargement des dépendances
pom.xml change beaucoup moins souvent que le code source. Copiez-le d’abord et téléchargez les dépendances dans un layer séparé :
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
Maintenant, si vous modifiez uniquement OrderController.java, Docker réutilise le layer dependency:go-offline. Le log de build ressemblera à :
=> CACHED [build 3/5] COPY pom.xml .
=> CACHED [build 4/5] RUN mvn -B dependency:go-offline
=> [build 5/5] COPY src ./src
Remarque :
dependency:go-offlinene télécharge pas toujours 100 % des plugins dont Maven a besoin à l’étapepackage. Certains plugins peuvent encore être téléchargés plus tard. Le résultat est néanmoins bien meilleur qu’avec une approche non séparée.
4.2. Cache mount avec BuildKit
BuildKit (comportement par défaut dans Docker actuellement) supporte --mount=type=cache. Le répertoire monté est conservé entre les builds mais n’est pas inclus dans l’image :
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
- La ligne
# syntax=docker/dockerfile:1active la syntaxe complète du Dockerfile moderne. /root/.m2est mis en cache côté builder. Même sipom.xmlchange, Maven télécharge uniquement les nouvelles dépendances au lieu de tout retélécharger.
Vous pouvez combiner les deux techniques. Sur CI, le cache mount n’est efficace que si le builder est conservé entre les exécutions. Sinon, la séparation des layers à la section 4.1 combinée avec un cache de registre reste un choix plus fiable.
5. Séparation des layers pour un jar Spring Boot
Un fat jar Spring Boot contient votre code ainsi que des dizaines de Mo de dépendances. Si vous copiez l’intégralité du jar dans l’image, chaque modification de code crée un nouveau layer de plusieurs dizaines de Mo. Le registre doit stocker ce layer supplémentaire et le serveur doit tout retélécharger.
Spring Boot supporte la décompression du jar en layers selon la fréquence de changement. À partir de Spring Boot 3.3, utilisez jarmode=tools :
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Points clés :
- Le stage
extractdécompresse le jar en quatre répertoires :dependencies,spring-boot-loader,snapshot-dependencies,application. - Les quatre commandes
COPYcréent quatre layers, ordonnés du moins changé au plus changé. - Quand vous modifiez le code, seul le layer
applicationchange. Ce layer ne fait généralement que quelques centaines de KB. - L’option
--launcherconserve le launcher. Par conséquent,ENTRYPOINTappelleJarLauncherau lieu de-jar.
Remarque : Avec Spring Boot 3.0–3.2, utilisez l’ancienne syntaxe
java -Djarmode=layertools -jar app.jar extract. Le package du launcher varie également selon les versions : à partir de 3.2, c’estorg.springframework.boot.loader.launch.JarLauncher, avant c’étaitorg.springframework.boot.loader.JarLauncher. Utiliser le mauvais nom de classe provoquera uneClassNotFoundExceptionau démarrage du conteneur.
6. Exécution des tests dans un stage séparé
Utiliser -DskipTests au stage de build accélère la compilation, mais vous ne souhaitez pas envoyer au registre une image non testée. Le multi-stage permet de séparer les tests dans un stage dédié et de choisir le stage à construire avec --target.
Le test du controller utilise JUnit 5 et AssertJ (inclus dans spring-boot-starter-test) :
package com.nonla.orders;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
String body = mockMvc.perform(get("/orders/42/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("CONFIRMED");
}
@Test
void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
String body = mockMvc.perform(get("/orders/0/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("UNKNOWN");
}
}
Ajoutez un stage test basé sur le stage de build :
FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test
Sur CI, vous exécutez les tests d’abord, puis vous construisez l’image runtime :
docker build --target test -t orders:test .
docker build -t orders:1.0.0 .
La première commande construit uniquement jusqu’au stage test. Si un test échoue, la commande RUN retourne un code d’erreur non nul et le build s’arrête :
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1
La deuxième commande construit le stage final. Avec BuildKit, le stage test n’étant pas référencé par le stage final sera ignoré. L’image production n’a pas besoin de relancer les tests. Grâce au cache, les stages communs ne sont pas reconstruits.
Remarque : Vous ne devriez pas exécuter les tests d’intégration nécessitant Docker (par exemple Testcontainers) au sein de
docker build. L’environnement de build n’a pas d’accès au daemon Docker. Exécutez ce type de test à une étape CI externe.
7. Sécurité : utilisateur non-root et .dockerignore
7.1. Ne pas s’exécuter en tant que root
Par défaut, les processus dans le conteneur s’exécutent en tant que root. Si l’application est compromise, un attaquant a les droits root dans le conteneur. Créez un utilisateur dédié au stage runtime :
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Vérification rapide :
docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)
Les numéros uid/gid spécifiques peuvent différer, du moment qu’ils ne sont pas 0. Avec les images Alpine, utilisez addgroup -S spring && adduser -S spring -G spring à la place de groupadd et useradd.
7.2. Ajouter .dockerignore
Le contexte de build est l’intégralité du répertoire que vous passez à docker build. Sans filtrage, Docker envoie target/, .git/ et les fichiers de configuration IDE au builder. Cela ralentit la compilation, brise le cache et peut exposer des informations sensibles.
target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*
Placez ce fichier à la racine du projet, à côté de Dockerfile.
8. Erreurs courantes
COPY . .trop tôt : brise le cache des dépendances. Copiezpom.xmld’abord,srcaprès.- Utiliser le JDK au stage runtime : fonctionne mais ajoute plusieurs dizaines à centaines de Mo. La production a seulement besoin de JRE, sauf si vous avez besoin d’outils comme
jcmdpour le diagnostic. - Wildcard correspondant à plusieurs jars :
COPY --from=build /workspace/target/*.jar app.jaréchouera s’il y a*-plain.jarou*-sources.jardanstarget/. Définissez<finalName>fixe danspom.xmlou désactivez les artefacts secondaires. - Forme shell pour
ENTRYPOINT:ENTRYPOINT java -jar app.jars’exécute via/bin/sh -c. Dans ce cas,javane reçoit pasSIGTERM, donc Spring n’arrête pas correctement et le conteneur est tué après l’expiration du délai. - Tag
latestpour l’image de base : la compilation d’aujourd’hui et celle du mois prochain peuvent différer. Épinglez la version (par exempleeclipse-temurin:21-jre) ou le digest pour une reproductibilité absolue. - Placer des secrets dans
ARG/ENV: les valeurs peuvent être exposées viadocker history. Quand vous avez besoin d’un token pour télécharger des dépendances privées, utilisezRUN --mount=type=secret.
Quand n’est-ce pas nécessaire ? Pour un petit script ou une image utilisée uniquement en environnement de développement personnel, un seul stage suffit. De plus, si votre équipe utilise déjà mvn spring-boot:build-image (Cloud Native Buildpacks) ou Jib, ces outils gèrent déjà la séparation des layers et non-root. Vous n’avez plus besoin d’écrire un Dockerfile manuellement.
9. Conclusion
Le multi-stage build sépare clairement « l’environnement de build » et « l’environnement d’exécution ». L’image finale ne contient que ce dont l’application a vraiment besoin. Combiné avec un ordre de layers logique, un cache mount, une séparation des layers Spring Boot, un stage de test dédié et un utilisateur non-root, vous obtenez un Dockerfile à compilation rapide, une image légère et significativement plus sûre.
Vous pouvez commencer avec le Dockerfile de la section 3. Ensuite, appliquez progressivement chaque amélioration et mesurez à nouveau le temps de build et la taille de l’image après chaque étape pour voir clairement les bénéfices.