OOM Docker dans les conteneurs : les limites mémoire qui empêchent les plantages silencieux

Cet article vous a aidé ?

Votre service « redémarre aléatoirement ». Il n’y a pas de trace, pas d’exception claire, pas de message d’au revoir. Une minute il sert du trafic ;
la suivante il est réapparu avec un PID neuf et les mêmes problèmes non résolus.

Dans neuf cas sur dix, le coupable est la mémoire : un conteneur a atteint sa limite, le noyau est intervenu, et le processus a été tué avec
l’enthousiasme d’un videur expulsant quelqu’un qui « veut juste parler ».

Ce que « OOM » signifie réellement dans Docker (et pourquoi ça ressemble à un plantage)

« OOM » n’est pas une fonctionnalité de Docker. C’est une décision du noyau Linux : Out Of Memory. Lorsque le système (ou un cgroup mémoire) ne peut pas satisfaire
une allocation, le noyau tente de récupérer de la mémoire. Si cela échoue, il tue quelque chose pour survivre.

Docker fournit simplement une arène pratique pour que cela se produise, parce que les conteneurs sont généralement placés dans des cgroups mémoire avec des
limites explicites. Une fois que vous fixez une limite, vous créez un petit univers où « plus de mémoire » peut se produire même si l’hôte dispose encore de beaucoup de RAM.
C’est le but : des frontières d’échec prévisibles plutôt qu’un service hors contrôle qui dévore le nœud.

La nuance clé : il existe deux grands scénarios OOM qui se ressemblent de l’extérieur.

  • OOM de cgroup (limite du conteneur atteinte) : le conteneur atteint sa limite mémoire, le noyau tue un ou plusieurs processus dans ce
    cgroup. L’hôte peut être parfaitement sain.
  • OOM système (OOM du nœud) : l’ensemble de l’hôte manque de mémoire. Le noyau tue alors des processus sur tout le système, y compris
    dockerd, containerd, et des innocents. C’est d’ici que vient l’expression « tout a dérapé en même temps ».

Dans les deux cas, le processus est généralement terminé avec SIGKILL. SIGKILL signifie pas de nettoyage, pas de « vidage des logs », pas d’arrêt gracieux. Votre appli
ne « plante » pas tant qu’elle est effacée de l’existence.

Une citation à garder dans votre canal d’incident, attribuée à Werner Vogels (idée paraphrasée) : Tout échoue ; le travail consiste à concevoir
des systèmes qui échouent bien et récupèrent rapidement.

Pourquoi vous obtenez parfois des échecs « silencieux »

Le silence n’est pas malveillant. C’est mécanique :

  • Le noyau tue le processus abruptement. Votre logger peut être mis en tampon. Vos dernières lignes peuvent ne jamais atteindre stdout/stderr.
  • Docker signale que le conteneur s’est arrêté. À moins d’interroger docker inspect ou de vérifier les logs du noyau, vous ne verrez peut‑être jamais « OOMKilled ».
  • Certains runtimes (ou points d’entrée) avalent les codes de sortie en redémarrant rapidement, vous laissant avec un service instable et peu de contexte.

Blague n°1 : Si vous pensez avoir « géré toutes les exceptions », félicitations — Linux vient de trouver une que vous ne pouvez pas attraper.

Faits et historique qui changent comment vous déboguez

Ce ne sont pas des trivia pour le plaisir. Chacun oriente la décision de dépannage dans la bonne direction.

  1. Les cgroups sont apparus des années avant que « conteneurs » ne deviennent un produit. Des ingénieurs de Google ont proposé les cgroups au milieu des années 2000 ; Docker
    les a simplement rendus accessibles. Implication : l’autorité c’est le noyau, pas Docker.
  2. Les premiers cgroups mémoire étaient conservateurs et parfois surprenants. Historiquement, la comptabilité mémoire et le comportement OOM des cgroups
    ont mûri sur plusieurs versions du noyau. Implication : « ça marche sur mon portable » peut être dû à une différence de version du noyau.
  3. cgroups v2 a changé les réglages. Sur de nombreuses distributions modernes, les fichiers de limite mémoire sont passés de memory.limit_in_bytes (v1)
    à memory.max (v2). Implication : vos scripts doivent détecter le mode utilisé.
  4. Le swap n’est pas de la « RAM supplémentaire », c’est une douleur différée. Le swap retarde l’OOM au prix de pics de latence et de contention. Implication :
    vous pouvez « corriger » un OOM en activant le swap et toujours générer une panne — juste plus lente.
  5. Le code de sortie 137 est un indice, pas un diagnostic. 137 signifie généralement SIGKILL (128+9). L’OOM en est une cause fréquente, mais pas la seule.
    Implication : confirmez avec les fichiers du cgroup / les logs du noyau.
  6. Le cache de pages est aussi de la mémoire. Le noyau utilise la mémoire libre pour le cache ; dans les cgroups, le cache de pages peut être imputé au cgroup selon
    les réglages et le noyau. Implication : des conteneurs à I/O intensif peuvent OOM « sans fuite ».
  7. L’overcommit est une politique, pas une promesse. Linux peut autoriser des allocations dépassant la mémoire physique (overcommit) puis refuser plus tard
    quand elles sont touchées. Implication : une grosse allocation peut réussir et provoquer un OOM plus tard sous charge.
  8. Le OOM killer choisit les victimes selon un score de gravité. Le noyau calcule un score ; les gros consommateurs de mémoire ayant une faible « importance » sont préférés.
    Implication : le processus tué peut ne pas être celui que vous attendiez.
  9. Les conteneurs n’isolent pas le noyau. Un OOM au niveau noyau peut encore emporter plusieurs conteneurs, ou le runtime lui‑même.
    Implication : la surveillance de l’hôte reste importante dans les environnements « containerisés ».

