Empaqueter Spring Boot en images Docker minimalistes

1. Aperçu

Empaqueter Spring Boot dans Docker est facile. Empaqueter pour que l’image soit petite, se construise rapidement et s’exécute de manière sécurisée demande quelques étapes supplémentaires. Avec le Dockerfile « trois lignes » classique, l’image pèse généralement quelques centaines de Mo, chaque modification de code oblige à repousser le fichier JAR complet, et le processus s’exécute avec les droits root.

Cet article part d’un Dockerfile naïf vers un Dockerfile multi-stage, utilisant les JAR en couches et un JRE minimal. Ensuite, vous verrez comment utiliser Buildpacks comme solution alternative, et écrire un test avec Testcontainers pour valider l’image générée.

2. Application exemple

Nous utilisons un petit service appelé order-service, avec un endpoint REST et Actuator pour faire un health check.

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    record Order(long id, String customer, BigDecimal total) {}

    @GetMapping("/{id}")
    Order find(@PathVariable long id) {
        return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
    }
}

La commande ./mvnw package génère le fichier target/order-service-1.0.0.jar. C’est un « fat jar » qui contient à la fois votre code et toutes les dépendances.

3. Dockerfile naïf et ses problèmes

La version que beaucoup de gens écrivent d’abord ressemble à ceci :

FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Ce Dockerfile fonctionne, mais il a quatre problèmes :

  • Base image trop grosse. eclipse-temurin:21 contient le JDK complet : compilateur, jshell, javadoc… À l’exécution, vous n’en avez pas besoin.
  • Une seule couche pour tout. Le fat jar fait environ 20–30 Mo, dont votre code n’occupe que quelques centaines de Ko. Modifier une ligne de code change la couche de 30 Mo, vous devez la recompiler et la repousser.
  • Dépendance envers la machine de build. L’image nécessite un fichier JAR pré-construit sur votre machine. Le JDK et la version de Maven peuvent différer d’une machine à l’autre.
  • Exécution en tant que root. Si l’application est compromise, l’attaquant a les droits root dans le conteneur.

Vérifier la taille :

docker build -t order-service:naive .
docker images order-service
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   naive   3f1c9a7e2b10   ~480MB

Le chiffre exact dépend de la version de la base image, mais près de 500 Mo pour un service « hello world » est excessif.

4. Build multi-stage avec JAR en couches

4.1. Qu’est-ce qu’un JAR en couches

À partir de Spring Boot 2.3, le fat jar est déjà divisé en couches selon la fréquence de changement :

Couche Contenu Fréquence de changement
dependencies Dépendances en version stable Rare
spring-boot-loader Chargeur de classe Spring Boot Très rare
snapshot-dependencies Dépendances -SNAPSHOT Occasionnellement
application Code et ressources de votre application Fréquent

Vous copiez chaque partie dans l’image avec une commande COPY séparée, dans l’ordre de la moins fréquemment modifiée à la plus fréquemment modifiée. Docker met en cache les couches précédentes. Quand vous modifiez uniquement du code, seule la couche application est reconstruite et repushée.

À partir de Spring Boot 3.3, vous extrayez les couches avec jarmode=tools :

java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application  dependencies  snapshot-dependencies  spring-boot-loader

Le répertoire application contient le fichier order-service-1.0.0.jar qui a été « allégé ». Ce fichier référence le répertoire lib/ situé dans la couche dependencies. Une fois les couches fusionnées, vous n’avez qu’à exécuter java -jar normalement.

Remarque : Avant Spring Boot 3.2, la syntaxe était java -Djarmode=layertools -jar app.jar extract. Le point d’entrée était alors org.springframework.boot.loader.launch.JarLauncher au lieu de -jar.

4.2. Dockerfile complet

# ---------- Stage 1 : build avec Maven ----------
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

# ---------- Stage 2 : extraire les couches ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---------- Stage 3 : runtime ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./

USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1

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

