Le pod redémarre. kubectl get pod affiche OOMKilled, le code de sortie est 137. Pourtant, les logs ne montrent aucune OutOfMemoryError, et le heap était loin d’être plein. Ce n’est pas la JVM qui a manqué de mémoire : c’est le conteneur. Le noyau Linux a tué le processus parce qu’il a dépassé sa limite.

Cet article montre ce que la JVM voit dans un conteneur, ce qui s’ajoute au heap, comment reproduire le kill, et comment régler la mémoire pour que tout tienne. Tous les exemples sont dans le dossier containers/ du dépôt public java-gc-lab. Ils utilisent les images officielles eclipse-temurin : pas d’image à construire.

Les commandes Docker de cet article

Si Docker vous est peu familier, voici les options utilisées plus bas.

OptionCe qu’elle fait
docker run <image> <commande>Démarre un conteneur à partir de l’image et y lance la commande.
--rmSupprime le conteneur quand il s’arrête.
--name over-limitDonne un nom au conteneur, pour les commandes suivantes.
--memory=512mLimite de mémoire du conteneur. C’est cette limite que la JVM lit.
--memory-swap=512mLimite pour mémoire + swap. Même valeur que --memory : pas de swap.
--cpus=2Limite de CPU : l’équivalent de 2 CPU de temps.
-v "$PWD/containers":/appMonte le dossier local containers/ en /app dans le conteneur.
-dLance le conteneur en arrière-plan.
docker inspect <nom>Montre l’état d’un conteneur arrêté : code de sortie, OOMKilled.
docker exec <nom> <commande>Lance une commande dans un conteneur qui tourne.
docker logs <nom>Montre ce que le conteneur a affiché.

Attention au swap : avec --memory seul, Docker donne en plus autant de swap. java -XshowSettings:system le montre : « Memory Limit: 1.00G », « Memory & Swap Limit: 2.00G ». Le conteneur utilise alors le swap au lieu d’être tué à la limite. Pour reproduire ce qui se passe dans Kubernetes, je mets --memory-swap à la même valeur.

Ce que la JVM voit dans un conteneur

Sous Linux, la JVM lit les limites du conteneur (les cgroups) : la mémoire et le nombre de CPU. C’est l’option -XX:+UseContainerSupport, active par défaut. Ces limites servent à calculer la taille du heap, le choix du GC et le nombre de threads.

ShowLimits.java affiche ce que la JVM a choisi :

docker run --rm --memory=1g --cpus=2 -v "$PWD/containers":/app eclipse-temurin:25 java /app/ShowLimits.java

CPUs: 2
Max heap: 256 MiB
GC: Copy, MarkSweepCompact

« Copy, MarkSweepCompact », c’est le GC Serial. Avec d’autres limites, mêmes résultats en Java 17, 21 et 25 :

--memory--cpusHeap maxGC
256m1126 MioSerial
1g2256 MioSerial
1791m2448 MioSerial
1792m2448 MioG1
4g21024 MioG1

La JVM donne elle-même ces valeurs, sans programme :

docker run --rm --memory=1g --cpus=2 eclipse-temurin:25 java -XX:+PrintFlagsFinal -version | grep -E " MaxHeapSize | UseSerialGC | UseG1GC "
docker run --rm --memory=1g --cpus=2 eclipse-temurin:25 java -XshowSettings:system -version

La première affiche les options calculées (MaxHeapSize = 268435456, UseSerialGC = true). La deuxième montre les limites lues dans le conteneur : « Provider: cgroupv2 », « Effective CPU Count: 2 », « Memory Limit: 1.00G ».

Le heap par défaut : 25 %, ou 50 % pour un petit heap

Sans -Xmx, le heap maximum vaut 25 % de la mémoire vue par la JVM : c’est -XX:MaxRAMPercentage, 25 par défaut. Dans un conteneur de 1 Go, cela fait 256 Mo. Les 75 % restants ne sont pas tous libres : le reste de la JVM en a besoin, on le verra plus bas.

Pour un petit heap, une autre option prend le relais : -XX:MinRAMPercentage, 50 par défaut. La documentation parle d’un heap « d’environ 125 Mo ». C’est ce que montre le tableau : avec 256 Mo, le heap fait 126 Mo, pas 64.

Les deux options se règlent au lancement :

java -XX:MaxRAMPercentage=50 -jar app.jar

Un pourcentage suit la limite du conteneur : si la limite passe de 1 à 2 Go, le heap suit. Un -Xmx fixe ne bouge pas (voir les options JVM qui comptent).

Serial ou G1 : une question de CPU et de mémoire

Le tableau a une surprise : avec 2 CPU et 1 Go, la JVM choisit Serial, pas G1. Le guide d’Oracle sur le GC donne la règle : G1 sur une machine « server-class », Serial sinon. Une machine est « server-class » si la JVM voit « deux processeurs ou plus et une mémoire supérieure ou égale à 1792 Mo ». Les tests le confirment : Serial à 1791 Mo, G1 à 1792 Mo.