Comptabilité mémoire des cgroups : ce qui est compté, ce qui ne l’est pas, et pourquoi vous en soucier

Avant de toucher une limite, comprenez ce que le noyau prend en compte. Sinon vous « corrigerez » la mauvaise chose et continuerez à réveiller le SRE le week-end.

Catégories de mémoire à reconnaître

À l’intérieur d’un conteneur, l’utilisation mémoire n’est pas juste le heap. Les suspects habituels :

  • Mémoire anonyme : heap, stacks, arenas malloc, runtimes de langage, caches en mémoire.
  • Mémoire backed par fichier : fichiers mappés en mémoire, bibliothèques partagées, et cache de pages associé aux fichiers.
  • Cache de pages : lectures disque mises en cache ; accélère tout jusqu’à ce que cela tue.
  • Mémoire noyau : buffers réseau, allocations slab. La comptabilité varie selon les versions et le mode des cgroups.
  • Mémoire partagée : utilisation de /dev/shm ; courant pour navigateurs, bases de données et apps à fort IPC.

cgroups v1 vs v2 : ce qui change pour le comportement OOM

En cgroups v1, les réglages mémoire sont dispersés entre contrôleurs, et le contrôleur « memsw » (mémoire+swap) est optionnel. En v2, tout est plus unifié : memory.current, memory.max, memory.high, memory.swap.max, et un meilleur système d’événements.

Opérationnellement, la grande amélioration est memory.high en v2 : vous pouvez fixer une limite « d’amortissement » pour induire une pression de récupération avant d’atteindre la limite dure.
Ce n’est pas magique, mais c’est un vrai levier pour « dégrader plutôt que mourir ».

OOM à l’intérieur du conteneur vs OOM à l’extérieur

Si vous ne retenez qu’une chose : un OOM au niveau conteneur est généralement un problème de budget ; un OOM au niveau hôte est généralement un problème de capacité ou de sur‑engagement.

  • OOM conteneur : vous avez fixé --memory trop bas, vous avez oublié le cache de pages, votre appli fuit, ou vous avez un pic ponctuel
    (JIT warm‑up, remplissage de cache, compactage).
  • OOM hôte : trop de conteneurs avec des limites généreuses, pas de limites du tout, pression mémoire du nœud par des processus non‑conteneur, ou
    mauvaise configuration du swap.

Les limites mémoire qui comptent (et celles qui vous rassurent seulement)

Docker vous donne une poignée d’options qui semblent simples. Elles le sont. La partie compliquée est comment elles interagissent avec le swap,
le comportement de reclaim du noyau, et les schémas d’allocation de votre application.

Limite dure : --memory

C’est la ligne dans le sable. Si le conteneur la franchit et ne peut pas récupérer suffisamment, le noyau tue des processus dans ce cgroup.

Que faire :

  • Fixer une limite dure pour chaque conteneur en production. Aucune exception.
  • Laisser une marge au‑dessus de l’utilisation en régime permanent. Si votre service utilise 600MiB, ne mettez pas 650MiB et appelez ça « serré ». Appelez‑le « fragile ».

Politique de swap : --memory-swap

Le comportement de swap de Docker embrouille même les opérateurs expérimentés parce que la signification dépend du mode cgroup et du noyau, et parce que la valeur par défaut
n’est pas toujours ce que vous supposez.

Position pratique :

  • Si vous le pouvez, préférez peu ou pas de swap pour les services sensibles à la latence, et dimensionnez correctement la limite dure.
  • Si vous devez autoriser le swap, faites‑le intentionnellement et surveillez les major page faults et la latence. Le swap masque la pression mémoire jusqu’à ce que cela devienne un incendie.

Limites « souples » : réservations et memory.high en v2

Docker expose --memory-reservation, qui n’est pas un minimum garanti. C’est un indice, utilisé principalement pour des décisions d’ordonnancement dans certains
orchestrateurs et pour le comportement de reclaim. Sur les systèmes cgroups v2, memory.high est le véritable outil de « plafond souple ».

