← Terug naar tutorials

Production Incident Walkthrough: geheugenlek debuggen in een Dockerized API

devopsproduction-incidentmemory-leakdockerobservabilityprofilingkubernetesapi

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

1.2 Eerste check: is het echt een geheugenlek?

Een “geheugenlek” kan ook zijn:

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:

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:

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:

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

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:

  1. Onbegrensde in-memory cache (bijv. LRU ontbreekt, TTL ontbreekt).
  2. Per-request objecten blijven hangen door globale arrays/maps.
  3. Event listeners die blijven stapelen (Node: EventEmitter warnings).
  4. Prometheus metrics labels cardinality explosion (bijv. label per userId).
  5. HTTP client die responses buffert of keep-alive sockets lekken.
  6. Logging die grote buffers vasthoudt (structured logging met huge payloads).
  7. Image processing / PDF generation die buffers niet vrijgeeft.
  8. Native addon / C-extension leak (Node/Python).
  9. 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:

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?

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:

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:

  1. Ephemeral debug container (Kubernetes: kubectl debug).
  2. Sidecar met tools.
  3. Herbuild met debug-tools (tijdelijk).
  4. 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


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:

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:

7.3 Wat zoek je in de snapshot?

Typische leak pattern:

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:

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:

  1. Zet feature flags uit (caching, metrics, tracing).
  2. Deploy canary met één verandering.
  3. Observeer memory slope (MB/min).

Een praktische aanpak:

Voorbeeld:

ConfigSlope (MB/min)Opmerking
baseline (minimaal)0.2ok
+ metrics8.0verdacht
+ tracing0.3ok
+ cache0.4ok

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:

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

13.2 Memory limits en requests (Kubernetes)

Zet realistische limits:

Voorbeeld richtlijn:

Daarnaast: zet alerts op:


14. Post-incident: preventie en guardrails

14.1 Observability die geheugenlekken vroeg detecteert

14.2 Load tests in CI

Je kunt slope grof meten door begin/eind docker stats te vergelijken.

14.3 Code patterns om te vermijden


15. Snelle checklist (tijdens incident)

  1. Bevestig OOM: dmesg, kubectl describe, exit code 137.
  2. Meet trend: docker stats, /proc/1/status, fds count.
  3. Bepaal type: heap vs RSS vs fds.
  4. Reproduceer: isolate endpoint + load test.
  5. Profile: heap snapshot/tracemalloc/smaps_rollup.
  6. Fix: begrens caches, verwijder high-cardinality labels, sluit resources.
  7. Valideer: soak test + slope check.
  8. Rollout: canary + alerts.
  9. 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:

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.