Kubernetes

Java sur Kubernetes en 2026 : pourquoi les réglages par défaut tuent vos performances

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

Jean-Michel Helem

Jean-Michel Helem

11 mars 2026 · 4 min de lecture

Java sur Kubernetes en 2026 : pourquoi les réglages par défaut tuent vos performances

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/).

Pour aller plus loin