Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

advancedprometheus-tsdb~35 min

Break/Fix: Prometheus OOM

Reported symptoms

  • ●Prometheus has been OOM-killed between three and six times a day for about a month, always between 07:00 and 22:00 on a weekday, never overnight and never at a weekend
  • ●Resident memory sits at 9 GiB against a 24 GiB cgroup limit and has not moved in months; the kills are cliffs, 9 GiB to the limit in under forty seconds, with no ramp anywhere
  • ●The host has 64 GiB and shows about 40 GiB free at the moment of every kill
  • ●Head series is flat at about 1.4M and has been for four months; ingestion is flat at about 48,000 samples per second; the cardinality runbook comes back clean at every step
  • ●Two alerts that should have fired during a genuine incident last Thursday did not fire at all, and nobody has explained why
  • ●Grafana users on unrelated dashboards see `query timed out` during the events
  • ●Two panels on the NOC capacity dashboard have shown `query processing would load too many samples into memory` for a month and are filed as broken panels
  • ●The only change in the window is a dashboard edit whose pull request reads "add instance selector, tidy panel titles"

Evidence

  • · `process_resident_memory_bytes` shows a flat 9 GiB baseline with vertical excursions to the 24 GiB limit lasting under a minute, several times a working day
  • · `prometheus_engine_queries` reaches 20 during every event and sits in single digits the rest of the time; `prometheus_engine_queries_concurrent_max` is 20
  • · On every restart, Prometheus logs the queries that did not finish in its previous run, read back from `data/queries.active` - and it is the same handful of expressions every time
  • · The expression named in that list is `node_memory_MemAvailable_bytes{instance=~".*"}`, with `start` and `end` thirty days apart and `step=15`
  • · `node_memory_MemAvailable_bytes` has 260 series, one per host; 30 days at a 15-second step is 172,800 points per series
  • · The unit carries `--query.max-concurrency=20`, `--query.timeout=2m` and `--query.max-samples=50000000`
  • · `prometheus_rule_group_iterations_missed_total` climbs during each event; `prometheus_rule_group_last_duration_seconds` for the platform group rises well past its 30-second interval
  • · `journalctl -k` shows a cgroup kill of the Prometheus process at each event, with the host itself never short of memory
  • · The dashboard JSON diff turns `Multi-value` and `Include All` on for the `instance` variable and removes the `Min interval` from five panels
Diagnosis and resolutionclick to reveal

Root cause

The estate is not running out of memory; it is being handed more query work at once than its query memory budget can hold. A month ago the NOC capacity dashboard gained an `instance` variable with `Include All` enabled, and `All` renders as the regex `.*`, so panels that previously asked about one host began asking about all 260. The same edit removed the `Min interval` from five panels, which unpinned nothing and pinned everything: without it Grafana sends a fixed 15-second step regardless of the range, and the dashboard opens on thirty days. The arithmetic is the whole incident. Thirty days at a 15-second step is 172,800 points per series; 260 series is about 45 million samples for one panel. That is under `--query.max-samples=50000000`, so Prometheus accepts it and serves it, and every part of the platform behaves exactly as configured. What no flag bounds is the sum. `max-samples` is a per-query ceiling and there is no aggregate one, so the real bound on resident query memory is `max-samples` multiplied by `max-concurrency` - here fifty million times twenty, or a billion samples - and at roughly sixteen bytes per decoded sample that product is far larger than the 24 GiB limit the process runs under. Four kiosk displays refreshing a five-panel dashboard every thirty seconds is enough to fill the twenty concurrency slots with queries that are each individually legal, and the process crosses its limit in tens of seconds. Two panels on the same dashboard select per-mountpoint series instead of per-host ones, land above fifty million samples, and are rejected outright with the error the NOC has been reading for a month and filing as a broken panel - the platform has been printing the diagnosis on the screen that caused it. The missed alerts are the second-order cost: rule evaluation shares the query scheduler with the dashboard workload, so when the gate saturates the rule groups queue behind it and miss their tick.

