Contexte et observations sur sept jours
L'analyse des métriques d'une semaine révèle un profil de mémoire et de ramasse-miettes précis. Le taux d'occupation du CPU reste faible, autour de 10 %, ce qui laisse une marge de manœuvre importante. La mémoire est globalement peu sollicitée, avec une zone Old peuplée mais disposant encore d'espace disponible. En revanche, la zone Eden présente une activité de collecte très intense. Le nombre de cycles GC est élevé et la latence cumulée est significative. Une seule occurrence de Full GC a été identifiée sur la période.
Hypothèses d'optimisation
Plusieurs leviers apparaissent : le CPU est sous-utilisé, la majorité des objets meurent dans Eden avant d'atteindre Old, et le ratio actuel entre les générations favorise une promotion prématurée vers Old. La Metaspace est également surdimensionnée. Le réglage initial est le suivant :
HEAP_SIZE="5734m"
YOUNG_SIZE="2120m"
META_SIZE="512m"
JAVA_OPTS="-Xmx${HEAP_SIZE} -Xms${HEAP_SIZE} \
-XX:+UseConcMarkSweepGC \
-XX:+UseParNewGC \
-Xmn${YOUNG_SIZE} \
-XX:MetaspaceSize=${META_SIZE} \
-XX:MaxMetaspaceSize=${META_SIZE} \
-XX:+PrintGCTimeStamps \
-XX:+PrintGCDateStamps \
-XX:+PrintGCDetails"
Les objectifs sont de réduire la fréquence des collections Eden, de mieux calibrer les espaces Survivor et de réserver suffisamment de mémoire non-heap pour l'OS et les caches natifs.
Calcul de la nouvelle configuraton
Le pod dispose de 7,81 Go de mémoire. En réservant environ 0,81 Go pour la mémoire non-heap et le système d'exploitation, il est possible d'allouer 7 Go au heap. Après un Full GC, les objets résiduels dans Old occupent environ 785 Mo. En prévoyant un facteur de sécurité de trois pour absorber les pics de charge et les déchets flottants, l'espace Old nécessaire est d'environ 2,4 Go. Le reste peut être attribué à la génération jeune.
Concernant les Survivor, l'occupation moyenne observée est de 22,5 Mo. Avec un ratio Eden/Survivor de 8:1:1, presque tous les objets disparaissent avant un second passage. Un ratio 16:1:1 aurait donné environ 267 Mo par Survivor, ce qui permet de faire survivre les objets jusqu'à environ quinze cycles de filtrage avant promotion. Pour commencer de manière conservatrice, le ratio est fixé à 12 en attendant les retours de production.
Comme chaque Minor GC collecte désormais un volume plus important, il faut accélérer la collecte. Avec quatre cœurs disponibles, le paramètre -XX:ParallelGCThreads=4 aligne le nombre de threads de GC sur les ressources CPU. La Metaspace est ramenée à 256 Mo, la charge observée n'étant que de 156 Mo et l'application ne générant pas de classes dynamiques.
La configuraton résultante est la suivante :
HEAP_MAX="7168m"
YOUNG_MAX="4813m"
SURVIVOR_RATIO="12"
META_MAX="256m"
GC_THREADS="4"
JAVA_OPTS="-Xmx${HEAP_MAX} -Xms${HEAP_MAX} \
-XX:+UseConcMarkSweepGC \
-XX:+UseParNewGC \
-Xmn${YOUNG_MAX} \
-XX:SurvivorRatio=${SURVIVOR_RATIO} \
-XX:MetaspaceSize=${META_MAX} \
-XX:MaxMetaspaceSize=${META_MAX} \
-XX:ParallelGCThreads=${GC_THREADS} \
-XX:+PrintGCTimeStamps \
-XX:+PrintGCDateStamps \
-XX:+PrintGCDetails \
-XX:+PrintTenuringDistribution"
Validatino par tests de charge
Les changements de paramètres JVM doivent être déployés de manière isolée, en dehors des fenêtres de livraison. L'environnement de test reproduit la configuration Kubernetes et les options JVM de la production. L'interface choisie pour le benchmark est un endpoint de synchronisation de données, représentatif d'un usage intensif de la mémoire sans pour autant créer de gros objets résiduels.
Le scénario JMeter est configuré ainsi : 1000 utilisateurs virtuels, montée en charge sur 5 secondes et 10 itérations par utilisateur.
| Métrique | Avant | Après |
|---|---|---|
| Temps de réponse moyen | 37 880 ms | 36 920 ms |
| Temps de réponse maximal | 483 104 ms | 149 293 ms |
| Débit | 18,6 req/s | 22,8 req/s |
| Taux d'erreur | 0,03 % | 0,00 % |
Le débit augmente de 22,5 %, le temps de réponse maximal chute de 69 % et le taux d'erreur passe à zéro. Par ailleurs, le nombre de cycles GC est réduit de moitié.
Recommandations opérationnelles
La mémoire allouée au pod doit être répartie entre le heap, la Metaspace, le Code Cache et les besoins du système d'exploitation. La règle pratique consiste à garantir que le heap reste supérieur à la somme de la mémoire non-heap et des réserves OS.
Pour les applications dominées par des entrées-sorties et des objets éphémères, la taille de Old doit correspondre à environ trois fois l'occupation résiduelle mesurée juste après un Full GC. Le reste du heap peut être attribué à la génération jeune, ce qui limite la promotion prématurée. Le ratio entre Eden et Survivor doit être ajusté progressivement : trop petit, il provoque un débordement vers Old ; trop grand, il gaspille de l'espace.
Enfin, lorsque le volume collecté par Minor GC augmente, le nombre de threads parallèles doit suivre le nombre de cœurs CPU disponibles via -XX:ParallelGCThreads.