Serial arrête l’application pendant chaque GC, avec un seul thread. Pour un service avec peu de mémoire, ce n’est pas forcément un problème. Mais il faut le savoir, et le choisir. Pour imposer G1 : -XX:+UseG1GC.

Le nombre de CPU change aussi le nombre de threads du GC. Avec 2 CPU, G1 a 2 threads parallèles et 1 thread concurrent. L’option -XX:ActiveProcessorCount=<n> remplace le nombre de CPU que la JVM utilise pour ces calculs. La documentation la présente comme utile « quand plusieurs processus Java tournent dans des conteneurs docker ».

Et la demande de CPU de Kubernetes ? Dans un pod avec requests.cpu: 1 et sans limite de CPU, la JVM voit tous les CPU de la machine : 12 sur ma machine. Même chose avec l’option Docker --cpu-shares (le paramètre cgroup « cpu.shares »). Une limite, elle, réduit le nombre de CPU vus : avec limits.cpu: "2" (ou --cpus=2), la JVM en voit 2.

Avant Java 19 : la JVM utilisait aussi « cpu.shares » pour compter les CPU, et pouvait en voir moins que disponible. Java 19 l’ignore (JDK-8281181). Dans mes tests, Temurin 17.0.20 l’ignore aussi.

Ce qui s’ajoute au heap

La limite du conteneur couvre tout le processus, pas seulement le heap :

  • le Metaspace : la description des classes chargées ;
  • les piles des threads : par défaut 1024 Ko par thread sur Linux/x64, et 2048 Ko sur Linux/Aarch64 (par exemple Docker sur un Mac Apple Silicon) ;
  • le code cache : le code compilé par le JIT ;
  • la mémoire directe : les ByteBuffer.allocateDirect. Sans -XX:MaxDirectMemorySize, la JVM choisit la limite elle-même ;
  • les données du GC et d’autres zones internes.

Un heap à 75 % de la limite laisse 25 % pour tout cela. Si le reste dépasse, le conteneur est tué, même avec un heap à moitié vide.

Reproduire le kill

OverLimit.java garde 250 Mo dans le heap, puis prend de la mémoire directe par blocs de 10 Mo, sans fin. Conteneur de 512 Mo sans swap, heap max à 75 % (384 Mo) :

docker run --name over-limit --memory=512m --memory-swap=512m -v "$PWD/containers":/app eclipse-temurin:25 java -XX:MaxRAMPercentage=75 /app/OverLimit.java

Heap: 250 MB used
Direct memory: 10 MB
...
Direct memory: 170 MB

La sortie s’arrête net. Pas d’OutOfMemoryError, pas de pile : le processus a été tué. docker inspect confirme :

docker inspect over-limit --format "exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}"

exit=137 OOMKilled=true

137, c’est 128 + 9 : le processus a reçu le signal 9 (SIGKILL), qu’on ne peut pas intercepter. La JVM n’a rien pu écrire, et -XX:+HeapDumpOnOutOfMemoryError ne sert à rien ici : il n’y a pas eu d’OutOfMemoryError. Selon la version, le kill arrive entre 170 et 190 Mo de mémoire directe.

Voir où part la mémoire avec NMT

Native Memory Tracking (NMT) compte la mémoire de la JVM par zone. Je relance avec NMT, et l’argument 100 arrête la mémoire directe à 100 Mo ; le programme attend ensuite 30 secondes :

docker run -d --name over-limit --memory=512m --memory-swap=512m -v "$PWD/containers":/app eclipse-temurin:25 java -XX:MaxRAMPercentage=75 -XX:NativeMemoryTracking=summary /app/OverLimit.java 100
docker exec over-limit jcmd 1 VM.native_memory summary scale=MB

Total: reserved=1875MB, committed=533MB
-                 Java Heap (reserved=384MB, committed=384MB)
-                     Class (reserved=1024MB, committed=2MB)
-                    Thread (reserved=26MB, committed=1MB)
-                      Code (reserved=245MB, committed=9MB)
-                     Other (reserved=100MB, committed=100MB)
-                 Metaspace (reserved=64MB, committed=9MB)

(J’ai enlevé quelques lignes.) 1 est le numéro du processus Java dans le conteneur. Le heap occupe 384 Mo, et les 100 Mo de mémoire directe sont dans « Other ». Ce petit programme a peu de classes et de threads ; une vraie application a plus de Metaspace, de threads et de code compilé. docker stats --no-stream over-limit montre la mémoire que le conteneur utilise vraiment : environ 430 Mio dans mes essais, sur 512.

Mieux qu’un kill : une erreur Java

Avec une limite sur la mémoire directe, la JVM s’arrête avant le conteneur, avec un message clair :

