← All runbooks in Observability
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_bytesfrom 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_seriesis 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_secondsis 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.
- 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. - 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.
- 3Rule out WAL replay before touching the process. A recent
process_start_time_secondswith/-/readystill 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. - 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.
- 5Branch B: head series flat, RSS climbing. The memory is going into the query path. Read
prometheus_engine_queriesagainstprometheus_engine_queries_concurrent_max, and the p99 ofprometheus_engine_query_duration_seconds. A wide range selector over a large series set - usually a dashboard, sometimes a recording rule - is the usual answer. - 6Name the query before killing anything. The
queries.activefile 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. - 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. - 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.
- 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.
- 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.
- 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.
- 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_bytesis 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_seriesis 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_totalhas 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_queriesis belowprometheus_engine_queries_concurrent_maxand the p99 ofprometheus_engine_query_duration_secondsmatches its pre-incident level. - ✓
/-/readyreturns ready andprocess_start_time_secondshas 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/flagsand 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-concurrencyor--query.timeoutrequires 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_limitlowered 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.
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
2214592512Illustrative 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_series | process_resident_memory_bytes | What it is | Where to go |
|---|---|---|---|
| Climbing | Climbing | A cardinality incident | The high-cardinality runbook, not this one |
| Flat | Climbing | The query path | Branch B below |
| Any | High, just after a restart, falling | WAL replay | Wait. Do not restart |
| Flat | Stepped up once, then flat | A change | Branch C below |
| Slowly rising over weeks | Tracking it | Drift | A 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.
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.8Illustrative 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.
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 -40This 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.
| Action | Reversible? | What it costs | When it is right |
|---|---|---|---|
| Kill one in-flight query | n/a | Nothing; it will be re-issued | Branch B, immediately |
Lower --query.max-concurrency | Yes, via another restart | A restart now and a restart to revert | Branch B, when the source cannot be stopped |
| Raise the memory ceiling | On paper only | Nothing now; an OOM later if growth is unbounded | Only when the demand is intentional |
| Drop or limit a scrape job | Yes, on reload | That job has no data for the window, ever | When one job is the whole cause |
| Restart Prometheus | No | A replay-length gap, and the replay’s own memory | Last, and only when priced |
| Lower retention | Yes | Deleted history, and zero memory recovered | Never, 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.
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/walIllustrative 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
| Symptom | Likely cause | Resolution |
|---|---|---|
| Head series and RSS climbing together | Cardinality | The high-cardinality runbook; stop the inflow first |
| RSS high and falling shortly after a start | WAL replay in progress | Wait; measure the WAL to know how long |
| RSS climbing, head series flat, queries pinned at max | An expensive query, usually a dashboard | Name it from queries.active, then talk to its owner |
| RSS stepped once and stayed | A config, flag or rule change | Diff /api/v1/status/flags and the config against the repository |
| Memory falls fast then sits flat and high for an hour | The mitigation reached the head but not the real consumer | Re-diagnose; the branch was wrong |
| Recovers, then recurs on the same schedule | A periodic report or a scheduled rule evaluation | Correlate the recurrence against cron and dashboard refresh intervals |
| Instance restarts repeatedly with no memory alert | OOM kill between reads | Compare process_start_time_seconds over time, not RSS |
References
- Prometheus storage: head block, WAL and persisted blocks
- Prometheus HTTP API (status endpoints, flags, TSDB status)
- Prometheus command-line flags
- Prometheus management API (reload and lifecycle)
- PromQL querying basics: range selectors and evaluation cost
- Scrape configuration, including sample_limit and label limits
- promtool command-line reference