Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

advancedmetric-cardinality~30 min

Break/Fix: Cardinality Explosion

Reported symptoms

  • ●Prometheus resident memory has climbed from 6.2 GiB to 14 GiB over nine days as a smooth ramp with no step, and the process has been OOM-killed twice - both times while somebody had a 30-day dashboard open
  • ●`/var/lib/prometheus/data` has grown roughly fourfold in the same nine days, with retention, scrape interval and target count all unchanged
  • ●`AppHighErrorRate` did not fire during Thursday afternoon, when the error rate sat above its threshold for over an hour; the same rule fires normally at night
  • ●A 24-hour `increase()` panel on the deploy dashboard reports about a third of what the application access logs say
  • ●Neither cardinality alert has fired: head series is up around twenty percent, far below the five-million ceiling and nowhere near three times the six-hour baseline
  • ●Nothing in the application changed. No new metric, no new label in the instrumentation, and six weeks of quiet in the app repository

Evidence

  • · `prometheus_tsdb_head_series` has moved from about 2.1M to about 2.5M over nine days - a real rise, but not the shape the runbook is written for
  • · `rate(prometheus_tsdb_head_series_created_total[1h])` averages ~48 series/second on weekdays against a thirty-day baseline of ~0.7/second, and the trace is a sawtooth with a spike roughly every forty minutes
  • · `curl -s http://prometheus:9090/api/v1/status/tsdb | jq .data.labelValueCountByLabelName` lists `build` with 108 distinct values; `instance` has 40 and `job` has 11
  • · `count(count by (build) (app_http_requests_total))` returns 108; `count(count by (instance) (app_http_requests_total))` returns 40
  • · `ALERTS{alertname="AppHighErrorRate"}` carries a `build` label, and the rule expression is `sum without (status) (rate(app_http_requests_total[5m]))`
  • · `topk(20, count by (__name__) ({__name__=~".+"}))` shows no single metric family dominating - every family emitted by the `app` job has grown by the same factor
  • · Nine days ago the `app` scrape job moved from `static_configs` to `file_sd_configs`, and the CD pipeline now regenerates `/etc/prometheus/file_sd/app.json` on every deploy
  • · The `index` file in blocks written this week is several times the size of the `index` in blocks from ten days ago; `chunks` has grown far less
Diagnosis and resolutionclick to reveal

Root cause

The deploy pipeline writes the build SHA into the file service-discovery target file as a target label, so `build` is attached to every series every target in the `app` job exposes. A time series is identified by its metric name plus the whole of its label set, so changing one target label value does not annotate the existing series - it retires all of them and mints a fresh generation. With roughly a dozen deploys a weekday and about 3,000 series per target across 40 targets, each deploy creates another 120,000 series and abandons the 120,000 that came before. The level barely moves, which is why both cardinality alerts stayed quiet: retired series receive a staleness marker, stop being appended to, and are dropped from the head block at the next head truncation, so the instantaneous count only ever holds the few generations created inside the current truncation window. What moves is churn. Series created per hour is the quantity that drives WAL volume, block index size and compaction cost, and it rose by roughly seventy times. The index in each two-hour block now carries every generation minted during that window, which is why disk grew fourfold while chunk data barely changed, and why a 30-day query - which must merge every generation in every block it touches - is what kills the process. The same identity change explains the two symptoms that look like application problems rather than platform ones: a counter that changes identity restarts at zero under the new label set, so long-window range functions see a truncated window and undercount, and the alert rule aggregates with `without`, which preserves `build` in its output, so the alert's own series identity changes at every deploy and its `for: 10m` clock restarts before it can elapse.

Remediation

Stop the pipeline writing `build` into the target file, which removes the label at source, and re-point the deploy annotation at a single info series per target - `app_build_info{build="..."} 1` - which is the convention every exporter in the estate already follows for version information and costs one series per target rather than one label on all of them. If the pipeline cannot be changed inside the incident, `action: labeldrop` on `build` in the job's `metric_relabel_configs` is the fast brake: it strips the label after the scrape and before ingestion, so no further generations are minted, and it can ship in a config reload. Understand what the fix does not do. It does not shrink the blocks already written: the index bloat and the slow long-range queries persist until retention ages those blocks out, so the dashboard timeouts will not clear today and telling the reporting team otherwise is the fastest way to be called back. It also changes series identity one final time, so every rate and increase panel takes one more notch, every `without`-based rule resets its `for:` clock once more, and any recording rule whose output carried `build` will produce a new output series - which makes the middle of a release train the worst possible moment to apply it. The alert rule needs its own fix in the same change: aggregate with `by` over the labels the alert is about, so an unexpected label upstream can never again change the alert's identity. Hold is defensible if a release is in flight. Holding means leaving the label, telling the SRE team in writing that `AppHighErrorRate` cannot fire during releases and arranging cover for that, shortening the long-range dashboard panels so nobody OOMs the process again, and naming an engineer and a date at which the change ships.