En termes simples : les réservations servent à planifier ; les limites dures servent à survivre ; memory.high sert à façonner le comportement.

Ce que vous devriez éviter

  • Conteneurs illimités sur un nœud partagé. Ce n’est pas « flexible ». C’est la roulette russe pour votre runtime.
  • Limites dures sans observabilité. Si vous ne mesurez pas la mémoire, vous ne choisissez qu’un moment pour échouer.
  • Fixer des limites égales au max heap JVM (ou à « l’usage attendu » Python) sans marge. Les runtimes, libs natives, threads et le cache de pages se moqueront de votre tableur.

Mode d’emploi pour un diagnostic rapide

C’est la séquence « je suis d’astreinte et il est 02:13 ». Ne réfléchissez pas trop. Commencez ici, obtenez un signal rapidement, puis creusez.

Première étape : confirmer qu’il s’agissait bien d’un OOM

  • Vérifiez l’état du conteneur : était‑il OOMKilled, est‑il sorti 137, ou s’agit‑il d’un autre SIGKILL ?
  • Vérifiez les logs du noyau pour des événements OOM liés à ce cgroup ou PID.

Deuxième étape : déterminer où l’OOM est survenu

  • Était‑ce un OOM de cgroup (limite conteneur) ou un OOM système (nœud épuisé) ?
  • Regardez la pression mémoire du nœud et l’utilisation des autres conteneurs au même instant.

Troisième étape : décider si c’est un problème de dimensionnement ou une fuite/pic problématique

  • Comparez l’utilisation en régime permanent à la limite. Si l’usage monte lentement jusqu’à la mort, suspectez une fuite.
  • Si ça meurt pendant un événement connu (déploiement, warm‑up du cache, job batch), suspectez un pic et un manque de marge.
  • Si ça meurt sous charge I/O, suspectez le cache de pages, mmap, ou tmpfs (/dev/shm).

Quatrième étape : corriger en sécurité

  • À court terme : augmentez la limite ou réduisez la concurrence pour arrêter l’hémorragie.
  • À moyen terme : ajoutez de l’instrumentation, capturez des dumps/profile de heap, et reproduisez.
  • À long terme : appliquez des limites partout ; définissez des budgets ; créez des alertes sur « approche de limite » et pas seulement sur les redémarrages.

Tâches pratiques : commandes, sorties et décisions (12+)

Celles‑ci sont destinées à être exécutées sur un hôte Docker. Certaines tâches nécessitent root. Elles produisent toutes des informations exploitables.

Task 1: Check if Docker thinks the container was OOMKilled

cr0x@server:~$ docker inspect -f '{{.Name}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Error={{.State.Error}}' api-1
/api-1 OOMKilled=true ExitCode=137 Error=

Ce que cela signifie : OOMKilled=true est votre preuve ; le code de sortie 137 indique SIGKILL.
Décision : Traitez‑le comme un dépassement de limite mémoire sauf si les logs du noyau montrent un OOM système.

Task 2: Check restart count and last finish time (correlate with load/deploy)

cr0x@server:~$ docker inspect -f 'RestartCount={{.RestartCount}} FinishedAt={{.State.FinishedAt}} StartedAt={{.State.StartedAt}}' api-1
RestartCount=6 FinishedAt=2026-01-02T01:58:11.432198765Z StartedAt=2026-01-02T01:58:13.019003214Z

Ce que cela signifie : Des redémarrages fréquents rapprochés indiquent généralement une boucle d’échec dure, pas un incident isolé.
Décision : Geler le pipeline de déploiement et collecter des données forensiques avant que le prochain redémarrage n’écrase tout.

Task 3: Read the kernel’s OOM narrative

cr0x@server:~$ sudo dmesg -T | tail -n 20
[Thu Jan  2 01:58:11 2026] Memory cgroup out of memory: Killed process 24819 (python) total-vm:1328452kB, anon-rss:812340kB, file-rss:12044kB, shmem-rss:0kB, UID:1000 pgtables:2820kB oom_score_adj:0
[Thu Jan  2 01:58:11 2026] oom_reaper: reaped process 24819 (python), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

Ce que cela signifie : « Memory cgroup out of memory » indique un OOM au niveau conteneur, pas au niveau hôte.
Décision : Concentrez‑vous sur les limites du conteneur et le comportement mémoire par conteneur, pas (encore) sur la capacité du nœud.

Task 4: Confirm whether the host experienced a system OOM

cr0x@server:~$ sudo dmesg -T | egrep -i 'out of memory|oom-killer|Killed process' | tail -n 10
[Thu Jan  2 01:58:11 2026] Memory cgroup out of memory: Killed process 24819 (python) total-vm:1328452kB, anon-rss:812340kB, file-rss:12044kB, shmem-rss:0kB, UID:1000 pgtables:2820kB oom_score_adj:0

