Thanos and Mimir — Long-Term Metrics Storage

Prometheus stores data locally for 15 days by default. For long-term retention, global query across clusters, and high availability, you need Thanos or Mimir.

0/0 checks

The Problem Prometheus Alone Can't Solve

Problem Why Prometheus alone fails
Data older than retention period TSDB purges it
Query across multiple clusters Each Prometheus is isolated
Prometheus HA (2 replicas scraping same targets) Duplicate series, can't merge
Downsampling for long-range queries Raw data is slow at 1-year range
Object storage cost efficiency Local disk is expensive at scale

Two Prometheus instances each run in their own isolated cluster. Can one PromQL query see data from both, with no Thanos or Mimir in front of them?


Two different answers to that problem — flip between them before going deeper into either:

Bolts onto existing Prometheus instances via a Sidecar (or Receive) — Prometheus itself is unmodified. Lower operational complexity. Best for < 5 clusters, < 10M series.
A fully distributed, horizontally scalable TSDB speaking the Prometheus remote_write and query API. Higher operational complexity, but every component scales independently and multi-tenancy is built in. Best for > 5 clusters, SaaS, millions of series.

Thanos

Thanos extends Prometheus by adding components that bolt onto existing Prometheus instances.

graph TD
    subgraph Cluster A
        PA[Prometheus A] -->|sidecar reads WAL| SA[Thanos Sidecar A]
        SA -->|upload blocks| S3[(S3 / GCS)]
    end
    subgraph Cluster B
        PB[Prometheus B] -->|sidecar reads WAL| SB[Thanos Sidecar B]
        SB -->|upload blocks| S3
    end

    QF[Thanos Query Frontend] --> Q[Thanos Querier]
    Q -->|fan-out query| SA
    Q -->|fan-out query| SB
    Q -->|query historical| ST[Thanos Store Gateway]
    ST -->|reads blocks| S3

    COMP[Thanos Compactor] -->|compact + downsample| S3
    RULE[Thanos Ruler] -->|global recording/alert rules| Q

Components

Component Role
Sidecar Runs next to each Prometheus. Exposes StoreAPI, uploads TSDB blocks to object storage
Store Gateway Reads blocks from object storage, answers queries for historical data
Querier Fan-out query engine — merges results from Sidecars + Store Gateway, deduplicates HA replicas
Query Frontend Caching + query splitting layer in front of Querier. Splits long-range queries into parallelizable sub-queries
Compactor Merges small TSDB blocks, applies downsampling (5m and 1h resolution for old data)
Ruler Evaluates recording rules and alert rules globally (across all clusters)
Receive (alternative) Push-based ingestion via remote_write — no sidecar needed
Runs next to each Prometheus, exposes StoreAPI, and uploads TSDB blocks to object storage. This is the path in the architecture diagram above — Prometheus itself stays completely unmodified.
Push-based ingestion via remote_write — no sidecar needed. Prometheus (or anything speaking remote_write) pushes samples straight into Receive instead of Receive pulling from a Sidecar.

You can't run a Sidecar next to a given Prometheus. What's the alternative ingestion path, and what does Ruler add on top of either one?

A single query touches every one of these pieces at once — here's one PromQL query's path through the fan-out:

sequenceDiagram
    participant G as Grafana
    participant QF as Query Frontend
    participant Q as Querier
    participant S as Sidecars
    participant SG as Store Gateway
    G->>QF: PromQL query
    QF->>QF: split into sub-queries, check cache
    QF->>Q: forward sub-queries
    Q->>S: fan-out, live data
    Q->>SG: fan-out, historical data from object storage
    S-->>Q: recent series
    SG-->>Q: historical series
    Q->>Q: merge, dedupe by replica label
    Q-->>QF: merged result
    QF-->>G: response
1. Grafana sends PromQL to Query Frontend. The Frontend sits in front of the Querier.
2. Frontend splits and caches. Long-range queries get split into parallelizable sub-queries before being forwarded to the Querier.
3. Querier fans out. It queries every Sidecar for live data and the Store Gateway for historical data from object storage, in parallel.
4. Merge and dedupe. The Querier merges the results and deduplicates HA replicas by matching the replica label.
5. Response returns. The merged result flows back through the Frontend to Grafana as one PromQL response — the caller never sees which Sidecar or Store Gateway a given series came from.

Deduplication

When two Prometheus replicas scrape the same targets (HA setup), both upload identical data. Thanos Querier deduplicates by matching replica label:

# Prometheus HA: two replicas, identical config except replica label
global:
  external_labels:
    cluster: production
    replica: "0"   # replica "1" on the other instance
# Query with deduplication
thanos query --query.replica-label=replica
# Querier merges series with identical labels except the replica label

Two Prometheus replicas in an HA pair scrape the same targets and both upload blocks with identical series, differing only in their replica external label. How does Thanos Querier avoid double-counting them?

Object Storage Config

# thanos-storage-config.yaml (mounted as secret)
type: S3
config:
  bucket: my-thanos-metrics
  endpoint: s3.us-east-1.amazonaws.com
  region: us-east-1
  # Uses IRSA on EKS — no access key needed

Downsampling

Thanos Compactor creates downsampled versions of old data:

Data age Resolution Raw scrape interval
0 – 40 hours Raw (15s) Kept as-is
40h – 10 days 5-minute Aggregated to min/max/sum/count
10 days+ 1-hour Further aggregated
# Grafana/Thanos auto-selects resolution based on query range
# You can also explicitly request:
# step=5m → Thanos picks 5m downsampled if range > 40h