Verification

Verify churn, not level, because level was never the symptom. After the reload, `rate(prometheus_tsdb_head_series_created_total[1h])` must fall back towards its thirty-day baseline and, critically, must stay flat across the next deploy - watch one deliberate deploy go past and confirm there is no spike. Confirm the label is gone rather than merely reduced: `/api/v1/status/tsdb` must no longer list `build` under `labelValueCountByLabelName`, and `count(count by (build) (app_http_requests_total))` must return no data rather than a smaller number. Confirm the derived series are clean: `ALERTS{alertname="AppHighErrorRate"}` must no longer carry a `build` label, and the pending clock must survive a deploy - the honest test is to hold the rule in pending across a release and watch it reach firing. Confirm the counters are continuous by running a 24-hour `increase()` over the deploy window and reconciling it against the application access logs; agreement within a few percent is the pass. Then confirm the replacement works, because a fix that removes the capability the change was made for will be reverted: `app_build_info` must exist, carry one series per target, and drive the deploy annotation. Finally, watch the platform settle rather than declaring it settled - RSS descending and block index sizes returning to their previous scale are both gradual, and the long range queries stay slow until retention has aged the bloated blocks out.

Prevention

Treat target labels as the highest-leverage labels in the estate. A label applied by `relabel_configs` or written into a service-discovery file is not attached to one metric; it is attached to every series every target in that job exposes, so a one-line change to a target file multiplies into the whole job. Review changes to discovery output with that multiplier in mind, and keep label values stable for unchanged hosts - a generator that rewrites a label on a target that has not otherwise changed is manufacturing churn. Alert on churn as well as on level. A rule on `rate(prometheus_tsdb_head_series_created_total[1h])` against its own baseline catches this entire class, and the level alerts on `prometheus_tsdb_head_series` structurally cannot, because the level is not what moved. Put version and build information in an info metric, which is what `node_exporter_build_info` and `prometheus_build_info` already do: one series per target carrying the identity in its labels, joined to other metrics at query time rather than stamped onto them at ingestion. Prefer `by` over `without` in any rule whose output feeds an alert, so the alert's identity is a decision the rule author made rather than a consequence of whatever labels arrive from upstream. And add distinct-value counts for every target label to the cardinality budget review, because a label with 108 values where the inventory says there should be 40 is visible in one query and invisible in nine days of dashboards.

Reported symptoms

Prometheus was OOM-killed at 09:40 on Tuesday and again at 15:10 on Thursday. Both times somebody was looking at a 30-day panel when it went. Between the kills, resident memory does not spike - it ramps, from 6.2 GiB nine days ago to 14 GiB now, with no step anywhere on the graph.

The data directory has grown about fourfold over the same nine days. Retention has not changed. The scrape interval has not changed. The target count has not changed.

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

  • AppHighErrorRate has a for: 10m gate. On Thursday afternoon the error rate was above threshold for over an hour and the alert never fired. The same rule fires perfectly well at night, which is why the SRE team has it filed as flaky rather than broken.
  • The deploy dashboard’s 24-hour increase() panel reports about a third of the request count the application’s own access logs record. It has been wrong for over a week and was assumed to be a units mistake.
  • Long-range queries are slow to the point of timing out. Anything under a few hours is fine.

And the two alerts that exist for exactly this failure have both stayed quiet. Head series is up around twenty percent - real, but nowhere near the five-million ceiling and nowhere near three times the six-hour baseline that the step detector watches for.

The application team has already ruled themselves out, and they are right to. No metric was added, no label was added to any instrumentation, and the app repository has been quiet for six weeks.

Evidence provided

Read-only / Safethe level - up from ~2.1M nine days ago, and not the story
$ curl -s 'http://prometheus:9090/api/v1/query?query=prometheus_tsdb_head_series' \
| jq -r '.data.result[0].value[1]'
2497318

Illustrative output

Read-only / Safeseries created per second - the thirty-day baseline is 0.7
$ curl -s --data-urlencode 'query=rate(prometheus_tsdb_head_series_created_total[1h])' \
http://prometheus:9090/api/v1/query | jq -r '.data.result[0].value[1]'
48.2

Illustrative output

Read-only / Safedistinct values per label name, straight from the TSDB
$ curl -s http://prometheus:9090/api/v1/status/tsdb \
| jq '.data.labelValueCountByLabelName'
[
{ "name": "build",    "value": 108 },
{ "name": "instance", "value": 40 },
{ "name": "job",      "value": 11 },
{ "name": "status",   "value": 7 },
{ "name": "method",   "value": 5 }
]

Illustrative output

