Prometheus on a Raspberry Pi: How Labels Multiply Time Series and How to Find the Offender

Contents
A Raspberry Pi running Prometheus is usually sized by the number of exporters, which is the wrong unit. Prometheus does not store metrics; it stores one time series per distinct combination of label values, and the number of those combinations is a product.
A product behaves differently from a sum. Adding a sixth exporter adds a few hundred series. Adding a sixth label to an existing metric multiplies everything that was already there.

Bounded and Unbounded Labels
The useful distinction is not how many values a label has today but whether its set of values is known in advance. method has five values and will still have five next year. status has a dozen. instance grows when a machine is added, which is a decision somebody makes.
A raw request path has as many values as there are strings, and it grows fastest when the site is under attack. Every scan for /wp-admin, /.env and /phpmyadmin creates a permanent time series in a monitoring system that was watching a site which does not have those pages.
| Label | Values | Bounded by |
|---|---|---|
job, instance |
a handful | the machines that exist |
method, status |
5 to 12 | the HTTP specification |
handler, route |
tens | the routes in the code |
path (raw) |
unbounded | nothing – it grows with scanners |
user_id, email |
unbounded | nothing, and it belongs in no monitoring system |
container_id, pod |
unbounded over time | nothing – a new value on every deploy |
The last row is the subtle one, because the number of series alive at any moment stays small. What grows without bound is the index, which has to remember every series that has ever existed within the retention window. A deploy every hour with a fresh container id produces a monitoring system that gets slower every week while the dashboard shows nothing unusual.
What It Costs in Practice
Memory is the binding constraint long before disk is. The head block holds every active series and its index in RAM, on the order of a few kilobytes per series, and a query touching many series needs room on top of that.
The failure is not gradual. Prometheus grows until the kernel kills it, restarts, replays the write-ahead log, grows again and gets killed again – usually within minutes of the first larger dashboard query. What looks like a broken exporter is a metric that gained a label three deploys ago.
The Three Queries That Find It
# which metric names carry the most series
topk(10, count by (__name__)({__name__=~".+"}))
# how many distinct values one label has, for one metric
count(count by (path) (http_requests_total))
# total series in the head block, and how fast it is growing
prometheus_tsdb_head_series
delta(prometheus_tsdb_head_series[1h])
The first query names the metric, the second names the label, and the third says whether the problem is a size or a leak. A head series count that rises steadily while nothing is being added is churn, and churn is always a label that changes when it should not.
These are worth running once on a healthy system, purely to know what normal looks like. A number without a baseline is not a diagnosis.
Dropping a Label Is One Line
The cheapest fix happens at scrape time, before anything is stored. A metric_relabel_configs block drops the label from the incoming samples, and everything downstream shrinks by the factor that label contributed.
scrape_configs:
- job_name: 'app'
static_configs:
- targets: ['app:9100']
metric_relabel_configs:
- regex: 'path|user_id|container_id'
action: labeldrop
Dropping is not the same as aggregating: the series for the individual paths disappear, and the counts they held are merged into whatever series remain. That is usually what was wanted anyway – almost nobody looks at a per-path request count, and the ones who do want it grouped by route rather than by URL.
Where the detail genuinely matters, the answer is to bound it at the source: label by handler or by a normalised route template rather than by the raw path, so that /product/12345 and /product/12346 become one series instead of two.
A Rule for New Metrics
Before a label is added, the question worth asking is what its value set is and who decides it. If the answer names a person or a configuration file, the label is bounded. If the answer is a visitor, a request, or an identifier, it is not – and it will be discovered later, from a Pi that no longer boots into a working Prometheus.
The same question also settles the privacy side of the matter, because the labels that are unbounded are exactly the ones that identify people. An email address in a metric label is a cardinality problem and a data protection problem in the same breath, and both are solved by not putting it there.
Questions and answers
Does a shorter retention period help against running out of memory?
Only against part of it. The head block in RAM only covers the most recent two to three hours, whether retention is two weeks or a year; a shorter retention does, however, limit how far churn inflates the index of the older blocks, and so makes queries over long periods faster. For the same reason, prometheus_tsdb_head_series does not drop immediately after a labeldrop, but only once the old series fall out of the head block at one of the next compactions.
How can a new label be kept from bringing the Pi down again?
With three safeguards that act at different points. None of them replaces the question from the article before every new label, but together they make sure that an overlooked answer costs one job rather than the machine.
sample_limitper job. If a target delivers more samples than allowed aftermetric_relabel_configs, Prometheus discards the entire scrape and setsupto 0 for that target. A slow death by memory exhaustion becomes a visibly failed target, and the other jobs carry on. A starting point for the limit isscrape_samples_post_metric_relabeling, which Prometheus records for every target by itself, with generous headroom above today’s value.- An alerting rule on growth. The query
delta(prometheus_tsdb_head_series[1h])from the article, given a threshold, reports a leak before memory runs short. - A memory limit for the container. If Prometheus runs in Docker, the kernel then kills only Prometheus when the limit is reached, not some arbitrary other process on the Pi. On Raspberry Pi OS this requires the memory cgroup controller to be enabled.