🎯 OBJECTIF
Comprendre comment :
🧠 MODÈLE MENTAL
Deux problèmes distincts, souvent confondus : comment l'app meurt (graceful shutdown) et comment l'app vit (healthchecks). Le premier garantit que les requêtes en cours finissent avant l'extinction. Le second permet à Kubernetes de savoir s'il doit tuer le pod (liveness down) ou juste le retirer du load balancer (readiness down).
La règle d'or : application.yml est le centre de pilotage runtime. Aucune valeur environnement-dépendante en dur dans le code — ports, timeouts, features, tout passe par la config. Les secrets ne rentrent jamais dans Git : variables d'environnement, Vault, ou secrets Kubernetes.
Sans graceful shutdown + healthchecks coordonnés, un rolling update Kubernetes coupe des requêtes en cours, laisse des transactions ouvertes, et produit des 500 clients. Ces deux mécanismes sont la fondation de tout déploiement production stable.
server:
port: 8080
shutdown: graceful # ✓ arrêt propre
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev} # ✓ profil via env var
lifecycle:
timeout-per-shutdown-phase: 30s # ✓ attente max des requêtes en cours
management:
endpoints:
web:
exposure:
include: health,info,caches # ✓ endpoints Actuator exposés
endpoint:
health:
probes:
enabled: true # ✓ /actuator/health/liveness + /readinessyaml🚨 Règle absolue
Aucune valeur environnement-dépendante (URL de base, credentials, hostname) en dur dans le code Java. Aucun secret dans Git. Toujours passer par ${ENV_VAR} dans application.yml ou un système de secrets externe.
application.yml # base commune
application-dev.yml # surcharges dev
application-qa.yml # surcharges QA
application-prod.yml # surcharges prod
Activation :
# Via variable d'environnement (Kubernetes)
SPRING_PROFILES_ACTIVE=prod
# Via properties (local)
spring.profiles.active=devbashUsage typique :
# application-dev.yml
logging.level.root: DEBUG
spring.jpa.show-sql: true
# application-prod.yml
logging.level.root: WARN
management.endpoints.web.exposure.include: health,infoyaml@Bean
@Profile("dev")
DataSource devDataSource() { /* H2 in-memory */ }
@Bean
@Profile("prod")
DataSource prodDataSource() { /* RDS, Postgres */ }javaSecrets — jamais dans Git :
spring:
datasource:
password: ${DB_PASSWORD} # ✓ injecté via env var Kubernetes
url: ${DB_URL}yamlFeature flags — activation sans redéploiement :
feature:
new-workflow: true
experimental-pricing: falseyaml@ConfigurationProperties(prefix = "feature")
public record FeatureFlags(
boolean newWorkflow,
boolean experimentalPricing
) {}java// Approche 1 — condition en code
if (features.newWorkflow()) { ... }
// Approche 2 — bean conditionnel
@Bean
@ConditionalOnProperty(name = "feature.new-workflow", havingValue = "true")
NewWorkflowService newWorkflowService() { ... }javasequenceDiagram
participant K8s as Kubernetes
participant App as Spring Boot
participant Req as Requêtes en cours
K8s->>App: SIGTERM
App->>App: Phase 1 — cesse d'accepter<br/>les nouvelles connexions HTTP
App->>Req: attend la fin (max 30s)
Req-->>App: requêtes terminées ✓
App->>App: Phase 2 — ferme le contexte Spring<br/>(@PreDestroy, DataSource, etc.)
App-->>K8s: process terminé (exit 0)mermaidSans graceful shutdown :
🚨 @Async et threads custom ne sont PAS attendus par défaut
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor t = new ThreadPoolTaskExecutor();
t.setWaitForTasksToCompleteOnShutdown(true); // ✓ OBLIGATOIRE
t.setAwaitTerminationSeconds(30);
return t;
}javaSans ça, les threads @Async sont coupés brutalement à l'arrêt, même avec server.shutdown: graceful.
flowchart LR
K8s["Kubernetes probe"] --> L["/actuator/health/liveness"]
K8s --> R["/actuator/health/readiness"]
L -->|DOWN| RESTART["Pod redémarré\n(kill + new pod)"]
L -->|UP| OK1["Pod conservé"]
R -->|DOWN| REMOVE["Pod retiré\ndu load balancer\n(pas tué)"]
R -->|UP| OK2["Trafic envoyé"]mermaid| Liveness | Readiness | |
|---|---|---|
| Question | "La JVM est-elle vivante ?" | "L'app peut-elle traiter du trafic ?" |
| Si DOWN | Kubernetes redémarre le pod | Pod retiré du LB, mais conservé |
| Vérifier | Deadlock, OOM, état interne | DB up, Kafka connecté, cache prêt |
| Exemple DOWN | Thread deadlock permanent | DB temporairement indisponible |
🚨 Piège classique : mettre les dépendances externes en Liveness
# ❌ DANGEREUX — DB down → restart loop infini
management:
health:
db:
group: liveness # FAUX
# ✓ CORRECT — DB down → retiré du LB, pod conservé
management:
health:
db:
group: readinessyamlDB down avec liveness → pod redémarré → DB toujours down → restart loop → crash cascade.
Configuration Kubernetes :
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5yamlSpring fournit des indicators natifs : db, diskSpace, redis, kafka, etc.
Health indicator custom :
@Component
public class ExternalServiceHealthIndicator implements HealthIndicator {
@Override
public Health health() {
if (externalService.isReachable()) {
return Health.up().withDetail("latency", "12ms").build();
}
return Health.down().withDetail("reason", "timeout").build();
}
}
// Readiness custom
@Component
public class MigrationReadinessIndicator implements ReadinessStateHealthIndicator {
@Override
protected void doHealthCheck(Health.Builder builder) throws Exception {
builder.status(migrationComplete ? Status.UP : Status.OUT_OF_SERVICE);
}
}java🚨 Jamais de SELECT lourd dans un health indicator
Un SELECT COUNT(*) FROM large_table dans health() est appelé toutes les 5-10 secondes par Kubernetes. Sur une table de 10M lignes, c'est un scan complet toutes les 5 secondes — dégradation garantie en prod.
@CacheEvict — invalidation automatique dans le code métier :
// Au chargement : mise en cache
@Cacheable("stocks")
public StockDto getStock(Long id) {
return stockRepository.findById(id);
}
// À la modification : invalidation automatique
@CacheEvict(value = "stocks", key = "#id")
public void updateStock(Long id, UpdateStockRequest req) {
stockRepository.save(...);
}
// Vider tout le cache
@CacheEvict(value = "stocks", allEntries = true)
public void clearStockCache() {}
// Plusieurs caches
@Caching(evict = {
@CacheEvict(value = "stocks", allEntries = true),
@CacheEvict(value = "stockSummaries", allEntries = true)
})
public void onBulkUpdate() {}javaActuator — invalidation manuelle (admin, debug) :
# Lister les caches
GET /actuator/caches
# Vider un cache complet
DELETE /actuator/caches/stocks
# Vider une entrée
DELETE /actuator/caches/stocks?key=42bash| @CacheEvict | Actuator /caches | |
|---|---|---|
| Déclencheur | Appel de méthode métier | HTTP manuel |
| Usage | Cohérence automatique | Debug, admin ponctuel |
| Logique métier | ✅ possible | ❌ non |
| Sécurité | N/A | ✓ à protéger (Basic Auth, OAuth2) |
⚡ TL;DR — chaque concept en une ligne
application.yml + profiles
✓ Centre de pilotage runtime — ports, timeouts, features, comportement Spring. Profiles dev/qa/prod pour des comportements différenciés sans changer le code.
⚠ Jamais de valeur environnement-dépendante en dur dans le code. Jamais de secrets dans Git.
Graceful shutdown
✓ server.shutdown: graceful + timeout-per-shutdown-phase: 30s — Spring cesse d'accepter les nouvelles connexions, attend que les requêtes en cours terminent, puis éteint la JVM.
⚠ Les threads @Async et thread pools custom ne sont PAS attendus par défaut — configurer setWaitForTasksToCompleteOnShutdown(true) explicitement.
Liveness vs Readiness ✓ Liveness = "la JVM est-elle vivante ?" (si DOWN → Kubernetes redémarre le pod). Readiness = "l'app peut-elle recevoir du trafic ?" (si DOWN → retrait du load balancer sans redémarrage). ⚠ Ne jamais mettre une dépendance externe (DB, Kafka) en liveness — sinon DB down = restart loop infini au lieu d'un graceful dégradé.
@CacheEvict
✓ Invalide automatiquement le cache quand une méthode est appelée — cohérence garantie entre cache et base, sans intervention manuelle.
⚠ Actuator /actuator/caches permet l'invalidation manuelle (debug, admin), mais ne remplace pas @CacheEvict pour la cohérence métier automatique.
🎓 À retenir
timeout-per-shutdown-phase: 30s est un plafond, pas une durée — si toutes les requêtes finissent en 2s, Spring s'arrête en 2s. La valeur ne force pas une attente, elle plafonne le temps maximum.cache.gets, cache.misses) révèlent des problèmes de sizing — un hit ratio < 50% signifie souvent que le TTL est trop court ou que la clé de cache n'est pas assez discriminante. À monitorer en prod comme n'importe quelle métrique de performance.