Ce que cela signifie : Vous voyez un événement OOM de cgroup mais pas de préambule système « Out of memory: Kill process … ».
Décision : Vous n’avez probablement pas besoin d’évacuer le nœud ; il faut empêcher ce conteneur d’atteindre le mur.

Task 5: Check the container’s configured memory limits

cr0x@server:~$ docker inspect -f 'Memory={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}} MemoryReservation={{.HostConfig.MemoryReservation}}' api-1
Memory=1073741824 MemorySwap=1073741824 MemoryReservation=0

Ce que cela signifie : La limite dure est de 1GiB. La limite de swap égalise la mémoire (effectivement pas de swap au‑delà de la RAM pour ce cgroup).
Décision : Si le processus a besoin d’explosions occasionnelles au‑delà de 1GiB, augmentez la limite ou réduisez le pic.

Task 6: Identify cgroup version (v1 vs v2) to choose the right files

cr0x@server:~$ stat -fc %T /sys/fs/cgroup
cgroup2fs

Ce que cela signifie : cgroup2fs indique cgroups v2.
Décision : Utilisez memory.current, memory.max et memory.events pour des signaux précis.

Task 7: Map a container to its cgroup path and read current usage (v2)

cr0x@server:~$ CID=$(docker inspect -f '{{.Id}}' api-1); echo $CID
b1d6e0b6e4c7c14e2c8c3ad3b0b6e9b7d3c1a2f7d9f5d2e1c0b9a8f7e6d5c4b3
cr0x@server:~$ CG=$(systemctl show -p ControlGroup docker.service | cut -d= -f2); echo $CG
/system.slice/docker.service
cr0x@server:~$ sudo find /sys/fs/cgroup$CG -name "*$CID*" | head -n 1
/sys/fs/cgroup/system.slice/docker.service/docker/b1d6e0b6e4c7c14e2c8c3ad3b0b6e9b7d3c1a2f7d9f5d2e1c0b9a8f7e6d5c4b3
cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.current
965312512

Ce que cela signifie : Environ 920MiB utilisés maintenant (octets). Si la limite est 1GiB, vous êtes proche.
Décision : Si c’est un état stable, augmentez la limite. Si ça monte, commencez une investigation fuite/pic.

Task 8: Check memory limit and OOM events counter (v2)

cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.max
1073741824
cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.events
low 0
high 12
max 3
oom 3
oom_kill 3

Ce que cela signifie : Le cgroup a atteint memory.max trois fois ; trois kills OOM ont eu lieu. high non nul indique une pression de reclaim répétée.
Décision : Ce n’est pas un accident. Soit augmentez le budget, soit réduisez les demandes de mémoire de pointe (ou les deux).

Task 9: See which processes in the container are using memory

cr0x@server:~$ docker top api-1 -o pid,ppid,cmd,rss
PID    PPID   CMD                          RSS
25102  25071  python /app/server.py         612m
25134  25102  python /app/worker.py         248m
25160  25102  /usr/bin/ffmpeg -i pipe:0     121m

Ce que cela signifie : Le processus principal plus un worker et un binaire natif (ffmpeg) partagent le budget mémoire du conteneur.
Décision : Si le sous‑processus « style sidecar » est sans borne, limitez la concurrence ou déplacez‑le dans son propre conteneur avec ses propres limites.

Task 10: Check container memory usage trend quickly (Docker stats)

cr0x@server:~$ docker stats --no-stream api-1
CONTAINER ID   NAME    CPU %     MEM USAGE / LIMIT     MEM %     NET I/O     BLOCK I/O   PIDS
b1d6e0b6e4c7   api-1   184.21%   972.4MiB / 1GiB       94.96%    1.3GB/1.1GB  2.8GB/1.9GB  28

Ce que cela signifie : Le conteneur tourne à ~95% de sa limite. Ce n’est pas « efficace », c’est « à une requête du meurtre ».
Décision : Augmentez la limite immédiatement ou réduisez la charge. Ensuite, enquêtez pourquoi c’est si proche.

Task 11: Differentiate anonymous memory vs file cache (cgroup v2 memory.stat)

cr0x@server:~$ sudo egrep 'anon|file|shmem|slab' /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.stat | head -n 20
anon 843018240
file 76292096
shmem 0
slab 31260672
file_mapped 21434368

Ce que cela signifie : La majeure partie de l’utilisation est de la mémoire anonyme (heap/stack/allocations natives), pas le cache de pages.
Décision : Concentrez‑vous sur les allocations applicatives : recherche de fuites, réglage du runtime, taille des payloads, concurrence.

Task 12: Verify whether /dev/shm is quietly too small (or too large)

cr0x@server:~$ docker exec api-1 df -h /dev/shm
Filesystem      Size  Used Avail Use% Mounted on
shm              64M   12M   52M  19% /dev/shm

Ce que cela signifie : La taille shm par défaut est 64MiB sauf configuration. Certaines charges (Chromium, extensions Postgres, inférence ML) ont besoin de plus.
Décision : Si l’appli utilise beaucoup de mémoire partagée, fixez --shm-size et intégrez‑le dans votre budget mémoire.