Remediation

Fix the dashboard, because that is the cause; everything else is a brake. Restore a `Min interval` on the five panels so the step scales with the range instead of being pinned at fifteen seconds - a thirty-day panel should be asking for a step in the hundreds of seconds, which cuts the sample count by more than an order of magnitude on its own. Remove `Include All` from a 260-value variable, or cap what it expands to. For the panels that genuinely need thirty days of the whole fleet, move them onto a recording rule, which turns tens of millions of samples into tens of thousands and is the only option here that stays correct as the fleet grows. Fix the two rejected panels in the same change rather than leaving them filed as broken: they are the same bug, and the message on them is the diagnosis. Then bound the product, because the dashboard is not the last dashboard anyone will write. Lower `--query.max-concurrency` so that `max-samples` times the concurrency, at roughly sixteen bytes a sample, fits inside the memory limit with room for the 9 GiB baseline. Understand the cost before shipping it: a smaller gate does not make queries cheaper, it makes them queue, and a queued query eventually returns `query timed out` at the two-minute mark. That is a much better failure than a dead process, and it is still a visible degradation that the NOC and the on-call rotation should hear about before they discover it. Lowering `--query.max-samples` is the sharper brake and the one to pick from measurement rather than from taste, because it will also reject legitimate long-range work and can reject a rule evaluation. Do not add memory as the fix. The product of the two flags is a billion samples; any ceiling you raise it to, that arithmetic will find again, and a larger heap also lengthens the WAL replay after the next kill. Raise the limit only after the product is bounded. If the dashboard cannot be changed today, the defensible hold is to set the kiosk auto-refresh to five minutes or turn it off, tell the NOC why in writing, and name the engineer and the date the real fix ships - that converts six kills a day into approximately none while the work is scheduled properly.

Verification

Reproduce it deliberately, in a window, with somebody watching. Open the dashboard on its thirty-day default and watch `prometheus_engine_queries` and `process_resident_memory_bytes` together. The pass is that the gate does not saturate and resident memory moves by hundreds of megabytes rather than by gigabytes; anything that only tests the dashboard on a six-hour range has tested the case that was never broken. Confirm the step actually changed rather than trusting the panel setting: read `data/queries.active` while the dashboard is open, or read the `step` parameter off the request, and confirm a thirty-day panel is no longer asking for fifteen seconds. Confirm the two rejected panels now render, because if they still return the max-samples error the fix did not reach them and they are still telling you so. Confirm the alerting recovered, which is the part with real consequences: `prometheus_rule_group_iterations_missed_total` must stop climbing, and you should pick one rule group that was missing ticks and watch it evaluate on schedule through a working day. Test the brake itself, which almost nobody does - run one query deliberately above the `max-samples` ceiling and confirm it comes back as a rejection rather than as a kill. A limit nobody has seen reject anything is a limit nobody knows is wired up. Check that the 9 GiB baseline is unchanged; if it fell, something was altered that was not meant to be. And then wait, because frequency is the measurement: a full week with no kills, including a month-end, when the capacity dashboard gets its heaviest use.

Prevention

