Skip to main content
RunBook Academy

← All runbooks in Observability

high riskcluster affecting~45 min

Runbook: Investigate Prometheus High Memory

1 · Prerequisites

Confirm every item is in place before any state change.

  • Prometheus Health
  • Metrics Capacity
  • The TSDB Engine
  • Memory Observability
  • Cardinality Incident Anatomy
  • PromQL Anatomy
  • Read access to the Prometheus HTTP API on the affected instance, including /api/v1/status/flags
  • Shell access on the Prometheus host or pod, enough to read the cgroup memory limit and the size of the WAL directory
  • The documented series and memory budget for this instance - the number the platform was sized for, not the number it currently shows
  • The deploy and dashboard change log for the last few hours, for both the scraped services and the stack itself

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The measurement is the PROCESS, not the host: process_resident_memory_bytes from Prometheus own /metrics, compared against the limit that actually applies to it. A host at 90 percent used with plenty of MemAvailable is a page cache doing its job, not a Prometheus problem.
  • · The enforced limit is read rather than assumed. Prometheus does not limit its own memory; the container runtime, the Kubernetes limit or the systemd MemoryMax= does. An instance with no limit at all is a different and more urgent incident, because it will take the host with it.
  • · Headroom is measured with a slope, not as a level: RSS now, RSS an hour ago, and the ceiling. That ratio is what decides whether there is time to investigate or whether a holding action has to come first.
  • · prometheus_tsdb_head_series is read at the SAME moment as RSS. The relationship between the two is the branch point for this whole runbook, and either number on its own is ambiguous.
  • · process_start_time_seconds is read. A Prometheus that started recently may be replaying its write-ahead log, which looks exactly like a memory incident and must not be restarted.
  • · The HA replica state is established. If a second replica is healthy it is both the evidence store and the alerting path; work on one at a time and never restart both.
  • · The blast radius is known: one instance or every instance. All of them at once points at a shared scrape target, a shared rule file or a shared dashboard rather than at a host.
  • · Recent changes have been gathered for BOTH halves of the question: deploys of the services being scraped, and changes to dashboards, recording rules or the Prometheus flags themselves.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Read the two numbers together. curl -s http://localhost:9090/metrics | grep -E "^(prometheus_tsdb_head_series|process_resident_memory_bytes) ". Every branch below is a statement about their relationship, and reading them an hour apart makes the relationship unknowable.
  2. 2Compute headroom before diagnosing anything. RSS against the enforced ceiling, plus the change over the last hour. Below roughly 20 percent headroom with a rising slope, take a holding action first and investigate second - an investigation that is interrupted by an OOM kill has cost time and produced nothing.
  3. 3Rule out WAL replay before touching the process. A recent process_start_time_seconds with /-/ready still refusing means Prometheus is rebuilding the head from the write-ahead log. High memory during replay is the expected shape, not the incident. Restarting resets the replay and starts the clock again - this is the loop that turns one OOM into an hour of downtime.
  4. 4Branch A: head series climbing together with RSS. This is a cardinality incident. Stop here and follow the high-cardinality runbook, which handles it in the only safe order: stop the inflow first, free the memory second. Do not restart to reclaim memory while the offending label is still arriving.
  5. 5Branch B: head series flat, RSS climbing. The memory is going into the query path. Read prometheus_engine_queries against prometheus_engine_queries_concurrent_max, and the p99 of prometheus_engine_query_duration_seconds. A wide range selector over a large series set - usually a dashboard, sometimes a recording rule - is the usual answer.
  6. 6Name the query before killing anything. The queries.active file under the TSDB path lists what is in flight right now; it is the only place a running Prometheus tells you what it is actually working on. Attribute it to a dashboard or a user before acting, because the same query will be re-run five minutes later by whatever issued it.
  7. 7Branch C: RSS stepped up at a specific time and stayed flat. Compare against the flags actually in effect via /api/v1/status/flags, and against the change log. A raised concurrency limit, a new rule group, an added scrape job or a legitimately larger target set is a capacity change, not a defect - and the fix is a budget decision rather than a mitigation.
  8. 8Branch D: slow monotonic growth over weeks, RSS tracking head series. This is drift, not an incident. It belongs in the capacity review with a number and a date, and paging on it is how a team learns to ignore the page.
  9. 9Choose the holding action by what it costs, not by what is nearest. Killing one query is nearly free. Bounding the query path costs a restart, because concurrency and timeout are process flags. Raising the memory ceiling is legitimate when the demand is intentional and useless when it is unbounded. Restarting costs a data gap and a replay.
  10. 10Do not lower retention expecting memory to fall. The head block is what lives in memory; persisted blocks are memory-mapped and do not consume heap. Reducing retention frees disk and deletes history, and buys no RAM at all. This is the commonest wrong move on this incident.
  11. 11If a restart is unavoidable, price it first. Measure the WAL directory to estimate the replay, and confirm what covers alerting while the instance is down: an HA replica, a remote-write receiver, or nothing. Then take the restart deliberately, and record the start time.
  12. 12Verify and close. RSS descending AND settled, head series inside the documented budget, rule evaluation not missing intervals, and the gap window written down. If the cause was a ceiling rather than a defect, the follow-up needs an owner before the incident is closed.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓process_resident_memory_bytes is descending and has SETTLED. A rapid fall followed by ten to fifteen minutes of flat RSS is normal; still flat and elevated an hour later is not, and means the memory went somewhere the mitigation did not reach.
  • ✓prometheus_tsdb_head_series is inside the documented budget for this instance, not merely lower than its peak. "Lower than the worst moment" is not a target anyone sized the host against.
  • ✓prometheus_rule_group_iterations_missed_total has stopped climbing. Missed evaluations are the mechanism by which this incident becomes an alerting incident, and they are the last symptom to clear.
  • ✓The query path is back to baseline: prometheus_engine_queries is below prometheus_engine_queries_concurrent_max and the p99 of prometheus_engine_query_duration_seconds matches its pre-incident level.
  • ✓/-/ready returns ready and process_start_time_seconds has not moved since the fix. An unchanged start time is the proof the process is not being OOM-killed and quietly restarting between two of your reads.
  • ✓The enforced ceiling is documented and matches reality: the flags in /api/v1/status/flags and the cgroup limit agree with what the runbook and the capacity plan claim.
  • ✓Any metrics gap is recorded with a start time, an end time and the affected jobs, whether it came from a restart, a dropped scrape job or an OOM kill.
  • ✓If the cause was intentional growth rather than a defect, the capacity follow-up exists as a ticket with an owner. Raising a limit and closing the incident is how the same page arrives again next quarter.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Everything up to the point of a change is read-only and has nothing to undo.
  • ↶Killing an in-flight query needs no rollback: whoever issued it will re-run it, which is exactly why naming the source matters more than killing the query.
  • ↶Changing --query.max-concurrency or --query.timeout requires a process restart in both directions, because they are command-line flags rather than reloadable configuration. Reverting therefore costs a second WAL replay - which is the reason to treat this as a considered change rather than a first move.
  • ↶Raising the memory ceiling is reversible on paper only. Once the process has grown into the new ceiling, lowering it triggers the OOM kill it was raised to avoid. Reduce the demand first, confirm RSS has settled below the old ceiling, and only then put the limit back.
  • ↶If a scrape job was dropped or a sample_limit lowered to shed series, revert by reload once the real cause is fixed. The window while it was live has no data for that job and never will.
  • ↶A restart has no rollback. The WAL replay gap in local data is permanent; check whether the HA replica or the remote-write receiver covered those minutes, and if neither did, say so in the incident notes.
  • ↶An OOM kill is a restart nobody chose, with the same permanent gap. If it happened during the investigation, treat the timeline as having a hole in it rather than assuming the graphs are continuous.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the host or platform owner immediately if the Prometheus process has no enforced memory limit. An unbounded process on a shared host does not fail alone, and that changes the incident from "monitoring is degraded" to "the host is at risk".
  • · Escalate to the owner of the dashboard, rule or service that is producing the expensive query. The collector-side mitigation stops the bleeding; only they can stop the query being issued every thirty seconds.
  • · Escalate to a second operator before restarting the last healthy replica. When the other replica is already down, that restart makes the platform blind for the length of the replay, and it should be a decision with two names on it.
  • · Escalate to capacity planning when the instance is at its documented ceiling with no single offender. Past a few million active series on one commodity host, the answer is sharding, federation or a long-term store - not one more relabel rule and not one more restart.
  • · Escalate to incident command as soon as the metrics gap overlaps an investigation already in progress. A team debugging a production incident has to be told that the evidence for those minutes does not exist.
  • · Treat this as a full incident, not a task, once recovery has taken more than an hour: at that point alert evaluation has been degraded for longer than most alerting rules ever wait, and the blast radius includes everything those alerts protect.