Task 13: Check host memory pressure (to rule out node OOM risk)

cr0x@server:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:            62Gi        49Gi       1.2Gi       1.1Gi        12Gi        8.4Gi
Swap:            0B          0B          0B

Ce que cela signifie : L’hôte a peu de « free » mais un « available » correct grâce au cache ; le swap est désactivé.
Décision : Le nœud n’est pas OOM actuellement, mais vous opérez avec des marges faibles. Ne lancez pas de conteneurs illimités.

Task 14: See per-container limits quickly to spot “unlimited” landmines

cr0x@server:~$ docker ps -q | xargs -n1 docker inspect -f '{{.Name}} mem={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}}'
/api-1 mem=1073741824 swap=1073741824
/worker-1 mem=0 swap=0
/cache-1 mem=536870912 swap=536870912

Ce que cela signifie : mem=0 signifie pas de limite mémoire. Ce conteneur peut consommer le nœud.
Décision : Corrigez d’abord le conteneur illimité. Un processus sans limite peut transformer un OOM conteneur en OOM nœud.

Task 15: Detect whether the container was killed but immediately restarted by policy

cr0x@server:~$ docker inspect -f 'RestartPolicy={{.HostConfig.RestartPolicy.Name}} MaxRetry={{.HostConfig.RestartPolicy.MaximumRetryCount}}' api-1
RestartPolicy=always MaxRetry=0

Ce que cela signifie : « always » restart rend les échecs bruyants uniquement si vous surveillez les redémarrages ; sinon il transforme l’OOM en « ça s’est réparé tout seul ».
Décision : Conservez les politiques de redémarrage, mais ajoutez des alertes sur le taux de redémarrages et l’état OOMKilled.

Task 16: Confirm the container’s view of memory (important for JVM/Go tuning)

cr0x@server:~$ docker exec api-1 sh -lc 'cat /proc/meminfo | head -n 3'
MemTotal:       65843056 kB
MemFree:         824512 kB
MemAvailable:   9123456 kB

Ce que cela signifie : Certains processus « voient » encore la mémoire de l’hôte via /proc/meminfo, selon le noyau/runtime.
Décision : Assurez‑vous que votre runtime est conscient des cgroups (les JVM modernes et Go le sont généralement). Sinon, fixez des drapeaux de heap explicites.

Trois mini-récits d’entreprise tirés des tranchées OOM

Mini-récit 1 : Un incident causé par une mauvaise hypothèse

Une entreprise SaaS de taille moyenne a migré quelques endpoints du monolithe vers un nouveau conteneur « API ». L’équipe a fixé une limite mémoire de 512MiB parce que
le service « traitait seulement du JSON » et que « la mémoire sert surtout aux bases de données ». Cette hypothèse a tenu environ une journée ouvrée.

Le service utilisait une pile web Python populaire avec quelques dépendances natives. En trafic normal il oscillait autour de 250–300MiB. Sous un pic
lié au marketing, il a commencé à traiter des payloads exceptionnellement volumineux (toujours du JSON valide ; juste immense). Les corps de requête étaient parsés, copiés et
validés plusieurs fois. La mémoire de pointe augmentait par paliers avec la concurrence.

Les fautes ressemblaient à des redémarrages aléatoires. Les logs se coupaient en plein milieu d’une ligne. Leur APM montrait des traces incomplètes et des trous bizarres. Quelqu’un a accusé le load balancer. Quelqu’un a accusé le dernier déploiement. Le responsable de l’incident a fait la chose ennuyeuse : docker inspect, puis dmesg.
Là‑dessous : des kills OOM de cgroup.

La correction a nécessité deux mesures. D’abord, ils ont augmenté la limite pour arrêter l’hémorragie et réduit la concurrence des workers. Ensuite, ils ont ajouté des limites de taille de payload et un parsing en streaming pour les gros corps. La leçon n’était pas « donnez plus de RAM partout ». La leçon était que les limites mémoire doivent être définies en tenant compte de la taille d’entrée pire cas, pas du comportement moyen.

Mini-récit 2 : Une optimisation qui s’est retournée contre eux

Une plateforme proche de la finance était obsédée par le p99 de latence. Un ingénieur a remarqué des lectures disque fréquentes et a décidé de « chauffer le cache » agressivement au démarrage : lire un grand jeu de données de référence, précalculer des résultats, tout garder en mémoire pour la vitesse. Ça fonctionnait très bien en staging.

En production c’était différent : plusieurs réplicas redémarraient lors des déploiements, tous chauffer en même temps. Le nœud avait assez de RAM, mais chaque
conteneur avait une limite stricte de 1GiB parce que l’équipe cherchait à tasser les instances. La phase de warm‑up a brièvement poussé la mémoire à ~1.1GiB par instance
à cause d’allocations temporaires lors du parsing et de la construction d’index. Les allocations temporaires restent des allocations ; le noyau se fiche que vous ayez de bonnes intentions.

