Production Incident Walkthrough: geheugenlek debuggen in een Dockerized API
Dit is een end-to-end walkthrough van een realistisch productie-incident: een Dockerized API die na verloop van tijd steeds meer geheugen gebruikt, uiteindelijk door de OOM-killer wordt neergehaald, en daarmee downtime veroorzaakt. We gaan van symptomen → hypothesen → metingen → reproduceerbaarheid → root cause → fix → preventie. Alles met echte commando’s, en met nadruk op hoe je dit veilig doet in productie.
Context: voorbeelden gebruiken Linux, Docker (of containerd), en een API geschreven in Node.js of Python als illustratie. De aanpak is echter generiek en toepasbaar op Go/Java/.NET.
1. Symptomen en eerste triage
1.1 Signalen die je meestal ziet
- Latency loopt langzaam op, vooral bij lange uptime.
- Container memory usage groeit monotonic (zaagtandpatroon ontbreekt).
- Periodieke restarts (Kubernetes CrashLoopBackOff) of
OOMKilled. - Host swap usage stijgt, load average loopt op.
- Logs bevatten
KilledofOut of memory.
1.2 Eerste check: is het echt een geheugenlek?
Een “geheugenlek” kan ook zijn:
- Cache die onbeperkt groeit (bedoeld, maar onbegrensd).
- Request bursts die tijdelijk geheugen verhogen (maar daarna weer dalen).
- Fragmentatie (bijv. jemalloc/glibc gedrag).
- File descriptor leak of thread leak dat indirect memory triggert.
- Metrics misinterpretatie: RSS vs heap vs page cache.
We willen dus eerst objectief meten.
2. Snelle productie-diagnose (zonder meteen te debuggen in de code)
2.1 Container memory usage bekijken
Docker
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker stats --no-stream
Let op:
MEM USAGE / LIMITgroeit continu?- CPU is misschien laag, maar memory groeit.
Kubernetes (als je het gebruikt)
kubectl get pods -n prod
kubectl top pod -n prod --containers
kubectl describe pod <podnaam> -n prod | sed -n '/State:/,/Conditions:/p'
Zoek naar:
Last State: TerminatedmetReason: OOMKilledExit Code: 137
2.2 Host OOM events en kernel logs
Op de host (of node):
dmesg -T | egrep -i 'oom|killed process|out of memory' | tail -n 50
journalctl -k --since "2 hours ago" | egrep -i 'oom|killed process|out of memory' | tail -n 100
Je ziet vaak regels zoals:
Memory cgroup out of memory: Kill process 12345 (node) score 987 ...Killed process 12345 (node) total-vm:... rss:...
2.3 In-container: procesniveau (RSS, threads, fds)
Ga de container in:
docker exec -it <container> sh
Of als bash beschikbaar is:
docker exec -it <container> bash
Check dan:
ps aux --sort=-%mem | head -n 10
cat /proc/meminfo | head
cat /proc/1/status | egrep 'VmRSS|VmSize|Threads|FDSize'
ls /proc/1/fd | wc -l
VmRSS= resident set size (wat echt in RAM zit).VmSize= virtueel geheugen (kan misleidend hoog zijn).ls /proc/1/fd | wc -ldetecteert file descriptor leaks.
2.4 Cgroup limieten en werkelijk gebruik
In containers is het essentieel om cgroups te lezen (v1 vs v2). Probeer:
# cgroup v2 (meest modern)
cat /sys/fs/cgroup/memory.current 2>/dev/null
cat /sys/fs/cgroup/memory.max 2>/dev/null
# cgroup v1 (ouder)
cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null
cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null
Als memory.max max is, is er geen limiet. Als er wel een limiet is, kan je proces OOM gaan zonder dat de host vol is.
3. Hypothesen opstellen (en prioriteren)
Je wil snel een shortlist maken van waarschijnlijke oorzaken:
- Onbegrensde in-memory cache (bijv. LRU ontbreekt, TTL ontbreekt).
- Per-request objecten blijven hangen door globale arrays/maps.
- Event listeners die blijven stapelen (Node:
EventEmitterwarnings). - Prometheus metrics labels cardinality explosion (bijv. label per userId).
- HTTP client die responses buffert of keep-alive sockets lekken.
- Logging die grote buffers vasthoudt (structured logging met huge payloads).
- Image processing / PDF generation die buffers niet vrijgeeft.
- Native addon / C-extension leak (Node/Python).
- Thread/FD leak die indirect memory veroorzaakt.
We gaan nu meten om te zien in welke richting het wijst.
4. Reproduceerbaarheid creëren (zonder productie te slopen)
4.1 Neem een memory time series snapshot
Als je Prometheus/Grafana hebt: maak een grafiek van:
- container RSS / working set
- request rate
- latency p95/p99
- GC metrics (als beschikbaar)
- open fds
- heap used (app metrics)
Zonder observability: maak zelf een simpele sampling loop in de container:
# In de container (of via docker exec)
while true; do
date
cat /proc/1/status | egrep 'VmRSS|VmSize|Threads'
echo "fds: $(ls /proc/1/fd | wc -l)"
sleep 30
done
Laat dit 10–30 minuten lopen en kijk of VmRSS stijgt.
4.2 Load genereren (staging of een geïsoleerde prod canary)
Gebruik bijvoorbeeld wrk of hey.
Install (op je laptop/runner):
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y wrk
# of hey (Go binary)
sudo apt-get install -y hey
Run:
wrk -t4 -c50 -d5m https://api.example.com/endpoint
Of:
hey -z 5m -c 50 https://api.example.com/endpoint
Doel: memory growth versnellen zodat je binnen minuten iets ziet.
Tip: test een paar endpoints apart. Vaak lekt slechts één route.
5. Instrumentatie: onderscheid tussen heap, RSS en “overig”
Een cruciale stap: waar komt het geheugen vandaan?
- Heap: objecten van de runtime (JS heap, Python heap, JVM heap).
- Native: buffers buiten de heap (C-allocaties, mmap, TLS buffers).
- Page cache: meestal host-level, maar kan in RSS terugkomen bij mmaps.
- Allocator fragmentatie: RSS stijgt, heap blijft gelijk.
5.1 Node.js: heap vs RSS quick check
Als je Node draait, kun je vaak via een endpoint of REPL process.memoryUsage() loggen. In een pinch kun je tijdelijk een debug endpoint toevoegen, maar in productie liever feature-flagged.
Voorbeeld output velden:
heapUsedheapTotalrssexternalarrayBuffers
Als rss stijgt maar heapUsed niet: waarschijnlijk native buffers/fragmentatie/IO.
5.2 Python: tracemalloc vs RSS
Python’s tracemalloc meet Python allocaties, niet native. Als RSS stijgt maar tracemalloc niet, kan het C-extensies zijn (bijv. numpy, lxml) of memory fragmentation.
6. Live debugging in Docker: tooling en veiligheid
6.1 Debug-tools in een minimal image
Veel productie-images zijn distroless of alpine zonder tools. Je hebt opties:
- Ephemeral debug container (Kubernetes:
kubectl debug). - Sidecar met tools.
- Herbuild met debug-tools (tijdelijk).
- nsenter vanaf host.
Voor Docker op een host:
# Vind PID van container op de host
docker inspect --format '{{.State.Pid}}' <container>
Stel PID is 12345, dan:
sudo nsenter -t 12345 -n -p -m -u -i sh
Nu zit je in dezelfde namespaces als de container en kun je host-tools gebruiken.
6.2 Basis tools die je wil
curl,jqprocps(ps, top)lsofstracegdb(voor native)- taal-specifiek:
node --inspect,py-spy,gperftools,jemalloctools
7. Case study: Node.js API met heap leak
Stel: container memory groeit lineair met traffic. We gaan een heap dump maken en analyseren.
7.1 Node runtime flags en inspect
Als je controle hebt over de startcommand, zet:
--expose-gc(optioneel)--max-old-space-size=...(niet als “fix”, maar om gedrag te testen)--inspect=0.0.0.0:9229(alleen tijdelijk en afgeschermd)
Voorbeeld Docker run (testomgeving):
docker run --rm -p 3000:3000 -p 9229:9229 \
-e NODE_ENV=production \
myapi:debug \
node --inspect=0.0.0.0:9229 server.js
In productie liever geen open inspect-poort. Gebruik port-forward of bind op localhost en tunnel.
7.2 Heap snapshot maken zonder Chrome DevTools
Je kunt heapdump gebruiken (npm package). Voorbeeld:
npm install heapdump
In code (tijdelijk, guarded):
import heapdump from 'heapdump';
if (process.env.HEAPDUMP_ON_SIGUSR2 === '1') {
process.on('SIGUSR2', () => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err, name) => {
if (err) console.error(err);
else console.log(`heap snapshot written: ${name}`);
});
});
}
Build en deploy canary met HEAPDUMP_ON_SIGUSR2=1.
Trigger in container:
kill -USR2 1
ls -lh /tmp | tail
Kopieer snapshot naar je machine:
docker cp <container>:/tmp/heap-1700000000000.heapsnapshot .
Analyseer met Chrome DevTools:
- Open Chrome
chrome://inspect- “Open dedicated DevTools for Node”
- Memory tab → Load snapshot
7.3 Wat zoek je in de snapshot?
- Grote aantallen van één type object (bijv.
Array,Map,Buffer). - Retainers: waarom wordt het vastgehouden?
- Dominator tree: welke objecten domineren memory.
Typische leak pattern:
- Een globale
Mapmet keys per request/user zonder cleanup. - Een metrics library met labels per request.
- Event listeners die blijven hangen.
7.4 Voorbeeld root cause: metrics label cardinality explosion
Stel je hebt Prometheus metrics:
httpRequestDuration
.labels(req.path, req.headers['x-user-id'])
.observe(duration);
Als x-user-id uniek is per user, groeit het aantal time series onbeperkt. Dit is een klassieke “geheugenlek” in metrics.
Fix: verwijder high-cardinality labels, of bucketiseer.
httpRequestDuration
.labels(req.route?.path ?? 'unknown', req.method, res.statusCode)
.observe(duration);
En zorg dat je niet req.path gebruikt als het dynamische IDs bevat (/users/123), maar een route template (/users/:id).
8. Case study: Python API met native leak of buffering
Stel: RSS stijgt, maar Python heap lijkt stabiel.
8.1 py-spy voor live inspectie
Op de host:
sudo py-spy top --pid <pid>
Of in container (als je het kunt installeren):
pip install py-spy
py-spy top --pid 1
Dit toont CPU hotspots, niet direct memory, maar helpt bij suspecte codepaden.
8.2 tracemalloc snapshots
In je app (tijdelijk):
import tracemalloc, os, signal, time
tracemalloc.start(25)
def dump_traces(signum, frame):
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
print("Top allocators:")
for stat in top_stats[:20]:
print(stat)
signal.signal(signal.SIGUSR2, dump_traces)
Trigger:
kill -USR2 1
Als tracemalloc niets geks toont maar RSS blijft stijgen: kijk naar C-extensies, grote buffers, of response buffering.
8.3 Veelvoorkomend: requests/urllib3 response niet sluiten
Bijvoorbeeld:
r = requests.get(url, stream=True)
data = r.raw.read() # of iter_content zonder close
Fix:
with requests.get(url, stream=True, timeout=10) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=8192):
...
Ook bij database cursors, file handles, etc.
9. System-level analyse: is het heap, native of fd/socket?
9.1 Open files/sockets trend
In container:
watch -n 2 'ls /proc/1/fd | wc -l'
Als dit blijft stijgen: fd leak.
Detail:
ls -l /proc/1/fd | head -n 50
# of
lsof -p 1 | head -n 50
lsof -p 1 | awk '{print $5}' | sort | uniq -c | sort -nr | head
Veel TCP sockets in CLOSE_WAIT kan duiden op niet goed sluiten.
9.2 Memory maps en native allocaties
Bekijk mappings:
cat /proc/1/smaps_rollup | head -n 50
Interessante velden:
RssPssAnonymousFileShared_Clean/DirtyPrivate_Clean/Dirty
Als Anonymous enorm groeit: heap/native anon allocations.
Als File groeit: mmapped files, caches, of libraries.
9.3 malloc/allocator gedrag (geavanceerd)
In glibc kan RSS hoog blijven door fragmentatie. Soms helpt jemalloc of tcmalloc, maar pas op: dit is symptoombestrijding als er echt een leak is.
Je kunt ook MALLOC_ARENA_MAX beperken (glibc), maar doe dit alleen na testen.
10. Reproduceren lokaal met dezelfde container
10.1 Bouw exact dezelfde image
Zorg dat je dezelfde build gebruikt als prod (zelfde commit, dependencies). Pull de image tag uit prod:
docker pull registry.example.com/myapi:prod-2026-03-01
docker image inspect registry.example.com/myapi:prod-2026-03-01 | jq '.[0].Config.Env'
Run lokaal met dezelfde env vars (sanitizen secrets):
docker run --rm -p 3000:3000 --memory=512m --cpus=1 \
-e NODE_ENV=production \
registry.example.com/myapi:prod-2026-03-01
10.2 Load test lokaal
wrk -t2 -c20 -d10m http://localhost:3000/leaky-endpoint
Monitor:
docker stats
Als je het lokaal kunt reproduceren: perfect. Zo niet, kan het data-afhankelijk zijn (specifieke payloads) of alleen bij echte prod integraties.
11. Root cause isoleren met bisection en feature flags
Als je niet meteen ziet wat het is:
- Zet feature flags uit (caching, metrics, tracing).
- Deploy canary met één verandering.
- Observeer memory slope (MB/min).
Een praktische aanpak:
- Maak een tabel: configuratie → slope.
- Begin met “alles uit” en zet één component aan.
Voorbeeld:
| Config | Slope (MB/min) | Opmerking |
|---|---|---|
| baseline (minimaal) | 0.2 | ok |
| + metrics | 8.0 | verdacht |
| + tracing | 0.3 | ok |
| + cache | 0.4 | ok |
Dan weet je waar te zoeken.
12. Fix implementeren en valideren
12.1 Definitie van “fix”
Een fix is niet alleen “memory groeit minder”. Je wil:
- Geen monotone groei onder steady load.
- Na GC (of idle) daalt heap/RSS deels terug.
- Geen OOM meer binnen de geplande uptime (bijv. 7 dagen).
- Geen regressie in latency/CPU.
12.2 Validatie met soak test
Run een langdurige test (minstens 30–60 min, liever langer):
wrk -t4 -c50 -d60m http://localhost:3000/endpoint
Neem elke minuut een snapshot:
while true; do
date
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}" | sed -n '1,3p'
sleep 60
done
Je wil een plateau zien, of een zaagtand (GC), maar geen lineaire stijging.
13. Productie rollout strategie
13.1 Canary deploy
- 1–5% traffic naar nieuwe versie.
- Monitor memory slope en OOM events.
- Als stabiel, opschalen.
13.2 Memory limits en requests (Kubernetes)
Zet realistische limits:
- Te laag: OOM bij spikes.
- Te hoog: node pressure, noisy neighbor issues.
Voorbeeld richtlijn:
- Request: p50 + marge
- Limit: p99 + marge
Daarnaast: zet alerts op:
container_memory_working_set_bytes> 80% limit- slope > X MB/min gedurende 10 min
- OOMKilled events > 0
14. Post-incident: preventie en guardrails
14.1 Observability die geheugenlekken vroeg detecteert
- Export app-level memory metrics:
- Node:
process.memoryUsage() - Python:
psutil.Process().memory_info().rss
- Node:
- Exporteer:
- heap used
- rss
- gc pause time / count
- open fds
- event loop lag (Node)
- Voeg een “high cardinality” linting toe voor metrics labels.
14.2 Load tests in CI
- Run een korte soak test in pipeline (10–15 min) met memory trend check.
- Fail build als slope boven threshold komt.
Je kunt slope grof meten door begin/eind docker stats te vergelijken.
14.3 Code patterns om te vermijden
- Globale arrays/maps zonder eviction:
- Gebruik LRU cache met max size en TTL.
- Metrics labels met userId, sessionId, requestId.
- Onbegrensde queue/buffer:
- Gebruik backpressure, bounded queues.
- Niet sluiten van resources:
- Always
with/context managers,finally,defer.
- Always
15. Snelle checklist (tijdens incident)
- Bevestig OOM:
dmesg,kubectl describe, exit code 137. - Meet trend:
docker stats,/proc/1/status, fds count. - Bepaal type: heap vs RSS vs fds.
- Reproduceer: isolate endpoint + load test.
- Profile: heap snapshot/tracemalloc/smaps_rollup.
- Fix: begrens caches, verwijder high-cardinality labels, sluit resources.
- Valideer: soak test + slope check.
- Rollout: canary + alerts.
- Preventie: metrics, CI soak, code guidelines.
16. Extra: concrete commando’s per scenario
16.1 Als je alleen Docker hebt
# 1) Kijk naar memory usage
docker stats
# 2) Inspecteer PID op host
PID=$(docker inspect --format '{{.State.Pid}}' mycontainer)
echo $PID
# 3) Check smaps rollup (host)
sudo cat /proc/$PID/smaps_rollup | head -n 50
# 4) Check open fds
sudo ls /proc/$PID/fd | wc -l
sudo lsof -p $PID | head
16.2 Als je Kubernetes hebt
# OOM events
kubectl get pod -n prod -o wide
kubectl describe pod <pod> -n prod | egrep -i 'oom|killed|reason|exit'
# Memory usage
kubectl top pod -n prod --containers
# Ephemeral debug (als toegestaan)
kubectl debug -n prod -it <pod> --image=nicolaka/netshoot --target=<containernaam> -- sh
In de debug shell kun je ps, netstat, lsof gebruiken (afhankelijk van image).
17. Afronding: wat “goed” eruitziet na de fix
Na de fix wil je in je grafieken zien:
- Memory working set stabiliseert (plateau) of zaagtand.
- Geen OOMKilled events.
- Latency p99 blijft stabiel.
- Geen groei in open fds.
- Geen explosie in metrics time series.
Als je nog steeds memory growth ziet, herhaal de cyclus maar met scherpere isolatie: endpoint per endpoint, feature per feature, en maak snapshots op vaste intervallen (bijv. elke 10 minuten) om verschillen te vergelijken.
Als je me vertelt welke stack je API gebruikt (Node/Python/Go/Java), orchestrator (Docker Compose/Kubernetes), en welke symptomen je precies ziet (RSS vs heap, OOM logs, fds groei), kan ik deze walkthrough omzetten naar een stack-specifiek stappenplan met exact de juiste profiler/heapdump tooling en interpretatie.