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ède mvn package. Modifier un seul fichier .java invalide 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 build nomme le premier stage. Vous utilisez ce nom dans COPY --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, pas javac ni Maven.
  • COPY --from=build copie uniquement le fichier jar vers l’image finale.
  • ENTRYPOINT est en forme exec (tableau JSON). Ainsi, java s’exécute en tant que PID 1 et reçoit le signal SIGTERM quand 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-offline ne télécharge pas toujours 100 % des plugins dont Maven a besoin à l’étape package. 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:1 active la syntaxe complète du Dockerfile moderne.
  • /root/.m2 est mis en cache côté builder. Même si pom.xml change, 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 extract décompresse le jar en quatre répertoires : dependencies, spring-boot-loader, snapshot-dependencies, application.
  • Les quatre commandes COPY créent quatre layers, ordonnés du moins changé au plus changé.
  • Quand vous modifiez le code, seul le layer application change. Ce layer ne fait généralement que quelques centaines de KB.
  • L’option --launcher conserve le launcher. Par conséquent, ENTRYPOINT appelle JarLauncher au 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’est org.springframework.boot.loader.launch.JarLauncher, avant c’était org.springframework.boot.loader.JarLauncher. Utiliser le mauvais nom de classe provoquera une ClassNotFoundException au 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. Copiez pom.xml d’abord, src aprè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 jcmd pour le diagnostic.
  • Wildcard correspondant à plusieurs jars : COPY --from=build /workspace/target/*.jar app.jar échouera s’il y a *-plain.jar ou *-sources.jar dans target/. Définissez <finalName> fixe dans pom.xml ou désactivez les artefacts secondaires.
  • Forme shell pour ENTRYPOINT : ENTRYPOINT java -jar app.jar s’exécute via /bin/sh -c. Dans ce cas, java ne reçoit pas SIGTERM, donc Spring n’arrête pas correctement et le conteneur est tué après l’expiration du délai.
  • Tag latest pour l’image de base : la compilation d’aujourd’hui et celle du mois prochain peuvent différer. Épinglez la version (par exemple eclipse-temurin:21-jre) ou le digest pour une reproductibilité absolue.
  • Placer des secrets dans ARG/ENV : les valeurs peuvent être exposées via docker history. Quand vous avez besoin d’un token pour télécharger des dépendances privées, utilisez RUN --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.

dockerdockerfilemulti-stage buildspring bootjava