“Prometheus memory is climbing” is a symptom, not a diagnosis. At least four different incidents produce it, they have four different fixes, and exactly one of them is improved by adding RAM. Two of the four are not incidents at all.

The good news is that splitting them is one command. The bad news is that the reflex - restart it, or give it more memory - is right for one branch, actively harmful for two of the others, and merely expensive for the fourth.

When this runbook applies, and when it does not

It applies when the Prometheus process is consuming more memory than its budget allows, or is climbing towards that point.

It does not apply when:

  • The host is short of memory and Prometheus is not the largest consumer. That is a host incident. Prometheus may be its victim rather than its cause, and the fix is on the host, not in prometheus.yml.
  • Prometheus is already down and will not start. A process that exits during startup is a different runbook. This one assumes a running Prometheus you can still query, because every diagnostic here is an HTTP call to it.
  • The alert is on the host memory panel rather than on the process. Used memory on a Linux host is not a fault condition; a host at 92 percent used with healthy available memory is a page cache working correctly. Confirm the process before opening this runbook at all.
  • The memory pressure is on a remote-write receiver, a long-term store or a querier in front of Prometheus. Those have their own memory models and their own budgets; the head-series relationship this runbook is built on does not describe them.

Ask the second question first

Before diagnosing anything, answer a different question: how long do I have?

