Un rapport d'Akamas publié en 2026 tire une conclusion brutale : les applications Java sur Kubernetes avec leurs configurations par défaut laissent entre 40 et 60% de leurs performances sur la table. Ce n'est pas un problème de code ou d'architecture — c'est un problème de configuration JVM mal adaptée aux contraintes du monde conteneurisé. Ce guide explique pourquoi, et comment y remédier.
Le problème fondamental : la JVM n'a pas été conçue pour les conteneurs
La JVM a été conçue dans un monde où une application tournait sur une machine physique dédiée. Elle lit les ressources disponibles au démarrage (nombre de CPU, taille de la RAM) et s'y adapte. Dans Kubernetes, cette détection pose trois problèmes critiques.
Problème 1 : La JVM voit les ressources du nœud, pas du conteneur
Sans configuration explicite, la JVM détecte les ressources du nœud Kubernetes, pas celles allouées au pod. Sur un nœud de 64 cœurs et 128 Go de RAM, une JVM dans un pod limité à 2 cœurs et 4 Go voit... 64 cœurs et 128 Go.
Conséquence : le heap par défaut (1/4 de la RAM vue) est calculé à 32 Go au lieu de 1 Go. La JVM alloue un heap massif, déclenche des GC sur des données énormes, et crase l'OOMKiller de Kubernetes.
Solution : depuis Java 8u191+, les flags ergonomiques conteneur-aware sont activés par défaut (-XX:+UseContainerSupport). Mais vérifiez que votre image de base les active.
# Vérifier que le support conteneur est actif
java -XshowSettings:all -version 2>&1 | grep "Active CPUs"
# Doit afficher les CPUs du conteneur, pas du nœud
Problème 2 : CPU throttling invisible et dévastateur
Kubernetes implémente les limites CPU via CFS (Completely Fair Scheduler) quota. Quand votre pod atteint sa limite CPU, le nœud le "throttle" — suspend ses threads pour le reste de la période de scheduling (généralement 100ms).
La JVM multi-threadée est particulièrement sensible au throttling : un thread GC throttlé bloque tous les threads applicatifs en attente. Le résultat est des latences inexpliquées qui ne sont visibles ni dans vos métriques JVM, ni dans vos logs applicatifs.
# Détecter le CPU throttling (à lancer sur le nœud K8s)
cat /sys/fs/cgroup/cpu/cpu.stat | grep throttled
# throttled_time élevé = problème de configuration CPU
Solution : ne jamais mettre des requests CPU très inférieures aux limits. Un ratio requests/limits > 0.5 est recommandé pour les applications Java.
resources:
requests:
cpu: "1000m" # requests proches des limits
memory: "2Gi"
limits:
cpu: "2000m" # ratio 1:2 acceptable
memory: "2Gi" # memory: requests = limits (obligatoire)
Problème 3 : Memory limits trop sévères sans tuning du heap
La mémoire d'un processus Java se décompose en plusieurs zones : heap, metaspace, code cache, thread stacks, et mémoire native (JNI, allocations directes). Le heap n'est qu'une partie.
Définir -Xmx2g dans un pod limité à 2 Go mène inévitablement à un OOMKill : les ~300-400 Mo de overhead JVM s'ajoutent au heap et dépassent la limite.
# MAUVAIS : -Xmx non calculé par rapport au limit
env:
- name: JAVA_OPTS
value: "-Xmx2g"
resources:
limits:
memory: "2Gi" # OOMKill garanti
Configuration optimale pour Spring Boot sur Kubernetes
Template de déploiement production-ready
apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-boot-app
spec:
template:
spec:
containers:
- name: app
image: myapp:latest
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:+UseZGC
-XX:+ZGenerational
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps/heap-$(date +%Y%m%d-%H%M%S).hprof
-Djdk.nio.maxCachedBufferSize=262144
-Dfile.encoding=UTF-8
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2000m"
memory: "1Gi" # requests = limits pour la mémoire
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60 # Laisser le temps à la JVM de warm-up
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
volumeMounts:
- name: dumps
mountPath: /dumps
volumes:
- name: dumps
emptyDir: {}
Choisir le bon GC pour Kubernetes
| GC | Profil | Cas d'usage K8s |
|-----|--------|-----------------|
| ZGC (Generational) | Très faibles pauses (<1ms), throughput élevé | Microservices, APIs REST à faible latence |
| G1GC | Équilibré pauses/throughput | Applications batch mixtes |
| SerialGC | Faible overhead mémoire | Pods avec <256 Mo heap |
| Epsilon | Aucune collecte (profiling) | Tests de performance uniquement |
Pour Java 25 sur Kubernetes : ZGC Generational est la recommandation par défaut pour les applications Spring Boot. Il gère bien les containers à mémoire contrainte et ses pauses sous-milliseconde évitent les timeouts de liveness probe.
# Activer ZGC Generational (Java 25 default depuis 25.0.1)
-XX:+UseZGC -XX:+ZGenerational
Tuning du GC pour les profils Kubernetes spécifiques
Profil microservice faible latence (< 10ms P99)
JAVA_TOOL_OPTIONS="\
-XX:+UseZGC \
-XX:+ZGenerational \
-XX:MaxRAMPercentage=70.0 \
-XX:ZCollectionInterval=120 \
-XX:ConcGCThreads=2 \
-XX:+DisableExplicitGC"
Profil traitement batch haute charge
JAVA_TOOL_OPTIONS="\
-XX:+UseG1GC \
-XX:MaxRAMPercentage=80.0 \
-XX:G1HeapRegionSize=16m \
-XX:MaxGCPauseMillis=200 \
-XX:G1NewSizePercent=30 \
-XX:+ParallelRefProcEnabled"
Profil pod ultra-contraint (< 512 Mo)
JAVA_TOOL_OPTIONS="\
-XX:+UseSerialGC \
-XX:MaxRAMPercentage=60.0 \
-XX:TieredStopAtLevel=1 \
-Xss256k"
AOT Profiling en environnement Kubernetes
L'AOT profiling de Java 25 se combine bien avec Kubernetes si vous gérez correctement la persistance des profils :
# PersistentVolume pour stocker les profils AOT entre redémarrages
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jvm-profiles
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
# Dans le déploiement
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:AOTProfilingFile=/profiles/app.aprof"
volumeMounts:
- name: jvm-profiles
mountPath: /profiles
Première exécution : le profil est créé. Tous les redémarrages suivants bénéficient du warm-up accéléré.
Virtual Threads et Kubernetes : synergies
Les Virtual Threads de Java 21/25 changent le dimensionnement Kubernetes. Avec des threads classiques, une application qui gère 200 requêtes concurrentes a besoin de ~200 threads OS, soit ~200 Mo de stack memory.
Avec les Virtual Threads :
- Même 200 requêtes concurrentes
- Seulement N threads carrier (N = nombre de vCPU)
- Stack memory : proportionnelle aux vCPU, pas aux concurrences
Impact sur les requests/limits CPU : avec les Virtual Threads, vous pouvez augmenter la concurrence gérée sans augmenter les limits CPU. Réduire les requests CPU tout en maintenant le débit est possible.
# Avant Virtual Threads : 2 vCPU pour 100 req/s concurrent
resources:
requests:
cpu: "2000m"
Monitoring JVM dans Kubernetes
Sans observabilité JVM, le tuning est aveugle. Le stack recommandé :
# Actuator + Prometheus + Grafana
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
metrics:
export:
prometheus:
enabled: true
Métriques JVM critiques à surveiller dans Grafana :
- jvm_gc_pause_seconds : durée des pauses GC
- jvm_memory_used_bytes{area="heap"} : utilisation heap
- process_cpu_usage : CPU consommé par le process Java
- jvm_threads_live_threads : nombre de threads actifs
- system_cpu_count : vCPU vus par la JVM (doit correspondre au limit K8s)
Pour aller plus loin sur l'observabilité, consultez notre guide [OpenTelemetry en production](/observabilite-opentelemetry-production/) et notre article sur [Kubernetes pour les développeurs](/kubernetes-pour-developpeurs/).