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:
AppHighErrorRatehas afor: 10mgate. 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
$ curl -s 'http://prometheus:9090/api/v1/query?query=prometheus_tsdb_head_series' \
| jq -r '.data.result[0].value[1]'2497318Illustrative output
$ 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.2Illustrative output
$ 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
$ 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.
- 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?
buildhas 108 distinct values andinstancehas 40. The estate has 40 app targets. What is 108, and what happened roughly 108 times in the last nine days?- 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?
- Disk grew fourfold but the
chunksfiles barely changed; the growth is inindex. What does a block’s index contain that its chunks do not? - The
ALERTSseries carries abuildlabel. Where did the rule get it from, and what happens to afor:clock when the series it is counting against is not the same series it was a minute ago? - 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
- 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.
- Tell the SRE team that
AppHighErrorRatecannot 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. - Apply the brake if the pipeline cannot be changed immediately:
action: labeldroponbuildin theappjob'smetric_relabel_configs, which strips the label after the scrape and before ingestion. Validate withpromtool check config, then reload. No further generations are minted from the next scrape onward. - Fix the cause at source. Stop the CD pipeline writing
buildinto/etc/prometheus/file_sd/app.json. The relabel drop is a brake on a runaway; the target file is where the runaway starts. - Ship the replacement in the same change, not after it. Expose
app_build_info{build="..."} 1from the application - one series per target, the conventionnode_exporter_build_infoandprometheus_build_infoalready follow - and re-point the deploy annotation at it. A fix that removes the capability somebody asked for gets reverted by whoever asked. - Correct the alert rule to aggregate with
byover the labels the alert is actually about, so its identity is a decision the author made rather than whatever arrives from upstream. Test it withpromtool test rulesbefore it goes near production. - 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 carriedbuildproduces a new output series. Outside a release window, with the team told in advance. - 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.
- 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
- 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. - The label is absent rather than reduced.
/api/v1/status/tsdbmust no longer listbuildunderlabelValueCountByLabelName, andcount(count by (build) (app_http_requests_total))must return no data. A smaller number means something is still emitting it. - The derived series are clean.
ALERTS{alertname="AppHighErrorRate"}no longer carriesbuild, and the pending clock survives a release - hold the rule in pending across a deploy and watch it reach firing rather than reset. - 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. - The replacement carries the capability.
app_build_infoexists, has exactly one series per target, and the deploy annotation on the dashboard reads from it. - The platform is settling, not settled. RSS descending over hours rather than snapping back is the expected shape. Block
indexsizes returning to their previous scale applies only to blocks written from now on. - 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.
- 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_configslands 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="..."} 1is 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
byoverwithoutin rules that feed alerts.withoutinherits whatever labels arrive from upstream, which makes the alert’s identity - and therefore itsfor: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.