Write down `max-samples` multiplied by `max-concurrency`, multiply by the per-sample cost, and compare the result to the memory limit. That product is the only bound on resident query memory that exists, because `max-samples` is per-query and nothing sums across concurrent queries. If the product exceeds the limit - and by default it comfortably does - the process is one dashboard away from a kill, and the arithmetic tells you so before the dashboard is written. Make samples-touched part of dashboard review the way a query plan is part of schema review: series multiplied by range divided by step, worked out for the panel default and again for the widest range a user can pick. Never pin a step. A panel with a fixed step has a cost that scales with the time picker, and the time picker belongs to whoever opens the dashboard, not to whoever wrote it. Treat `Include All` on a high-cardinality variable as a multiplier rather than a convenience - a 260-value variable applies a 260-fold factor to every panel that uses it, and the expansion is a regex that matches everything. Use recording rules as a memory control and not only as a latency one; a wide panel reading a pre-aggregated series is a constant-cost query whatever the fleet grows to. Alert on `prometheus_engine_queries` reaching `prometheus_engine_queries_concurrent_max`, because a saturated gate precedes the kill by tens of seconds and is the earliest signal available. Alert on `prometheus_rule_group_iterations_missed_total` as well, because this failure degrades alerting silently, and an alerting platform that has quietly stopped alerting is the worst state it can occupy. Read `data/queries.active` after any unexplained restart; Prometheus writes it for exactly this and it is the crash dump you already have. And learn to read the shape of the memory graph before choosing an investigation: a ramp over days is storage, cardinality or churn, and a cliff in tens of seconds is the query path. Picking correctly between those two is most of the diagnosis.

Reported symptoms

Prometheus has been OOM-killed between three and six times a day for about a month. Always on a weekday, always between 07:00 and 22:00, never overnight and never at a weekend.

The memory graph does not look like an out-of-memory problem. Resident memory sits at 9 GiB against a 24 GiB cgroup limit and has sat there for months. The kills are vertical: from 9 GiB to the limit in under forty seconds, with no ramp before them and no elevated plateau after. The host has 64 GiB and shows around 40 GiB free at the moment of each kill, which is why the first week of investigation went into the host and found nothing.

The cardinality runbook comes back clean at every step. Head series is flat at about 1.4M and has been for four months. Ingestion is flat at about 48,000 samples per second. No metric family dominates. Series creation rate is unremarkable. Nothing has been added to the scrape config since spring.

Three other things are wrong, and none of them sounds like the same problem:

  • Two alerts that should have fired during a real incident last Thursday did not fire at all. Nobody has explained it and the incident review has the question open.
  • Users on unrelated dashboards see query timed out during the events.
  • Two panels on the NOC capacity dashboard have shown query processing would load too many samples into memory for a month. They are in the backlog as broken panels.

The only change anywhere in the window is a dashboard edit. Its pull request reads, in full, “add instance selector, tidy panel titles”.

Evidence provided

Read-only / SafePrometheus reads back data/queries.active on start - the crash dump you already have
$ journalctl -u prometheus --since '-1h' | grep -A4 'last run'
level=info msg="These queries didn't finish in prometheus' last run:"
query="node_memory_MemAvailable_bytes{instance=~".*"}" timestamp_sec=1755231043
query="node_memory_MemAvailable_bytes{instance=~".*"}" timestamp_sec=1755231043
query="node_load15{instance=~".*"}" timestamp_sec=1755231044
query="node_network_receive_bytes_total{instance=~".*"}" timestamp_sec=1755231044

Illustrative output

Read-only / Safeduring an event; single digits the rest of the time, and the max is 20
$ curl -s --data-urlencode 'query=prometheus_engine_queries' \
http://prometheus:9090/api/v1/query | jq -r '.data.result[0].value[1]'
20

Illustrative output

Read-only / Safeone series per host, and the variable expands to all of them
$ curl -s --data-urlencode 'query=count(node_memory_MemAvailable_bytes)' \
http://prometheus:9090/api/v1/query | jq -r '.data.result[0].value[1]'
260

Illustrative output

The request Grafana is sending, taken from the access log during a refresh:

POST /api/v1/query_range
  query = node_memory_MemAvailable_bytes{instance=~".*"}
  start = 1752639043
  end   = 1755231043
  step  = 15

The flags the unit runs with:

# /etc/default/prometheus
ARGS="--config.file=/etc/prometheus/prometheus.yml \
      --storage.tsdb.path=/var/lib/prometheus \
      --query.max-concurrency=20 \
      --query.timeout=2m \
      --query.max-samples=50000000"

And the rule-evaluation health across the same events:

prometheus_rule_group_iterations_missed_total   # climbs during every event
prometheus_rule_group_last_duration_seconds     # platform group well past its 30s interval

The dashboard diff turns Multi-value and Include All on for the instance variable, and removes the Min interval field from five panels. The dashboard default range is thirty days. Four kiosk displays in the NOC hold it open with a thirty-second auto-refresh.

Work the evidence before reading on

The cardinality runbook is correct, was executed correctly, and found nothing. Treat that as information rather than as a dead end.

  1. Head series is flat and the kills are cliffs of under forty seconds. What memory cost in Prometheus can appear and disappear on that timescale, and what cost cannot?
  2. The host has 40 GiB free at the moment of each kill. What killed the process, and what does that tell you about which accounting boundary was crossed?
  3. Work the arithmetic for one panel: 260 series, thirty days, a fifteen-second step. How many samples is that, and how does the number compare with --query.max-samples=50000000?
  4. Now do it again for twenty of them. Which flag bounds that total?
  5. Two panels on the same dashboard are rejected outright while three are served. What is different about the two, and why is being rejected the cheaper outcome?
  6. prometheus_rule_group_iterations_missed_total climbs during each event. What do rule evaluations and dashboard queries have in common, and what does a missed iteration mean for an alert with a for: clause?

Before continuing: name the product of two configured numbers that is the real bound on this process’s query memory, and say why neither number on its own ever looked wrong.

Root cause

Thirty days at a fifteen-second step is 172,800 points per series. The instance variable expands All to the regex .*, so the panel selects all 260 series that node_memory_MemAvailable_bytes has. That is about 45 million samples for one panel - under --query.max-samples=50000000, so Prometheus accepts it, serves it, and is behaving exactly as configured.

max-samples is a ceiling on a single query. There is no aggregate ceiling. The real bound on resident query memory is therefore max-samples multiplied by max-concurrency: fifty million times twenty, or a billion samples, which at roughly sixteen bytes per decoded sample is far more than the 24 GiB the process is allowed. Four kiosk displays refreshing a five-panel dashboard every thirty seconds is enough to fill all twenty slots with individually legal queries, and the process crosses its limit in tens of seconds.

That is the shape of the graph. A cliff rather than a ramp, because query memory is allocated and released on the timescale of a query rather than accumulating; nothing in the head, the WAL or the block layer moves at all.

The rejected panels were the diagnosis, printed on the screen

Two of the five panels select per-mountpoint series rather than per-host ones, so they cross fifty million samples and are rejected before any of it is loaded. query processing would load too many samples into memory is not a broken panel. It is Prometheus stating the cause of the incident, on the same dashboard, in the same room, for a month.

Being rejected is the cheap outcome and it is worth understanding why: the engine counts the samples the query will touch and refuses before allocating, so a rejected query costs almost nothing. The panels that killed the process are the ones that came in just under the ceiling and were therefore served.

The alerting degradation is the part that mattered most

Rule evaluation and dashboard queries share the query scheduler. When twenty slots are held by thirty-second range queries, rule groups queue behind them, prometheus_rule_group_last_duration_seconds rises past the group interval, and prometheus_rule_group_iterations_missed_total starts counting.

An alert with a for: clause needs consecutive evaluations to reach firing. A group that misses ticks during exactly the window when something is going wrong is a group whose alerts may never elapse - which is the answer to the open question from last Thursday’s incident review. The dashboard did not only kill Prometheus; while it was doing so, it disabled the alerting.