Here's what happens to a single data point as it ages past each threshold:

1. 0–40 hours: raw. Kept at the original 15s scrape resolution — no aggregation yet.
2. 40h–10 days: 5-minute resolution. The Compactor aggregates it into 5-minute buckets (min/max/sum/count).
3. 10 days+: 1-hour resolution. The Compactor aggregates it further into 1-hour buckets — this is what keeps a 1-year range query fast instead of scanning raw 15s data end to end.

A Grafana dashboard queries a full year of data. Why doesn't Thanos have to scan a year of raw 15s-resolution samples to answer it?


Mimir (Grafana's Prometheus-compatible, horizontally scalable backend)

Mimir is a fully distributed, horizontally scalable TSDB. It implements the Prometheus remote_write and query API — drop-in replacement at scale.

graph LR
    subgraph Ingest Path
        RW[Prometheus remote_write] --> DIST[Distributor]
        DIST -->|hash ring| ING[Ingester x3]
        ING -->|flush| S3[(S3 object store)]
    end
    subgraph Query Path
        GF[Grafana] -->|PromQL| QF2[Query Frontend]
        QF2 -->|split + cache| QUER[Querier]
        QUER -->|recent data| ING
        QUER -->|historical| SG[Store Gateway]
        SG --> S3
    end
    subgraph Background
        COMP2[Compactor] --> S3
        RULER2[Ruler] --> QUER
    end

Key differences vs Thanos

Thanos Mimir
Architecture Bolt-on to Prometheus Standalone distributed system
Ingest Sidecar (pull) or Receive (push) remote_write only (push)
Multi-tenancy No (single tenant) Yes — tenant per X-Scope-OrgID header
Horizontal scale Limited (Querier/Store Gateway) Full — every component scales independently
Consistency Eventually consistent (block uploads) Strong — ingester replication factor
Operational complexity Lower (reuses Prometheus) Higher (many components)
Best for < 5 clusters, < 10M series > 5 clusters, SaaS, millions of series

Mimir Multi-Tenancy

# Prometheus remote_write with tenant header
remote_write:
  - url: http://mimir-distributor:8080/api/v1/push
    headers:
      X-Scope-OrgID: "team-platform"
# Query with tenant
curl -H "X-Scope-OrgID: team-platform" \
  "http://mimir-query-frontend:8080/prometheus/api/v1/query?query=up"

Two teams both push metrics to the same Mimir cluster via remote_write. What keeps their data (and queries) from mixing?


Choosing Between Them

Small-medium (1-5 clusters, < 5M series, < 1 year retention)
  → Thanos with Sidecar + S3 + Store Gateway
  → Simplest to operate, reuses existing Prometheus

Large (5+ clusters, 5-50M series, multi-tenant)
  → Mimir
  → Purpose-built for scale

SaaS / platform team serving many teams
  → Mimir (multi-tenancy built-in)

Already on Grafana Cloud
  → Grafana Mimir (hosted)

The same decision, as a flowchart:

graph TD
    Q{"What's the scale?"}
    Q -->|"1-5 clusters, under 5M series, under 1yr retention"| T["Thanos: Sidecar + S3 + Store Gateway<br/>simplest to operate, reuses existing Prometheus"]
    Q -->|"5+ clusters, 5-50M series, multi-tenant"| M1["Mimir<br/>purpose-built for scale"]
    Q -->|"SaaS / platform team serving many teams"| M2["Mimir<br/>multi-tenancy built-in"]
    Q -->|"already on Grafana Cloud"| M3["Grafana Mimir (hosted)"]

A platform team is standing up metrics for many separate internal teams on one shared backend. Which system fits, and why?


Hands-on: Thanos on EKS

# 1. Add external_labels to existing Prometheus ConfigMap
kubectl edit configmap prometheus-config -n monitoring
# Add:
# global:
#   external_labels:
#     cluster: production-us-east-1
#     replica: "0"

# 2. Deploy Thanos Sidecar alongside Prometheus
# (add as second container in Prometheus Deployment)
# - shares /data volume with Prometheus
# - uploads blocks every 2h

# 3. Create S3 bucket for blocks
aws s3 mb s3://my-thanos-blocks --region us-east-1

# 4. Deploy Store Gateway (reads historical from S3)
# 5. Deploy Querier (fan-out to Sidecar + Store Gateway)
# 6. Point Grafana at Thanos Querier instead of Prometheus directly

# Verify blocks are uploading
kubectl logs -n monitoring deployment/thanos-sidecar | grep "uploaded"

# Check Thanos Querier sees all stores
thanos tools bucket inspect \
  --objstore.config-file=storage.yaml \
  --output=table

# Query via Thanos (same PromQL as Prometheus)
curl "http://thanos-querier:9090/api/v1/query?query=up"

Retention and Cost

Raw Prometheus (15 days, local disk):  $0.10/GB/month (EBS gp3)
Thanos on S3 (1 year):                $0.023/GB/month (S3 Standard)
  + downsampling reduces query cost for old data
  + lifecycle rules: move to S3-IA after 30 days → $0.0125/GB/month

Typical savings: 80-90% vs keeping data on Prometheus local disk
# S3 lifecycle rule for cost optimization
aws s3api put-bucket-lifecycle-configuration \
  --bucket my-thanos-blocks \
  --lifecycle-configuration '{
    "Rules": [{
      "ID": "move-to-ia",
      "Status": "Enabled",
      "Filter": {"Prefix": ""},
      "Transitions": [{"Days": 30, "StorageClass": "STANDARD_IA"}]
    }]
  }'