Déployer¶
Dernière étape : faire tourner todo-api en production, dans un conteneur, avec PostgreSQL et HTTPS, puis vérifier qu'elle va bien.
Dans ce chapitre
- Le jar exécutable et la configuration de production par variables d'environnement
- Le
Dockerfilemulti-étapes du projet, ligne par ligne - Actuator : santé, sondes liveness/readiness, et ce qu'il ne faut pas exposer
- Derrière un reverse proxy :
server.forward-headers-strategy - Déployer sur Dokploy : base PostgreSQL, application, variables, domaine HTTPS
- Réglages de la JVM en conteneur, virtual threads, et la suite
Le jar et la configuration de production¶
./mvnw package produit target/todo-api-0.0.1-SNAPSHOT.jar : un fat jar de 56 Mo qui contient l'appli, Tomcat et toutes les dépendances (voir Premier projet et Maven). Il ne lui faut qu'une JVM 25. Rien n'est à recompiler entre la dev et la prod : tout ce qui change vient de l'environnement.
$ SPRING_PROFILES_ACTIVE=prod \
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/todo \
SPRING_DATASOURCE_USERNAME=todo SPRING_DATASOURCE_PASSWORD=... \
java -jar target/todo-api-0.0.1-SNAPSHOT.jar
...
INFO xyz.dunkan.todo.TodoApiApplication : The following 1 profile is active: "prod"
INFO org.flywaydb.core.FlywayExecutor : Database: jdbc:postgresql://localhost:5432/todo (PostgreSQL 14.15)
INFO o.f.core.internal.command.DbMigrate : Migrating schema "public" to version "1 - create todo"
INFO o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
INFO xyz.dunkan.todo.TodoApiApplication : Started TodoApiApplication in 6.718 seconds (process running for 7.562)
Le profil prod (application-prod.properties, détaillé dans Configuration) lit la base dans les variables d'environnement, coupe la console H2 et règle les en-têtes de proxy (plus bas). Les variables à fournir :
| Variable | Rôle | Exemple |
|---|---|---|
SPRING_PROFILES_ACTIVE |
active application-prod.properties |
prod (déjà dans l'image) |
SPRING_DATASOURCE_URL |
URL JDBC de la base | jdbc:postgresql://hôte:5432/todo |
SPRING_DATASOURCE_USERNAME, _PASSWORD |
accès à la base | jamais dans le code |
TODO_MAX_OPEN |
règle métier todo.max-open |
50 |
JAVA_TOOL_OPTIONS |
options de la JVM (plus bas) | -XX:MaxRAMPercentage=75 |
L'appli échoue au démarrage plutôt qu'à la première requête, ce qui est voulu : un conteneur qui s'arrête se voit tout de suite dans les logs.
SPRING_DATASOURCE_URLabsente :'url' must start with "jdbc"(expliqué dans Configuration) ;- base injoignable :
Connection to localhost:15999 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
L'image Docker¶
# Image Docker de l'API, construite en deux étapes (« multi-stage build »).
# --- Étape 1 : compilation avec un JDK complet ---
FROM eclipse-temurin:25-jdk AS build
WORKDIR /app
# D'abord le wrapper Maven et le pom seuls : tant qu'ils ne changent pas, Docker
# réutilise la couche des dépendances téléchargées (build suivant bien plus rapide).
COPY mvnw pom.xml ./
COPY .mvn .mvn
# chmod : au cas où le bit exécutable de mvnw aurait été perdu (archive zip, Windows...)
RUN chmod +x mvnw && ./mvnw -B dependency:go-offline
# Puis le code source, et construction du jar exécutable (les tests tournent en CI, pas ici).
COPY src src
RUN ./mvnw -B package -DskipTests
# --- Étape 2 : exécution avec un simple JRE (image plus petite, sans compilateur) ---
FROM eclipse-temurin:25-jre
WORKDIR /app
# Utilisateur sans privilèges : si l'appli est compromise, l'attaquant n'est pas root.
RUN groupadd --system spring && useradd --system --gid spring spring
# On ne récupère que le jar de l'étape 1 (target/*.jar exclut le .jar.original).
COPY --from=build /app/target/*.jar app.jar
USER spring
# Active application-prod.properties (PostgreSQL). Les variables SPRING_DATASOURCE_URL,
# SPRING_DATASOURCE_USERNAME et SPRING_DATASOURCE_PASSWORD sont à fournir au lancement.
ENV SPRING_PROFILES_ACTIVE=prod
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
- Deux étapes (multi-stage build)
- La première (
AS build) part d'un JDK complet : compilateur, wrapper Maven, dépôt de dépendances. La seconde repart de zéro avec un simple JRE et n'y copie que le jar (COPY --from=build). Les sources,target/et les dépendances téléchargées par Maven n'arrivent jamais dans l'image finale. Le JRE lui-même est plus léger : 117 Mo compressés poureclipse-temurin:25-jrecontre 151 Mo pour25-jdk(linux/amd64, d'après le Docker Hub). C'est l'équivalent d'un build Rust dansrust:...puis d'une copie du binaire dans une petite image. - Le cache des dépendances
- Docker réutilise une couche tant que ce qu'elle copie n'a pas changé. Le
pom.xmlest copié seul, puisdependency:go-offlinetélécharge les dépendances : tant que lepom.xmlne bouge pas, cette couche (la plus longue) est reprise du cache. Modifier une classe Java n'invalide qu'à partir deCOPY src src. Le fichier.dockerignoreexcluttarget/et.git/du contexte envoyé à Docker. -DskipTests- Les tests tournent avant, en local ou en intégration continue (
./mvnw verify) ; refaire tourner les 20 tests à chaque déploiement allongerait le build sur le serveur. - L'utilisateur non root
- Par défaut un conteneur tourne en
root.useradd --systemcrée un compte système (sans mot de passe ni dossier personnel), etUSER springl'utilise pour tout ce qui suit, y compris l'ENTRYPOINT. ENTRYPOINTen tableau JSON- La forme exec lance
javadirectement, avec le PID 1. Quand Docker arrête le conteneur, il envoieSIGTERMà ce processus, et Spring Boot s'arrête proprement :Commencing graceful shutdown. Waiting for active requests to complete, puisGraceful shutdown completeet la fermeture du pool de connexions. Avec la forme shell (ENTRYPOINT java -jar app.jar), le PID 1 serait/bin/sh, qui ne transmet pas le signal : l'appli serait tuée sans ménagement à la fin du délai.
EXPOSE 8080 ne publie rien : c'est de la documentation. Pour construire et lancer l'image sur ta machine :
docker build -t todo-api .
docker run -p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://hote-postgres:5432/todo \
-e SPRING_DATASOURCE_USERNAME=todo -e SPRING_DATASOURCE_PASSWORD=... \
todo-api
Actuator : la santé de l'appli¶
Le projet expose /actuator/health et /actuator/info (présentés dans Configuration ; par défaut, seul health l'est). health agrège des indicateurs (base de données, espace disque...) : il répond 200 et UP si tout va bien, 503 et DOWN sinon. Sa réponse, {"groups":["liveness","readiness"],"status":"UP"}, annonce deux groupes, les sondes que comprennent les orchestrateurs :
- liveness : « le processus est-il vivant ? ». Si non, il faut redémarrer le conteneur ;
- readiness : « peut-il recevoir du trafic ? ». Si non, on ne lui envoie plus de requêtes, sans le tuer (pendant le démarrage ou l'arrêt, par exemple).
Dans Spring Boot 4, ces sondes sont actives par défaut, chacune à son URL :
$ curl http://localhost:8080/actuator/health/liveness
{"status":"UP"}
$ curl http://localhost:8080/actuator/health/readiness
{"status":"UP"}
Piège : ne pas tout exposer
management.endpoints.web.exposure.include=* est tentant en dev, dangereux en prod : env et configprops décrivent ta configuration, heapdump télécharge un vidage de la mémoire (avec les mots de passe qu'elle contient), loggers permet de changer les niveaux de log à distance. Expose le strict nécessaire (/actuator/env répond ici 404) et laisse le détail de health à sa valeur par défaut, never. Encore mieux : management.server.port=8081 sert Actuator sur un autre port, que le reverse proxy ne publie pas (vérifié : /actuator/health répond 404 sur 8080 et 200 sur 8081).
Derrière un reverse proxy¶
En production, l'appli n'est pas exposée directement : un reverse proxy (Traefik dans Dokploy, ou nginx) reçoit le HTTPS, puis transmet la requête en HTTP simple au conteneur, avec des en-têtes X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-For qui décrivent la requête d'origine. Sans réglage, Spring les ignore, et construit de mauvaises URL. Même requête, avec les en-têtes qu'ajouterait le proxy :
$ curl -i -X POST http://localhost:8080/api/todos -H 'Content-Type: application/json' -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: todo.example.com' -d '{"title":"Derrière un proxy"}'
HTTP/1.1 201
Location: http://localhost:8080/api/todos/1
Avec le profil prod, la même requête renvoie Location: https://todo.example.com/api/todos/1 : l'URL publique, grâce à server.forward-headers-strategy=native.
Sous le capot
Avec native, c'est Tomcat (sa valve RemoteIpValve) qui lit ces en-têtes, et seulement s'ils viennent d'un proxy de confiance : par défaut, une adresse privée (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8...), réglable par server.tomcat.remoteip.internal-proxies. Les réseaux Docker en font partie. Un client sur Internet ne peut donc pas falsifier ses X-Forwarded-* en contactant l'appli directement.
Déployer sur Dokploy¶
Dokploy est une plateforme auto-hébergée qui construit des images Docker, les lance et les publie derrière Traefik avec des certificats Let's Encrypt. Le parcours pour todo-api :
- Le code. Le dossier
todo-api(avec sonDockerfileà la racine) dans un dépôt GitHub, ou une archive zip du projet (sanstarget/). - Le projet. Dans Dokploy, crée un projet (par exemple
todo) ; il contient un environnementproduction. - La base. Create Service → Database → PostgreSQL. Donne un nom, la base
todo, l'utilisateurtodo, un mot de passe fort, et une image à version fixe (postgres:18plutôt quepostgres:latest, pour ne pas changer de version majeure par surprise). Deploy. Note l'Internal Host affiché dans la page de la base. Ne lui donne pas de port externe : elle n'a pas à être joignable depuis Internet, seulement depuis le réseau Docker interne de Dokploy. - L'application. Create Service → Application. Source : GitHub (dépôt et branche), Git (une URL de dépôt) ou Drop (dépôt d'une archive zip). Type de build : Dockerfile, chemin
Dockerfile. -
Les variables. Onglet Environment (
SPRING_PROFILES_ACTIVEest déjà dans l'image) : -
Le domaine. Onglet Domains : l'hôte (
todo.example.com), le chemin/, le port du conteneur8080, HTTPS activé avec Let's Encrypt. L'enregistrement DNS (typeAvers le serveur) doit exister avant : sinon le certificat n'est pas obtenu, et une fois le DNS en place, désactiver puis réactiver HTTPS relance la demande. - Le déploiement. Deploy : l'onglet des déploiements montre le build Docker (le premier télécharge toutes les dépendances Maven, compte quelques minutes), puis l'onglet Logs les logs de Spring, migration Flyway comprise.
-
La vérification.
Puis un
POST: l'en-têteLocationdoit commencer parhttps://todo.example.com/.
Piège : l'URL de connexion de Dokploy n'est pas une URL JDBC
Dokploy affiche aussi une Internal Connection URL de la forme postgresql://utilisateur:motdepasse@hôte:5432/base. Collée telle quelle dans SPRING_DATASOURCE_URL, elle fait échouer le démarrage avec 'url' must start with "jdbc". Le pilote Java attend jdbc:postgresql://hôte:5432/base, l'utilisateur et le mot de passe à part.
Le build consomme la mémoire du serveur
Le build tourne sur le même serveur que tes applis : pour ce projet, Maven et javac occupent environ 400 Mo de mémoire (mesuré), plus le CPU pendant une à quelques minutes. Sur une petite machine, construis l'image ailleurs (intégration continue) et déploie-la avec la source Docker de Dokploy. Avec la source GitHub, le déploiement automatique à chaque git push est disponible.
La JVM dans un conteneur¶
La JVM détecte la mémoire allouée au conteneur, mais ne prend par défaut que 25 % pour son tas (MaxRAMPercentage). Simulation d'un conteneur limité à 512 Mo (-XX:MaxRAM=512m) :
$ java -XX:MaxRAM=512m -XX:+PrintFlagsFinal -version 2>&1 | grep -E "Picked up| MaxHeapSize "
size_t MaxHeapSize = 134217728 {product} {ergonomic}
$ JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 java -XX:MaxRAM=512m -XX:+PrintFlagsFinal -version 2>&1 | grep -E "Picked up| MaxHeapSize "
Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75
size_t MaxHeapSize = 402653184 {product} {ergonomic}
128 Mio de tas sur 512 Mo, contre 384 Mio avec 75 %. Ne monte pas à 100 % : la JVM utilise aussi de la mémoire hors du tas (métadonnées des classes, piles des threads, code compilé), et un conteneur qui dépasse sa limite est tué par le noyau.
Pourquoi JAVA_TOOL_OPTIONS ? L'ENTRYPOINT en tableau JSON ne passe par aucun shell : une variable $JAVA_OPTS n'y serait jamais remplacée. JAVA_TOOL_OPTIONS, elle, est lue par la JVM elle-même : il suffit de la définir dans Dokploy, sans toucher au Dockerfile. La limite mémoire du conteneur se règle dans les paramètres avancés de l'application. Autre effet des petites limites : avec un seul processeur alloué (simulé avec -XX:ActiveProcessorCount=1), la JVM choisit le ramasse-miettes Serial au lieu de G1.
Les virtual threads¶
Les virtual threads sont présentés dans Threads et concurrence. Pour que Tomcat traite chaque requête sur un virtual thread, une propriété suffit : spring.threads.virtual.enabled=true (ou SPRING_THREADS_VIRTUAL_ENABLED=true dans Dokploy). Un endpoint de test qui renvoie Thread.currentThread() le montre :
Thread[#41,http-nio-8080-exec-2,5,main] # par défaut : pool de Tomcat
VirtualThread[#48,tomcat-handler-1]/runnable@ForkJoinPool-1-worker-1 # avec spring.threads.virtual.enabled=true
Le gain concerne les requêtes qui attendent (appels HTTP, base lente) quand il y en a des milliers en même temps. Le pool de connexions à la base reste limité (10 connexions par défaut avec HikariCP) : pour todo-api, la base restera le goulot d'étranglement bien avant les threads.
Pour aller plus loin¶
- Spring Security : authentification (sessions, JWT, OAuth2) et autorisations par route ; c'est aussi lui qui permet
show-details=when-authorizedsurhealth. - springdoc-openapi : génère la description OpenAPI de l'API à partir des contrôleurs, avec une page Swagger UI pour l'essayer.
- GraalVM native image : compile l'appli en exécutable natif (démarrage en une fraction de seconde, moins de mémoire), grâce au traitement AOT de Spring Boot ; au prix d'un build long et de restrictions sur la réflexion.
- Testcontainers : tester contre un vrai PostgreSQL (voir Tests).
- Métriques : Micrometer et Prometheus, via Actuator (exercice 2).
Exercices¶
1. Plus de détails en dev. Fais afficher le détail de /actuator/health (base, disque...) dans le profil par défaut, mais pas en production.
Solution
# application.properties
management.endpoint.health.show-details=always
# application-prod.properties : remplace la valeur précédente
management.endpoint.health.show-details=never
En dev (extrait, le JSON réel contient aussi diskSpace et ssl) :
$ curl http://localhost:8080/actuator/health
{"components":{"db":{"details":{"database":"H2","validationQuery":"isValid()"},"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},...},"groups":["liveness","readiness"],"status":"UP"}
En prod, toujours {"groups":["liveness","readiness"],"status":"UP"} : le fichier du profil s'ajoute à application.properties et l'emporte sur les clés communes.
2. Les métriques. Expose l'endpoint metrics (en dev seulement), fais quelques requêtes, puis lis le nombre et la durée des GET /api/todos.
Solution
# application.properties
management.endpoints.web.exposure.include=health,info,metrics
# application-prod.properties
management.endpoints.web.exposure.include=health,info
/actuator/metrics liste les noms disponibles (http.server.requests, hikaricp.connections, jvm.memory.used...). On filtre ensuite par étiquettes (tags) :
$ curl "http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/api/todos&tag=method:GET"
{"availableTags":[{"tag":"exception","values":["none"]},{"tag":"error","values":["none"]},{"tag":"outcome","values":["SUCCESS"]},{"tag":"status","values":["200"]}],"baseUnit":"seconds","measurements":[{"statistic":"COUNT","value":2.0},{"statistic":"TOTAL_TIME","value":0.081542628},{"statistic":"MAX","value":0.074162488}],"name":"http.server.requests"}
Deux appels, 82 ms au total. L'étiquette uri vaut le modèle de la route (/api/todos/{id}), pas l'URL réelle : sinon chaque id créerait une nouvelle série.
3. Une section info maison. Fais apparaître dans /actuator/info le nombre de tâches ouvertes et la limite configurée.
Solution
package xyz.dunkan.todo.todo;
import org.springframework.boot.actuate.info.Info;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component // un bean de type InfoContributor est ajouté automatiquement à /actuator/info
public class TodoInfoContributor implements InfoContributor {
private final TodoRepository repository;
private final TodoProperties properties;
public TodoInfoContributor(TodoRepository repository, TodoProperties properties) {
this.repository = repository;
this.properties = properties;
}
@Override
public void contribute(Info.Builder builder) {
builder.withDetail("todos", Map.of(
"open", repository.countByDoneFalse(),
"maxOpen", properties.maxOpen()));
}
}
$ curl http://localhost:8080/actuator/info
{"build":{"artifact":"todo-api","name":"todo-api","version":"0.0.1-SNAPSHOT","group":"xyz.dunkan"},"todos":{"open":1,"maxOpen":50}}
L'ordre des clés de todos peut varier : Map.of ne garantit aucun ordre. Cette information reste publique, comme tout /actuator/info exposé.
À retenir¶
- Un seul artefact, le fat jar, pour tous les environnements : la configuration de prod vient des variables d'environnement (
SPRING_PROFILES_ACTIVE,SPRING_DATASOURCE_*,TODO_MAX_OPEN). - Le
Dockerfilemulti-étapes compile avec un JDK et exécute avec un JRE, sous un utilisateur non root ; copier lepom.xmlseul d'abord met les dépendances en cache. - L'
ENTRYPOINTen tableau JSON fait dejavale PID 1 :SIGTERMdéclenche un arrêt propre. - Actuator :
/actuator/health(aveclivenessetreadiness) et/actuator/info; n'expose jamaisenvniheapdumpen public. - Derrière Traefik ou nginx,
server.forward-headers-strategy=nativerend les URL générées correctes (https://...). - Sur Dokploy : base PostgreSQL interne, application en build Dockerfile, URL JDBC dans les variables, domaine HTTPS sur le port 8080 du conteneur, puis vérification de
/actuator/health. - En conteneur, règle le tas avec
JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75; les virtual threads s'activent par une propriété.