Command Palette

Search for a command to run...

Hectal
Case 1.4·Metrics in AngerSEV2

“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

heap vs RSS vs external memorygauges and derivpredict_linearsawtooth patterns

00 It starts

Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.

terminal
$ # add to the shoplite service in docker-compose.yml: mem_limit: 512m
docker compose up -d shoplite
curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"memory-leak"}'
── lab output ──
{"active":["memory-leak"]}
Each /products request now keeps 100 KB alive forever: about 1.4 MB/s at lab traffic.

01 The investigation

  1. 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
  2. 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· Prometheus
    nodejs_heap_size_used_bytes{job="shoplite"}
    result
    {instance="shoplite:8080", job="shoplite"}  1.93e+07
  3. 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· Prometheus
    process_resident_memory_bytes{job="shoplite"} / 1024 / 1024
    result
    {instance="shoplite:8080", job="shoplite"}  431.6
    shoplite memory (MB)RSSheap used
    0.00294589limit 512 MB08:2008:55
  4. 08:55

    QUERY

    Heap flat, RSS growing: where's the memory? external

    Node Buffers are allocated OUTSIDE the V8 heap. nodejs_external_memory_bytes tracks them, and it's growing at the same slope as RSS. Something is keeping Buffers alive.

    PromQL· Prometheus
    deriv(nodejs_external_memory_bytes{job="shoplite"}[5m]) / 1024 / 1024
    result
    {instance="shoplite:8080", job="shoplite"}  1.38
  5. 09:20

    FINDING

    A heap snapshot shows a module-level array of Buffers growing forever

    In the lab, it's the leak array. In real code, it's usually a cache without eviction, a growing Map keyed by request data, or event listeners never removed.

  6. 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

  1. 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

  1. 01Predictive alert: out of memory within an hour

    predict_linear over 30 minutes is robust to short spikes. for: 10m avoids firing on a single burst of allocations. The limit is hard-coded for the lab; in Kubernetes, use container_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.

01

With the leak on, write a query that estimates how many seconds until shoplite hits 512 MB.

02

Why would rate(process_resident_memory_bytes[5m]) be wrong here?

06 Interview questions from this case

01

How do you detect a memory leak with metrics before it causes an outage?

02

Heap usage is flat but the container keeps getting OOM-killed. What could it be?

0/4 · 0%