← All runbooks in Observability
Runbook: Investigate High Cardinality
1 · Prerequisites
Confirm every item is in place before any state change.
- Cardinality Incident Anatomy
- Detection
- Investigation
- Mitigation
- Recovery
- The Cardinality Budget
- Read access to the Prometheus HTTP API on the affected instance, including /api/v1/status/tsdb
- Shell access on the Prometheus host, or the ability to reach /-/reload if the lifecycle API is enabled
- Write access to the repository that owns prometheus.yml, and a path to merge and ship a change under incident conditions
- The change log or deploy timeline for the services this Prometheus scrapes - without it the investigation stops one step short
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The symptom is confirmed as cardinality, not memory:
prometheus_tsdb_head_seriesandprocess_resident_memory_bytesare BOTH climbing. A high RSS with a flat head series count is a different problem and this runbook does not apply. - · The shape is recorded: a vertical step (a deploy) or a staircase (drift).
rate(prometheus_tsdb_head_series_created_total[1h])separates churn from level. - · The blast radius is known: how many Prometheus instances are affected. All of them at once points at a shared scrape target or a shared rule file, not at one host.
- · The headroom is measured, not guessed: current RSS against host RAM. This is the number that decides whether there is time to investigate or whether the platform needs a hold action first.
- · Whether an HA replica is healthy is established. If one replica is surviving, that replica is the evidence store and must not be touched while the other is worked on.
- ·
prometheus_config_last_reload_successfulis read BEFORE any change, so the post-change read has a baseline to compare against. - · It is established whether the Prometheus process is under a wrapper (container runtime, process manager) that may not forward SIGHUP.
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Prove it is cardinality.
curl -s http://localhost:9090/metrics | grep -E "^(prometheus_tsdb_head_series|process_resident_memory_bytes) ". Both climbing together is the signature. Only RSS climbing is query load or exemplars - stop, this is the wrong runbook. - 2Separate level from churn. Query
prometheus_tsdb_head_series(the level) andrate(prometheus_tsdb_head_series_created_total[1h])alongsiderate(prometheus_tsdb_head_series_removed_total[1h])(the churn). High level with low churn is accumulation; high churn is a label whose values are being recreated, which behaves differently on restart. - 3Name the metric family.
curl -s http://localhost:9090/api/v1/status/tsdb | jq ".data.seriesCountByMetricName[:10]". If two families are within an order of magnitude of each other, investigate both - the smaller one is often the new arrival. - 4Name the label. From the same endpoint read
.data.labelValueCountByLabelName[:10], then confirm against the suspect family withcount by (LABEL) (METRIC)in PromQL. The offending label is the one whose distinct-value count is on the same order of magnitude as the family series count. - 5Name the target.
topk(5, sum by (job, instance) (rate(prometheus_target_scrape_pool_samples_added_total[5m])))attributes the growth to a scrape pool. Cross-checkcount by (job) ({__name__="METRIC"}). - 6Anchor to a change. Compare the first appearance of the new label values against
process_start_time_secondsfor the emitting targets and against the change log. The investigation is finished only when the sentence "label X on metric Y from job Z appeared at HH:MM with deploy N" has no blank clauses. - 7Choose the mitigation by blast radius, not by convenience.
labeldropon the one unbounded label is almost always right.action: dropon the whole family is right only when nothing downstream queries it. Check first: grep the rule files and the provisioned dashboards for the metric name and the label name. - 8Stage the rule. Add the
metric_relabel_configsentry to the affected scrape job in the repository, thenpromtool check config /etc/prometheus/prometheus.yml. A syntactically valid rule can still be semantically wrong; the staging Prometheus with the same rule files loaded is where that shows up. - 9Apply and confirm the reload actually happened.
promtool check configthenkill -HUP "$(pidof prometheus)"(orcurl -X POST http://localhost:9090/-/reloadwhere--web.enable-lifecycleis set). Then readprometheus_config_last_reload_successful; it must be1and the reload timestamp must be the one you just caused. Akillthat returns 0 is not evidence. - 10Confirm the inflow stopped before touching memory. Re-run the Layer 2 query. The count of series carrying the dropped label must flatline within one scrape interval. If it has not, the regex did not match - fix the rule, do not restart.
- 11Then, and only then, decide about the head. The relabel rule changes what is appended, not what is already held. If the host can survive until the head truncates, hold and watch. If it cannot, restart Prometheus to free the head and accept the WAL replay gap.
- 12Verify recovery, then write the follow-up. Head series back inside budget, RSS descending and settling, rule groups evaluating. The incident is not closed until the source-side fix has an owner and a ticket - the relabel rule is a brake, not a fix.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓
prometheus_config_last_reload_successfulis1and its reload timestamp matches the change you made, not a previous one. - ✓The offending series are no longer being appended:
count({__name__="METRIC", LABEL=~".+"})is flat or falling across two consecutive scrape intervals. - ✓
prometheus_tsdb_head_seriesis descending and lands inside the documented budget for this instance, not merely lower than its peak. - ✓
process_resident_memory_bytesis descending and has SETTLED. A fast head-count drop with a flat RSS is normal for ten to fifteen minutes and abnormal after that. - ✓Churn is back at baseline:
rate(prometheus_tsdb_head_series_created_total[1h])has returned to its pre-incident level. - ✓Nothing downstream broke: every recording rule that referenced the dropped label or metric still returns rows, and
prometheus_rule_group_iterations_missed_totalhas stopped climbing. - ✓The dashboards that used the affected metric render. A mitigation that stops the climb and blanks a panel has traded one incident for another.
- ✓The metrics gap is documented with a start time, an end time, and the list of jobs affected, because someone will ask.
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Everything up to the config change is read-only and has nothing to undo.
- ↶To undo the relabel rule: revert the commit,
promtool check config, reload, and confirmprometheus_config_last_reload_successfulis1again. The series resume on the next scrape; the window while the rule was live is not recoverable, which is why the blast-radius check comes before the rule and not after it. - ↶If
action: dropwas used and a dashboard went blank, replace it with the narrowerlabeldroprather than removing the mitigation entirely - the cardinality problem has not gone away while you fix the query. - ↶If
sample_limitwas lowered as a holding action, raise it back deliberately once the relabel rule is confirmed working; a scrape that fails on the limit is a whole target going dark, not a trimmed metric. - ↶If Prometheus was restarted, there is no rollback for the WAL replay gap. Record the gap window in the incident notes and check whether the HA replica or the remote-write receiver covered it.
- ↶If
delete_serieswas called, there is no rollback at all. The tombstones stand until compaction and the data does not come back. - ↶Any rule merged under pressure is temporary until it exists in the repository with a reviewer on it.
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate to the team that owns the emitting service as soon as the label is named - they own the source-side fix, and the relabel rule only buys the time to make it.
- · Escalate to a second operator BEFORE calling
delete_seriesor moving the WAL aside. Both are irreversible and both have been used at 03:00 with a selector nobody read twice. - · Escalate if the head series count does not fall within two scrape intervals of a confirmed reload: either the rule is matching nothing, or a second scrape job is emitting the same family and only one was patched.
- · Escalate to security if the exploding label values look like request input (raw paths, headers, tokens) - a cardinality incident driven by user-controllable values is a possible denial-of-service attempt, not an accident.
- · Escalate to the data-protection owner if any label value contains personal data or credentials. Those values are in every block, every backup, and every remote-write receiver for the retention period; deletion becomes a requirement rather than a choice.
- · Escalate to capacity planning if the platform is at its documented ceiling with no single offender. Past roughly five million active series on one commodity instance, the answer is sharding or a long-term store, not another relabel rule.
A cardinality incident is a meta-incident: the thing that breaks is the thing you use to see everything else break. While Prometheus is OOM-looping, alert rules are not evaluating, recording rules are not producing, and every dashboard that depends on the affected series is blank. The business is flying without instruments, and it is usually flying without instruments during a deploy window, because the deploy window is when the bad label arrived.
That framing sets the order of this runbook. The expensive mistake is not a wrong regex; it is doing things in the wrong sequence. There are two sequencing traps, and between them they account for most of the hours lost to this incident class.
Trap one: restarting before stopping the inflow
A restart frees the head block. It does nothing about what is arriving. If the offending label is still being scraped, the head refills on the same curve and you have bought exactly one WAL replay of time - while making the next hour harder, because replay itself consumes memory and delays the first scrape cycle.
Stop the inflow first. Free the memory second. That order holds even when the host is visibly close to the OOM line, because a restart that happens before the relabel rule exists has to happen again afterwards.
Trap two: writing the rule before measuring the blast radius
A labeldrop collapses N series into one. Anything downstream that
grouped by (that_label) now returns a single row where it returned a
breakdown, and it does so silently: no error, just a different answer.
An action: drop on a whole metric family blanks every panel and every
recording rule that reads it.
The check is thirty seconds - grep the rule files and the provisioned dashboards for the metric name and the label name - and it is the difference between one incident and two.
When this runbook applies, and when it does not
It applies when prometheus_tsdb_head_series and
process_resident_memory_bytes are climbing together. That pairing
is the definition of the symptom.
It does not apply when:
- RSS is high and head series is flat. The memory is going somewhere else - concurrent queries, exemplar storage, a large range selector running repeatedly. Re-diagnose before touching a relabel rule.
- One scrape job is failing its
sample_limitwhile the head is stable. That is the limit working as designed: loud at the source, quiet at the reservoir. It is a ticket for the job owner, not a page. - Prometheus is down for an unrelated reason. A process that will not start is a different runbook; this one assumes a running Prometheus you can still query.
- The growth is a staircase across months with no step. That is drift, and drift is a budget review, not an incident. It is the shape the step detector is blind to and the quarterly review exists to catch.
Blast radius
Steps 1 to 6 are read-only. From step 7 the runbook changes what Prometheus stores, and the changes are not symmetrical:
| Action | Reversible? | What it costs if wrong |
|---|---|---|
labeldrop on one label | Yes, on the next reload | The window while it was live has no per-label breakdown, ever |
action: drop on a family | Yes, on the next reload | Blank panels and empty recording rules for that window |
Lower sample_limit | Yes | The whole target goes dark, not just the noisy metric |
| Restart Prometheus | No | A gap the length of the WAL replay in local data |
delete_series | No | Tombstones stand until compaction; the data does not return |
Read that table in order. Every step above the line is preceded by the
thing that makes it reversible - a promtool check config, a repository
commit, a measured blast radius. Everything below the line needs a second
person.
Step 1 - Prove it is cardinality
curl -s http://localhost:9090/metrics \
| grep -E '^(prometheus_tsdb_head_series|process_resident_memory_bytes) 'prometheus_tsdb_head_series 1.84e+07
process_resident_memory_bytes 1.1817e+10Illustrative output
Eighteen million active series on a host with 16 GiB of RAM. With a planning figure of roughly 3 to 8 KiB of resident memory per active series, the arithmetic does not need a graph.
The cheap negative result matters as much as the positive one. If head series is flat, close this runbook. Every subsequent step in it is designed to reduce series, and reducing series does nothing for a process whose memory is going into query execution.
Step 2 - Separate the level from the churn
Two different failures wear the same badge on a memory graph.
PROM=http://localhost:9090
curl -s "$PROM/api/v1/query" --data-urlencode \
'query=prometheus_tsdb_head_series' | jq -r '.data.result[].value[1]'
curl -s "$PROM/api/v1/query" --data-urlencode \
'query=rate(prometheus_tsdb_head_series_created_total[1h])' | jq -r '.data.result[].value[1]'
curl -s "$PROM/api/v1/query" --data-urlencode \
'query=rate(prometheus_tsdb_head_series_removed_total[1h])' | jq -r '.data.result[].value[1]'A high level with creations and removals roughly balanced is accumulation: series arrived and are staying. A high creation rate with a matching removal rate is churn: the same conceptual series keeps being recreated under a new identity, which is what a per-pod or per-restart label value does.
The distinction changes what recovery looks like. Accumulation drains when the label stops arriving. Churn keeps paying WAL and index cost even after the level stabilises, and it is the shape that makes restarts expensive, because the WAL is large.
Step 3 - Name the metric family
The TSDB status endpoint answers this without running a query over the whole index.
curl -s http://localhost:9090/api/v1/status/tsdb | jq '{
series: .data.seriesCountByMetricName[:10],
labels: .data.labelValueCountByLabelName[:10]
}'{
"series": [
{ "name": "http_requests_total", "value": "8421301" },
{ "name": "http_request_duration_seconds", "value": "1204421" },
{ "name": "node_cpu_seconds_total", "value": "88201" }
],
"labels": [
{ "name": "session_id", "value": "8390114" },
{ "name": "instance", "value": "412" },
{ "name": "job", "value": "37" }
]
}Illustrative output
Read both halves. The first names the family that grew; the second names the label whose value space is on the same order of magnitude. When those two numbers are close, you have your answer in one call.
The equivalent PromQL is topk(10, count by (__name__) ({__name__=~".+"})).
Use count by, never sum by: count walks the inverted index, sum
reads sample values. On a Prometheus that is already under memory
pressure that difference is the difference between an answer and a
timeout.
If the live instance is too sick to answer, the offline path is
promtool tsdb analyze against a copy of the data directory. Copy
it - the analyzer reads the WAL, and running it against
/var/lib/prometheus/data on the live host adds work to a process that
has none to spare.
Step 4 - Name the label, and confirm it
The status endpoint gives a strong candidate. Confirm it against the suspect family before writing anything, because a label that is large platform-wide is not necessarily the label that is large on this family.
Run the two queries below against the suspect family. Substitute the family name and the candidate label name from step 3 before running them.
# How many series does the suspect family have in total?
count(http_requests_total)
# How many distinct values does the candidate label take on that family?
count(count by (session_id) (http_requests_total))
PROM=http://localhost:9090
curl -s "$PROM/api/v1/query" --data-urlencode \
'query=count(http_requests_total)' | jq -r '.data.result[].value[1]'
curl -s "$PROM/api/v1/query" --data-urlencode \
'query=count(count by (session_id) (http_requests_total))' | jq -r '.data.result[].value[1]'Two numbers within a factor of two of each other means the label is carrying the explosion: nearly every series is unique because of it. A label with a few dozen values on a family with millions of series is innocent, however alarming its name looks.
Step 5 - Name the target, then anchor to a change
curl -s http://localhost:9090/api/v1/query --data-urlencode \
'query=topk(5, sum by (job, instance) (rate(prometheus_target_scrape_pool_samples_added_total[5m])))' \
| jq -r '.data.result[] | "\(.metric.job)\t\(.metric.instance)\t\(.value[1])"'Then anchor. process_start_time_seconds on the emitting targets, the
deploy markers on the dashboard, and the change log together turn a
suspect into a cause.
The output of a finished investigation is one sentence with four filled clauses: label X on metric Y from job Z appeared at HH:MM, with change N. If the last clause is blank, you have a correlation and not a cause, and you are about to write a relabel rule you cannot justify in the review. The rule may still be correct - but you will not know, and if the head count does not fall you will not know whether the regex was wrong or the label was.
Step 6 - Choose the mitigation by blast radius
Three actions are available in metric_relabel_configs, and the choice
is a question about downstream consumers, not about elegance.
# /etc/prometheus/prometheus.yml
scrape_configs:
- job_name: app
metrics_path: /metrics
sample_limit: 10000
static_configs:
- targets: ['192.0.2.11:9100', '192.0.2.12:9100']
metric_relabel_configs:
# Preferred: keep the metric, remove the unbounded label.
# labeldrop matches LABEL NAMES.
- regex: 'session_id'
action: labeldrop
# Heavier: discard the whole family. drop matches label VALUES
# via source_labels, which is why __name__ appears here.
# - source_labels: [__name__]
# regex: 'http_requests_total'
# action: drop
labeldrop matches label names; drop matches label values selected
by source_labels. Confusing the two is the commonest reason a
syntactically valid rule changes nothing, and the confusion survives
several rewrites because the reload keeps succeeding.
Before you commit either one:
grep -rn 'http_requests_total' /etc/prometheus/rules/
grep -rn 'session_id' /etc/prometheus/rules/
grep -rln 'http_requests_total' /var/lib/grafana/dashboards/A hit in a rule file that groups by (session_id) means labeldrop will
collapse that rule to one row. That is not a reason to skip the
mitigation; it is a reason to tell the rule owner before they discover it
from a flat graph.
Step 7 - Apply, and confirm the reload actually happened
promtool check config /etc/prometheus/prometheus.yml
kill -HUP "$(pidof prometheus)"
# or, where Prometheus runs with --web.enable-lifecycle:
# curl -s -X POST http://localhost:9090/-/reload
sleep 30
curl -s http://localhost:9090/metrics \
| grep '^prometheus_config_last_reload_successful 'prometheus_config_last_reload_successful 1Illustrative output
The exit code of kill is not evidence. Container runtimes and some
process managers accept SIGHUP without forwarding it, and the failure
mode is an operator rewriting a correct regex three times while the
configuration on disk was never read. prometheus_config_last_reload_successful
is the only statement about what the running process is using; the
effective configuration is also readable at /api/v1/status/config, and
on an HA pair it is worth reading on both replicas, because patching one
and reloading the other is a real and frequent mistake.
Step 8 - Confirm the inflow stopped, before touching memory
Re-run the step 4 query. The count of series carrying the dropped label must flatline within one scrape interval.
It flattens; it does not fall. This is the part that makes operators think the rule failed. A relabel rule changes what is appended, not what is stored: series already in the head keep their memory until they stop receiving samples, go stale, and are dropped at the next head truncation. The graph goes flat immediately and RSS descends later, sometimes an hour or two later.
If the count is still climbing after two scrape intervals, the rule did not match. Fix the rule. Do not restart - a restart at this point destroys the evidence and buys nothing.
Step 9 - Decide about the head, deliberately
Now the question is only: can this host survive until the head drains itself?
If it can, hold. Holding is a first-class outcome here, not a failure to act. It has an owner (the operator on the incident), a checkpoint (head series and RSS re-read every ten minutes), and an exit condition (RSS below the pre-incident band, or crossing an agreed ceiling that triggers the restart). Write those three things into the incident channel so the next person does not restart it on your behalf.
If it cannot:
systemctl restart prometheus
journalctl -u prometheus | grep -i 'wal' | tail -5
curl -s http://localhost:9090/-/readyThe restart is safe now in a way it was not at step 1, because the relabel rule is live and the head will not refill on the same curve. The cost is a local data gap for the length of the WAL replay - minutes to tens of minutes, proportional to WAL size. On an HA pair, restart one replica at a time and let the surviving replica cover the window.
What “recovered” means
Not “the alert stopped firing”. The alert can stop firing because the scrape config now produces fewer samples, which is a statement about ingestion and not about health.
Recovered is: reload confirmed, offending series flatlined, head series inside the documented budget, RSS descended and settled, churn back at baseline, rule groups evaluating on schedule, and the gap in the graphs written down with a start and an end time. Closing before RSS settles hands the next shift a Prometheus at eighty percent memory that the next ordinary deploy will push over the line.
Common patterns
| What you see | Likely cause | Where to look |
|---|---|---|
| Vertical step in head series, aligned to a deploy | New unbounded label on an existing family | status/tsdb, then the change log for that window |
| Head series flat, RSS high | Not cardinality - query load or exemplars | Concurrent query count; re-diagnose before acting |
| Head falls after reload, then climbs again | A second scrape job emits the same family | count by (job) on the family; patch both jobs |
| Reload reports success, head unchanged | labeldrop regex matched a value, not a name | The two actions match different things; re-read step 6 |
| Head falls, RSS does not, for over 20 minutes | Head compaction stalled, or the Go runtime holding memory | prometheus_tsdb_compactions_failed_total, disk headroom |
| Every Prometheus in the estate at once | Shared scrape target or shared rule file | The common config, not the individual hosts |
| High churn, level stable | Per-restart or per-pod label value | Restart cost is high here - the WAL is large |
| Slow staircase over months, no step | Drift, not an incident | The quarterly budget review, not this runbook |
Prevention, in one paragraph
The relabel rule is the brake. The fix is at the source, and it needs a
named owner and a ticket before this incident is closed. The controls
that stop the next one are unglamorous and cheap: a sample_limit on
every scrape job so a misbehaving target fails loudly instead of quietly
draining the reservoir; an alert on both an absolute head-series ceiling
and a step change against a recent baseline, because each catches what
the other misses; a per-metric ceiling with an owner, reviewed on a fixed
cadence; and a drop-rules snippet already in the repository so that
mitigation under fire is a merge rather than an act of authorship.