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:21contient 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 alorsorg.springframework.boot.loader.launch.JarLauncherau 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.xmlpuis exécuterdependency:go-offlineavant de copiersrc. 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 dansapplicationet le répertoirelib/dansdependencies, après fusion/appa la structureapp.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.0permet au tas d’utiliser au maximum 75% de la limite mémoire du conteneur.ExitOnOutOfMemoryErrorforce 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 awgetde BusyBox, donc vous n’avez pas à installercurlsupplémentaire.ENTRYPOINTen forme exec (tableau JSON) :javaest le PID 1 et reçoit le signalSIGTERMquand 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 :
ImageFromDockerfileconstruit l’image à partir duDockerfileà la racine du projet. Le contexte de build est le répertoire contenant le Dockerfile. Le paramètrefalseconserve 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 -udans le conteneur. Si quelqu’un supprime accidentellement la ligneUSER spring:spring, le résultat sera0et 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.jaren forme shell. Dans ce cas,/bin/shest le PID 1 et ne transmet pasSIGTERMà 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-Xmx2gmais la limite du conteneur est 1 GB, le conteneur sera tué par OOM sans stack trace. UtilisezMaxRAMPercentagepour que le tas s’adapte à la limite. - Alpine et les bibliothèques natives. Alpine utilise
muslau lieu deglibc. 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-jammyou-nobledans ce cas. L’image est plus grosse de quelques dizaines de Mo mais plus stable. - Ordre des couches
COPYincorrect. Si vous copiezapplicationavantdependencies, 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
latestpour la base image. Un build aujourd’hui et un la semaine prochaine peuvent produire deux images différentes. Épinglez un tag spécifique comme21.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
jdepsetjlinkpour 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.