Resolution

  1. Take the pressure off first, on the same day. Set the kiosk auto-refresh to five minutes or turn it off, and tell the NOC why. Six kills a day is not a state to do careful work in.
  2. Say out loud that alerting has been degraded for a month. prometheus_rule_group_iterations_missed_total has the periods; the on-call rotation and whoever ran last Thursday needs them in writing, because some alerts could not have fired.
  3. Restore a Min interval on the five panels so the step scales with the range instead of being pinned at fifteen seconds. A thirty-day panel asking for a step in the hundreds of seconds cuts the sample count by more than an order of magnitude on its own.
  4. Remove Include All from the 260-value instance variable, or cap what it expands to. All is a regex that matches everything, and it is a 260-fold multiplier on every panel that uses the variable.
  5. Move the panels that genuinely need thirty days of the whole fleet onto a recording rule. That turns tens of millions of samples into tens of thousands and stays correct as the fleet grows, which neither of the previous two steps does.
  6. Fix the two rejected panels in the same change. They are the same bug, they have been carrying the diagnosis for a month, and leaving them broken guarantees the next person also treats the message as noise.
  7. Bound the product, because this will not be the last dashboard anyone writes. Lower --query.max-concurrency until max-samples times the gate, at roughly sixteen bytes a sample, fits inside the memory limit above the 9 GiB baseline.
  8. Understand what the smaller gate costs before shipping it: queries do not get cheaper, they queue, and a queued query returns query timed out at the two-minute mark. That is a far better failure than a dead process and it is still a degradation people should hear about first.
  9. Treat lowering --query.max-samples as the sharper brake and pick the number from measurement. It will also reject legitimate long-range work, and it can reject a rule evaluation.
  10. Do not add memory as the fix. The product of the two flags is a billion samples; whatever ceiling you raise it to, that arithmetic will find again, and a larger heap lengthens the WAL replay after the next kill.

Verification

  1. Reproduce it deliberately, in a window, with somebody watching. Open the dashboard on its thirty-day default and watch prometheus_engine_queries and process_resident_memory_bytes together. Testing it on a six-hour range tests the case that was never broken.
  2. The gate does not saturate and resident memory moves by hundreds of megabytes rather than gigabytes. Both, not either.
  3. The step really changed. Read data/queries.active while the dashboard is open, or read the step parameter off the request, and confirm a thirty-day panel is no longer asking for fifteen seconds. The panel setting is the intent; the request is the fact.
  4. The two rejected panels render. If they still return the max-samples error, the fix did not reach them and they are still telling you so.
  5. Alerting recovered. prometheus_rule_group_iterations_missed_total stops climbing, and one group that was missing ticks evaluates on schedule through a full working day. This is the check with real consequences attached.
  6. The brake is wired up. Run one query deliberately above the max-samples ceiling and confirm it is rejected rather than fatal. A limit nobody has watched reject anything is a limit nobody knows works.
  7. The 9 GiB baseline is unchanged. If it fell, something was altered that was not meant to be, and you should find out what before closing.
  8. Frequency is the measurement. A full week with no kills, including a month-end, when the capacity dashboard gets its heaviest use.

Prevention

  • Write the product down. max-samples times max-concurrency, times the per-sample cost, against the memory limit. That product is the only bound on resident query memory that exists, and with the defaults it comfortably exceeds most limits - which means the arithmetic can tell you the process is one dashboard away from a kill before the dashboard is written.
  • Make samples-touched part of dashboard review: series multiplied by range divided by step, worked for the panel default and again for the widest range a user can pick.
  • Never pin a step. A panel with a fixed step has a cost that scales with the time picker, and the time picker belongs to whoever opens the dashboard.
  • Treat Include All on a high-cardinality variable as a multiplier, not a convenience. It expands to a regex that matches everything.
  • Use recording rules as a memory control and not only as a latency one. A wide panel reading a pre-aggregated series is constant-cost however large the fleet grows.
  • Alert on prometheus_engine_queries reaching prometheus_engine_queries_concurrent_max. A saturated gate precedes the kill by tens of seconds and is the earliest signal on offer.
  • Alert on prometheus_rule_group_iterations_missed_total. This failure mode degrades alerting quietly, and an alerting platform that has silently stopped alerting is the worst state it can occupy.
  • Read data/queries.active after any unexplained restart. Prometheus maintains it for precisely this and reports it on the next start.