Le pattern OOM était cruel : les conteneurs mouraient pendant le warm‑up, redémarraient, se réchauffaient, mouraient encore. Une boucle de crash classique, sauf qu’il n’y avait pas de stack de crash.
L’optimisation de l’équipe est devenue une auto‑infligée déni de service pendant les déploiements.

La correction a été de rendre le warm‑up incrémental et borné. Ils ont aussi introduit un budget mémoire de démarrage : mesurer la pointe pendant le warm‑up et fixer la limite du conteneur avec une marge. Le travail de performance qui ignore les budgets mémoire n’est pas du travail de performance ; c’est juste une panne avec de plus jolis graphiques.

Mini-récit 3 : Une pratique ennuyeuse mais correcte qui a sauvé la mise

Une société de logistique exploitait une flotte Docker sur quelques nœuds costauds. Rien de fancy. Leur habitude la plus « innovante » était un audit hebdomadaire : chaque conteneur avait
des limites CPU et mémoire explicites, et chaque service disposait d’un budget mémoire convenu avec un petit tampon.

Une semaine, une mise à jour d’une bibliothèque fournisseur a introduit une fuite dans un chemin de code rarement utilisé. La mémoire montait lentement — des dizaines de mégaoctets par heure — jusqu’à ce que le service atteigne sa limite de 768MiB et se fasse OOM‑killer. Mais le rayon d’impact est resté petit : seul ce conteneur est mort, pas le nœud.

Leur alerte n’était pas « conteneur down », ce qui arrive trop tard. C’était « mémoire du conteneur à 85% de la limite pendant 10 minutes ». L’astreignant a vu la tendance tôt, a rollbacké la bibliothèque, et a ouvert un ticket pour une enquête plus approfondie. Les utilisateurs n’ont vu qu’un bref accroc, pas une panne multi‑services.

La pratique ennuyeuse — limites partout, budgets révisés, et alarmes sur l’approche des limites — n’a pas empêché le bug. Elle a empêché le bug de devenir un incident de plateforme. C’est le niveau requis.

Erreurs courantes : symptôme → cause racine → correctif

1) « Le conteneur redémarre avec le code 137, mais il n’y a pas de drapeau OOMKilled »

Symptôme : Exit 137, redémarrages, pas d’indication OOM claire dans l’état Docker.
Cause racine : Il a été tué par autre chose : kill manuel, timeout orchestrateur, kill au niveau hôte, ou un superviseur envoyant SIGKILL après expiration du délai SIGTERM.
Correctif : Vérifiez dmesg pour des lignes OOM et inspectez les événements de l’orchestrateur. Si aucune preuve d’OOM n’existe, traitez‑le comme un kill forcé et examinez les health checks et les timeouts d’arrêt.

2) « Nous avons mis une limite mémoire, mais le nœud OOM quand même »

Symptôme : L’hôte subit un OOM système, plusieurs conteneurs meurent, parfois dockerd/containerd est touché.
Cause racine : Certains conteneurs n’ont pas de limites, ou les limites dépassent la capacité du nœud lorsqu’on les somme, ou l’hôte a de gros consommateurs mémoire non‑conteneur. Aussi : le cache de pages et la mémoire noyau ne négocient pas avec votre tableur.
Correctif : Appliquez des limites à tous les conteneurs, réservez de la mémoire pour l’OS, et arrêtez le sur‑packing. Si vous autorisez le swap, faites‑le intentionnellement et surveillez. Si vous fonctionnez sans swap, soyez plus strict sur la marge.

3) « Nous avons augmenté la limite et ça OOM encore »

Symptôme : Même pattern, chiffre plus élevé, même mort.
Cause racine : Fuite mémoire ou charge non bornée (profondeur de queue, concurrence, taille des payloads, cache). Augmenter la limite ne change que le temps avant la panne.
Correctif : Limitez la concurrence, taille des queues, taille des payloads, et instrumentez la mémoire. Puis profilez : dumps de heap, profilage d’allocations, suivi de mémoire native pour les charges mixtes.

4) « Ça OOM seulement lors des déploiements / redémarrages »

Symptôme : Stable pendant des jours, puis meurt lors du rollout.
Cause racine : Pic de démarrage : warm‑up de cache, compilation JIT, migrations, construction d’index, ou effet de foule quand beaucoup de réplicas se réchauffent en même temps.
Correctif : Échelonnez les redémarrages, bornez le travail de warm‑up, et fixez les limites en fonction de l’usage de pointe au démarrage, pas seulement de l’état stable. Envisagez des gates de readiness empêchant le trafic tant que le warm‑up n’est pas terminé.

5) « Ça OOM sous charge I/O alors que le heap a l’air correct »