Explication de chaque partie :

  • Stage build : COPY pom.xml puis exécuter dependency:go-offline avant de copier src. Quand vous modifiez uniquement du code, Docker réutilise la couche contenant les dépendances déjà téléchargées. Vous n’avez pas besoin de retélécharger tout de Maven Central.
  • Stage extract : extrait le JAR en quatre répertoires de couches. Ce stage n’est qu’intermédiaire et n’apparaît pas dans l’image finale.
  • Stage runtime : utilise 21-jre-alpine, qui contient uniquement le JRE sur Alpine Linux.
  • Les quatre commandes COPY : chacune crée une couche Docker, dans l’ordre du moins fréquemment au plus fréquemment modifié. Puisque le JAR « allégé » (app.jar) est dans application et le répertoire lib/ dans dependencies, après fusion /app a la structure app.jar + lib/.
  • USER spring:spring : le processus Java ne s’exécute plus avec les droits root.
  • JAVA_TOOL_OPTIONS : la JVM lit cette variable automatiquement. MaxRAMPercentage=75.0 permet au tas d’utiliser au maximum 75% de la limite mémoire du conteneur. ExitOnOutOfMemoryError force la JVM à quitter proprement en cas de manque mémoire, permettant à l’orchestrateur de redémarrer le conteneur au lieu de la laisser dans un état indéterminé.
  • HEALTHCHECK : Alpine a wget de BusyBox, donc vous n’avez pas à installer curl supplémentaire.
  • ENTRYPOINT en forme exec (tableau JSON) : java est le PID 1 et reçoit le signal SIGTERM quand le conteneur s’arrête. Grâce à cela, Spring Boot peut se fermer proprement.

4.3. N’oubliez pas .dockerignore

La commande de build envoie l’intégralité du répertoire actuel (contexte de build) au daemon Docker. Excluez ce qui n’est pas nécessaire :

target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile

Sans ce fichier, les répertoires target/ ou .git/ peuvent gonfler le contexte à des centaines de Mo. Chaque build ralentira sensiblement.

4.4. Résultats

docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   1.0.0   9b2e44d1c0af   ~200MB
order-service   naive   3f1c9a7e2b10   ~480MB

0B      ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB    COPY /extract/extracted/application/ ./
0B      COPY /extract/extracted/snapshot-dependencies/ ./
290kB   COPY /extract/extracted/spring-boot-loader/ ./
22MB    COPY /extract/extracted/dependencies/ ./

L’image est deux fois plus petite. Plus important encore, la couche application n’est que quelques dizaines de Ko. Quand vous modifiez OrderController et relancez le build, seule cette couche change. Le push vers le registry ne prend que quelques secondes.

5. Alternative : Cloud Native Buildpacks

Si vous ne voulez pas maintenir vous-même un Dockerfile, le plugin Maven de Spring Boot peut construire l’image directement avec Buildpacks :

./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=shop/order-service:1.0.0

Vous pouvez également configurer cela de manière permanente dans pom.xml :

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>shop/order-service:${project.version}</name>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
            </env>
        </image>
    </configuration>
</plugin>

Buildpacks fait automatiquement ce que nous avons fait manuellement ci-dessus : extrait les couches, choisit le JRE, s’exécute avec un utilisateur non-root, calcule la mémoire pour la JVM. Vous n’avez pas besoin d’un Dockerfile, juste du daemon Docker en cours d’exécution.

En contrepartie, vous avez moins de contrôle. L’image de base est déterminée par le buildpack, le premier build est assez lent car il faut télécharger l’image du builder, et la personnalisation avancée est plus difficile que de modifier un Dockerfile. Les équipes qui veulent normaliser rapidement devraient utiliser Buildpacks. Les équipes qui ont besoin de contrôle fin, par exemple pour utiliser une image de base interne approuvée pour la sécurité, devraient utiliser un Dockerfile.

6. Valider l’image avec Testcontainers

Une image « légère » qui ne démarre pas est inutile. Vous pouvez écrire des tests qui construisent l’image à partir du Dockerfile, lancent le conteneur et vérifient qu’il fonctionne correctement.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>

Le BOM de Spring Boot gère la version des deux dépendances, donc vous n’avez pas besoin de déclarer <version>.

@Testcontainers
class OrderServiceImageIT {

    static final ImageFromDockerfile IMAGE =
            new ImageFromDockerfile("order-service-it", false)
                    .withDockerfile(Path.of("./Dockerfile"));

    @Container
    static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
            .withExposedPorts(8080)
            .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
            .withStartupTimeout(Duration.ofMinutes(2));