Headroom decides the order of everything else. With sixty percent of the ceiling free and a gentle slope, there is time to investigate properly and arrive at the real cause. With ten percent free and RSS rising, the process will be killed part-way through the investigation, and you will have paid for the investigation and received nothing - plus a replay, plus a gap.

That is not an argument for acting blindly. It is an argument for knowing which mode you are in, because the two modes have different correct first moves.

Read-only / Safehow much room is left, and how fast it is going
PROM=http://localhost:9090

# The process, not the host.
curl -s "$PROM/metrics" \
| grep -E '^(prometheus_tsdb_head_series|process_resident_memory_bytes|process_start_time_seconds) '

# The ceiling that actually applies. Prometheus does not limit itself.
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|| systemctl show prometheus -p MemoryMax

# The slope, over the last hour.
curl -sG "$PROM/api/v1/query" --data-urlencode \
'query=process_resident_memory_bytes - (process_resident_memory_bytes offset 1h)' \
| jq -r '.data.result[].value[1]'
prometheus_tsdb_head_series 1.84e+06
process_resident_memory_bytes 1.1817e+10
process_start_time_seconds 1.755e+09
16106127360
2214592512

Illustrative output

Read the host separately and read it correctly: a host at 92 percent used with healthy MemAvailable is a page cache doing its job. The number that predicts an OOM is available memory and memory pressure, not used memory. A dashboard that reports “used” will show this incident as fine right up to the kill.

The branch: two numbers, read at the same moment

prometheus_tsdb_head_seriesprocess_resident_memory_bytesWhat it isWhere to go
ClimbingClimbingA cardinality incidentThe high-cardinality runbook, not this one
FlatClimbingThe query pathBranch B below
AnyHigh, just after a restart, fallingWAL replayWait. Do not restart
FlatStepped up once, then flatA changeBranch C below
Slowly rising over weeksTracking itDriftA capacity review, not a page

Reading these an hour apart makes the relationship unknowable, which is why they come from a single call. “Head series was high yesterday and memory is high today” is two facts and no diagnosis.

Branch A - head series climbing with RSS

This is cardinality, and it has its own runbook because the ordering matters more than the commands: the inflow has to be stopped before the memory is freed, or the head simply refills on the same curve after the restart.

The only thing to add here is the handoff. Do not restart to reclaim memory while the offending label is still arriving. A restart in that state buys exactly one WAL replay of time and makes the following hour harder, because the replay consumes memory of its own and delays the first scrape cycle.

Go to the high-cardinality runbook and come back when the inflow is confirmed stopped.

Branch B - head series flat, RSS climbing

The series count is stable, so nothing new is being stored. The memory is being spent evaluating something.

Read-only / Safethe query path
PROM=http://localhost:9090

curl -s "$PROM/metrics" \
| grep -E '^prometheus_engine_queries(_concurrent_max)? '

curl -sG "$PROM/api/v1/query" --data-urlencode \
'query=histogram_quantile(0.99, sum by (le) (rate(prometheus_engine_query_duration_seconds_bucket[5m])))' \
| jq -r '.data.result[].value[1]'

# What is running RIGHT NOW. The path comes from the running flags,
# not from the unit file.
TSDB=$(curl -s "$PROM/api/v1/status/flags" | jq -r '.data["storage.tsdb.path"]')
cat "$TSDB/queries.active"
prometheus_engine_queries 20
prometheus_engine_queries_concurrent_max 20
14.8

Illustrative output

Concurrent queries pinned at the maximum is the signature. Each one holds its working set in memory until it completes or is killed, so the memory cost is the sum of the in-flight queries rather than the cost of the worst one.

queries.active is the piece operators usually do not know exists. It is the only place a running Prometheus names the queries it is currently working on, and it turns “something is expensive” into “this expression, from this dashboard”.

Name the source before killing anything. A query issued by a dashboard on a thirty-second refresh comes back thirty seconds later, and killing it in a loop while the dashboard is open is a way to spend an hour learning nothing.

Branch C - it stepped up once and stayed

A single step with a flat line either side is a change, not a leak. The step time is the search key.

Read-only / Safewhat the running process actually believes
PROM=http://localhost:9090