Symptôme : Les métriques de heap semblent stables ; l’OOM survient lors de lectures/écritures intensives.
Cause racine : Cache de pages et fichiers mmap, ou utilisation tmpfs (y compris /dev/shm), comptés sur le cgroup. Parfois aussi les buffers noyau pour réseau ou stockage.
Correctif : Examinez le fichier memory.stat vs usage anon. Réduisez l’empreinte mmap, ajustez la stratégie de cache, ou augmentez la limite pour inclure le budget de cache. Assurez‑vous que la taille tmpfs/shm est intentionnelle.

6) « Nous avons désactivé le swap et maintenant nous voyons plus d’OOM »

Symptôme : Les kills OOM ont augmenté après la désactivation du swap.
Cause racine : Le swap masquait auparavant le sur‑engagement mémoire. Sans swap, le système atteint les contraintes dures plus tôt.
Correctif : Ne réactivez pas le swap par réflexe. Corrigez d’abord les budgets des conteneurs et réduisez le sur‑engagement. Si le swap est requis, définissez des règles claires basées sur les SLO : quels workloads peuvent swapper et comment détecter quand le swap impacte la latence utilisateur.

7) « L’OOM tue le mauvais processus dans le conteneur »

Symptôme : Un process secondaire meurt, ou le processus principal meurt en premier de façon inattendue.
Cause racine : Le scoring de gravité OOM plus l’usage mémoire par processus au moment du kill. Les conteneurs multi‑processus compliquent la sélection de la victime.
Correctif : Préférez un seul processus principal par conteneur. Si vous devez en exécuter plusieurs, isolez les helpers gourmands en mémoire dans des conteneurs séparés ou concevez l’architecture pour qu’un seul kill ne crée pas de défaillance en cascade.

Blague n°2 : Le OOM killer est le seul collègue qui ne rate jamais un délai — malheureusement, il a les compétences sociales d’une guillotine.

Listes de vérification / plan étape par étape

Checklist : définir des limites mémoire qui empêchent les plantages silencieux

  1. Mesurez la mémoire en régime permanent pendant au moins un cycle complet de trafic (jour/semaine selon la charge).
  2. Mesurez la mémoire de pointe pendant les déploiements/démarrages, pics de trafic et tâches de fond.
  3. Choisissez une limite dure avec une marge au‑dessus de la pointe (pas au‑dessus de la moyenne). Si vous ne pouvez pas vous permettre de marge, votre densité de nœud est fantaisiste.
  4. Décidez la politique de swap : aucun pour les services sensibles à la latence ; swap limité pour les jobs batch si acceptable.
  5. Comptez les surcoûts non‑heap : threads, libs natives, memory maps, cache de pages, tmpfs, métadonnées runtime.
  6. Placez des alertes sur 80–90% de la limite mémoire du conteneur, pas seulement sur les redémarrages.
  7. Suivez les événements OOM via les logs du noyau et les compteurs des cgroups.

Plan étape par étape : quand un conteneur OOM en production

  1. Confirmer l’OOM : docker inspect pour OOMKilled et dmesg pour les lignes OOM de cgroup.
  2. Stabiliser : augmenter temporairement la limite ou réduire la concurrence/charge. Si c’est une boucle de crash, envisagez de suspendre les déploiements et de réduire le trafic vers l’instance affectée.
  3. Classer : fuite vs pic vs cache. Utilisez memory.stat pour séparer anon vs file.
  4. Collecter des preuves : capturer dumps/profils runtime (le cas échéant), tailles de requête, profondeurs de queue, et corrélation temporelle avec les déploiements.
  5. Corriger le déclencheur : ajouter des plafonds (payload, concurrence, taille cache), corriger la fuite, ou réduire le pic de démarrage.
  6. Rendre la répétition plus difficile : appliquer des limites à tous les services et formaliser les budgets via CI/CD ou enforcement policy.

Checklist : éviter de transformer un OOM conteneur en OOM nœud

  1. Pas de conteneurs mémoire illimités sur des nœuds partagés (sauf pool de nœuds isolé et délibéré).
  2. Réservez de la mémoire pour l’hôte : daemons système, cache FS, monitoring, et overhead runtime.
  3. Ne pas sommer les limites dures à 100% de la RAM ; laissez une vraie marge.
  4. Surveillez la pression mémoire du nœud et l’activité de reclaim, pas seulement la « mémoire libre ».
  5. Décidez la politique de swap au niveau nœud et assurez‑vous qu’elle correspond aux SLOs des workloads.

FAQ (les questions que l’on pose juste après l’incident)

1) Quelle est la différence entre OOMKilled et le code de sortie 137 ?

OOMKilled est Docker qui vous dit que le noyau a tué le conteneur en raison d’un OOM du cgroup. Le code de sortie 137 signifie que le processus a reçu SIGKILL,
ce qui est cohérent avec un OOM mais peut aussi provenir d’autres kills forcés. Confirmez toujours avec dmesg et les compteurs d’événements du cgroup.

2) Si l’hôte a de la mémoire libre, pourquoi mon conteneur OOM ?

