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.
| Option | Ce qu’elle fait |
|---|---|
docker run <image> <commande> | Démarre un conteneur à partir de l’image et y lance la commande. |
--rm | Supprime le conteneur quand il s’arrête. |
--name over-limit | Donne un nom au conteneur, pour les commandes suivantes. |
--memory=512m | Limite de mémoire du conteneur. C’est cette limite que la JVM lit. |
--memory-swap=512m | Limite pour mémoire + swap. Même valeur que --memory : pas de swap. |
--cpus=2 | Limite de CPU : l’équivalent de 2 CPU de temps. |
-v "$PWD/containers":/app | Monte le dossier local containers/ en /app dans le conteneur. |
-d | Lance 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 | --cpus | Heap max | GC |
|---|---|---|---|
256m | 1 | 126 Mio | Serial |
1g | 2 | 256 Mio | Serial |
1791m | 2 | 448 Mio | Serial |
1792m | 2 | 448 Mio | G1 |
4g | 2 | 1024 Mio | G1 |
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 :
| Commande | Ce qu’elle fait |
|---|---|
kubectl create configmap over-limit-code --from-file=containers/OverLimit.java | Range le fichier Java dans le cluster ; le pod le monte dans /app. |
kubectl apply -f containers/pod-before.yaml | Crée le pod décrit dans le fichier. |
kubectl get pod over-limit | Montre l’état du pod. |
kubectl describe pod over-limit | Montre le détail : état, raison, code de sortie, limites. |
kubectl logs over-limit | Montre ce que le programme a affiché. |
kubectl delete pod over-limit | Supprime 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 encore | Ce qui est vrai aujourd’hui | Depuis |
|---|---|---|
-XX:+UseCGroupMemoryLimitForHeap pour que Java voie la limite | Rien à ajouter : la JVM lit les limites seule (UseContainerSupport) ; l’ancienne option est refusée | JDK 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).