Read-only / Safethe alert series itself, and what it is carrying
$ curl -s --data-urlencode 'query=ALERTS{alertname="AppHighErrorRate"}' \
http://prometheus:9090/api/v1/query | jq -c '.data.result[].metric'
{"__name__":"ALERTS","alertname":"AppHighErrorRate","alertstate":"pending","build":"9a2c1f","instance":"app-07:9100","job":"app","severity":"critical"}

Illustrative output

The app job’s discovery, as the CD pipeline writes it every deploy:

[
  {
    "targets": ["app-01:9100", "app-02:9100", "app-03:9100"],
    "labels": {
      "job": "app",
      "env": "prod",
      "build": "9a2c1f"
    }
  }
]

And the rule that has not been firing:

- alert: AppHighErrorRate
  expr: |
    sum without (status) (rate(app_http_requests_total{status=~"5.."}[5m]))
      /
    sum without (status) (rate(app_http_requests_total[5m]))
      > 0.05
  for: 10m
  labels:
    severity: critical

For scale: the app job has 40 targets, each exposing roughly 3,000 series, and the pipeline deploys about a dozen times on a weekday.

Work the evidence before reading on

The two alerts written for cardinality incidents are both correct and both silent. Take that as information rather than as a fault.

  1. The head series count rose by twenty percent. The series creation rate rose by a factor of about seventy. What is the difference between those two quantities, and which one does a level alert measure?
  2. build has 108 distinct values and instance has 40. The estate has 40 app targets. What is 108, and what happened roughly 108 times in the last nine days?
  3. The top-metric-families query returns a flat list. Under what cause would every metric family from one job grow by the same factor, and under what cause would one family dominate?
  4. Disk grew fourfold but the chunks files barely changed; the growth is in index. What does a block’s index contain that its chunks do not?
  5. The ALERTS series carries a build label. Where did the rule get it from, and what happens to a for: clock when the series it is counting against is not the same series it was a minute ago?
  6. Both OOMs happened while a 30-day panel was open. Why would query range matter here when the live series count is only twenty percent up?

Before continuing: which single change nine days ago is upstream of all six of those observations?

Root cause

A target label is attached to everything the target exposes

build is not a label on a metric. It is a label on a target, written into the file service-discovery output by the CD pipeline so the deploy dashboard could annotate releases. Target labels are merged into every sample the target returns, so build rides on all 3,000 series each app instance exposes, not on one.

A time series is identified by its metric name together with the whole of its label set. Changing one label value therefore does not annotate an existing series - it produces a different series. Every deploy retires the whole generation and mints a fresh one: 40 targets times 3,000 series, or about 120,000 series abandoned and 120,000 created, twelve times a weekday. Nine days of that is the 108 distinct build values the TSDB is reporting.

That single mechanism is upstream of everything on the list. Every family from that job grew by the same factor because the multiplier is applied at the target, not at the metric - which is exactly why the top-metric-families query that the cardinality runbook opens with returned a flat, useless list.

The level did not move, so the level alerts could not see it

This is the part worth internalising. A retired series receives a staleness marker, stops being appended to, and is dropped from the head block at the next head truncation. The instantaneous series count therefore only ever holds the generations created inside the current truncation window - a handful, not 108 - so prometheus_tsdb_head_series rose by twenty percent and sat there. Both alerts on that gauge behaved exactly as designed and neither had anything to fire on.

The quantity that moved is churn: series created per unit time, which prometheus_tsdb_head_series_created_total counts and nothing in this estate was watching. Churn is its own axis. It is what drives WAL volume, the size of each block’s index, and compaction cost, and it can rise by two orders of magnitude while the level stays almost flat.

That is also why the disk growth is in index and not in chunks. Every generation minted inside a two-hour window gets index entries in that window’s block - series references, postings for each label pair, symbol-table entries

  • while the samples themselves are unchanged in volume. The index carries the cost of how many series existed; the chunks carry the cost of how many samples arrived, and only the first of those changed.

And it is why a 30-day panel is what kills the process. A long-range query has to merge every generation in every block it touches. Ninety days of normal operation would have been about 120,000 app series to merge; nine days of this is over ten million. The live series count is a poor predictor of that cost, which is why the process looks survivable right up until somebody widens a time picker.

The two symptoms that looked like application faults

A counter that changes identity restarts at zero under its new label set. The old series ends, the new one begins, and nothing connects them - so a increase() over 24 hours sees only the fragment of the window that its current series existed for. That is the deploy dashboard undercounting by roughly two thirds, and it is arithmetic rather than a units mistake.

The alert is the same mechanism one level up. The rule aggregates with sum without (status), which drops status and keeps everything else - including build. The output series therefore carries the build SHA, so at every deploy the alert is evaluating a new series and its for: 10m clock starts again from zero. On a quiet night, with no deploys, the clock runs its full ten minutes and the alert fires. On a release afternoon, with a deploy every forty minutes or less, it never gets there. The rule is not flaky. It is being reset, by the pipeline, on a schedule.