Parce que vous avez donné au conteneur son propre budget via les cgroups. Le conteneur peut atteindre --memory et être tué même si le nœud a de la RAM.
C’est la frontière d’isolation qui fait son travail — à condition que vous ayez défini correctement le budget.

3) Dois‑je définir des limites mémoire sur chaque conteneur ?

Oui. Les nœuds de production sans limites par conteneur deviennent finalement « à un mauvais déploiement près » d’un OOM nœud. Si vous avez besoin d’un conteneur spécial sans limite, donnez‑lui un nœud dédié ou au moins une discussion de risque dédiée.

4) Combien de marge dois‑je laisser ?

Assez pour survivre aux pics connus plus une marge pour l’inconnu. Pratiquement : mesurez la pointe et ajoutez un buffer. Si vous ne pouvez pas ajouter de buffer, réduisez les pics via des plafonds ou la densité. La marge coûte moins cher que les incidents.

5) Le swap est‑il bon ou mauvais pour les conteneurs Docker ?

Le swap est un compromis. Pour les services sensibles à la latence, le swap convertit souvent « panne brutale » en « effondrement lent », ce qui peut être pire. Pour les jobs batch, un swap limité peut améliorer le débit en évitant les killer storms. Décidez selon le workload et surveillez les page faults et la latence.

6) Pourquoi mes logs se coupent juste avant le crash ?

Les kills OOM sont généralement SIGKILL : le processus ne peut pas vider ses tampons. Si vous avez besoin d’une meilleure observabilité de dernière minute, videz les logs plus fréquemment, écrivez les événements critiques de façon synchrone (avec parcimonie), ou utilisez un collecteur externe de logs qui ne dépende pas du processus mourant pour être poli.

7) Puis‑je faire en sorte que le noyau tue un autre processus à la place ?

À l’intérieur d’un cgroup mono‑conteneur, la sélection de la victime est limitée aux processus de ce cgroup. Vous pouvez parfois influencer le comportement avec oom_score_adj, mais ce n’est pas un mécanisme fiable pour « choisir celui‑ci ». La vraie correction est architecturale : évitez les conteneurs multi‑processus pour les helpers gourmands, ou isolez‑les.

8) Les limites mémoire Docker Compose : fonctionnent‑elles vraiment ?

Elles fonctionnent lorsqu’elles sont déployées dans un mode qui les respecte. Compose en mode classique mappe aux contraintes du runtime Docker ; en mode swarm, les contraintes s’expriment différemment. La seule réponse sûre est de vérifier avec docker inspect et les fichiers cgroup sur l’hôte.

9) Comment savoir si c’est une fuite mémoire ?

Cherchez une montée monotone dans le temps sous une charge à peu près similaire, se terminant par un OOM. Confirmez avec des métriques runtime (heap, RSS) et les compteurs de cgroup. Si la montée n’apparaît que pendant des événements spécifiques, il s’agit plus probablement d’un pic ou d’un cache non borné.

10) Pourquoi la vue de la mémoire depuis le conteneur ressemble à celle de l’hôte ?

Selon la configuration du noyau et du runtime, les rapports de /proc peuvent refléter les totaux de l’hôte, tandis que l’application est quand même limitée par les cgroups. Les runtimes modernes détectent souvent les limites cgroup directement, mais n’en faites pas l’hypothèse : fixez des drapeaux de mémoire explicites pour les runtimes qui se comportent mal.

Conclusion : prochaines étapes qui réduisent vraiment les outages

Les OOM conteneurs sont l’un des rares modes d’échec à la fois prévisibles et évitables. Prévisibles parce que la limite est explicite.
Évitables parce que vous pouvez la dimensionner, l’observer, et façonner le comportement avant que le noyau n’arrête tout.

Faites ceci ensuite, dans cet ordre :

  1. Appliquez des limites mémoire à chaque conteneur et chassez les coupables mem=0.
  2. Ajoutez des alertes sur l’approche de la limite (80–90%) et sur les événements OOMKilled, pas seulement sur les redémarrages.
  3. Mesurez la mémoire de pointe lors des déploiements et du warm‑up ; fixez les limites sur la base de la pointe réelle, pas des moments calmes.
  4. Bornez les comportements non limités : taille des payloads, profondeur des queues, concurrence, croissance des caches.
  5. Décidez la politique de swap intentionnellement au lieu d’hériter de ce que le nœud a par défaut.

Les limites mémoire ne rendent pas votre service « sûr ». Elles rendent votre échec contenu. C’est tout l’intérêt des conteneurs : ne pas empêcher la défaillance, mais empêcher qu’elle se propage comme une rumeur au bureau.

← Précédent
Debian 13 : erreur dans fstab empêche le démarrage — solution la plus rapide en mode rescue
Suivant →
Proxmox « pmxcfs n’est pas monté » : pourquoi /etc/pve est vide et comment récupérer

Laisser un commentaire