A single Prometheus is one of the best engineering deals in infrastructure: scrape, store, alert, and query from one static binary with no external dependencies. That deal expires the moment your active series count, retention requirement, or dashboard concurrency outgrows one machine’s RAM and disk — and at that point most teams reach for a horizontally scalable backend without ever understanding the machinery between their Prometheus and that backend. The result is predictable: remote-write queues that silently fall hours behind, recording rules that cost more than they save, WALs that eat the disk during a receiver outage, and a “long-term storage” bill that nobody can explain.
This article is the missing operations manual for that middle layer. You will go deep on recording rules — the level:metric:operations naming discipline, rule-group evaluation mechanics, dependent rules, and the honest cost/benefit arithmetic of each rule — and deeper still on remote_write: the WAL-tailing mechanism underneath it, every queue_config parameter with its default and failure mode, retry and backoff semantics, write_relabel_configs filtering, metadata/exemplar/native-histogram forwarding, and what the Remote-Write 2.0 protocol changes on the wire. Then the receiving side: Thanos Receive, Grafana Mimir, VictoriaMetrics, and Cortex compared as remote-write backends, with retention and downsampling strategy per backend, HA-pair deduplication, capacity planning with worked numbers, and the alert rules that watch the shipping pipeline itself.
The framing assumption throughout: you already run Prometheus seriously and it hurts. Every section is written against real configuration keys, real metric names, and real failure modes — the things you only otherwise learn at 02:00 when prometheus_remote_storage_samples_pending is a straight line up and the on-call dashboard is thirty minutes stale.
What problem this solves
Prometheus is deliberately a vertically scaled, single-node system. It keeps an in-memory index of every active series (unique label-set), holds roughly the last two hours of samples in the head block, and persists immutable 2-hour blocks to local disk. There is no clustering, no replication, and no built-in horizontal query path. That design is why it is so reliable — and why it hits three hard walls:
| Wall | The metric that shows it | Symptom in production | The lever in this article |
|---|---|---|---|
| Cardinality / RAM | prometheus_tsdb_head_series climbing; process_resident_memory_bytes tracking it |
OOM-kills during head compaction; scrapes slow; restarts take 20+ min replaying WAL | Recording rules, write_relabel_configs, source-side relabeling |
| Retention / disk | prometheus_tsdb_storage_blocks_bytes vs volume size |
15 days of local data max; capacity planning and compliance queries impossible | Remote-write to object-storage-backed backend; retention tiers |
| Query load | prometheus_engine_query_duration_seconds; dashboard p99 |
30-day histogram_quantile panels time out; one Grafana refresh spikes CPU 4x |
Recording rules; downsampling; query-frontend caching at the backend |
| Durability / HA | (none — that is the problem) | Node dies, metrics history dies with it; HA pair shows duplicate series in Grafana | Remote-write from HA pairs + receiver-side deduplication |
| Global view | N/A — per-cluster silos | 12 Prometheus servers, 12 datasources, no cross-cluster SLO query | One remote-write backend, one datasource, external labels |
Without this layer done right, teams suffer in specific, recurring ways. The on-call engineer restarts a 200 GB-WAL Prometheus and waits 25 minutes for replay while flying blind. A deploy adds one label and doubles series; the remote-write queue can no longer drain and the WAL grows until the disk fills — taking scraping down with it, which is how a storage problem becomes a visibility outage. Somebody sets max_shards: 200 after reading a blog post, and the receiving Mimir cluster’s distributors saturate, returning 429s to every tenant, converting one team’s mistake into everyone’s incident. A year later, finance asks why object storage costs ₹4 lakh a month and nobody can say which tenant, which metric, or which retention tier is responsible.
Who hits this: any platform team past roughly 1–2 million active series or more than two or three Kubernetes clusters, anyone with retention requirements past 30 days, and every team standing up a central observability platform for multiple product teams. The knowledge here is the difference between a metrics platform with a predictable cost curve and an unbounded science experiment.
Learning objectives
By the end of this article you can:
- Name and apply the
level:metric:operationsrecording-rule convention, structure rule groups so dependent rules evaluate in the right order, and compute the cost/benefit of any individual rule in series and CPU terms. - Explain the remote-write pipeline end to end — appender → WAL → WAL watcher → queue manager → shards → HTTP — including exactly when samples are dropped versus retried, and how long a receiver outage is survivable.
- Tune every
queue_configparameter (capacity,min_shards/max_shards,max_samples_per_send,batch_send_deadline,min_backoff/max_backoff,retry_on_http_429,sample_age_limit) from measured ingest rate and round-trip latency, not folklore. - Filter the remote stream with
write_relabel_configs, forward exemplars and native histograms deliberately, and state what Remote-Write 2.0 changes (string interning, inline metadata, created timestamps, stats headers). - Choose between Thanos Receive, Mimir, VictoriaMetrics, and Cortex as a remote-write receiver using an operational comparison — tenancy, dedup mechanism, downsampling, storage medium, license — rather than feature folklore.
- Decide between agent mode, full server + remote-write, and federation for each cluster topology, and defend the decision.
- Do the capacity math: active series → samples/s → bytes/s → GB/month → INR/month, plus receiver component sizing, with worked numbers you can adapt.
- Run HA Prometheus pairs into each backend without duplicate series, and monitor the pipeline itself with
prometheus_remote_storage_*metrics and production-grade alert rules.
Prerequisites & where this fits
You should be fluent in PromQL (rate, sum by, histogram_quantile) — PromQL in Anger: Rate, Histograms, and Aggregation Patterns That Actually Work is the companion — and comfortable with Prometheus configuration: scrape_configs, relabeling, external_labels. You need a working mental model of series cardinality; Taming Metric Cardinality: Relabeling, Limits, and Cost Governance in Prometheus covers the source-side controls this article assumes. Kubernetes familiarity helps for the receiver deployments but nothing here requires it — everything in the lab runs in plain Docker.
This article sits at the centre of the observability track and hands off to three sibling deep-dives on the receiving side:
| Layer | What it covers | Where it is covered |
|---|---|---|
| Query language & dashboard cost | Why queries are expensive; what rules should precompute | PromQL in Anger, Engineering Grafana Dashboards That Get Used |
| Source-side cardinality | relabel_configs vs metric_relabel_configs, per-scrape limits |
Taming Metric Cardinality |
| This article | Rules, remote_write internals, receiver choice, capacity, HA, pipeline monitoring | — |
| Thanos operations | Sidecar vs Receive, Store Gateway, Compactor, query fan-out | Thanos in Production |
| Mimir operations | Microservices deployment, tenancy, limits, rollout | Running Grafana Mimir |
| VictoriaMetrics operations | Cluster components, vmagent, storage tuning | Configure VictoriaMetrics Cluster for High-Cardinality Long-Term Metrics Storage |
| Alert delivery | Routing what the ruler fires | Designing Alertmanager Routing Trees |
Core concepts
Six mental models carry every later section.
1. Samples flow through the WAL, and remote-write is a WAL tail. Every scraped sample is appended to the in-memory head and to the write-ahead log (WAL) — 128 MB segment files under wal/. Since Prometheus 2.8, remote-write does not intercept scrapes; a WAL watcher tails those segment files from disk and feeds a per-endpoint queue. This is the single most important fact in this article, because it explains everything else: why a receiver outage does not immediately lose data (the WAL is the buffer), why the buffer is finite (the WAL is truncated roughly every two hours), why remote-write survives a Prometheus restart (it checkpoints its read position), and why a fallen-behind queue cannot recover data older than the last truncation (remote-write never reads from compacted blocks — WAL only).
2. An active series is the unit of memory; a sample is the unit of throughput. Memory on the sender and the receiver scales with active series (unique label-sets currently receiving samples). Network, CPU, and storage scale with samples per second, which is simply active_series / scrape_interval. Keep the two axes separate in your head: relabeling attacks series; scrape interval and filtering attack samples.
3. Recording rules move cost from query time to write time. A rule evaluates an expression every interval and appends the result as a new series — permanently, into the TSDB and the remote stream. You pay evaluation CPU plus stored-series cost forever; you save every dashboard refresh and alert evaluation that would have recomputed it. The trade is only profitable when the rule aggregates labels away and the output is read often. A rule that keeps all input labels is pure overhead.
4. The receiver owns durability, tenancy, and the long read path. Once samples cross the remote-write HTTP boundary, everything — replication, object storage, retention, compaction, deduplication, per-tenant limits, long-range query — belongs to the backend. Your Prometheus becomes a shipping agent with a short local window (or, in agent mode, no queryable window at all).
5. Backpressure travels backwards. Receiver slow → shards saturate → queue backs up → WAL watcher falls behind → WAL grows on disk → disk fills → scraping and rule evaluation stop. Every incident in this pipeline is a variation of that chain, and every monitoring section later maps a metric to a link in it.
6. HA is two independent scrapers, deduplicated at the read or ingest side. Prometheus HA is not clustering — it is two identical servers scraping the same targets, distinguished by an external label, with the duplicate stream collapsed by the receiver (Mimir/Cortex HA tracker at ingest; Thanos replica-label dedup at query; VictoriaMetrics dedup at storage). The pair doubles sender-side cost; whether it doubles receiver-side cost depends on the backend, and that difference shows up directly in the bill.
The moving parts, in one table:
| Component | Lives where | One-line role | Failure signature |
|---|---|---|---|
| Head block | Prometheus RAM | Last ~2 h of samples + series index | OOM on cardinality spikes |
| WAL | <data>/wal/ on disk |
Crash-recovery log; remote-write source | Disk growth when remote-write lags |
| WAL watcher | Prometheus process | Tails WAL segments for each remote endpoint | highest_sent_timestamp stalls |
| Queue manager | Prometheus process | Per-endpoint buffering, sharding, retries | samples_pending climbs |
| Shard | Prometheus process (1…max_shards) | One goroutine batching + POSTing samples | shards_desired > shards_max |
| Recording rule / group | Prometheus or backend ruler | Precompute expressions into new series | rule_group_iterations_missed_total |
| Receiver (distributor) | Backend edge | Validate, rate-limit, shard to ingesters | 429/400 responses; discarded_samples |
| Ingester / vmstorage / receive TSDB | Backend | Hold recent samples in memory, cut blocks | Ingest 5xx; series limits hit |
| Object storage / disk | S3/GCS/Azure Blob (VM: local disk) | Durable block store | Cost growth; compactor backlog |
| Compactor | Backend | Merge blocks, enforce retention, downsample (Thanos) | Query slowdown; storage bloat |
| Query path | Backend (querier/frontend) | PromQL across recent + historical | Slow 30-day queries |
| Ruler (backend-side) | Backend | Evaluate rules against the global view | Rules on partial data if run per-cluster |
One more distinction that saves arguments in design reviews — what stays local versus what ships:
| Concern | Keep on local Prometheus | Push to the backend |
|---|---|---|
| Short-window on-call queries (last 2–24 h) | Yes — fastest path, survives backend outage | Also available, slightly behind |
| Alert evaluation for cluster-scoped alerts | Yes — fewest moving parts between signal and page | Optional duplicate at ruler |
| Cross-cluster / global SLO rules | No — each Prometheus sees a slice | Yes — backend ruler sees everything |
| Retention beyond ~15–30 days | No — single-disk risk and cost | Yes — object storage economics |
| High-cardinality debug metrics | Optionally, short retention | Filter out with write_relabel_configs |
| Multi-team query concurrency | No — one node melts | Yes — horizontally scaled read path |
Recording rules: precompute what dashboards read
A recording rule is a contract: “this expression is important enough to evaluate every interval and store forever.” Treat each one as a small piece of infrastructure with a name, an owner, a cost, and a test.
The level:metric:operations naming convention
The community convention — used by the kubernetes-mixin, node-exporter mixin, and every serious rule corpus — encodes what a recorded series is into its name:
level : metric : operations
| Segment | Meaning | Examples | Rule |
|---|---|---|---|
level |
The labels that REMAIN after aggregation (the output’s identity) | job, cluster_namespace, instance_path, job_le |
Join multiple labels with _, most general first |
metric |
The source metric name, _total suffix dropped for counters under rate |
http_requests, node_cpu_seconds |
Singular source metric; if the expr joins several, name the dominant one |
operations |
Operations applied, NEWEST FIRST, joined with _ |
rate5m, ratio_rate5m, sum_rate1m, irate3m |
The window is part of the name — rate5m and rate1m are different series |
Worked examples, from cheap to layered:
| Recorded name | Expression shape | Reading it back |
|---|---|---|
instance:node_cpu:rate5m |
sum by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m])) |
Per-instance CPU rate over 5m |
job:http_requests:rate5m |
sum by (job) (rate(http_requests_total[5m])) |
Per-job request rate |
job_method:http_requests:rate5m |
sum by (job, method) (rate(...)) |
Two levels kept |
job:http_request_errors:ratio_rate5m |
error rate5m / total rate5m per job | A ratio of two rates; ratio is the newest op so it leads |
job_le:http_request_duration_seconds_bucket:rate5m |
sum by (job, le) (rate(..._bucket[5m])) |
Bucket rates ready for histogram_quantile |
cluster:slo_errors:budget_burn_rate5m |
multiwindow SLO input | Feeds burn-rate alerts (see the SLO article) |
The convention is not cosmetic. It makes collisions with scraped metrics impossible (scraped names never contain :), makes the aggregation level machine-parsable, and lets a reviewer spot a broken rule from the name alone — a job:... rule whose expression forgets sum by (job) is visibly wrong. Enforce it in CI with a regex gate: every record: must match ^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+$.
Rule groups: intervals, ordering, and the fields that matter
Rules live in files referenced by rule_files: in prometheus.yml, organized into groups. The group is the unit of scheduling, ordering, and failure isolation:
| Group field | Default | What it controls | Expert notes |
|---|---|---|---|
name |
required | Identity in metrics (rule_group label) and the UI |
One concern per group; the name shows up in every pipeline metric |
interval |
global evaluation_interval (default 1m) |
How often the whole group evaluates | Align with the query window: a rate5m rule at interval: 4m leaves visible stair-steps; 30s–1m is typical |
limit |
0 (unlimited) |
Max series a single rule (or alerts an alerting rule) may produce | Set it — a bad label join can emit 500k series in one tick; limit: 5000 turns that into a contained evaluation failure |
query_offset |
0s (or global rule_query_offset, Prometheus ≥ 2.53) |
Evaluates expr as of now - offset |
Essential when the ruler runs on remote-write-fed data that arrives late; 1m absorbs typical ingest lag |
rules |
required | The ordered list | Order is semantics, not style — see below |
Evaluation mechanics you must know cold:
- Rules within a group run sequentially, sharing one evaluation timestamp. Rule N can reference the output of rule N-1 from the same tick — the earlier result is committed before the later rule runs. This is what makes dependent rule chains deterministic.
- Groups run concurrently and independently. A rule that reads another group’s output sees that group’s previous evaluation — up to one interval stale. Never split a chain (e.g., a rate and the ratio built on it) across groups unless a one-interval skew is acceptable.
- A slow group self-destructs quietly. If evaluation takes longer than
interval, the next tick is skipped andprometheus_rule_group_iterations_missed_totalincrements. Nothing crashes; your recorded series just silently gain gaps, which then break therate()calls that read them. - Groups are the unit of parallelism. Fifty rules in one group evaluate serially; five groups of ten evaluate five-wide. Split hot groups both for latency and for blast-radius isolation — one failing rule marks the group’s evaluation failed, not the file’s.
A production-shaped rule file, with a dependent chain done correctly:
# rules/http-aggregations.yml
groups:
- name: http_requests_aggregation
interval: 30s
limit: 5000
rules:
# Layer 1: collapse instance/pod. This is the expensive evaluation.
- record: job_method_code:http_requests:rate5m
expr: sum by (job, method, code) (rate(http_requests_total[5m]))
# Layer 2: reads Layer 1 FROM THIS SAME TICK - same group, later rule.
- record: job:http_requests:rate5m
expr: sum by (job) (job_method_code:http_requests:rate5m)
- record: job:http_request_errors:ratio_rate5m
expr: |
sum by (job) (job_method_code:http_requests:rate5m{code=~"5.."})
/
sum by (job) (job_method_code:http_requests:rate5m)
- name: http_latency_aggregation
interval: 30s
rules:
# Keep le - histogram_quantile needs it. Drop everything else.
- record: job_le:http_request_duration_seconds_bucket:rate5m
expr: sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
- record: job:http_request_duration_seconds:p99_rate5m
expr: histogram_quantile(0.99, job_le:http_request_duration_seconds_bucket:rate5m)
Layering matters for cost: layer 1 touches the raw high-cardinality data once; every further rollup reads the already-small recorded series. Ten dashboards and six alerts then read layer 2 for near-zero cost.
The cost/benefit ledger for a single rule
Every rule has four costs and one benefit. Make the arithmetic explicit before merging:
| Ledger entry | How to compute it | Worked example (fleet: 40 jobs × 30 pods, histogram 12 buckets × 3 methods) |
|---|---|---|
| Evaluation CPU (cost) | Raw series the expr touches × evals/min | sum by (job, le) (rate(..._bucket[5m])) touches 40×30×12×3 = 43,200 series every 30 s |
| New stored series (cost) | Output cardinality, forever | job_le output: 40×12 = 480 series |
| New samples/s (cost) | Output series / interval | 480 / 30 s = 16 samples/s — also shipped over remote-write |
| Staleness (cost) | Up to interval + expr window |
30 s behind live; irrelevant for dashboards, relevant for sub-minute alerting |
| Query-side saving (benefit) | (Raw series − recorded series) × reads/day | Latency panel: reads 480 instead of 43,200 series; at 6 panels × 4 dashboards × 1 refresh/30s, ~90× less data scanned per refresh |
The heuristics that fall out of the ledger:
| Rule shape | Verdict | Why |
|---|---|---|
| Aggregates 100:1 or better, read by dashboards/alerts | Always record | Overwhelming query saving, trivial storage |
| Aggregates ~5:1, read once a day | Do not record | Evaluation + storage forever for one cheap ad-hoc query |
| Keeps every input label (“rename” rule) | Never record | Doubles series for zero query saving |
histogram_quantile directly recorded per pod |
Never record | Quantiles do not aggregate — record bucket rates, compute quantiles at read |
| Ratio of two recorded aggregates | Record | Tiny inputs, read constantly by SLO dashboards |
| Input to burn-rate alerting (multi-window) | Always record | Alert expressions stay readable and cheap — see SLOs and Error Budgets in Practice |
One trap deserves its own paragraph: recording quantiles is lossy. histogram_quantile(0.99, ...) per job is fine to record for that job-level view only — you can never re-aggregate recorded p99s across jobs (the average of p99s is not the p99). Record the bucket rates at each level you need, and compute quantiles at read time. The same logic applies to recorded avg — record sum and count separately so downstream rollups stay correct.
Testing rules with promtool
Rules are code; test them like code. promtool ships in the Prometheus tarball and container image and covers syntax, semantics, and behaviour:
| Command | What it validates | When it runs |
|---|---|---|
promtool check config prometheus.yml |
Config + referenced rule files parse and cross-reference | CI, every commit |
promtool check rules rules/*.yml |
Rule YAML syntax, expression parse, duplicate detection | CI, every commit |
promtool test rules tests/*.yml |
Behaviour: synthetic input series → expected outputs at points in time | CI, every commit |
promtool query instant http://prom:9090 'job:http_requests:rate5m' |
The rule actually produces data in a live environment | Post-deploy smoke |
promtool tsdb analyze /prometheus |
Where cardinality actually lives (top metrics/labels per block) | Capacity reviews |
A behaviour test uses the expanding notation value+incrementxCount to synthesize counters, then asserts on the recorded output:
# tests/http-aggregations.test.yml
rule_files:
- ../rules/http-aggregations.yml
evaluation_interval: 30s
tests:
- interval: 30s
input_series:
# A counter increasing 60/min per pod: rate = 2/s each.
- series: 'http_requests_total{job="checkout", method="GET", code="200", pod="a"}'
values: '0+60x20'
- series: 'http_requests_total{job="checkout", method="GET", code="500", pod="a"}'
values: '0+6x20'
promql_expr_test:
- expr: job:http_requests:rate5m
eval_time: 5m
exp_samples:
- labels: 'job:http_requests:rate5m{job="checkout"}'
value: 2.2 # (60+6)/30 = 2.2 samples/sec
- expr: job:http_request_errors:ratio_rate5m
eval_time: 5m
exp_samples:
- labels: 'job:http_request_errors:ratio_rate5m{job="checkout"}'
value: 0.09090909090909091 # 6/66
$ promtool test rules tests/http-aggregations.test.yml
Unit Testing: tests/http-aggregations.test.yml
SUCCESS
The discipline that makes this pay: assert exact float values (they are deterministic given synthetic inputs), test the dependent chain end to end (the ratio rule above implicitly tests its layer-1 input), and add a regression test whenever a rule change breaks a dashboard — the test file becomes the executable history of every subtle rate window and label-matching bug you have shipped.
Rule anti-patterns and their fixes
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
| Rule keeps all input labels | Stores a full copy of the input; zero read saving | Aggregate at least one label away or delete the rule |
| Recorded per-pod quantiles re-averaged upstream | Mathematically wrong results | Record bucket rates per level; histogram_quantile at read |
| 200 rules in one group | Serial evaluation exceeds interval; missed iterations | Split by concern; watch last_duration_seconds per group |
| Cross-group dependency assumed same-tick | Reads data up to one interval stale; races on restart | Put chains in one group, dependency-ordered |
interval: 5s on everything |
12× the evaluation CPU and samples of 1m for invisible benefit | Match interval to read cadence; 30s–1m almost always |
Rule on top of rate() of a recorded rate() |
Meaningless math (rate of a gauge-like rate) | Chain sums/ratios, never re-rate recorded rates |
No limit on groups |
One join mistake emits unbounded series | limit: sized ~4× expected output |
| Rules only on local Prometheus in an agent-mode fleet | Agent mode does not evaluate rules — they silently never run | Move rules to the backend ruler (Mimir/Thanos/Cortex ruler) |
Inside remote_write: WAL, watcher, shards
Everything about remote-write tuning becomes obvious once you can narrate the pipeline. Here is the path of one sample, in order, with the failure mode at each hop:
| # | Stage | What happens | What can go wrong | Metric that shows it |
|---|---|---|---|---|
| 1 | Scrape → appender | Sample appended to head block + WAL segment | Cardinality explosion (head grows) | prometheus_tsdb_head_series |
| 2 | WAL write | Record hits wal/ segment (128 MB files) |
Disk latency stalls appends | prometheus_tsdb_wal_fsync_duration_seconds |
| 3 | WAL watcher | Per-endpoint tailer reads new records from disk | Falls behind if queue is full | prometheus_remote_storage_highest_timestamp_in_seconds vs sent |
| 4 | Series cache | Watcher resolves series refs → label sets (in-memory map, rebuilt from WAL/checkpoints) | Memory cost ∝ active series per endpoint | prometheus_remote_storage_string_interner_zero_reference_releases_total (interning churn) |
| 5 | write_relabel_configs |
Per-endpoint filtering/rewriting | Dropping too much/too little | prometheus_remote_storage_samples_dropped_total |
| 6 | Queue manager → shards | Samples fan out to N shards by series hash | Under-sharded: backlog; over-sharded: receiver overload | ..._shards, ..._shards_desired |
| 7 | Shard buffer | Each shard buffers up to capacity samples |
Full buffers block the watcher (backpressure, by design) | prometheus_remote_storage_samples_pending |
| 8 | Batch + send | Up to max_samples_per_send per POST, snappy-compressed protobuf, or on batch_send_deadline |
Latency/timeouts; 4xx/5xx | ..._sent_batch_duration_seconds, ..._samples_failed_total |
| 9 | Retry loop | 5xx/429: retry same batch with backoff, IN ORDER (head-of-line blocking per shard) | Extended outage → WAL growth | ..._samples_retried_total |
| 10 | WAL truncation | Every ~2 h Prometheus checkpoints and truncates old segments | If watcher is still behind the truncation point → permanent data loss for the remote | prometheus_wal_watcher_current_segment vs prometheus_tsdb_wal_segment_current |
Four consequences of this design that you must internalize:
Ordering is per-shard and strict. A shard never sends batch N+1 until batch N succeeds or is dropped. That is why receivers can enforce out-of-order limits, and why one slow batch head-of-line-blocks everything hashed to that shard. Retries are not reordering — they are the same batch again.
The buffer is the WAL, and it is time-boxed. In-memory shard buffers are tiny (seconds); the real outage tolerance is how far back the WAL reaches — roughly the last two to three hours under default head/truncation behaviour. A receiver outage shorter than that is fully recovered, in order, on reconnect. Longer than that, the watcher’s position is truncated away and the gap is unrecoverable from the sender: remote-write never reads from persisted blocks. Plan receiver maintenance windows against this number and size the WAL volume for growth during outages (ingest bytes/s × outage seconds, plus normal WAL).
Restarts resume, they do not re-send history. The watcher persists progress; on restart it resumes from the last checkpointed position. But a config change to write_relabel_configs or a new remote endpoint starts from the current WAL position — a brand-new endpoint does not receive the WAL’s past two hours; it starts from approximately now.
Sample loss is explicit and enumerable. Exactly four ways a sample legitimately fails to arrive: (a) filtered by write_relabel_configs (intentional, counted in samples_dropped_total); (b) receiver returned a non-retryable 4xx (counted in samples_failed_total); © sample_age_limit exceeded during a long backlog (dropped as too old); (d) WAL truncated past the watcher during an extended outage. Everything else is retried forever within the WAL window.
The remote_write block, field by field
Every top-level field, its default, and when you touch it:
| Field | Default | What it does | When to change / gotcha |
|---|---|---|---|
url |
required | Receiver push endpoint | Path differs per backend (see receiver table) — a wrong path returns 404s that count as failures |
name |
auto | Names the queue in metrics (remote_name label) |
Set it (central-mimir) — multi-endpoint debugging is misery without it |
remote_timeout |
30s |
Per-request HTTP timeout | Raise to 1m only if receiver p99 justifies; long timeouts slow shard error detection |
headers |
— | Extra HTTP headers | X-Scope-OrgID for Mimir/Cortex multi-tenancy; keep secrets out — use auth blocks |
write_relabel_configs |
— | Filter/rewrite series before queueing | The cost lever — see filtering section |
send_exemplars |
false |
Forward exemplars | Requires --enable-feature=exemplar-storage on the sender; receiver must support them |
send_native_histograms |
false |
Forward native histograms | Requires native-histograms enabled; check receiver version support first |
protobuf_message |
prometheus.WriteRequest |
Wire format: RW 1.0 vs io.prometheus.write.v2.Request (RW 2.0) |
Flip per endpoint only after confirming receiver support; see protocol section |
basic_auth / authorization / oauth2 |
— | Standard auth | Bearer for Thanos/Mimir gateways; never inline passwords — credentials_file |
sigv4 |
— | AWS SigV4 signing | Required for Amazon Managed Prometheus (region must match workspace) |
azuread |
— | Azure AD auth | For Azure Monitor managed Prometheus ingestion |
tls_config |
— | CA/cert/key, insecure_skip_verify |
mTLS to the receiver edge; never ship insecure_skip_verify: true to prod |
proxy_url / follow_redirects / enable_http2 |
— / true / true |
Transport plumbing | Some LBs mishandle HTTP/2 POST streams — disabling enable_http2 is a legitimate fix, not a hack |
queue_config |
see next section | The tuning surface | — |
metadata_config |
send true / 1m |
Metric metadata forwarding | See metadata section |
Tuning queue_config from first principles
queue_config is where most remote-write incidents are created and most are fixed. Every parameter, with the current defaults:
| Parameter | Default | What it really controls | Raise when | Lower when | Gotcha |
|---|---|---|---|---|---|
min_shards |
1 |
Floor on parallel senders | Known-high steady rate — skips slow ramp-up after restart | Rarely | Resharding takes minutes to ramp; a restart at peak with min_shards: 1 starts behind |
max_shards |
50 |
Ceiling on parallel senders | shards_desired > shards_max and receiver has headroom |
Receiver saturating (429s) — protect it | The most misused knob: raising it when the receiver is the bottleneck makes the outage worse |
capacity |
10000 |
Per-shard in-memory buffer (samples) | Bursty ingest, receiver latency jitter | Memory pressure on sender | Total buffered ≈ shards × capacity — 50 × 10k = 500k samples in RAM |
max_samples_per_send |
2000 |
Batch size per POST | High-throughput, low-latency links (fatter batches amortize RTT) | Receiver enforces smaller request limits | Bigger batches = bigger retry cost on failure |
batch_send_deadline |
5s |
Max wait for a partial batch | Chatty low-volume endpoints (reduce request rate) | Freshness-critical streams | Only bites on low-rate shards; at high rate batches fill instantly |
min_backoff |
30ms |
First retry delay | — | — | Doubles each retry up to max_backoff |
max_backoff |
5s |
Retry delay ceiling | Receiver needs longer recovery breathing room (30s–1m) |
— | With default 5s, a dead receiver gets hit every 5 s per shard, forever |
retry_on_http_429 |
false |
Whether 429 is retried or dropped | Always set true against rate-limited backends (Mimir/Cortex) |
— | Default false means a tenant rate-limit silently discards your samples |
sample_age_limit |
0s (off) |
Drop samples older than this instead of sending | You prefer bounded staleness over full catch-up after outages | — | Trades gap-free history for freshness; alerts on recent data recover faster |
The retry semantics behind those knobs — what the sender does per HTTP response:
| Receiver response | Sender behaviour | Counted in | Operational meaning |
|---|---|---|---|
2xx |
Advance to next batch | samples_total |
Healthy |
429 Too Many Requests |
Drop batch (default) or retry with backoff (retry_on_http_429: true), honouring Retry-After |
samples_failed_total or samples_retried_total |
Tenant/global rate limit at the receiver — fix limits or reduce volume |
Other 4xx (400, 404, 413…) |
Drop the batch, log, continue — never retried | samples_failed_total |
Malformed/rejected data: out-of-order, too-old, label limits, wrong path |
5xx |
Retry same batch, exponential backoff min_backoff → max_backoff, indefinitely |
samples_retried_total |
Receiver outage/overload; WAL is your buffer |
| Network error / timeout | Same as 5xx | samples_retried_total |
LB resets, DNS, MTU — check transport before blaming the receiver |
Note the asymmetry: 4xx loses data by design (re-sending would fail identically), 5xx never loses data within the WAL window. When a receiver misclassifies overload as 400 instead of 429/503 — or an ingress rewrites 503 into 404 — the sender throws samples away. Verify with curl -v what status codes your actual edge returns under pressure.
Sizing shards: the arithmetic
A single shard’s throughput is bounded by round-trip time:
per-shard throughput ≈ max_samples_per_send / round_trip_time
required shards ≈ ingest samples/s ÷ per-shard throughput (×1.5 headroom)
Worked table at max_samples_per_send: 2000:
Receiver RTT (p50 of sent_batch_duration_seconds) |
Per-shard throughput | Shards for 100k samples/s | Shards for 500k samples/s | Verdict at default max_shards: 50 |
|---|---|---|---|---|
| 25 ms (same-AZ) | 80,000/s | 2 | 10 | Fine |
| 100 ms (same-region) | 20,000/s | 8 | 38 | Fine, near ceiling at 500k |
| 300 ms (cross-region) | 6,700/s | 23 | 112 | Exceeds default ceiling — raise max_shards or fix the path |
| 1 s (overloaded receiver) | 2,000/s | 75 | 375 | Do not raise shards — fix the receiver |
Prometheus recomputes desired shards every ~10 s from observed in-rate, out-rate, and send latency, and reshards within [min_shards, max_shards]. The failure signature to memorize: prometheus_remote_storage_shards_desired pinned above prometheus_remote_storage_shards_max while samples_pending climbs — genuinely under-sharded. The counterfeit version: shards_desired explodes because latency exploded (receiver dying); more shards would just be more clients hammering a drowning backend. Always read sent_batch_duration_seconds before touching max_shards.
Sender-side memory cost of the queue is real but modest: buffered samples cost roughly 100–200 bytes each (labels are interned references). At max_shards: 50 × capacity: 10000 that is ~0.5M samples ≈ 50–100 MB — but the WAL-watcher series cache adds memory proportional to active series per remote endpoint, which is why five remote_write endpoints on a 4M-series Prometheus is a memory problem independent of shard counts.
A production-tuned block for a high-volume, same-region Mimir endpoint:
remote_write:
- name: central-mimir
url: https://mimir.internal.example.com/api/v1/push
remote_timeout: 30s
headers:
X-Scope-OrgID: team-platform
queue_config:
min_shards: 4 # skip ramp-up after restarts
max_shards: 100 # sized: 300k samples/s ÷ (2000/0.1s) = 15; ceiling 100 for incident headroom
capacity: 10000
max_samples_per_send: 2000
batch_send_deadline: 5s
min_backoff: 30ms
max_backoff: 30s # give a recovering receiver room to breathe
retry_on_http_429: true # Mimir rate limits MUST be retried, not dropped
sample_age_limit: 0s # full catch-up after outages; set 15m if freshness > completeness
Symptom → knob decision table
| Symptom | First check | Knob / action | What NOT to do |
|---|---|---|---|
samples_pending climbing, shards_desired > shards_max, send latency normal |
RTT table above | Raise max_shards toward computed need |
Raise capacity (just buffers the backlog) |
samples_pending climbing, send latency climbing too |
Receiver health, distributor CPU | Scale/fix the receiver; consider max_backoff: 30s |
Raise max_shards — amplifies the assault |
samples_failed_total increasing steadily |
Receiver logs for 400 reasons | Fix data (OOO, labels) or path; check 429-as-drop | Ignore it — this is permanent data loss |
samples_retried_total bursts every ~2 h |
Receiver compaction/GC pauses | Usually benign; raise capacity to absorb |
Panic-tune during the burst |
| WAL directory growing unbounded | highest_sent vs highest_in gap |
Restore receiver; temporarily add sample_age_limit or a write_relabel_configs emergency filter |
Delete WAL segments by hand while Prometheus runs |
| Sender OOM after adding endpoints | Endpoint count × series cache | Consolidate endpoints; filter per endpoint | Blindly raise container limits every week |
| Catch-up after outage takes hours | Backlog ÷ spare throughput | Pre-provision max_shards ~2× steady need; receiver headroom |
Expect sample_age_limit: 0s to catch up instantly — drain rate is bounded by shard math |
Filtering, metadata, exemplars, and the 2.0 protocol
write_relabel_configs: the per-endpoint cost lever
write_relabel_configs runs relabeling on every sample after the WAL watcher and before queueing — the same engine as scrape-time relabeling, applied to the outbound stream of one endpoint. It sees series labels (including __name__) but not scrape metadata. All actions work; these are the ones that matter here:
| Action | Effect on the remote stream | Canonical use |
|---|---|---|
keep |
Series not matching regex are dropped | Allow-list what the backend needs |
drop |
Matching series dropped | Deny-list debug/noise metric families |
labeldrop |
Remove matching label NAMES | Strip pod_template_hash, scratch labels |
labelkeep |
Keep only matching label names | Aggressive minimal-label streams (careful: can merge series) |
replace |
Rewrite a label from source labels + regex | Normalize env values; add stream tags |
hashmod |
Set a label to hash(source) % modulus |
Shard one Prometheus’s stream across N endpoints |
lowercase / uppercase |
Case-normalize a label value | Merge PROD/prod before they become distinct series |
keepequal / dropequal |
Compare two label values | Niche equality filtering |
Three worked patterns:
remote_write:
# Pattern 1 - allow-list: ship ONLY recorded aggregates + SLO inputs.
# The cheapest long-term-storage bill is the series you never send.
- name: lts-aggregates-only
url: https://mimir.internal.example.com/api/v1/push
write_relabel_configs:
- source_labels: [__name__]
regex: "([a-z_]+:[a-z0-9_]+:[a-z0-9_]+|up|probe_success)" # ':' = recorded rules only
action: keep
# Pattern 2 - deny-list: full stream minus known-noisy families and churn labels.
- name: lts-full-minus-noise
url: https://receive.internal.example.com/api/v1/receive
write_relabel_configs:
- source_labels: [__name__]
regex: "go_(gc|memstats)_.*|apiserver_request_slo_duration_seconds_bucket"
action: drop
- regex: "pod_template_hash|controller_revision_hash"
action: labeldrop
# Pattern 3 - hashmod sharding: split one giant stream across two receivers.
- name: shard-a
url: https://receive-a.internal/api/v1/receive
write_relabel_configs:
- source_labels: [__name__]
modulus: 2
target_label: __tmp_shard
action: hashmod
- source_labels: [__tmp_shard]
regex: "0"
action: keep
- regex: "__tmp_shard"
action: labeldrop
Two traps. First, labelkeep/aggressive labeldrop can collapse two distinct series into one label-set — the receiver then sees interleaved duplicate/out-of-order samples for “the same” series and returns 400s. Always ask: after this drop, is the remaining label-set still unique per source series? Second, dropping instance/pod at the remote-write layer to “save cardinality” while keeping per-instance metric names achieves exactly that collapse; the correct tool for de-instancing is a recording rule that sum bys, not a label drop.
Metadata, exemplars, native histograms
| Payload | Config | Default | Prerequisites | Receiver notes |
|---|---|---|---|---|
| Metadata (HELP/TYPE/UNIT) | metadata_config: { send, send_interval, max_samples_per_send } |
true / 1m / 500 |
none | RW 1.0 sends it as separate periodic messages, detached from series — receivers store best-effort. RW 2.0 attaches metadata per series in-band |
| Exemplars (trace-ID’d samples on histograms) | send_exemplars: true |
false |
--enable-feature=exemplar-storage on sender; app instrumented |
Powers Grafana trace drill-down — see Wiring OpenTelemetry Metrics and Exemplars for Click-Through Trace Correlation. Mimir/Thanos/VM support ingesting them; enable storage limits at the receiver |
| Native histograms (sparse, exponential-bucket) | send_native_histograms: true |
false |
native histograms enabled on sender; client libs emitting them | Dramatically fewer series than classic _bucket families, but every hop — sender, receiver, dashboards — must support them; verify receiver version before flipping |
Remote-Write 1.0 vs 2.0 on the wire
Remote-Write 2.0 (spec finalized 2024; sender support arriving through Prometheus 2.5x and standard in 3.x) is a new protobuf message negotiated over the same HTTP+snappy transport. Selected per endpoint via protobuf_message:
| Dimension | RW 1.0 (prometheus.WriteRequest) |
RW 2.0 (io.prometheus.write.v2.Request) |
|---|---|---|
| Label encoding | Full label strings repeated per series, per request | String interning: one symbols table per request, series reference it — 40–60% smaller uncompressed payloads, less receiver GC |
| Metadata | Separate periodic metadata messages | In-band per series (type/help/unit travel with the data) |
| Exemplars | Supported (bolt-on) | First-class |
| Native histograms | Supported via extension | First-class |
| Created timestamps | Not carried | Carried — receivers can compute accurate counter resets/_created |
| Content negotiation | Content-Type: application/x-protobuf, X-Prometheus-Remote-Write-Version: 0.1.0 |
Content-Type: application/x-protobuf;proto=io.prometheus.write.v2.Request, version header 2.0.0 |
| Response feedback | Status code only | Stats headers: X-Prometheus-Remote-Write-Samples-Written, ...-Histograms-Written, ...-Exemplars-Written — senders can detect silent partial writes |
| Unsupported receiver behaviour | n/a | Receiver returns 415 Unsupported Media Type; sender must be configured back to 1.0 (no automatic downgrade guarantee across all receivers) |
| Compression | snappy | snappy (unchanged) |
Operational guidance: treat RW 2.0 as a per-endpoint rollout. Flip one low-risk endpoint, confirm the receiver’s written-samples headers match sent counts, watch receiver CPU (interning usually reduces it), then migrate. Keep 1.0 config in version control for rollback — a receiver behind an old ingress that strips content-type parameters will fail opaquely.
Receiver backends: Thanos Receive, Mimir, VictoriaMetrics, Cortex
All four accept Prometheus remote-write and serve PromQL back. They differ in architecture, tenancy, dedup, downsampling, storage medium, and license — differences that dominate your operations for years. Endpoints and tenancy first:
| Backend | Push endpoint | Tenancy mechanism | Auth story |
|---|---|---|---|
| Thanos Receive | /api/v1/receive |
THANOS-TENANT header (configurable via --receive.tenant-header); default tenant if absent |
BYO (mTLS/ingress); no built-in multi-tenant authz |
| Grafana Mimir | /api/v1/push (also OTLP at /otlp/v1/metrics) |
X-Scope-OrgID header; enforced when multitenancy_enabled: true (default); anonymous when disabled |
BYO gateway; enterprise adds authn |
| VictoriaMetrics | single-node /api/v1/write; cluster /insert/<accountID>/prometheus/api/v1/write |
Tenant in URL path (accountID / accountID:projectID), cluster version only | BYO; vmauth component available as auth proxy |
| Cortex | /api/v1/push |
X-Scope-OrgID header |
BYO gateway |
The operational comparison — this is the table to argue from in design reviews:
| Dimension | Thanos Receive | Grafana Mimir | VictoriaMetrics | Cortex |
|---|---|---|---|---|
| Architecture | Hashring of receivers (router/ingestor split optional) + Store Gateway, Querier, Compactor | Microservices: distributor → ingester; query-frontend → querier → store-gateway; compactor, ruler | vminsert → vmstorage ← vmselect (cluster); one binary (single-node); vmagent optional edge | Same lineage as Mimir (Mimir forked Cortex 1.10) — distributor/ingester/querier |
| Durable storage | Object storage (S3/GCS/Azure) | Object storage | Local disk (block storage) only — backups via vmbackup to S3 | Object storage (blocks) |
| Downsampling | Yes — compactor produces 5m and 1h resolutions | No (raw only) | Enterprise feature (-downsampling.period); OSS relies on high compression |
No |
| Multi-tenancy | Basic (header → per-tenant TSDBs); limits per tenant improving | First-class: per-tenant limits, shuffle sharding, per-tenant retention | Cluster: path-based tenants; limits coarser | First-class (same design Mimir inherited) |
| HA dedup | Query-time replica-label dedup; receive replication quorum | HA tracker at ingest (elects one replica per cluster via KV store) | Storage-time dedup (-dedup.minScrapeInterval) |
HA tracker at ingest (origin of the design) |
| Replication | --receive.replication-factor (quorum writes) |
Ingester RF=3 default, zone-aware | Cluster -replicationFactor (vminsert-level) |
Ingester RF=3 |
| Query engine | Thanos Query (PromQL, dedup-aware fan-out) | PromQL (Mimir engine, query sharding in frontend) | MetricsQL — PromQL superset with deviations | PromQL |
| Out-of-order ingest | TSDB OOO window supported (--tsdb.out-of-order.time-window) |
Per-tenant out_of_order_time_window (default 0) |
Tolerant by design (accepts OOO natively) | Limited/experimental |
| Resource efficiency | Moderate | Moderate-heavy (many components; strong at huge scale) | Best-in-class RAM/disk per series (typ. ~1 byte/sample on disk) | Moderate-heavy |
| License / governance | Apache-2.0, CNCF | AGPL-3.0, Grafana Labs | Apache-2.0 core, open-core (Enterprise: downsampling, retention filters) | Apache-2.0, CNCF |
| Deep-dive article | Thanos in Production | Running Grafana Mimir | Configure VictoriaMetrics Cluster | (legacy — evaluate Mimir first) |
Capability details that decide real projects:
| Capability | Thanos Receive | Mimir | VictoriaMetrics | Cortex |
|---|---|---|---|---|
| Exemplar ingestion | Yes | Yes (per-tenant limit, off until sized) | Yes | Partial |
| Native histograms | Yes (recent versions) | Yes | Yes (recent versions; check MetricsQL support for histogram_* functions) |
Lagging |
| RW 2.0 receiver | Rolling out — verify your version | Rolling out — verify your version | Yes (also accepts Influx/OTLP/Datadog protocols) | Lagging |
| Per-tenant retention | Bucket-level via compactor flags (coarse) | compactor_blocks_retention_period per tenant |
Enterprise retention filters; OSS global -retentionPeriod |
Per-tenant retention limit |
| Server-side ruler | Thanos Ruler (evaluates against global view) | Mimir Ruler (per-tenant rule groups, API-managed) | vmalert (separate component) | Cortex Ruler |
And the decision table:
| If your situation is… | Pick | Because |
|---|---|---|
| Fleet of existing Prometheus servers, want global query + LTS with minimal re-architecture | Thanos (sidecar model first, Receive where push is needed) | Bolts onto what you run; object storage + downsampling for multi-year retention |
| Central multi-team platform, hard tenant isolation, per-tenant limits and billing | Mimir | Tenancy, limits, shuffle sharding are first-class, not bolted on |
| Cost/efficiency dominated, single platform team, comfortable without object storage | VictoriaMetrics | Dramatically lower RAM/disk; simplest ops; accept MetricsQL deltas and disk-based durability model |
| Push-based ingestion into Thanos ecosystem (edge clusters, no inbound scrape) | Thanos Receive | Native remote-write ingestion with hashring scale-out into the same query fabric |
| Already on Cortex in production | Stay/migrate deliberately | Cortex works, but ecosystem gravity (docs, mixins, hiring) has moved to Mimir |
| AGPL is unacceptable to legal | Thanos or VictoriaMetrics | Mimir is AGPL-3.0 |
| Must keep PromQL byte-for-byte semantics | Thanos / Mimir / Cortex | MetricsQL is a superset with subtle evaluation differences |
Minimal receiver-side configuration sketches — enough to recognize the shape of each system:
# Thanos Receive (hashring member): quorum-replicated, multi-tenant TSDBs
# thanos receive \
# --receive.replication-factor=3 \
# --receive.hashrings-file=/etc/thanos/hashrings.json \
# --receive.hashrings-algorithm=ketama \
# --receive.tenant-header=THANOS-TENANT \
# --tsdb.retention=2d \
# --objstore.config-file=/etc/thanos/objstore.yaml
# Mimir per-tenant guardrails (runtime overrides file)
overrides:
team-platform:
ingestion_rate: 250000 # samples/s
ingestion_burst_size: 500000
max_global_series_per_user: 3000000
out_of_order_time_window: 5m
compactor_blocks_retention_period: 13m # 13 months
team-payments:
ingestion_rate: 100000
max_global_series_per_user: 1000000
compactor_blocks_retention_period: 90d
# VictoriaMetrics single-node: one flag each for retention and HA dedup
/victoria-metrics-prod \
-retentionPeriod=12 \
-dedup.minScrapeInterval=30s \
-storageDataPath=/var/lib/victoria-metrics
Agent mode, and federation vs remote-write
Agent mode: Prometheus as a pure shipper
Agent mode (--agent in Prometheus 3.x; --enable-feature=agent in 2.x) strips Prometheus down to scrape + WAL + remote-write. There is no head-block query path, no local blocks, no rules, no alerting — the data directory (data-agent/) holds only the WAL, truncated as soon as samples are confirmed shipped.
| Capability | Server mode | Agent mode |
|---|---|---|
| Scraping + service discovery | Yes | Yes |
| Remote-write | Yes | Yes (the whole point) |
Local PromQL queries (/api/v1/query) |
Yes | No — API returns an error; no local data |
| Recording rules / alerting rules | Yes | No — rules require a query path |
| Local TSDB blocks / retention | Yes | No (WAL only) |
Federation endpoint (/federate) |
Yes | No |
Own /metrics (self-monitoring) |
Yes | Yes — pipeline metrics still exposed |
| Memory footprint | Head + index + query | Roughly 20–40% lower (no head queries, no blocks), still ∝ active series |
| Disk footprint | WAL + blocks (retention) | WAL only (small, unless remote is down) |
When agent mode is right — and when it quietly ruins your week:
| Scenario | Verdict | Why |
|---|---|---|
| Edge/IoT/small clusters shipping to a central backend | Agent | No local users; smallest footprint; central ruler runs the rules |
| Standard product cluster with an on-call team | Server + remote-write | On-call needs local queries when the backend or WAN is down |
| Cluster-scoped alerting must survive WAN partition | Server | Agent cannot evaluate alert rules — a partition means no pages |
| Recording rules feeding the remote stream (ship aggregates only) | Server | Rules do not run in agent mode; there is nothing recorded to ship |
| Thousands of scrape endpoints, dedicated shipper per zone | Agent (or Grafana Alloy / vmagent) | Purpose-built shippers; note Alloy and vmagent also cover this niche with clustering |
The last two rows are the classic mistakes: teams flip a fleet to agent mode and keep rule_files in the config (silently ignored — validate with promtool check config which warns), or discover during their first backend outage that no cluster can answer “is prod up?” locally. Decide alerting topology first, mode second.
Federation: the legacy global view, and where it still fits
Federation is a pull pattern: a central Prometheus scrapes /federate on leaf servers, copying whatever series match the match[] selectors at that instant.
scrape_configs:
- job_name: federate-clusters
scrape_interval: 30s
honor_labels: true # keep the leaf's job/instance labels
metrics_path: /federate
params:
"match[]":
- '{__name__=~"job:.*"}' # recorded aggregates ONLY - never ".+"
static_configs:
- targets:
- prom-eu1.internal:9090
- prom-us1.internal:9090
Head-to-head, because interviews and design docs keep asking:
| Dimension | Federation | Remote-write |
|---|---|---|
| Direction / transport | Pull, whole snapshot per scrape | Push, continuous stream from WAL |
| Resolution | One sample per federation interval (leaf samples between scrapes are never seen) | Full resolution, every sample |
| Central cardinality | Central node stores everything it federates in ITS head — inherits the sum | Receiver is horizontally scalable |
| Delivery guarantees | None — a missed scrape is a permanent gap | Retries + WAL buffer within ~2 h window |
| Timestamp semantics | Samples re-stamped around scrape; alignment artifacts on rates | Original timestamps preserved |
| Long-term storage | None (central node’s local disk) | The entire point |
| Setup cost | One scrape job | Backend to operate |
| Sensible modern use | Pulling a THIN layer of recorded aggregates (tens–hundreds of series/leaf) into an existing small Prometheus; air-gapped pull-only networks | Everything else: global view, LTS, HA dedup, tenancy |
The one-line rule: federate aggregates, remote-write everything you must keep. A federation job whose match[] is {__name__=~".+"} is an incident in waiting — it doubles your biggest Prometheus into your central one, at 1/6 the resolution, with no delivery guarantees.
Long-term retention and downsampling strategies
Retention strategy is per-backend because the mechanisms differ fundamentally. First, the sample-count arithmetic that motivates downsampling — one series, one year, by resolution:
| Resolution | Samples per series-year | Bytes at ~1.5 B/sample | 30-day range query reads (per series) |
|---|---|---|---|
| 15 s raw | 2,102,400 | ~3.0 MB | 172,800 samples |
| 30 s raw | 1,051,200 | ~1.5 MB | 86,400 |
| 5 m downsampled | 105,120 | ~150 KB (×5 aggregate series in Thanos — see below) | 8,640 |
| 1 h downsampled | 8,760 | ~13 KB (×5) | 720 |
A 90-day dashboard over raw 15 s data touches 240× more samples than over 1 h resolution. That is why long-range queries against downsample-capable backends feel instant and against raw-only backends need aggressive query splitting/caching (Mimir’s answer) or brute efficiency (VictoriaMetrics’ answer).
Thanos: real downsampling, with a catch
The Thanos Compactor produces 5-minute resolution for blocks older than ~40 hours and 1-hour resolution for blocks older than ~10 days. Each downsampled series stores five aggregates (count, sum, min, max, counter) so that rate, avg, min, max all remain answerable. Retention is set per resolution:
thanos compact \
--objstore.config-file=/etc/thanos/objstore.yaml \
--data-dir=/var/thanos/compact \
--retention.resolution-raw=30d \
--retention.resolution-5m=180d \
--retention.resolution-1h=2y \
--wait
The catch experts know: downsampling is a query-speed feature, not primarily a storage saver. Because each downsampled series carries five aggregates, 5m data is nowhere near 1/20th of raw size — and for the window where you retain both raw and 5m, total storage increases. Never set --retention.resolution-raw below ~40 h (the 5m downsampling input) or 5m retention below ~10 d (the 1h input), or the compactor cannot produce the next tier. Queriers pick resolution automatically via max_source_resolution (--query.auto-downsampling).
Mimir, Cortex, VictoriaMetrics: retention without downsampling
| Backend | Retention knob | Granularity | Downsampling story |
|---|---|---|---|
| Mimir | -compactor.blocks-retention-period / per-tenant compactor_blocks_retention_period |
Per tenant | None — long-range queries survive via query splitting, sharding, and results caching in the query-frontend; storage is raw forever |
| Cortex | compactor_blocks_retention_period (same lineage) |
Per tenant | None |
| VictoriaMetrics OSS | -retentionPeriod=12 (months; accepts 100d, 2y) |
Global per cluster | None in OSS — mitigated by ~1 B/sample on-disk efficiency |
| VictoriaMetrics Enterprise | -downsampling.period=30d:5m,180d:1h (+ retention filters per tenant/label) |
Global or filtered | True downsampling, Enterprise license |
| Thanos | --retention.resolution-{raw,5m,1h} |
Per bucket (per-tenant via external labels is coarse) | Yes (above) |
The tiering strategy that actually works
Design retention as explicit tiers with named audiences, then map tiers onto your backend’s mechanism:
| Tier | Window | Resolution | Audience / queries | Where it lives |
|---|---|---|---|---|
| Hot | 0–24 h | Raw | On-call drill-down, alert evaluation | Local Prometheus TSDB (--storage.tsdb.retention.time=24h… or 15d if it doubles as hot query path) |
| Warm | 0–30 d | Raw | Dashboards, incident review, SLO windows | Backend, raw blocks |
| Cool | 30–180 d | 5 m (Thanos/VM-EE) or raw (Mimir/VM-OSS) | Capacity trends, quarterly reviews | Backend downsampled tier or raw + query cache |
| Cold | 180 d–2 y | 1 h or recorded aggregates only | Compliance, YoY planning | Backend coarsest tier; consider shipping ONLY job:* recorded rules this far |
| Archive | 2 y+ | Recorded aggregates, exported | Audit | Object-storage export/park (rarely worth keeping queryable) |
The most cost-effective trick in this table is the last column of “Cold”: on raw-only backends, run a second remote_write endpoint carrying only recorded aggregates (the keep on [a-z_]+:.*:.* pattern from the filtering section) into a long-retention tenant, and keep the full-fidelity tenant at 30–90 days. You get two-year trend queries over series that cost hundreds, not millions.
Capacity planning: the series and samples math
All sizing flows from two numbers you can measure today: active series and scrape interval. Everything else is multiplication. A worked fleet — 3 Kubernetes clusters, 60 nodes total, ~2,400 pods, typical exporters (assumptions stated so you can swap in your own):
| Source | Count | Series each (assumed) | Active series |
|---|---|---|---|
| node_exporter | 60 nodes | ~1,200 (default collectors) | 72,000 |
| kubelet + cAdvisor (after standard mixin drops) | 2,400 pods | ~120/pod | 288,000 |
| kube-state-metrics | 3 clusters | ~180,000/cluster (2,400-pod cluster share) | 540,000 |
| App metrics (RED + histograms) | 240 services × 10 pods avg | ~450/pod | 1,080,000 |
| Control plane + operators + platform | 3 clusters | ~150,000 | 450,000 |
| Recording-rule outputs | — | — | 30,000 |
| Total active series | ≈ 2.46 M |
Now the derived numbers:
| Quantity | Formula | Value at 30 s interval | Value at 15 s |
|---|---|---|---|
| Samples/s (per replica) | series ÷ interval | 82,000/s | 164,000/s |
| Samples/s from HA pairs (sender total) | ×2 | 164,000/s | 328,000/s |
| Receiver ingest after HA dedup | ×1 (Mimir/VM dedup) | 82,000/s | 164,000/s |
| Receiver storage/day | samples/s × 86,400 × ~1.5 B (block format, compacted) | ~10.6 GB/day | ~21.3 GB/day |
| Receiver storage 30 d raw | ×30 | ~320 GB | ~640 GB |
| Receiver storage 13 months raw (Mimir-style, no downsampling) | ×395 | ~4.2 TB | ~8.4 TB |
| Remote-write bandwidth per replica (RW 1.0, measured typically 15–40 B/sample on wire after snappy) | samples/s × ~25 B | ~2.0 MB/s ≈ 16 Mbps | ~4.1 MB/s ≈ 33 Mbps |
Do not trust the bandwidth constant — measure yours in one query, because label verbosity dominates it:
# Actual compressed bytes per sample on the wire, per remote endpoint
rate(prometheus_remote_storage_bytes_total[15m])
/ rate(prometheus_remote_storage_samples_total[15m])
Sender (Prometheus) sizing rules of thumb — verify against your own ratio (process_resident_memory_bytes / prometheus_tsdb_head_series, commonly 3–8 KB/series including index, churn, and query headroom):
| Sender profile | Active series | RAM | Disk (15 d local) | Notes |
|---|---|---|---|---|
| Small cluster | 500 k | 4–8 GB | ~100 GB | One binary, easy |
| This worked fleet (per replica) | 2.5 M | 16–24 GB | ~470 GB | WAL headroom for 3 h receiver outage: +~25 GB |
| Big single node (approaching the wall) | 8 M | 48–96 GB | ~1.5 TB | Compaction spikes; restart replay ~10–25 min — split or shed |
| Agent mode shipper | 2.5 M | 10–16 GB | WAL only (~30–60 GB headroom) | No blocks, no query path |
Receiver sizing (published rules of thumb — treat as starting points, then load-test; series-in-memory at the receiver = active series × replication factor):
| Component (Mimir-shaped) | Rule of thumb | This fleet (2.46 M series, RF=3, 82 k samples/s) |
|---|---|---|
| Distributor | ~1 CPU + 1 GB per ~25 k samples/s | 4 CPU / 4 GB total |
| Ingesters | ~1 CPU + 2.5 GB per 300 k in-memory series | 7.4 M in-memory series → ~25 CPU / ~62 GB across the ring |
| Store-gateway | Scales with blocks touched by queries | 3 × (2 CPU / 8 GB) start |
| Querier + frontend | Scales with query concurrency | 3 × (2 CPU / 8 GB) start |
| Compactor | 1–2 instances, bursty CPU | 4 CPU / 8 GB |
| VictoriaMetrics comparison | vmstorage commonly cited ≤1 KB RAM/series + ~1 B/sample disk | Same fleet: ~8–12 GB RAM total storage tier — the efficiency gap is real |
Three planning rules that prevent the classic failures. Plan for churn, not just count: a deploy that restarts every pod creates a full generation of new series while the old ones linger in the head for hours — head series can be 1.5–2× steady-state during rollout storms, and receiver per-tenant series limits must absorb that or you get 429/per_user_series_limit drops precisely during deploys. Plan the catch-up path: after a 2-hour receiver outage, the backlog is 2 h × samples/s; at 30% spare shard throughput the drain takes ~6.6 h — pre-provision max_shards and receiver ingest for ~1.5–2× steady state. Alert on growth, not thresholds: predict_linear(prometheus_tsdb_head_series[6h], 86400 * 7) against your RAM budget turns capacity from an incident into a ticket.
HA pairs and deduplication at the receiver
Prometheus HA is brutally simple: run two identical replicas scraping the same targets. Each sees every sample (modulo scrape phase), each remote-writes independently, and the receiver — not the pair — is responsible for storing one copy. The pair is identified by external_labels:
# prometheus-a.yml # prometheus-b.yml differs ONLY in replica
global:
external_labels:
cluster: eu1-prod
__replica__: prom-a # prom-b on the sibling
Two label conventions exist: Mimir/Cortex expect cluster + __replica__ (the __replica__ label is stripped on accept, so stored series are replica-free); Thanos convention is a visible replica or prometheus_replica label that survives storage and is collapsed at query time. Per backend:
| Backend | Dedup mechanism | Where dedup happens | Config | Failover behaviour |
|---|---|---|---|---|
| Mimir | HA tracker: elects one replica per cluster, accepts only its samples, others get 202 |
Ingest (distributor) | limits.accept_ha_samples: true, ha_cluster_label: cluster, ha_replica_label: __replica__, KV store (memberlest/etcd/consul) for election state |
Elected replica silent → failover after ha_tracker_failover_timeout (default 30 s); small overlap/gap possible at switch |
| Cortex | HA tracker (identical design — it originated here) | Ingest | Same keys | Same |
| Thanos Receive | Store both replicas; query-time dedup via --query.replica-label=replica on Thanos Query |
Read path | Send both streams; replication factor separately controls receive durability | No election; queries always see the union, dedup penalty paid per query |
| VictoriaMetrics | Storage-time dedup: keeps one raw sample per -dedup.minScrapeInterval per series |
Storage (vmstorage/single-node) | Set -dedup.minScrapeInterval equal to scrape interval (e.g. 30s); replicas’ __replica__-free streams must be label-identical |
Continuous — no election, no failover window; requires identical external labels apart from none |
The corresponding cost profile is worth spelling out: Mimir/Cortex HA tracking discards the duplicate stream at the edge — you pay double sender bandwidth but single storage. Thanos Receive stores both replicas (double storage before compaction-level dedup) and pays query-time dedup CPU. VictoriaMetrics ingests both but persists one sample per interval — near-single storage, no election machinery. For Mimir, the config that makes HA work:
# mimir.yaml (relevant fragment)
limits:
accept_ha_samples: true
ha_cluster_label: cluster
ha_replica_label: __replica__
distributor:
ha_tracker:
enable_ha_tracker: true
kvstore:
store: memberlist
Two sharp edges. First, never mix conventions: if the replica label is not exactly what the tracker expects, both streams are accepted as distinct series — instant 2× series bill and rate() sawtooth artifacts in every dashboard. Second, do not put a load balancer that merges HA streams round-robin into one connection pool without the tracker enabled — interleaved timestamps from two replicas for the same series produce out-of-order 400s that the sender permanently drops. The confirm-and-fix for both lives in the troubleshooting table.
Monitoring the shipping pipeline itself
The pipeline that carries your telemetry needs its own telemetry, and it already exists — Prometheus instruments remote-write exhaustively. The metrics that matter, grouped by the question they answer (all carry remote_name and url labels):
| Question | Metric | Type | Healthy looks like |
|---|---|---|---|
| Am I keeping up? | prometheus_remote_storage_highest_timestamp_in_seconds minus prometheus_remote_storage_queue_highest_sent_timestamp_seconds |
gauge pair | Gap < 10–30 s |
| How much is buffered? | prometheus_remote_storage_samples_pending |
gauge | Low hundreds-thousands, flat |
| Am I losing data? | prometheus_remote_storage_samples_failed_total |
counter | Flat (zero rate) |
| Am I struggling? | prometheus_remote_storage_samples_retried_total |
counter | Occasional bursts only |
| Am I filtering as intended? | prometheus_remote_storage_samples_dropped_total |
counter | Matches your relabel intent |
| Throughput | prometheus_remote_storage_samples_total, ..._bytes_total |
counters | Tracks ingest rate |
| Sharding health | ..._shards, ..._shards_desired, ..._shards_max, ..._shards_min |
gauges | desired < max |
| Send latency | prometheus_remote_storage_sent_batch_duration_seconds |
histogram | p99 well under remote_timeout |
| Queue admission stalls | prometheus_remote_storage_enqueue_retries_total |
counter | Near-flat |
| Exemplar pipeline | prometheus_remote_storage_exemplars_* (mirrors samples set) |
counters | As per samples |
| WAL position | prometheus_wal_watcher_current_segment vs prometheus_tsdb_wal_segment_current |
gauges | Watcher within 1–2 segments |
| Rule health (feeding the stream) | prometheus_rule_group_last_duration_seconds, ..._iterations_missed_total, prometheus_rule_evaluation_failures_total |
mixed | duration ≪ interval; zero missed/failed |
The alert pack — these five rules, largely aligned with the upstream prometheus-mixin, catch every failure class in this article before users do:
groups:
- name: remote-write-pipeline
rules:
- alert: PrometheusRemoteWriteBehind
expr: |
(
max_over_time(prometheus_remote_storage_highest_timestamp_in_seconds[5m])
- ignoring(remote_name, url) group_right
max_over_time(prometheus_remote_storage_queue_highest_sent_timestamp_seconds[5m])
) > 120
for: 15m
labels: {severity: critical}
annotations:
summary: "Remote write to {{ $labels.url }} is {{ $value | humanize }}s behind"
- alert: PrometheusRemoteWriteFailures
expr: |
(
rate(prometheus_remote_storage_samples_failed_total[5m])
/
(rate(prometheus_remote_storage_samples_failed_total[5m])
+ rate(prometheus_remote_storage_samples_total[5m]))
) * 100 > 1
for: 15m
labels: {severity: critical}
annotations:
summary: "{{ printf \"%.1f\" $value }}% of samples to {{ $labels.url }} failing (permanent loss)"
- alert: PrometheusRemoteWriteDesiredShards
expr: |
prometheus_remote_storage_shards_desired
> prometheus_remote_storage_shards_max
for: 15m
labels: {severity: warning}
annotations:
summary: "Queue {{ $labels.remote_name }} wants more shards than max_shards allows"
- alert: PrometheusRuleEvaluationProblems
expr: |
increase(prometheus_rule_evaluation_failures_total[5m]) > 0
or increase(prometheus_rule_group_iterations_missed_total[5m]) > 0
for: 10m
labels: {severity: warning}
annotations:
summary: "Recording/alerting rules failing or missing evaluations on {{ $labels.instance }}"
- alert: PrometheusWALGrowing
expr: |
predict_linear(prometheus_tsdb_wal_storage_size_bytes[30m], 6*3600)
> 0.8 * (node_filesystem_size_bytes{mountpoint="/prometheus"})
for: 30m
labels: {severity: warning}
annotations:
summary: "WAL on {{ $labels.instance }} will breach 80% of the volume within 6h (remote-write backlog?)"
Watch the receiver side with its own counters and close the loop per tenant — on Mimir/Cortex, cortex_discarded_samples_total broken down by reason (rate_limited, per_user_series_limit, out_of_order, too_old, label_value_too_long…) is the single most valuable receiver metric: it tells each tenant why their data vanished. Thanos Receive exposes thanos_receive_replications_total / ..._forward_requests_total failures; VictoriaMetrics exposes vm_rows_ignored_total with reason labels. Route these alerts through a routing tree that separates platform pages from tenant notifications — Designing Alertmanager Routing Trees covers that layer.
Architecture at a glance
Picture the reference topology as three zones left to right. Zone one, per cluster: an HA pair of Prometheus servers (or agents, where local alerting is not required), each scraping the same targets through the same relabeling, stamped with external_labels: {cluster, __replica__}. Inside each server, samples flow appender → WAL → WAL watcher → per-endpoint queue → 4–100 shards, with write_relabel_configs deciding, per endpoint, whether the full stream or only job:* recorded aggregates leave the building. Recording rules evaluate locally in dependency-ordered groups, so the expensive aggregations are computed once, next to the raw data, and their outputs join the outbound stream. Cluster-scoped alerts also evaluate here, because the pair must be able to page even when the WAN or the backend is down.
Zone two, the receiving edge: a load balancer terminating TLS in front of the backend’s write path — Mimir distributors, a Thanos Receive hashring, or vminsert nodes — where tenancy headers are enforced, per-tenant rate and series limits are applied, and HA deduplication (Mimir’s tracker electing one replica per cluster) collapses the pair back into one logical stream. Ingesters/receivers hold the recent window in memory, replicate it (RF=3, zone-aware), and cut 2-hour blocks. Zone three, durable storage and read: object storage holds compacted blocks; the compactor merges, enforces per-tenant retention, and — on Thanos — downsamples to 5m/1h tiers; store-gateways serve historical blocks; queriers and a caching query-frontend fan PromQL across recent + historical; and a backend ruler evaluates the global rules no single cluster could. Grafana sees exactly one datasource. Every arrow in that picture is monitored from the sending side by prometheus_remote_storage_* and from the receiving side by per-tenant discard counters — the two vantage points you triangulate from during every incident in this pipeline.
Real-world scenario
NordPay, a payments processor, ran six EKS clusters with HA Prometheus pairs (2.1 M active series per cluster average, 15 s scrape on SLO-critical jobs, 60 s elsewhere) remote-writing to a central Mimir on S3 — roughly 420 k samples/s at the receiver after HA dedup, 13-month retention for the payments tenant. Two incidents in one quarter taught them everything in this article.
Incident one: the silent 30-minute blindfold. A routine Mimir upgrade rolled ingesters slowly; distributor p99 push latency rose from 90 ms to 700 ms for 40 minutes. Each Prometheus computed higher desired shards, hit the default max_shards: 50, and fell behind — samples_pending climbed to 14 M per replica and dashboards went ~25 minutes stale. Nobody was paged: the team had alerts on Mimir’s own health (all green — it was up, just slow) but nothing on sender-side lag. The postmortem added the PrometheusRemoteWriteBehind alert (gap > 120 s for 15 m) and re-derived shard math: at 300 ms degraded RTT they needed ~63 shards for 210 k samples/s per replica; they set max_shards: 120, min_shards: 8, and — critically — verified Mimir distributors had 2× ingest headroom so the extra shards helped rather than harmed. Catch-up after the fix drained a 40-minute backlog in 11 minutes.
Incident two: the ₹3.1 lakh label. A checkout-service deploy added card_bin (six digits, ~1,400 observed values) to http_requests_total and its 14-bucket duration histogram. Series for that job went from 38 k to 2.9 M over two hours. Mimir’s per_user_series_limit (3 M for the tenant) began rejecting with err-mimir-max-series-per-user; cortex_discarded_samples_total{reason="per_user_series_limit"} spiked — and because those rejections are HTTP 400s, the senders permanently dropped the excess: the team lost 90 minutes of partial checkout telemetry during a payment-provider brownout they were simultaneously debugging. Containment was a sender-side emergency filter (write_relabel_configs dropping card_bin via labeldrop — safe here because the underlying series remained unique per pod), then a proper fix at the source with metric_relabel_configs, plus a CI gate rejecting any exporter change that adds labels to SLO-critical metric families without a cardinality budget line. Finance saw the counterfactual afterwards: had the limit not stopped it, 2.9 M extra series × 13-month retention ≈ 5.6 TB-months ≈ ₹3.1 lakh/quarter in storage and ingester memory they never intended to buy. The limit was the guardrail working — the missing piece had been alerting on the guardrail, so now cortex_discarded_samples_total by tenant/reason pages the owning team, not the platform team.
The durable outcomes: sender lag and receiver discards are both paged with tenant attribution; max_shards is derived from measured RTT quarterly; every tenant has a series budget with predict_linear growth alerts; and the payments tenant ships full fidelity for 90 days but only job:* recorded aggregates to the 13-month tenant — cutting the long tier’s series count by 97% with zero dashboard changes.
Advantages and disadvantages
| Advantages of rules + remote-write + LTS backend | Disadvantages / costs |
|---|---|
| Durable, replicated history beyond any single node’s disk | A distributed system to operate (or pay for) where one binary used to suffice |
| One global datasource; cross-cluster SLOs become single queries | Remote-write adds 10–60 s of end-to-end staleness vs local scrape |
| Recording rules cut dashboard/alert query cost 10–100× | Rules cost evaluation CPU + stored series forever; bad rules are pure waste |
| HA pairs with receiver dedup: no gaps on node loss, no duplicate series | Double sender-side scrape/ship cost; dedup mechanism must be configured exactly right |
| Retention tiers + (Thanos/VM-EE) downsampling make multi-year queries fast and affordable | Downsampling adds storage during overlap windows; raw-only backends pay full price for long retention |
| Per-tenant limits convert one team’s cardinality mistake into that team’s contained incident | Limits drop data by design — without discard alerting they are silent data loss |
| WAL-based shipping survives receiver outages up to ~2–3 h with zero loss | Beyond the WAL window, loss is permanent and invisible without failed-samples alerting |
Filtering at the edge (write_relabel_configs) gives a precise cost dial per endpoint |
Every filter is a future “where did that metric go?” ticket — document them |
When do the disadvantages win? Below roughly one million active series and 30-day retention with a single team, a well-sized single Prometheus (or one per cluster plus thin federation of aggregates) is genuinely better: fewer moving parts, zero staleness, no bill surprise. The stack in this article earns its complexity at multi-cluster, multi-team, or multi-quarter-retention scale — adopt it because you measured the walls approaching, not because the architecture diagram looks mature.
Hands-on lab
Build the whole pipeline on your laptop with Docker: an edge Prometheus in agent mode scraping node metrics and remote-writing — with tuned queue, filtering, and HA-style external labels — into a central Prometheus acting as a remote-write receiver that evaluates the recording rules. You will test rules with promtool, watch the pipeline metrics live, and break the receiver on purpose to watch the WAL buffer save you. Cost: zero; everything is local.
1. Create the workspace.
mkdir -p ~/rw-lab/{edge,central,tests} && cd ~/rw-lab
2. Central config — receiver + recording rules. The --web.enable-remote-write-receiver flag turns any Prometheus into a spec-compliant remote-write endpoint at /api/v1/write — perfect for labs and small hub-and-spoke setups.
# central/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 30s
rule_files:
- /etc/prometheus/rules.yml
scrape_configs: [] # receives everything via remote-write
# central/rules.yml
groups:
- name: node_cpu_aggregation
interval: 30s
limit: 500
rules:
- record: instance_mode:node_cpu_seconds:rate1m
expr: sum by (instance, mode) (rate(node_cpu_seconds_total[1m]))
- record: instance:node_cpu_utilisation:ratio_rate1m
expr: |
1 - sum by (instance) (instance_mode:node_cpu_seconds:rate1m{mode="idle"})
/ sum by (instance) (instance_mode:node_cpu_seconds:rate1m)
3. Edge config — agent scraping + tuned remote_write.
# edge/prometheus.yml
global:
scrape_interval: 15s
external_labels:
cluster: lab
__replica__: edge-a
scrape_configs:
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]
remote_write:
- name: central
url: http://central:9090/api/v1/write
queue_config:
min_shards: 1
max_shards: 10
max_samples_per_send: 500
batch_send_deadline: 5s
retry_on_http_429: true
write_relabel_configs:
- source_labels: [__name__]
regex: "go_(gc|memstats)_.*"
action: drop
4. Compose file.
# docker-compose.yml
services:
node-exporter:
image: quay.io/prometheus/node-exporter:v1.8.2
central:
image: prom/prometheus:v3.4.1
command:
- --config.file=/etc/prometheus/prometheus.yml
- --web.enable-remote-write-receiver
- --storage.tsdb.retention.time=2d
volumes: ["./central:/etc/prometheus"]
ports: ["9090:9090"]
edge:
image: prom/prometheus:v3.4.1
command:
- --config.file=/etc/prometheus/prometheus.yml
- --agent
volumes: ["./edge:/etc/prometheus"]
ports: ["9091:9090"]
5. Test the rules BEFORE running anything.
# tests/rules.test.yml
rule_files:
- ../central/rules.yml
evaluation_interval: 30s
tests:
- interval: 15s
input_series:
- series: 'node_cpu_seconds_total{instance="n1", mode="idle"}'
values: '0+12x40' # 0.8 cores idle
- series: 'node_cpu_seconds_total{instance="n1", mode="user"}'
values: '0+3x40' # 0.2 cores user
promql_expr_test:
- expr: instance:node_cpu_utilisation:ratio_rate1m
eval_time: 5m
exp_samples:
- labels: 'instance:node_cpu_utilisation:ratio_rate1m{instance="n1"}'
value: 0.2
docker run --rm -v "$PWD:/lab" --entrypoint promtool prom/prometheus:v3.4.1 \
test rules /lab/tests/rules.test.yml
# Expected:
# Unit Testing: /lab/tests/rules.test.yml
# SUCCESS
docker run --rm -v "$PWD/central:/c" --entrypoint promtool prom/prometheus:v3.4.1 \
check rules /c/rules.yml
# Expected: SUCCESS: 2 rules found
6. Launch and validate the flow.
docker compose up -d && sleep 45
# Samples arriving at central, carrying the edge's external labels:
curl -s 'http://localhost:9090/api/v1/query?query=up{cluster="lab"}' | python3 -m json.tool | grep -E '"cluster"|"__replica__"|"value"'
# Expected: "cluster": "lab", "__replica__": "edge-a", value [..., "1"]
# Recording rules evaluating ON RECEIVED DATA at the central:
curl -s 'http://localhost:9090/api/v1/query?query=instance:node_cpu_utilisation:ratio_rate1m' \
| python3 -m json.tool | grep -c '"instance"'
# Expected: 1 (one series, a sane 0.0-1.0 value)
# Pipeline health from the edge's own /metrics (agent mode still self-instruments):
curl -s http://localhost:9091/metrics | grep -E \
'remote_storage_(samples_total|samples_pending|samples_failed_total|shards) '
# Expected: samples_total climbing, pending near 0, failed 0, shards 1
7. Break it — watch the WAL buffer work.
docker compose stop central && sleep 180 # 3-minute receiver outage
curl -s http://localhost:9091/metrics | grep -E 'samples_(pending|retried_total|failed_total) '
# Expected: pending in the thousands, retried_total climbing, failed_total STILL 0
docker compose start central && sleep 60
curl -s http://localhost:9091/metrics | grep -E 'samples_pending '
# Expected: pending back near 0 - the backlog drained in order, nothing lost.
# Confirm no gap: the range query over the outage window has continuous values.
curl -s 'http://localhost:9090/api/v1/query_range?query=up{cluster="lab"}&start='"$(date -u -v-10M +%s)"'&end='"$(date -u +%s)"'&step=15'
8. Teardown.
docker compose down -v && cd ~ && rm -rf ~/rw-lab
What you proved: rules tested offline, evaluated centrally on remote-written data (the agent-mode edge could not have run them), filtering applied per endpoint, external labels flowing end to end, and — the money lesson — a receiver outage covered entirely by the WAL with samples_failed_total at zero. Swap step 4’s central for a Mimir or Thanos Receive container later and only the url, a tenant header, and the dedup config change.