docker run --rm --memory=512m --memory-swap=512m -v "$PWD/containers":/app eclipse-temurin:25 java -XX:MaxRAMPercentage=75 -XX:MaxDirectMemorySize=100m /app/OverLimit.java

Exception in thread "main" java.lang.OutOfMemoryError: Cannot reserve 10485760 bytes of direct buffer memory (allocated: 104857600, limit: 104857600)

Une erreur avec une pile dit ce qui est plein. Un kill ne dit rien. Les autres messages d’OutOfMemoryError sont dans l’article sur les OutOfMemoryError.

Choisir MaxRAMPercentage

Il n’y a pas de bon pourcentage pour toutes les applications. La règle est simple : le heap maximum plus tout le reste doit tenir dans la limite. Le reste se mesure avec NMT, sur l’application en charge.

Dans l’exemple, le programme a besoin de 250 Mo de heap et de 200 Mo de mémoire directe. Avec 768 Mo et 50 % :

docker run --name over-limit --memory=768m --memory-swap=768m -v "$PWD/containers":/app eclipse-temurin:25 java -XX:MaxRAMPercentage=50 /app/OverLimit.java 200

Done: 250 MB heap, 200 MB direct, waiting 30 s

docker inspect donne exit=0 OOMKilled=false. Le heap peut monter à 384 Mo (50 % de 768), et il reste 384 Mo pour la mémoire directe et le reste de la JVM. Plus une application utilise de mémoire hors heap (threads, mémoire directe, beaucoup de classes), plus le pourcentage doit être bas.

Dans Kubernetes

Dans un pod, chaque conteneur a des requests et des limits. D’après la documentation de Kubernetes, la demande (requests) sert à choisir le nœud où placer le pod ; la limite (limits) est appliquée par le noyau. Pour la mémoire, un conteneur qui dépasse sa limite peut être tué (OOM kill). Pour le CPU, il est ralenti.

La limite de mémoire est donc celle que la JVM lit. Sans limits.memory, la JVM voit la mémoire du nœud : dans un pod avec seulement requests.memory: 1Gi, le heap max fait 1984 Mio sur ma machine (25 % des 7,7 Go de la VM de Docker), bien plus que la demande.

Pour l’exemple, le fichier Java arrive dans le pod par une ConfigMap : pas d’image à construire. Le manifeste pod-before.yaml reprend le cas tué :

      command: ["java", "-XX:MaxRAMPercentage=75", "/app/OverLimit.java"]
      resources:
        requests:
          memory: "512Mi"
          cpu: "1"
        limits:
          memory: "512Mi"

Les commandes kubectl utilisées :

CommandeCe qu’elle fait
kubectl create configmap over-limit-code --from-file=containers/OverLimit.javaRange le fichier Java dans le cluster ; le pod le monte dans /app.
kubectl apply -f containers/pod-before.yamlCrée le pod décrit dans le fichier.
kubectl get pod over-limitMontre l’état du pod.
kubectl describe pod over-limitMontre le détail : état, raison, code de sortie, limites.
kubectl logs over-limitMontre ce que le programme a affiché.
kubectl delete pod over-limitSupprime le pod.
kubectl create configmap over-limit-code --from-file=containers/OverLimit.java
kubectl apply -f containers/pod-before.yaml
kubectl get pod over-limit

NAME         READY   STATUS      RESTARTS   AGE
over-limit   0/1     OOMKilled   0          7s

kubectl describe pod over-limit

    State:          Terminated
      Reason:       OOMKilled
      Exit Code:    137

pod-after.yaml reprend la taille qui tient : limits.memory: 768Mi, -XX:MaxRAMPercentage=50. Après kubectl delete pod over-limit et kubectl apply -f containers/pod-after.yaml, le pod finit en Completed, code de sortie 0.

Ce qui a changé

Ce qu’on lit encoreCe qui est vrai aujourd’huiDepuis
-XX:+UseCGroupMemoryLimitForHeap pour que Java voie la limiteRien à ajouter : la JVM lit les limites seule (UseContainerSupport) ; l’ancienne option est refuséeJDK 10, et Java 8u191 ; option supprimée en Java 11
-XX:MaxRAMFraction=4-XX:MaxRAMPercentage=25 (et InitialRAMPercentage, MinRAMPercentage)JDK 10
« cpu.shares » change le nombre de CPU vus par Java« cpu.shares » est ignoréJava 19 (Temurin 17.0.20 aussi, dans mes tests)

Testé avec Docker Desktop 29.8 (VM Linux aarch64, cgroup v2), son Kubernetes (v1.36), et les images eclipse-temurin 17.0.20, 21.0.12 et 25.0.4.

Sources : la documentation de la commande java (JDK 25), le guide d’Oracle sur le GC, partie « Ergonomics », les notes de version du JDK 10, de Java 8u191 et du JDK 19, et la documentation de Kubernetes (Resource Management for Pods and Containers, Assign Memory Resources to Containers and Pods).

Pour aller plus loin