Incident en production : déboguer une fuite mémoire dans une API Dockerisée
Ce tutoriel raconte et reproduit un scénario réaliste : une API (Node.js dans les exemples, mais la méthode s’applique à Java, Python, Go, etc.) tourne en production dans Docker, la mémoire du conteneur grimpe progressivement, puis l’API devient lente, se fait tuer par l’OOM killer, ou redémarre en boucle. L’objectif est de diagnostiquer, confirmer et corriger une fuite mémoire, puis de prévenir sa réapparition.
1) Symptômes typiques en production
Une fuite mémoire se manifeste rarement par un crash immédiat. Les signaux sont souvent :
- RSS (Resident Set Size) qui augmente continuellement.
- Latence qui augmente (GC plus fréquent, swapping, contention).
- Redémarrages du conteneur (exit code 137,
OOMKilled). - Dégradation progressive : tout fonctionne après un redémarrage, puis se dégrade.
Indices dans Docker / Kubernetes
Dans Docker :
docker stats
Vous verrez une ligne du type :
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
a1b2c3d4e5f6 api-prod 2.35% 850MiB / 1GiB 83.0% ...
Dans Kubernetes :
kubectl top pod -n production
kubectl describe pod api-xxxxx -n production | sed -n '/State:/,/Events:/p'
Cherchez OOMKilled: true ou des événements Killed / Evicted.
2) Comprendre la mémoire dans un conteneur (pièges fréquents)
Un conteneur ne “virtualise” pas la mémoire : il isole et limite via cgroups. Deux pièges :
- Le processus croit avoir plus de mémoire s’il n’est pas “cgroup-aware” (moins vrai aujourd’hui, mais encore possible selon runtime/langage).
- La mémoire “utilisée” côté Docker (RSS, page cache, etc.) n’est pas exactement la mémoire “heap” de votre runtime.
Vérifier la limite mémoire effective (cgroups)
Dans un conteneur Linux (cgroup v2 souvent) :
cat /sys/fs/cgroup/memory.max 2>/dev/null || true
cat /sys/fs/cgroup/memory.current 2>/dev/null || true
Sur cgroup v1 :
cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || true
cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || true
3) Mettre en place un scénario reproductible (API Node.js)
Même si votre stack est différente, avoir un cas reproductible aide à apprendre les outils. On va créer une API Express avec une fuite volontaire : un cache global qui grossit sans limite.
3.1 Code minimal
Arborescence :
api/
Dockerfile
package.json
server.js
package.json :
{
"name": "api-fuite-memoire",
"version": "1.0.0",
"main": "server.js",
"type": "commonjs",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.19.2"
}
}
server.js (fuite volontaire) :
const express = require("express");
const crypto = require("crypto");
const app = express();
const port = process.env.PORT || 3000;
// Fuite volontaire : stockage global non borné
const leakyCache = [];
function allocateSomeMemory() {
// On simule des objets lourds : chaînes + buffers
const payload = {
id: crypto.randomUUID(),
ts: Date.now(),
data: crypto.randomBytes(256 * 1024).toString("hex") // ~512KB en hex
};
leakyCache.push(payload);
return leakyCache.length;
}
app.get("/health", (req, res) => {
res.json({ ok: true });
});
app.get("/leak", (req, res) => {
const n = allocateSomeMemory();
res.json({ ok: true, cacheSize: n });
});
app.get("/metrics-lite", (req, res) => {
const mu = process.memoryUsage();
res.json({
rss: mu.rss,
heapTotal: mu.heapTotal,
heapUsed: mu.heapUsed,
external: mu.external,
arrayBuffers: mu.arrayBuffers
});
});
app.listen(port, () => {
console.log(`API sur :${port} (pid=${process.pid})`);
});
3.2 Dockerfile
FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev || npm install --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
3.3 Build et run avec limite mémoire
docker build -t api-fuite:local ./api
docker run --rm -p 3000:3000 --memory=512m --name api-fuite api-fuite:local
Dans un autre terminal, bombardez /leak :
for i in $(seq 1 200); do
curl -s "http://localhost:3000/leak" > /dev/null
curl -s "http://localhost:3000/metrics-lite" | jq .
sleep 0.2
done
Surveillez :
docker stats api-fuite
Vous devriez voir la mémoire grimper jusqu’à provoquer un crash si la limite est basse.
4) Première étape en incident : confirmer “fuite” vs “pic”
Avant de plonger dans des outils lourds, clarifiez :
- Est-ce monotone (augmente sans redescendre) ?
- Ou est-ce un pic suivi d’un retour à la normale (GC, cache normal, warm-up) ?
4.1 Courbe mémoire et GC
Sur Node.js, process.memoryUsage() donne des indices, mais attention :
heapUsedconcerne le tas V8.rssinclut heap + stacks + libs + allocations natives + fragmentation.
Si heapUsed monte sans redescendre malgré des GC, c’est suspect.
Si rss monte mais heapUsed reste stable, suspectez :
- allocations natives (
Buffer,external) - fragmentation
- fuite côté addon natif
- page cache / mmap
4.2 Forcer un GC (diagnostic uniquement)
En local (pas recommandé en prod), lancez Node avec :
node --expose-gc server.js
Puis ajoutez temporairement un endpoint :
app.post("/debug/gc", (req, res) => {
if (global.gc) global.gc();
res.json({ ok: true, memory: process.memoryUsage() });
});
Si après GC la mémoire ne redescend pas (surtout heapUsed), la fuite est probable.
5) Collecter des informations sur le conteneur en production
5.1 Identifier le process et ses limites
Dans le host :
docker exec -it api-fuite sh
ps aux
cat /proc/1/status | egrep 'VmRSS|VmSize|Threads'
ulimit -a
5.2 Lire la mémoire au niveau cgroup
Dans le conteneur :
cat /sys/fs/cgroup/memory.current 2>/dev/null || true
cat /sys/fs/cgroup/memory.max 2>/dev/null || true
5.3 Logs de kill OOM
Sur le host :
dmesg -T | egrep -i 'killed process|out of memory|oom' | tail -n 50
Dans Kubernetes :
kubectl get pod api-xxxxx -n production -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{"\n"}'
kubectl describe pod api-xxxxx -n production | egrep -i 'oom|killed|reason' -n
6) Instrumenter sans tout casser : profiler et heap dump
L’objectif est d’obtenir une preuve : quels objets grossissent, qui les retient, et d’où ils viennent.
6.1 Options Node.js utiles
--inspect=0.0.0.0:9229(debug/profiling)--heapsnapshot-near-heap-limit=3(snapshots automatiques proche de la limite)--trace-gc(verbeux, utile en staging)--max-old-space-size=...(contrôle du heap V8, différent de la limite Docker)
Exemple (staging) :
node --inspect=0.0.0.0:9229 --heapsnapshot-near-heap-limit=3 server.js
Dans Docker, exposez le port 9229 (attention sécurité) :
docker run --rm -p 3000:3000 -p 9229:9229 --memory=512m api-fuite:local \
node --inspect=0.0.0.0:9229 --heapsnapshot-near-heap-limit=3 server.js
Important : ne laissez pas --inspect ouvert en production publique. Utilisez un réseau privé, un port-forward, ou un sidecar/outil sécurisé.
7) Méthode “heap snapshots” (la plus pédagogique)
7.1 Prendre deux snapshots à des moments différents
Le principe :
- Snapshot A quand l’API vient de démarrer (ou mémoire “saine”).
- Snapshot B après reproduction (trafic, endpoint fautif, etc.).
- Comparer B vs A : types d’objets qui augmentent, “retainers”.
Option 1 : Chrome DevTools
- Connectez-vous à l’inspecteur :
- Ouvrez Chrome →
chrome://inspect - “Configure…” → ajoutez
localhost:9229
- Ouvrez Chrome →
- “Open dedicated DevTools for Node”
- Onglet Memory → Heap snapshot → “Take snapshot”.
Option 2 : heapdump via dépendance (utile sans inspect)
Installez heapdump (à activer prudemment) :
npm install heapdump
Puis dans le code, déclenchez un dump via signal :
const heapdump = require("heapdump");
process.on("SIGUSR2", () => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err, name) => {
console.log(err || `Heap snapshot écrit: ${name}`);
});
});
Dans le conteneur :
kill -USR2 1
ls -lh /tmp/*.heapsnapshot
Puis copiez vers le host :
docker cp api-fuite:/tmp/heap-XXXX.heapsnapshot .
Ouvrez le fichier dans Chrome DevTools (Memory → “Load”).
7.2 Lire un snapshot : quoi regarder ?
Dans DevTools (Memory) :
- Summary : tri par “Shallow size” et “Retained size”.
- Comparison : compare deux snapshots, repère ce qui augmente.
- Retainers : qui garde la référence (la cause réelle).
Dans notre fuite volontaire, vous verrez typiquement :
- Beaucoup d’objets
Object/String/Array - Une chaîne de rétention vers
leakyCache(variable globale).
Le point clé : une fuite n’est pas “trop d’allocations”, c’est des références qui empêchent la libération.
8) Méthode “profiling allocations” (trouver la ligne de code)
Les snapshots disent “quoi” et “qui retient”. Le profiling allocations aide à dire “où c’est alloué”.
8.1 Allocation instrumentation (DevTools)
Toujours via DevTools Node :
- Onglet Memory
- “Allocation instrumentation on timeline”
- Démarrez l’enregistrement
- Reproduisez le trafic (ex: 50 appels à
/leak) - Stoppez et inspectez les stacks d’allocation
Vous obtiendrez des traces qui pointent vers allocateSomeMemory() et leakyCache.push.
8.2 Alternative : clinic.js (staging)
clinic est très pratique (CPU, event loop, GC). En staging :
npm install -g clinic
clinic heapprofiler -- node server.js
Dans Docker, c’est possible mais demande souvent des droits/volumes. L’idée : générer un rapport et l’ouvrir en local.
9) Ne pas oublier les fuites “hors heap” (RSS vs heap)
Cas classique : rss augmente, mais heapUsed reste raisonnable. Causes fréquentes :
- Buffers natifs (
Buffer),external,ArrayBuffer - Bibliothèques C/C++ (images, compression, crypto)
- Fuites de descripteurs (sockets, fichiers) qui entraînent des buffers
- Fragmentation mémoire
9.1 Vérifier les descripteurs ouverts
Dans le conteneur :
ls -l /proc/1/fd | wc -l
ls -l /proc/1/fd | head
Si le nombre de FD augmente sans limite, cherchez :
- connexions sortantes non fermées
- streams non consommés
- fichiers temporaires non clos
9.2 Observer la map mémoire
cat /proc/1/smaps_rollup | egrep 'Rss|Pss|Private|Shared'
Pour plus de détails (volumineux) :
grep -E '^(Size|Rss|Pss|Private|Shared)' -n /proc/1/smaps | head -n 50
10) Corriger : patterns de fuite et remèdes
10.1 Cache non borné (notre cas)
Cause : un tableau global grossit indéfiniment.
Fix : borner (LRU), TTL, taille max, eviction.
Exemple simple (sans lib) : garder seulement les N derniers éléments.
const MAX = 200;
function allocateSomeMemory() {
const payload = { /* ... */ };
leakyCache.push(payload);
if (leakyCache.length > MAX) leakyCache.shift();
}
Mieux : utiliser un cache LRU (ex: lru-cache) :
npm install lru-cache
const { LRUCache } = require("lru-cache");
const cache = new LRUCache({
max: 500, // nombre d’entrées
ttl: 1000 * 60 * 5 // 5 minutes
});
10.2 Listeners / EventEmitter
Fuite fréquente : ajouter des listeners à chaque requête.
Symptôme : warning Node :
MaxListenersExceededWarning: Possible EventEmitter memory leak detected
Fix : ne pas attacher de listener par requête à un emitter global, ou le retirer :
emitter.on("event", handler);
// ...
emitter.off("event", handler);
10.3 Promesses “pendantes” / requêtes non terminées
Si des requêtes sortantes ne time-out pas, elles s’accumulent.
Fix : timeouts explicites (HTTP client), abort signals, limites de concurrence.
Exemple avec fetch (Node 18+) :
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 2000);
try {
const r = await fetch(url, { signal: ac.signal });
// ...
} finally {
clearTimeout(t);
}
10.4 Stockage en mémoire de payloads trop gros
Ex : logger des bodies, stocker des réponses, accumuler des buffers.
Fix : streaming, limites (body-parser), backpressure, ne pas conserver.
11) Valider la correction : tests de charge + métriques
11.1 Générer de la charge
Outil simple : wrk :
wrk -t4 -c50 -d30s http://localhost:3000/leak
Ou hey :
hey -n 2000 -c 50 http://localhost:3000/leak
11.2 Observer mémoire pendant le test
docker stats api-fuite
Et côté application :
watch -n 1 'curl -s http://localhost:3000/metrics-lite | jq'
Critère : la mémoire peut monter, puis se stabiliser (cache borné) ou redescendre après GC.
12) Durcir la prod : limites, alertes, et “crash contrôlé”
Même après correction, vous voulez éviter l’incident silencieux.
12.1 Définir des limites cohérentes
- Limite Docker/K8s (cgroup) :
--memoryouresources.limits.memory - Limite runtime : ex Node
--max-old-space-size
Exemple : conteneur limité à 512 MiB, heap V8 à 256–320 MiB pour laisser de la place au RSS non-heap :
node --max-old-space-size=320 server.js
12.2 Redémarrage contrôlé et diagnostic automatique
En Node, vous pouvez activer :
node --heapsnapshot-near-heap-limit=3 server.js
Ainsi, proche de la limite heap, Node écrit des snapshots (selon version/config). Combinez avec :
- un volume pour persister
/tmp - une collecte automatique des dumps (sidecar, cron, etc.)
12.3 Alerting
Surveillez au minimum :
container_memory_working_set_bytes(K8s/Prometheus)- taux de redémarrage
- latence p95/p99
- event loop lag (Node)
- nombre de FD (
process_open_fdssi exporté)
13) Checklist “incident réel” (procédure opérationnelle)
- Confirmer le symptôme
docker stats/kubectl top- vérifier redémarrages, OOM kills
- Déterminer le type
- heap vs RSS
- monotone vs pics
- Capturer des preuves
- heap snapshots (A/B)
- allocation profiling
- nombre de FD, smaps_rollup
- Reproduire
- endpoint suspect, trafic comparable
- Isoler la cause
- retainer chain → variable globale/cache/listeners
- Corriger
- bornes (LRU/TTL), timeouts, nettoyage listeners, streaming
- Valider
- test de charge + stabilité mémoire
- Prévenir
- limites runtime, alertes, snapshots automatiques, runbooks
14) Commandes récapitulatives (copier-coller)
Docker : observer et inspecter
docker stats
docker logs -f api-fuite
docker exec -it api-fuite sh
cat /proc/1/status | egrep 'VmRSS|VmSize|Threads'
ls -l /proc/1/fd | wc -l
cat /sys/fs/cgroup/memory.current 2>/dev/null || true
cat /sys/fs/cgroup/memory.max 2>/dev/null || true
OOM côté host
dmesg -T | egrep -i 'killed process|out of memory|oom' | tail -n 50
Heap dump via signal (si intégré)
kill -USR2 1
ls -lh /tmp/*.heapsnapshot
docker cp api-fuite:/tmp/heap-XXXX.heapsnapshot .
Charge
wrk -t4 -c50 -d30s http://localhost:3000/leak
hey -n 2000 -c 50 http://localhost:3000/leak
Conclusion
Déboguer une fuite mémoire en production dans une API Dockerisée demande une approche structurée : observer, confirmer, capturer des preuves, analyser la rétention, puis corriger avec des garde-fous (cache borné, timeouts, nettoyage, limites). Les heap snapshots et l’allocation profiling sont souvent les outils décisifs, mais il ne faut pas négliger les fuites hors-heap (FD, buffers natifs, fragmentation) qui expliquent de nombreux cas où le RSS grimpe sans que le heap ne paraisse exploser.
Si vous me donnez votre stack (langage, orchestrateur, métriques disponibles) et un extrait de docker stats/kubectl describe + la courbe heapUsed vs rss, je peux proposer une stratégie de diagnostic encore plus ciblée (outils spécifiques JVM, Python tracemalloc, Go pprof, etc.).