“ShopLite restarts every six minutes. The heap graph is perfectly flat.”
case name: The leak the heap graph couldn't see
- Service
- shoplite-api
- Impact
- In-flight requests dropped on every OOM kill; ~2% of requests failed
- Detected by
- The restart alert from Case 1.1
- Time to resolve
- 1 h 5 min
Skills you'll use on this case
derivpredict_linearsawtooth patterns00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 08:40
ALERT
ShopLiteRestartingFrequently: 4 restarts in 15 minutes
terminal$ docker inspect shoplite --format 'OOMKilled={{.State.OOMKilled}} restarts={{.RestartCount}}'── expected output ──OOMKilled=true restarts=4 - 08:44
DEAD END
The heap is flat, so 'it can't be a memory leak'
19 MB and steady. The team starts looking at 'maybe the kernel is killing it for some other reason'.
PromQL· Prometheusnodejs_heap_size_used_bytes{job="shoplite"}result{instance="shoplite:8080", job="shoplite"} 1.93e+07 - 08:52
QUERY
Resident memory tells a different story: a sawtooth
RSS (resident set size) is everything the process holds in RAM. It climbs linearly to the 512 MB limit, the kernel kills the process, it restarts at ~70 MB, and the cycle repeats.
PromQL· Prometheusprocess_resident_memory_bytes{job="shoplite"} / 1024 / 1024result{instance="shoplite:8080", job="shoplite"} 431.6shoplite memory (MB)RSSheap used - 08:55
QUERY
Heap flat, RSS growing: where's the memory?
externalNode
Buffers are allocated OUTSIDE the V8 heap.nodejs_external_memory_bytestracks them, and it's growing at the same slope as RSS. Something is keeping Buffers alive.PromQL· Prometheusderiv(nodejs_external_memory_bytes{job="shoplite"}[5m]) / 1024 / 1024result{instance="shoplite:8080", job="shoplite"} 1.38 - 09:20
FINDING
A heap snapshot shows a module-level array of Buffers growing forever
In the lab, it's the
leakarray. In real code, it's usually a cache without eviction, a growing Map keyed by request data, or event listeners never removed. - 09:45
RESOLVED
Unbounded cache replaced with an LRU; RSS flat at ~90 MB
Root cause
A module-level structure retained a Buffer per request and was never cleared. Buffers live outside the V8 heap, so the heap graph, the only memory panel on the dashboard, stayed flat while resident memory grew until the container's limit triggered an OOM kill. The automatic restart hid the leak as 'occasional blips'.
02 The concepts behind it
Which memory number to watch
RSS (process_resident_memory_bytes) is what the OS and container limits count, so it's the number that gets you OOM-killed. Heap (nodejs_heap_size_used_bytes, JVM heap, Go heap) is memory managed by the runtime's garbage collector. Native/external memory (Node Buffers, JVM direct buffers and metaspace, C extensions in Python) is outside the heap and invisible to heap graphs.
Alert on RSS against the limit. Use heap vs external vs RSS to figure out WHERE a leak is.
Gauges: deriv and predict_linear
Memory is a GAUGE. Never apply rate() to it; that's for counters. deriv(gauge[5m]) gives the per-second slope using linear regression. predict_linear(gauge[30m], 3600) extrapolates the trend one hour ahead. Alerting on 'will hit the limit within an hour' catches leaks BEFORE the OOM kill, and gives a much better signal than 'memory > 90%', which fires constantly for services that legitimately run near their limit.
Sawtooth = leak + restart
A steady ramp that drops to a baseline and ramps again is the signature of a leak with automatic restarts. Graph restarts (changes(process_start_time_seconds[5m])) on the same panel and the pattern is obvious. Without the restart overlay, a sawtooth can be mistaken for normal GC behaviour.
03 The fix
01Bound what you retain
Any in-process cache needs a size or TTL bound, such as an LRU. Switch the lab scenario off; RSS stops growing immediately, but the leaked memory is only freed when the process restarts.
terminal$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"memory-leak","enabled":false}'docker compose restart shoplite── expected output ──✔ Container shoplite Started
04 Make sure it never surprises you again
01Predictive alert: out of memory within an hour
predict_linearover 30 minutes is robust to short spikes.for: 10mavoids firing on a single burst of allocations. The limit is hard-coded for the lab; in Kubernetes, usecontainer_spec_memory_limit_bytes.obs-lab/prometheus/alerts.ymladd to fileyaml - alert: ShopLiteMemoryExhaustionPredicted expr: predict_linear(process_resident_memory_bytes{job="shoplite"}[30m], 3600) > 512 * 1024 * 1024 for: 10m labels: { severity: warning, service: shoplite } annotations: summary: "shoplite RSS is on course to exceed its 512 MB limit within an hour"
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
With the leak on, write a query that estimates how many seconds until shoplite hits 512 MB.
Why would rate(process_resident_memory_bytes[5m]) be wrong here?
06 Interview questions from this case
How do you detect a memory leak with metrics before it causes an outage?
Heap usage is flat but the container keeps getting OOM-killed. What could it be?