curl -s "$PROM/api/v1/status/flags" | jq '.data | {
retention: .["storage.tsdb.retention.time"],
path: .["storage.tsdb.path"],
concurrency: .["query.max-concurrency"],
timeout: .["query.timeout"]
}'

curl -s "$PROM/api/v1/status/config" | head -40

This reads the running process rather than the unit file or the repository. If the two disagree, that disagreement is itself the finding - and it is a common one after a change that was applied by editing a file nobody reloaded.

A step that lines up with a new scrape job, a new rule group or a raised concurrency limit is a capacity change. It is legitimate, it is not a defect, and the correct response is a budget decision made in daylight rather than a mitigation applied at 03:00.

Branch D - it has been growing for weeks

A line that has been rising gently since the last capacity review, with head series and RSS tracking each other, is drift. Nothing broke. The platform has been absorbing new targets, new services and new rules, one small increment at a time, and it has walked into its ceiling.

This is the branch where the correct response is to not treat it as an incident. There is no offender to name and no mitigation to apply; a relabel rule written at 03:00 against drift removes a label somebody chose on purpose. What it needs is the arithmetic, in daylight: current active series, the measured bytes per series on this instance rather than the planning range, the growth rate per week, and the date the projection crosses the ceiling.

The reason to be strict about this is the page. An instance that pages every few weeks for drift teaches the rotation that this alert does not mean anything, and the rotation is right - which is a problem the first time the same alert fires for Branch A.

Choosing the holding action

The actions are not interchangeable, and the table is the reason.

ActionReversible?What it costsWhen it is right
Kill one in-flight queryn/aNothing; it will be re-issuedBranch B, immediately
Lower --query.max-concurrencyYes, via another restartA restart now and a restart to revertBranch B, when the source cannot be stopped
Raise the memory ceilingOn paper onlyNothing now; an OOM later if growth is unboundedOnly when the demand is intentional
Drop or limit a scrape jobYes, on reloadThat job has no data for the window, everWhen one job is the whole cause
Restart PrometheusNoA replay-length gap, and the replay’s own memoryLast, and only when priced
Lower retentionYesDeleted history, and zero memory recoveredNever, for this symptom

Raising the ceiling deserves its own note, because it is the action this runbook is most often read as forbidding. It is not. When the series are intentional - a real service, deliberately instrumented, inside a budget somebody agreed to - then the instance is simply undersized and more memory is the honest answer. The test is whether the demand is bounded. Intentional growth has a ceiling you can name; an unbounded label does not, and against that, more memory buys time proportional to the growth rate and nothing else.

Pricing the restart

If the answer really is a restart, price it before taking it. The two costs are the replay time and the alerting gap, and both are measurable in advance.

Read-only / Safewhat the restart will cost
PROM=http://localhost:9090
TSDB=$(curl -s "$PROM/api/v1/status/flags" | jq -r '.data["storage.tsdb.path"]')

# The WAL is what has to be replayed. Bigger WAL, longer blind window.
du -sh "$TSDB/wal"

# Is anything else carrying alerting while this one is down?
curl -s "$PROM/api/v1/alertmanagers" | jq -r '.data.activeAlertmanagers[].url'
4.7G	/var/lib/prometheus/data/wal

Illustrative output

A large WAL is itself a finding: high series churn produces a big WAL, and churn is what makes restarts expensive. That is why the same mitigation costs five minutes on one instance and forty on another, and why “just restart it” is not a portable instruction.

Then answer the question the restart raises: who pages while this instance is replaying? An HA replica, a remote-write receiver, or nobody. If the answer is nobody, say so out loud before doing it rather than discovering it afterwards.

Common patterns

SymptomLikely causeResolution
Head series and RSS climbing togetherCardinalityThe high-cardinality runbook; stop the inflow first
RSS high and falling shortly after a startWAL replay in progressWait; measure the WAL to know how long
RSS climbing, head series flat, queries pinned at maxAn expensive query, usually a dashboardName it from queries.active, then talk to its owner
RSS stepped once and stayedA config, flag or rule changeDiff /api/v1/status/flags and the config against the repository
Memory falls fast then sits flat and high for an hourThe mitigation reached the head but not the real consumerRe-diagnose; the branch was wrong
Recovers, then recurs on the same scheduleA periodic report or a scheduled rule evaluationCorrelate the recurrence against cron and dashboard refresh intervals
Instance restarts repeatedly with no memory alertOOM kill between readsCompare process_start_time_seconds over time, not RSS

References

  1. Prometheus storage: head block, WAL and persisted blocks
  2. Prometheus HTTP API (status endpoints, flags, TSDB status)
  3. Prometheus command-line flags
  4. Prometheus management API (reload and lifecycle)
  5. PromQL querying basics: range selectors and evaluation cost
  6. Scrape configuration, including sample_limit and label limits
  7. promtool command-line reference