    @Test
    void shouldServeOrdersAndReportHealthy() throws Exception {
        String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
        HttpClient client = HttpClient.newHttpClient();

        HttpResponse<String> response = client.send(
                HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
                HttpResponse.BodyHandlers.ofString());

        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
    }

    @Test
    void shouldNotRunAsRoot() throws Exception {
        var result = APP.execInContainer("id", "-u");

        assertThat(result.getStdout().trim()).isNotEqualTo("0");
    }
}

Ce test fait ce qui suit :

  • ImageFromDockerfile construit l’image à partir du Dockerfile à la racine du projet. Le contexte de build est le répertoire contenant le Dockerfile. Le paramètre false conserve l’image après le test pour que la prochaine build soit plus rapide grâce à la mise en cache.
  • Wait.forHttp("/actuator/health") attend que l’application soit vraiment prête, pas juste que le conteneur ait démarré.
  • Le premier test appelle l’endpoint via le port mappé aléatoirement sur l’hôte et vérifie le JSON retourné.
  • Le deuxième test exécute id -u dans le conteneur. Si quelqu’un supprime accidentellement la ligne USER spring:spring, le résultat sera 0 et le test échoue immédiatement.

Nommez la classe avec le suffixe IT et exécutez-la avec maven-failsafe-plugin. Ce test construit l’image entière donc prend quelques dizaines de secondes, pas adapté pour s’exécuter lors de tests unitaires ordinaires.

7. Erreurs courantes et quand ne pas l’appliquer

  • Utiliser ENTRYPOINT java -jar app.jar en forme shell. Dans ce cas, /bin/sh est le PID 1 et ne transmet pas SIGTERM à la JVM. Le conteneur est tué brutalement après le délai d’attente, perdant la fermeture gracieuse. Utilisez toujours la forme tableau JSON.
  • Fixer -Xmx. Si vous définissez -Xmx2g mais la limite du conteneur est 1 GB, le conteneur sera tué par OOM sans stack trace. Utilisez MaxRAMPercentage pour que le tas s’adapte à la limite.
  • Alpine et les bibliothèques natives. Alpine utilise musl au lieu de glibc. Certaines bibliothèques avec du code natif (Netty native transport, certains drivers, bibliothèques de traitement d’image) peuvent échouer ou s’exécuter lentement. Passez à eclipse-temurin:21-jre-jammy ou -noble dans ce cas. L’image est plus grosse de quelques dizaines de Mo mais plus stable.
  • Ordre des couches COPY incorrect. Si vous copiez application avant dependencies, chaque modification de code invalide le cache de la couche dependency. Vous perdez tous les bénéfices du JAR en couches.
  • Utiliser le tag latest pour la base image. Un build aujourd’hui et un la semaine prochaine peuvent produire deux images différentes. Épinglez un tag spécifique comme 21.0.5_11-jre-alpine, ou épinglez le digest pour une reproductibilité absolue.

Remarque : Si vous avez besoin d’images encore plus petites, vous pouvez utiliser jdeps et jlink pour créer un JRE contenant uniquement les modules requis par l’application, ou compiler en natif avec GraalVM. Les deux réduisent considérablement la taille, mais rendent le build beaucoup plus complexe et fragile avec les réflexions. N’appliquez que si la taille de l’image ou le temps de démarrage est vraiment un problème, par exemple avec le serverless ou le scale-to-zero.

8. Conclusion

Dans cet article, vous avez transformé un Dockerfile en trois lignes en un Dockerfile multi-stage comprenant les étapes suivantes :

  • construire dans un conteneur pour ne pas dépendre de la machine de build ;
  • extraire le JAR en couches pour que seule la couche contenant le code change après chaque modification ;
  • utiliser JRE Alpine pour réduire la taille de l’image ;
  • exécuter avec un utilisateur non-root, avec un health check et une configuration JVM adaptée à la limite mémoire du conteneur.

Vous avez également vu que Buildpacks est une alternative qui n’a pas besoin de Dockerfile. Enfin, les tests Testcontainers garantissent que l’image démarre toujours et ne s’exécute pas en tant que root après chaque modification.

spring-bootdockerjavadevopscontainer