Resolution

  1. Protect the process before you fix the cause. The OOMs come from long-range queries against bloated block indexes, so cap or shorten the 30-day panels first; that is the difference between working the incident and working it between restarts.
  2. Tell the SRE team that AppHighErrorRate cannot fire during a release, in writing, and arrange cover. That gap has existed for nine days and it does not close until the rule is changed.
  3. Apply the brake if the pipeline cannot be changed immediately: action: labeldrop on build in the app job's metric_relabel_configs, which strips the label after the scrape and before ingestion. Validate with promtool check config, then reload. No further generations are minted from the next scrape onward.
  4. Fix the cause at source. Stop the CD pipeline writing build into /etc/prometheus/file_sd/app.json. The relabel drop is a brake on a runaway; the target file is where the runaway starts.
  5. Ship the replacement in the same change, not after it. Expose app_build_info{build="..."} 1 from the application - one series per target, the convention node_exporter_build_info and prometheus_build_info already follow - and re-point the deploy annotation at it. A fix that removes the capability somebody asked for gets reverted by whoever asked.
  6. Correct the alert rule to aggregate with by over the labels the alert is actually about, so its identity is a decision the author made rather than whatever arrives from upstream. Test it with promtool test rules before it goes near production.
  7. Pick the moment. Removing the label changes series identity one final time: every rate and increase panel takes one more notch, every without-based rule resets its clock once more, and any recording rule that carried build produces a new output series. Outside a release window, with the team told in advance.
  8. Do not restart Prometheus to reclaim the memory. The RSS descent after the fix is gradual - the head truncates, blocks compact - and a restart adds a WAL replay under exactly the memory pressure you are trying to relieve.
  9. Open the follow-up before closing the incident: the churn alert, and the cardinality budget entry for target labels. The brake and the source fix both leave the estate one review away from the next generator that decides a label would be useful.

Verification

  1. Churn is back to baseline and stays there through a deploy. rate(prometheus_tsdb_head_series_created_total[1h]) returning to single digits proves the brake works; watching one deliberate deploy go past with no spike proves the cause is gone. Only the second is the real test.
  2. The label is absent rather than reduced. /api/v1/status/tsdb must no longer list build under labelValueCountByLabelName, and count(count by (build) (app_http_requests_total)) must return no data. A smaller number means something is still emitting it.
  3. The derived series are clean. ALERTS{alertname="AppHighErrorRate"} no longer carries build, and the pending clock survives a release - hold the rule in pending across a deploy and watch it reach firing rather than reset.
  4. The counters are continuous again. Run the 24-hour increase() over a window that contains several deploys and reconcile it against the application access logs; agreement within a few percent is the pass, and a third of the truth is the failure you started with.
  5. The replacement carries the capability. app_build_info exists, has exactly one series per target, and the deploy annotation on the dashboard reads from it.
  6. The platform is settling, not settled. RSS descending over hours rather than snapping back is the expected shape. Block index sizes returning to their previous scale applies only to blocks written from now on.
  7. Long-range queries are still slow, and you have said so. The blocks already on disk keep their bloated indexes until retention ages them out; telling the reporting team the dashboards are fixed today is a claim the disk will contradict.
  8. The churn alert fires when it should. Point it at the nine days of history you have just lived through and confirm it would have tripped on day one. An alert that cannot detect the incident that motivated it is decoration.

Prevention

  • Review target labels as job-wide changes, because that is what they are. A label written into a discovery file or added by relabel_configs lands on every series every target in that job exposes. The diff is one line; the blast radius is the job.
  • Keep label values stable for unchanged hosts. A generator that rewrites a label on a target which has not otherwise changed is manufacturing churn on a schedule, and the schedule is usually the deploy cadence - which is to say, it is worst exactly when the alerts matter most.
  • Alert on churn as well as level. A rule on rate(prometheus_tsdb_head_series_created_total[1h]) compared against its own baseline catches this whole class. The level alerts cannot, and this incident is nine days of proof.
  • Put identity in an info metric. app_build_info{build="..."} 1 is one series per target, joined at query time, and it is what every exporter in the estate already does with its own version. Build, release, commit and version belong there and nowhere else.
  • Prefer by over without in rules that feed alerts. without inherits whatever labels arrive from upstream, which makes the alert’s identity - and therefore its for: clock - a property of somebody else’s config.
  • Add distinct-value counts for target labels to the cardinality budget review. count(count by (build) (app_http_requests_total)) returning 108 against an inventory of 40 targets is one query and an obvious answer; nine days of dashboards had it hidden in plain sight.