Régler la JVM en conteneur

Ça arrive tout le temps. Une appli tourne parfaitement en local, puis se fait tuer dès qu’elle passe en Kubernetes. Dans les événements du pod, un OOMKilled, et rien pour l’expliquer. Autre version : elle démarre, mais elle rame, et le code n’y est pour rien. La plupart du temps, le problème est ailleurs. La JVM lit mal les limites du conteneur, ou la heap a été taillée sans compter ce qu’il y a autour.

Il n’y a pourtant que quelques réglages à connaître. Le tout, c’est de savoir ce que la JVM voit vraiment quand elle démarre dans un conteneur.

Ce que la JVM voit d’un conteneur

Un conteneur, ce n’est pas une machine. C’est juste un processus isolé sur l’hôte, avec des limites de ressources posées par les cgroups du noyau. Le vieux piège : avant, la JVM ignorait ces limites et lisait la RAM et le nombre de cœurs de l’hôte entier. Sur un nœud de 64 Go et 32 cœurs, une JVM bridée à 512 Mo par le conteneur se croyait à l’aise et taillait ses structures pour toute la machine. Résultat, elle se faisait tuer.

C’est réglé depuis longtemps. À partir de JDK 10, la JVM lit les cgroups, et -XX:+UseContainerSupport est actif par défaut. Elle prend donc la limite mémoire et le quota CPU du conteneur, pas ceux de l’hôte. Sur n’importe quelle version courante (17, 21, ou la 25 sortie récemment), il n’y a rien à activer.

Reste un cas qui piège encore en prod : cgroup v2. Les systèmes récents (systemd moderne, Docker et Kubernetes sur noyau récent) utilisent cette version de la hiérarchie cgroup, et la JVM ne sait la lire que depuis JDK 15, avec un rétroportage en 11.0.16. Sur une 11 plus ancienne posée sur un hôte en cgroup v2, la détection échoue en silence et la JVM repart sur les chiffres de l’hôte. Si vos réglages ont l’air ignorés, commencez par regarder la version exacte du JDK.

La mémoire : 25 % par défaut, et tout le reste

C’est le réglage qui étonne le plus. En conteneur, on ne fixe pas la heap avec un -Xmx en dur, mais avec un pourcentage de la mémoire disponible :

-XX:MaxRAMPercentage=75.0

Et ce pourcentage vaut 25 % par défaut. Sur un conteneur de 1 Go, la heap plafonne donc à 256 Mo. Le reste, les trois quarts, ne sert à rien. Beaucoup d’équipes tournent comme ça sans le savoir, et paient de la RAM qu’elles n’utilisent pas. Il suffit de monter la valeur autour de 70 à 75 % pour récupérer cette marge.

Pourquoi pas 100 % ? Parce que la heap n’est qu’un morceau de ce que la JVM consomme. Il faut aussi de la place pour :

Si la heap monte jusqu’au plafond du conteneur, il ne reste plus rien pour le reste, et l’OOM-killer du noyau finit par tuer le processus. Gardez de la marge. Et comme pour le GC, mettez l’initial au même niveau que le max, histoire de réserver la mémoire tout de suite :

-XX:InitialRAMPercentage=75.0 -XX:MaxRAMPercentage=75.0

OOMKilled n’est pas OutOfMemoryError

Les deux se ressemblent, mais n’ont rien à voir. Les confondre fait perdre des heures.

Un java.lang.OutOfMemoryError: Java heap space vient de la JVM. La heap est pleine, le GC n’arrive plus à faire de la place, et une exception part dans le code. Vous avez une stack trace, souvent un heap dump, et le souci est du côté des objets Java. C’est le sujet de Diagnostiquer une fuite mémoire avec un heap dump.

Un OOMKilled (code de sortie 137, soit 128 + 9 pour SIGKILL) vient du noyau. Le RSS du processus a dépassé la limite mémoire du cgroup, et le conteneur est tué sur-le-champ. Pas d’exception, pas de stack trace, juste un processus qui disparaît. Là, le coupable est souvent en dehors de la heap : un métaspace qui gonfle, une fuite de mémoire native, trop de threads, ou une MaxRAMPercentage trop haute qui n’a rien laissé au reste.

Si le RSS grimpe alors que la heap se porte bien, activez le Native Memory Tracking pour voir où part la mémoire hors-heap :

-XX:NativeMemoryTracking=summary

Puis, sur le processus en marche :

jcmd <pid> VM.native_memory summary

Vous obtenez la répartition entre heap, métaspace, threads, code et mémoire interne. De quoi savoir quel poste déborde avant de toucher à quoi que ce soit.

Le CPU : availableProcessors pilote tout

La mémoire attire l’œil, mais la détection du CPU fait autant de dégâts, en plus discret. Runtime.availableProcessors() renvoie un nombre de CPU calculé à partir du quota du cgroup, arrondi au-dessus. Avec --cpus=1.5, la JVM voit 2 processeurs.

Ce chiffre ne sert pas qu’à votre code. Il fixe par défaut le nombre de threads du GC, la taille du ForkJoinPool commun, et plein de pools réglés sur le nombre de cœurs. Trop bas, le parallélisme s’écroule. Trop haut, vous lancez plus de threads que le conteneur ne peut en faire tourner, et le temps file en changements de contexte.

Si la détection se trompe, ou si vous voulez la forcer, il y a un flag pour ça :

-XX:ActiveProcessorCount=2

Il impose le nombre de CPU que voit la JVM, quoi qu’expose le cgroup. Pratique quand plusieurs JVM se partagent un gros nœud et que chacune surestime sa part.

Vérifier ce que la JVM a détecté

Ne réglez rien à l’aveugle. La JVM sait afficher ce qu’elle a lu du conteneur, avec le logging unifié :

java -Xlog:os+container=trace -version

La sortie donne la limite mémoire détectée, le quota et la période CPU, et le nombre de processeurs retenu. C’est le premier réflexe quand un réglage a l’air ignoré : on voit tout de suite si la JVM a bien compris les limites, ou si elle est repartie sur celles de l’hôte, ce qui trahit un souci de cgroup v2 ou de version.

En résumé

Regardez la version de votre JDK et son support de cgroup v2. Montez MaxRAMPercentage autour de 75 %, en laissant de la place pour le hors-heap. Apprenez à faire la différence entre un OOMKilled et un OutOfMemoryError. Surveillez le nombre de CPU que la JVM croit avoir. Et vérifiez le tout avec -Xlog:os+container=trace. Bien régler une JVM en conteneur n’a rien de compliqué. Il faut juste regarder ce qu’elle voit, au lieu de le deviner.