Spring Boot et Spring Cloud
Packager et déployer Spring Boot 3 : jar exécutable, conteneurs et images natives
Intermédiaire10 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Vous saurez mener une application Spring Boot 3 du code source jusqu'à la production : comprendre la structure du jar exécutable, construire une image de conteneur légère et favorable au cache avec un Dockerfile en couches ou avec les Cloud Native Buildpacks, dimensionner la JVM dans un conteneur, configurer le graceful shutdown pour les déploiements progressifs, et décider quand un WAR ou une image native GraalVM est le bon choix.
Ce que vous allez apprendre
- Décrire le jar exécutable produit par les plugins Maven et Gradle de Spring Boot, et la façon dont il démarre
- Construire une image de conteneur avec un Dockerfile multi-stage en couches ou avec le goal build-image et les Cloud Native Buildpacks
- Configurer la mémoire et le CPU de la JVM pour les conteneurs, et injecter la configuration par l'environnement
- Activer le graceful shutdown et le signalement de readiness pour qu'un déploiement progressif ne perde aucune requête
- Expliquer quand déployer un WAR sur un serveur externe et quand compiler une image native GraalVM
C'est au moment du build que les promesses d'un service Spring Boot sont tenues ou trahies : l'artefact qui a passé les tests est-il bien celui qui arrive en production, une modification d'une ligne impose-t-elle de reconstruire toute l'image, un déploiement progressif perd-il des requêtes, et la JVM à l'intérieur d'un conteneur sait-elle de combien de mémoire elle dispose réellement ? Cette page suit une application Spring Boot 3 depuis package jusqu'à un conteneur en cours d'exécution et passe en revue les décisions à prendre en chemin, y compris les deux autres modèles de packaging, le WAR et l'image native.
Le jar exécutable
./mvnw package (ou ./gradlew bootJar) exécute l'étape repackage du plugin Spring Boot, qui transforme le jar ordinaire en exécutable autonome :
app.jar
├── META-INF/MANIFEST.MF Main-Class: org.springframework.boot.loader.launch.JarLauncher
│ Start-Class: com.example.shop.ShopApplication
├── org/springframework/boot/loader/ the launcher classes
├── BOOT-INF/
│ ├── classes/ your compiled classes and resources
│ ├── lib/ every runtime dependency as a nested jar
│ ├── classpath.idx load order of the dependencies
│ └── layers.idx layer assignment, used for container images
java -jar app.jar lance JarLauncher, qui construit un class loader lisant les jars imbriqués sur place (c'est pour cela qu'ils sont stockés sans compression), puis appelle la méthode main de la Start-Class. Rien n'est extrait sur le disque, aucun serveur d'applications n'est nécessaire, et le même fichier tourne sur un portable, une machine virtuelle ou dans un conteneur. Le jar d'avant le repackage est conservé sous Maven en app.jar.original : c'est ce jar simple que vous publiez si un autre module dépend de vos classes, car le jar exécutable ne peut pas servir de bibliothèque.
Avec Maven, le plugin est hérité de spring-boot-starter-parent ; avec un parent maison, il faut déclarer explicitement le goal repackage. Le plugin Gradle org.springframework.boot fournit bootJar et conserve la tâche standard jar, dont la sortie porte le classifier -plain pour qu'on ne puisse pas confondre les deux. build-info sous Maven et springBoot { buildInfo() } sous Gradle écrivent META-INF/build-info.properties, que l'endpoint Actuator info expose : c'est la manière la plus propre de savoir exactement quelle version tourne.
Exécuter le jar tel quel convient très bien sur une machine virtuelle, derrière un gestionnaire de processus. Une unité systemd est la solution habituelle :
[Unit]
Description=Shop service
After=network.target
[Service]
User=shop
WorkingDirectory=/opt/shop
ExecStart=/usr/bin/java -XX:MaxRAMPercentage=75 -jar /opt/shop/app.jar
EnvironmentFile=/etc/shop/env
SuccessExitStatus=143
Restart=on-failure
[Install]
WantedBy=multi-user.target
SuccessExitStatus=143 empêche systemd de signaler un échec sur le code de sortie que renvoie la JVM après un SIGTERM. La configuration provient de /etc/shop/env et d'un config/application.yml placé à côté du jar, tous deux en dehors de l'artefact.
Conteneurs : le problème de la reconstruction rapide
Copier app.jar dans une image fonctionne, mais pose un problème bien précis : le jar entier forme une seule couche, si bien que chaque modification de code invalide la couche qui contient 50 Mo de dépendances inchangées, et chaque déploiement pousse et tire la totalité. Boot règle ce problème avec les layered jars. layers.idx répartit le contenu du jar en couches ordonnées de la moins à la plus susceptible de changer : dependencies, spring-boot-loader, snapshot-dependencies, application. Le mode tools du jar les extrait :
java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# Boot 3.2 and earlier: java -Djarmode=layertools -jar app.jar extract
Un Dockerfile multi-stage copie ensuite chaque couche séparément, de sorte qu'une modification de code ne reconstruit que le dernier COPY :
# ---- build stage: produce and explode the jar
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q dependency:go-offline
COPY src src
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q -DskipTests package \
&& cp target/*.jar app.jar \
&& java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# ---- runtime stage: small JRE, non-root, one COPY per layer
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 1001 app
WORKDIR /app
COPY --from=build --chown=app /workspace/extracted/dependencies/ ./
COPY --from=build --chown=app /workspace/extracted/spring-boot-loader/ ./
COPY --from=build --chown=app /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=app /workspace/extracted/application/ ./
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
La structure produite par extract --layers place dans chaque couche un app.jar minimal et un répertoire lib/, et le lanceur les lit depuis le système de fichiers plutôt que depuis des entrées imbriquées, ce qui démarre d'ailleurs un peu plus vite que le fat jar. Comme dependencies ne change que lorsque pom.xml change, les builds du quotidien ne produisent qu'une petite couche nouvelle.
Le guide des fondamentaux de Docker explique de façon générale les builds multi-stage, les cache mounts et le .dockerignore ; les points propres à Boot sont l'extraction des couches et le choix d'une image JRE (-jre, pas -jdk) pour l'étape d'exécution.
Les buildpacks à la place d'un Dockerfile
./mvnw spring-boot:build-image (ou ./gradlew bootBuildImage) produit une image OCI sans aucun Dockerfile, grâce aux Cloud Native Buildpacks (l'implémentation Paketo par défaut). Le buildpack détecte une application Boot, sélectionne une JVM, applique le même découpage en couches, calcule au démarrage du conteneur les options mémoire de la JVM à partir de la limite cgroup grâce à son memory calculator, s'exécute avec un utilisateur non-root et enregistre une nomenclature logicielle (SBOM). La configuration passe par les propriétés du plugin ou par des variables d'environnement :
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>registry.example.com/shop/orders:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
<BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:+ExitOnOutOfMemoryError</BPE_APPEND_JAVA_TOOL_OPTIONS>
</env>
</image>
</configuration>
</plugin>
Les buildpacks ont besoin d'un démon Docker (ou d'une socket compatible, comme celle de Podman) pendant le build, et les images de base sont celles que publie Paketo, ce qui est un avantage ou un obstacle selon la politique d'images de votre organisation. Quand une équipe plateforme impose une image de base durcie, c'est la voie du Dockerfile qui convient.
Configurer la JVM dans un conteneur
Les JVM modernes (Java 17 et 21 compris) lisent les limites cgroup du conteneur et dimensionnent le heap par défaut en conséquence, mais ce défaut vaut un quart de la limite, ce qui gaspille l'essentiel d'un conteneur de 1 Gio. Préférez un pourcentage à un -Xmx fixe, pour que la même image fonctionne avec des limites différentes :
| Paramètre | Recommandation |
|---|---|
| Heap | -XX:MaxRAMPercentage=75 (moins si l'application utilise beaucoup de mémoire off-heap) ; évitez -Xmx dans les images |
| Heap initial | un -XX:InitialRAMPercentage égal au maximum évite les pauses de redimensionnement dans les services en régime stable |
| CPU | La JVM déduit son parallélisme du quota CPU ; une limite inférieure à 2 CPU ralentit nettement la montée en régime de G1 et du JIT |
| Garbage collector | G1 est le défaut et convient ; ZGC ou Shenandoah pour les services sensibles à la latence avec de gros heaps ; Serial GC pour les très petits conteneurs |
| Diagnostic | -XX:+ExitOnOutOfMemoryError pour que l'orchestrateur redémarre un processus mort au lieu de laisser un zombie ; -XX:+HeapDumpOnOutOfMemoryError vers un volume monté |
JAVA_TOOL_OPTIONS est lue à chaque lancement de JVM : c'c'est la variable d'environnement habituelle pour injecter ces options depuis un manifeste Kubernetes sans reconstruire l'image. La mémoire totale du conteneur doit dépasser le heap d'une marge suffisante pour le metaspace, les piles des threads (chaque thread Tomcat réserve de l'espace de pile), le code cache et les direct buffers ; un conteneur tué avec le code de sortie 137, c'est le noyau qui fait respecter la limite, pas une OutOfMemoryError Java.
Boot 3.2 et Java 21 ajoutent un réglage à connaître : spring.threads.virtual.enabled=true fait utiliser des virtual threads à Tomcat et aux task executors de Boot, ce qui supprime le plafond du pool de threads pour les services limités par les E/S, sans modifier le code.
Configuration et secrets à l'exécution
L'image ne doit contenir aucune valeur propre à un environnement. Injectez-les par les mécanismes que décrit le guide de configuration, dans cet ordre de préférence : variables d'environnement pour les valeurs simples, fichiers montés pour les secrets (spring.config.import=optional:configtree:/run/secrets/), serveur de configuration ou gestionnaire de secrets pour tout ce qui est partagé entre de nombreux services. Un extrait de déploiement Kubernetes :
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef: { name: orders-db, key: password }
resources:
requests: { cpu: "500m", memory: "768Mi" }
limits: { memory: "768Mi" }
Aligner la request mémoire sur la limite garantit au conteneur son allocation ; on omet souvent la limite CPU pour que la JVM puisse profiter d'un surcroît de CPU pendant le démarrage et la compilation JIT.
Graceful shutdown et déploiements progressifs
Déployer une nouvelle version, c'est arrêter les anciennes instances alors que des requêtes sont en cours. Deux réglages rendent l'opération sûre :
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
À la réception de SIGTERM, Boot cesse d'accepter de nouvelles connexions, laisse les requêtes actives se terminer dans la limite du timeout, puis arrête le contexte. Avec la readiness probe Kubernetes pointée sur /actuator/health/readiness (voir le guide d'observabilité), la séquence devient : le pod est marqué en cours d'arrêt, la readiness bascule et refuse le trafic, le Service cesse de lui router des requêtes, les requêtes en cours s'achèvent, puis le processus s'arrête. Réglez le terminationGracePeriodSeconds du pod au-dessus du timeout de Boot, et envisagez un court sleep en preStop pour laisser aux load balancers le temps de remarquer le changement de readiness avant que le processus ne commence à s'arrêter.
Le démarrage compte aussi : une mise à jour progressive qui promeut un pod avant qu'il puisse servir du trafic provoque des erreurs, exactement comme un arrêt brutal. Utilisez une startup probe ou un initialDelaySeconds de readiness qui reflète le vrai temps de démarrage, que vous pouvez lire dans l'endpoint Actuator startup ou dans la ligne de log que Boot écrit quand l'application est prête.
Alternatives : fichiers WAR et images natives
Le déploiement en WAR sur un Tomcat, un Jetty ou un WildFly externe reste pris en charge pour les organisations qui ont standardisé un serveur d'applications. Passez le packaging à war, déclarez spring-boot-starter-tomcat en provided et étendez SpringBootServletInitializer pour que le serveur puisse démarrer l'application sans méthode main. Vous perdez la maîtrise des versions de la JVM et du serveur, ne gagnez rien en performance et compliquez la configuration : ne le choisissez que si le modèle d'exploitation l'exige.
Les images natives GraalVM compilent l'application à l'avance en un exécutable propre à la plateforme. Spring Boot 3 le permet grâce à son moteur ahead-of-time (AOT), qui précalcule les définitions de beans et génère les hints de réflexion, de proxy et de ressources dont le compilateur natif a besoin. Avec les GraalVM Native Build Tools configurés (le profil native d'un projet généré par Spring Initializr), ./mvnw -Pnative native:compile produit un binaire qui démarre en quelques dizaines de millisecondes et tourne avec une fraction de la mémoire ; ./mvnw -Pnative spring-boot:build-image le construit dans un buildpack, sans GraalVM en local. Le prix à payer : un build lent et gourmand en mémoire, des restrictions sur la réflexion et les proxies dynamiques à l'exécution (les bibliothèques ont besoin de hints, que Spring fournit pour ses propres projets et pour de nombreuses dépendances courantes), et une hypothèse de monde fermé : profils et conditions sont évalués au build, si bien qu'une image native ne peut pas changer d'ensemble de beans en activant un profil à l'exécution. Testez le binaire natif lui-même en CI : des tests qui passent sur la JVM ne prouvent pas que l'image fonctionne.
Entre ces deux extrêmes, Boot 3.3 a introduit un gain moins coûteux : le Class Data Sharing (CDS). Extrayez le jar avec -Djarmode=tools, exécutez-le une fois pendant le build de l'image avec -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh (la seconde option fait sortir Boot dès que le contexte est prêt, si bien que l'exécution d'entraînement n'a besoin d'aucune base de données), puis lancez-le dans l'image avec -XX:SharedArchiveFile=app.jsa ; le temps de démarrage baisse en général sensiblement, sans rien changer au comportement de l'application.
Erreurs courantes
Construire l'image à partir du fat jar en une seule couche. Chaque déploiement transfère de nouveau toutes les dépendances. Extrayez les couches ou utilisez les buildpacks.
Utiliser une image JDK à l'exécution. L'étape d'exécution a besoin d'un JRE ; le JDK double la taille de l'image et ajoute des outils qu'un attaquant apprécierait.
Tourner en root dans le conteneur. Une instruction USER ou l'utilisateur non-root par défaut du buildpack ne coûte rien et limite les dégâts d'une évasion de conteneur.
Un -Xmx fixe gravé dans l'image. La même image ne peut alors plus être planifiée avec une autre limite mémoire sans gaspillage ou sans plantages. Utilisez des pourcentages.
Faire l'impasse sur le graceful shutdown. Par défaut, l'arrêt est immédiat ; chaque mise à jour progressive produit alors une rafale de requêtes en échec que personne n'attribue au déploiement.
Des fichiers de configuration dans l'image. Une image qui embarque application-prod.yml ne peut pas être promue d'un environnement à l'autre sans modification, et elle divulgue les réglages de production à quiconque peut la récupérer.
Ne tester les images natives que sur la JVM. Les problèmes de réflexion et de ressources n'apparaissent que dans le binaire compilé.
Points clés à retenir
- Le plugin Boot reconditionne votre jar en exécutable contenant les dépendances imbriquées ; publiez le jar
.originalsi le code sert aussi de bibliothèque. - Des layered jars extraits avec
-Djarmode=toolset copiés couche par couche, ou des images produites parbuild-imageavec les Cloud Native Buildpacks, limitent le volume des reconstructions et des push de conteneurs. - Dimensionnez la JVM avec
MaxRAMPercentageet injectez les options viaJAVA_TOOL_OPTIONS; laissez de la marge dans la limite du conteneur pour la mémoire hors heap. - Gardez la configuration propre à chaque environnement et les secrets hors de l'image, et injectez-les par l'environnement, des fichiers montés ou un serveur de configuration.
server.shutdown=graceful, des readiness probes et une période de grâce de terminaison adaptée rendent les déploiements progressifs sans perte.- Le packaging WAR existe pour les modèles d'exploitation hérités ; les images natives GraalVM échangent temps de build et comportement dynamique contre un démarrage instantané et une faible empreinte mémoire, le CDS offrant un compromis moins coûteux.
Sources et lectures complémentaires
Questions fréquentes
- Pourquoi mon jar Spring Boot est-il tellement plus gros que mon code ?
- Parce que l'étape repackage du plugin construit un exécutable autonome : vos classes sous BOOT-INF/classes, toutes les dépendances d'exécution sous BOOT-INF/lib, et un petit lanceur capable de charger des jars imbriqués dans un jar. Le jar simple produit par le compilateur est conservé lui aussi, avec le suffixe .original sous Maven : c'est lui qu'il faut publier si un autre projet doit dépendre de votre code comme d'une bibliothèque.
- Dois-je utiliser un Dockerfile ou le goal build-image ?
- Prenez build-image (Cloud Native Buildpacks) si vous voulez une image bien réglée sans maintenir de Dockerfile : il choisit une JVM, fixe les options mémoire, s'exécute sous un utilisateur non-root et découpe automatiquement l'application en couches. Prenez un Dockerfile quand vous devez maîtriser l'image de base, les certificats de l'entreprise, des outils supplémentaires ou un standard de durcissement précis. Les deux approches produisent des images en couches qui se reconstruisent vite.
- Combien de mémoire donner à un conteneur Spring Boot ?
- Partez du heap dont l'application a réellement besoin sous charge, puis ajoutez de la place pour le metaspace, les piles des threads, le code cache et la mémoire native utilisée par la JVM et les bibliothèques ; une limite de conteneur d'environ le heap plus 25 à 50 % est un point de départ courant pour les petits services. Laissez la JVM déduire le heap de la limite avec MaxRAMPercentage plutôt qu'avec un Xmx fixe, et surveillez les redémarrages de conteneur causés par le noyau qui tue un processus ayant dépassé sa limite.
- Une image native GraalVM est-elle toujours plus rapide ?
- Elle démarre en quelques dizaines de millisecondes et consomme bien moins de mémoire au repos, ce qui compte pour les fonctions serverless et les clusters denses. Le débit maximal est en général un peu inférieur à celui d'une JVM dont le JIT a chauffé, le build prend plusieurs minutes et beaucoup de mémoire, et les bibliothèques qui font un usage intensif de la réflexion ont besoin de hints. Pour des services de longue durée au trafic régulier, la JVM reste le choix par défaut ; optez pour le natif quand le temps de démarrage ou l'empreinte mémoire est la contrainte.