Everything for Prometheus in one place — pick a section below. 52 reviewed items across 4 content types, plus a hands-on tutorial.
Quick Answer
Both add high availability and long-term storage to Prometheus by deduplicating data from replicas and saving metrics in object storage like S3. Thanos bolts a sidecar onto each existing Prometheus and adds a global query layer. Mimir receives metrics via remote-write into a standalone, horizontally scalable system. Thanos is easier to adopt; Mimir scales further but needs more infrastructure.
Detailed Answer
Think of Prometheus like a single security camera with a local hard drive. It records great footage but the drive fills up in two weeks, and if the camera breaks, you lose everything. Thanos is like adding a cloud backup to each existing camera -- a sidecar uploads footage to cheap cloud storage, and a central monitor can search all cameras at once. Mimir is like replacing the local drives entirely -- all cameras stream footage straight to a centralized, scalable recording system in the cloud.
Thanos achieves high availability by running a sidecar container next to each Prometheus instance. The sidecar does two things: it uploads Prometheus's local two-hour TSDB blocks to object storage such as S3, GCS, or MinIO, and it exposes a gRPC Store API that the Thanos Query component can reach. Thanos Query acts as a global query layer. It fans out PromQL queries to all sidecars for recent data and to the Store Gateway for historical data in object storage, then deduplicates results from high-availability Prometheus pairs using external labels. A separate Thanos Compactor merges and downsamples blocks in object storage so long-range queries stay fast.
Mimir, originally called Cortex and donated by Grafana Labs, works differently. Instead of sidecars, each Prometheus sends metrics via remote-write to Mimir's Distributor component. Mimir is built as a set of microservices that you can scale independently. Distributors accept writes and shard them across Ingesters, which hold recent data in memory and periodically flush blocks to object storage. Queriers answer PromQL requests by reading from both Ingesters for fresh data and Store Gateways for older data. Because each piece scales on its own, Mimir handles very large workloads without bottlenecks.
The operational trade-offs come down to complexity versus capability. Thanos is easier to adopt because you keep your existing Prometheus instances and just attach sidecars -- no change to the write path. But the sidecar model means every Prometheus still needs local disk for TSDB blocks, and queries fan out to many sidecars, which can slow down in large environments. Mimir offloads storage entirely from Prometheus so it becomes almost stateless, giving you better multi-tenancy, faster global queries, and no local disk dependency. The trade-off is that you now operate a distributed system with Distributors, Ingesters, Compactors, and Store Gateways, each needing proper resource tuning.
At scale, say 50-plus Prometheus instances and 10 million or more active series, Mimir generally performs better because queries hit centralized, pre-indexed storage rather than fanning out to dozens of sidecars. For smaller setups with 5 to 10 Prometheus instances, Thanos is simpler and perfectly fine. A common migration path is to start with Thanos, then move to Mimir when query performance becomes a bottleneck.
Code Example
# ─── Thanos Architecture ───
# Sidecar alongside each Prometheus instance
# prometheus.yaml (add external labels for dedup)
global:
external_labels:
cluster: payments-prod # Identifies this Prometheus
replica: prometheus-0 # HA pair identifier
# Thanos Sidecar container (runs next to Prometheus)
# --tsdb.path=/prometheus # Reads Prometheus TSDB
# --objstore.config-file=bucket.yml # S3 bucket config
# --grpc-address=0.0.0.0:10901 # Store API endpoint
# Thanos Query (global query layer)
# --store=prometheus-0-sidecar:10901
# --store=prometheus-1-sidecar:10901
# --store=thanos-store-gateway:10901
# --query.replica-label=replica # Dedup HA pairs
# ─── Mimir Architecture ───
# Prometheus remote-writes to Mimir
# prometheus.yaml
remote_write:
- url: http://mimir-distributor:8080/api/v1/push
headers:
X-Scope-OrgID: payments-team # Multi-tenancy
# Mimir components (each independently scalable):
# Distributor → receives writes, shards to ingesters
# Ingester → buffers recent data, flushes to S3
# Querier → handles PromQL queries
# Store Gateway→ serves historical data from S3
# Compactor → merges blocks, downsamples
# Query both systems:
# Thanos: http://thanos-query:9090 (PromQL-compatible)
# Mimir: http://mimir-querier:8080/prometheus (PromQL-compatible)Interview Tip
A junior engineer typically says 'Thanos and Mimir both store Prometheus data long-term' without digging into why the architectures feel so different. To show depth, contrast the two designs. Thanos is sidecar-based: it keeps Prometheus's local storage and adds upload plus a global query layer. Mimir is remote-write-based: Prometheus becomes nearly stateless and all storage is centralized. Walk through the trade-offs: Thanos is simpler to roll out but queries fan out to many sidecars and can slow down beyond around 50 instances, while Mimir scales horizontally per component but is operationally heavier. Mention that Thanos deduplicates using external labels and replica labels, and Mimir handles dedup at the ingester level. If you can explain when to choose which based on fleet size, you show you can make real architectural decisions at scale.
◈ Architecture Diagram
Thanos Architecture:
┌────────────┐ ┌────────────┐
│Prometheus-0│ │Prometheus-1│
│ + Sidecar │ │ + Sidecar │
└─────┬──────┘ └─────┬──────┘
│ upload │ upload
▼ ▼
┌──────────────────────────┐
│ Object Storage (S3) │
└──────────┬───────────────┘
│
┌──────────┴───────────────┐
│ Thanos Query │
│ fans out to sidecars │
│ + Store Gateway │
│ deduplicates HA pairs │
└──────────────────────────┘
Mimir Architecture:
┌────────────┐ ┌────────────┐
│Prometheus-0│ │Prometheus-1│
│remote-write│ │remote-write│
└─────┬──────┘ └─────┬──────┘
│ │
▼ ▼
┌──────────────────────────┐
│ Distributor │
└──────────┬───────────────┘
│ shards
┌───────┼───────┐
▼ ▼ ▼
┌──────┐┌──────┐┌──────┐
│Ingest││Ingest││Ingest│
└──┬───┘└──┬───┘└──┬───┘
│ flush │ │
▼ ▼ ▼
┌──────────────────────────┐
│ Object Storage (S3) │
└──────────────────────────┘💬 Comments
Quick Answer
High cardinality means a metric has too many unique label combinations, like using user_id or request_id as labels, causing millions of time series. Spot it with `prometheus_tsdb_head_series` and the TSDB status page. Fix it by dropping the bad label via relabeling or removing the metric at its source.
Detailed Answer
Think of cardinality like a filing cabinet. Each unique set of labels creates a new folder. A metric like http_requests_total{method="GET", status="200"} creates one folder -- low cardinality. But http_requests_total{method="GET", user_id="abc123"} creates a brand new folder for every single user. If you have 10 million users, that is 10 million folders, and the filing cabinet explodes.
In Prometheus, every unique combination of metric name plus label key-value pairs creates a time series. Each active series uses about 1 to 2 KB of memory in the TSDB head block. At 1 million series, that is roughly 2 GB of RAM just for the head block. At 10 million, it is 20 GB. High-cardinality metrics are the number-one cause of Prometheus running out of memory and getting killed, as well as slow queries that time out.
Detection starts with watching Prometheus itself. The metric prometheus_tsdb_head_series tells you the total active series count. If this suddenly jumps, say from 500K to 5M, you have a cardinality explosion. The TSDB status page at /tsdb-status on the Prometheus web UI shows the top 10 metrics by series count and the top 10 labels by unique value count, which immediately reveals the offender. You can also run topk(10, count by (__name__)({__name__=~".+"})) to find the metrics with the most series.
Once you find the culprit, for example a developer who added request_id as a label on a counter, you fix it at multiple levels. The immediate fix is adding a metric_relabel_config in the Prometheus scrape config to drop the high-cardinality label or the entire metric. This takes effect on the next scrape cycle. The medium-term fix is changing the application code to stop emitting the label and using histograms or summaries instead of per-ID counters. The long-term fix is setting up guardrails using tools like Prom-Label-Proxy or Mimir's per-tenant cardinality limits to prevent future explosions.
One important detail: you cannot simply delete old high-cardinality series from Prometheus. Once they are in the TSDB, they stay until the retention period expires. You can use the Admin API at /api/v1/admin/tsdb/delete_series to mark them for deletion, then run clean_tombstones to reclaim space. But this is an emergency measure -- the real fix is always upstream at the source of the metric. The 'context deadline exceeded' error in Prometheus usually means a query is trying to process too many series and timing out, which is a direct symptom of high cardinality.
Code Example
# Check total active series count
curl -s http://prometheus:9090/api/v1/query?query=prometheus_tsdb_head_series
# {"status":"success","data":{"result":[{"value":[1718700000,"5234891"]}]}}
# 5.2M series — likely too high!
# Find the top 10 metrics by series count
curl -s http://prometheus:9090/tsdb-status | jq '.headStats'
# Or run this PromQL:
# topk(10, count by (__name__)({__name__=~".+"}))
# Find which labels have the most unique values
# topk(10, count by (request_id)({__name__="http_requests_total"}))
# If this returns millions of results, request_id is the problem
# IMMEDIATE FIX: Drop the high-cardinality label via relabeling
# In prometheus.yml under the offending scrape job:
scrape_configs:
- job_name: payments-api
metric_relabel_configs:
- source_labels: [__name__] # Match the problematic metric
regex: http_requests_total
action: drop # Nuclear option — drop entirely
- source_labels: [request_id] # Or just drop the bad label
action: labeldrop
regex: request_id
# EMERGENCY: Delete existing high-cardinality series
curl -X POST http://prometheus:9090/api/v1/admin/tsdb/delete_series \
-d 'match[]={__name__="http_requests_total",request_id=~".+"}'
# Reclaim disk space after deletion
curl -X POST http://prometheus:9090/api/v1/admin/tsdb/clean_tombstones
# LONG-TERM: Set cardinality limits in Mimir
# limits:
# max_global_series_per_user: 1500000
# max_label_names_per_series: 30Interview Tip
A junior engineer typically says 'high cardinality means too many metrics,' which is not quite right. Cardinality is the number of unique time series, which is the product of all label values combined. Show you understand the memory cost: roughly 1-2 KB per series in the head block. Walk through how you would actually investigate: check prometheus_tsdb_head_series for a sudden jump, look at the TSDB status page for the worst offenders, then use a topk query to pinpoint the exact metric. Explain the three layers of fixing it: immediately drop labels with relabel_configs, then fix the application code in the medium term, and finally add cardinality limits in Mimir or Cortex for the long term. If you also mention that 'context deadline exceeded' is a classic symptom of high cardinality, it shows you have connected the dots in real incident response.
◈ Architecture Diagram
Low Cardinality (healthy):
http_requests_total
┌────────────────────────────┐
│ method=GET, status=200 │ → 1 series
│ method=GET, status=404 │ → 1 series
│ method=POST, status=200 │ → 1 series
│ method=POST, status=500 │ → 1 series
└────────────────────────────┘
Total: 4 series ✓
High Cardinality (dangerous):
http_requests_total
┌────────────────────────────┐
│ user_id=abc, method=GET │ → 1 series
│ user_id=def, method=GET │ → 1 series
│ user_id=ghi, method=POST │ → 1 series
│ ... × 10 million users │
└────────────────────────────┘
Total: 10M+ series ✗ → OOM!
Detection:
prometheus_tsdb_head_series
──────┐
│ 500K → 5M (sudden spike)
▼
/tsdb-status → top metric by series
│
▼
Fix: relabel_configs → drop label
or drop metric entirely💬 Comments
Quick Answer
Each cluster runs its own Prometheus that remote-writes to a central metrics backend like Mimir, Thanos, or VictoriaMetrics, tagged with a unique cluster label. Grafana connects to that central backend as a single data source and uses the cluster label to filter or compare across clusters.
Detailed Answer
Think of it like a retail chain with 20 stores. Each store has its own security camera system recording locally. The head office needs to see footage from all stores on one screen. You set up a central recording server and have each store upload its footage with a store ID tag. The head office monitor can then filter by store or show all stores side by side.
The architecture has three layers. First, each Kubernetes cluster runs its own Prometheus instance, or a Prometheus Operator stack, that scrapes all metrics locally. Each Prometheus is configured with a unique external_label, something like cluster=payments-prod-us-east-1, that identifies which cluster the metrics come from. This label is critical because without it you cannot tell metrics from different clusters apart.
Second, each Prometheus uses remote-write to push metrics to a centralized metrics backend. The three main choices are Mimir from Grafana Labs for large-scale setups, Thanos Receive for accepting remote-write and storing in object storage, or VictoriaMetrics for a high-performance and simpler-to-operate option. The remote-write endpoint usually sits behind a load balancer or ingress, secured with mTLS or bearer tokens. Each cluster's Prometheus pushes metrics continuously, typically every 15 to 30 seconds, to this central system.
Third, Grafana connects to the centralized backend as a single Prometheus-compatible data source. Every PromQL query can filter by cluster label: sum(rate(http_requests_total{cluster="payments-prod"}[5m])) for one cluster, or sum by (cluster)(rate(http_requests_total[5m])) for a side-by-side comparison. Dashboard variables use label_values(cluster) to create a dropdown that lets users switch between clusters with a click.
For Grafana itself, in a multi-cluster setup it typically runs in a central management cluster rather than in every cluster. Dashboards and alerts are managed as code using Grafana's provisioning system or tools like Grafonnet and Terraform. This ensures consistency -- updating one dashboard template updates the view for all clusters at once.
The key operational concern is network reliability. If a cluster's Prometheus cannot reach the central backend because of a network partition or backend overload, metrics can be lost. Prometheus buffers remote-write data in a WAL, or write-ahead log, and retries, but the buffer has a size limit of around 300 MB by default. For critical environments, consider running a local Thanos Sidecar or VictoriaMetrics agent as a write buffer that can survive longer outages without dropping data.
Code Example
# ─── Cluster 1: Prometheus config ───
# prometheus.yaml on payments-prod-us-east-1
global:
external_labels:
cluster: payments-us-east-1 # Unique cluster identifier
environment: production
scrape_interval: 15s
remote_write:
- url: https://mimir.internal.company.com/api/v1/push
headers:
X-Scope-OrgID: platform-team # Multi-tenant isolation
tls_config:
cert_file: /certs/client.crt # mTLS for security
key_file: /certs/client.key
queue_config:
max_samples_per_send: 5000 # Batch size
batch_send_deadline: 30s
max_shards: 10 # Parallel writers
# ─── Cluster 2: Same config, different label ───
global:
external_labels:
cluster: payments-eu-west-1 # Different cluster
environment: production
remote_write:
- url: https://mimir.internal.company.com/api/v1/push
# ─── Grafana Data Source ───
# Single data source pointing to centralized Mimir
# URL: https://mimir.internal.company.com/prometheus
# All clusters' data accessible via cluster label
# ─── Grafana Dashboard Variable ───
# Variable name: cluster
# Query: label_values(up, cluster)
# This creates a dropdown: [payments-us-east-1, payments-eu-west-1, ...]
# ─── Example PromQL in dashboard panels ───
# CPU usage per cluster (filtered by variable):
# sum(rate(container_cpu_usage_seconds_total{cluster="$cluster"}[5m])) by (namespace)
# Cross-cluster comparison:
# sum by (cluster)(rate(http_requests_total[5m]))Interview Tip
A junior engineer typically says 'install Grafana and add multiple data sources, one per cluster.' That works for two clusters but falls apart at twenty. For a senior role, explain the remote-write architecture: each cluster's Prometheus pushes metrics to a central backend like Mimir, Thanos, or VictoriaMetrics, tagged with a unique cluster external_label. Grafana then uses one data source pointing to the central backend and filters by cluster label. Bring up the operational details: mTLS for security, WAL buffering during network problems, queue_config tuning for high-throughput clusters, and multi-tenancy via X-Scope-OrgID headers. Mention that dashboards use template variables with `label_values(cluster)` for easy cluster switching. This shows you have designed observability at the platform level, not just set up Grafana for a single cluster.
◈ Architecture Diagram
┌─── Cluster A ──────┐ ┌─── Cluster B ──────┐
│ │ │ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Prometheus │ │ │ │ Prometheus │ │
│ │ cluster: │ │ │ │ cluster: │ │
│ │ us-east-1 │ │ │ │ eu-west-1 │ │
│ └──────┬───────┘ │ │ └──────┬───────┘ │
└─────────┼───────────┘ └─────────┼───────────┘
│ remote-write │ remote-write
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ Central Mimir / Thanos │
│ │
│ ┌────────┐ ┌────────┐ ┌────────────┐ │
│ │Distrib.│ │Ingester│ │Store Gatway│ │
│ └────────┘ └────────┘ └────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ S3 Storage │ │
│ └─────────────┘ │
└──────────────────┬───────────────────────┘
│
┌──────▼──────┐
│ Grafana │
│ │
│ cluster: │
│ [dropdown] │
│ us-east-1 ▼ │
└─────────────┘💬 Comments
Quick Answer
Store dashboard JSON and alert rule YAML in Git. Use Grafana provisioning, Grafonnet (a Jsonnet library), or Terraform's Grafana provider to define dashboards as code. Changes go through PR review, CI validates syntax, and CD applies them automatically. Updating 10 dashboards means changing one template and pushing a single commit.
Detailed Answer
Think of it like managing a chain of restaurants where every location has to serve the same menu. Instead of calling each manager and dictating changes over the phone, which is like clicking around in Grafana's UI, you update the master menu in a shared drive, the managers review it, and an automated system prints and ships the new menus to all locations at once.
The GitOps workflow for Grafana has three main approaches, from simple to powerful. The simplest is Grafana's built-in provisioning: you put dashboard JSON files and alert rule YAML files in a directory that Grafana watches, usually mounted via a ConfigMap in Kubernetes. When the files change, Grafana reloads them. You store these files in Git, and your CI/CD pipeline updates the ConfigMap every time a change merges to main.
The second approach uses Grafonnet, a Jsonnet library for generating Grafana dashboard JSON programmatically. Instead of writing raw 500-line JSON files by hand, you write concise Jsonnet code that generates them. This is where updating 10 dashboards at once becomes easy: if all 10 share a common template, say a service dashboard with CPU, memory, error rate, and latency panels, you define the template once and pass in parameters per service. Changing the template changes all 10 dashboards in one commit. Jsonnet compiles down to JSON, which then gets provisioned into Grafana.
The third approach uses Terraform with the Grafana provider. You define dashboards, folders, alert rules, and notification channels as Terraform resources. The CI pipeline runs terraform plan on pull requests to show what would change and terraform apply on merge. This gives you state management, drift detection, and the full Terraform workflow. For large organizations managing hundreds of dashboards across multiple Grafana instances, this is the most maintainable path.
For alerts, Grafana's alerting rules and notification policies can also be defined in YAML and provisioned alongside dashboards. The entire alerting chain -- rules, routing policies, contact points, and message templates -- lives in Git, version-controlled and reviewable.
The day-to-day workflow looks like this: a developer creates a branch, modifies dashboard Jsonnet or Terraform files, opens a pull request, CI runs syntax checks like jsonnet lint or terraform validate and optionally renders a preview, a reviewer approves, the PR merges to main, and the CD pipeline applies changes to Grafana. The big win is that every change is code-reviewed, version-controlled, and reversible with a simple git revert.
Code Example
# ─── Approach 1: Grafana Provisioning via ConfigMap ───
# dashboards.yaml (Grafana provisioning config)
apiVersion: 1
providers:
- name: default
type: file
options:
path: /var/lib/grafana/dashboards # Watch this directory
foldersFromFilesStructure: true
# Mount dashboards from ConfigMap in Kubernetes
# kubectl create configmap grafana-dashboards \
# --from-file=dashboards/ -n monitoring
# ─── Approach 2: Grafonnet (Jsonnet) ───
# service-dashboard.jsonnet — one template, many dashboards
local grafana = import 'grafonnet/grafana.libsonnet';
local dashboard = grafana.dashboard;
local prometheus = grafana.prometheus;
# Template function — reused for all services
local serviceDashboard(name, namespace) =
dashboard.new(name + ' Service Dashboard')
+ dashboard.withUid(name + '-svc')
+ dashboard.withPanels([
# CPU panel
grafana.panel.timeSeries.new(name + ' CPU')
+ { targets: [prometheus.new(
'sum(rate(container_cpu_usage_seconds_total{namespace="' + namespace + '", pod=~"' + name + '.*"}[5m]))'
)] },
# Error rate panel
grafana.panel.timeSeries.new(name + ' Error Rate')
+ { targets: [prometheus.new(
'sum(rate(http_requests_total{namespace="' + namespace + '", status=~"5.."}[5m]))'
)] },
]);
# Generate 10 dashboards from one template
{
'payments-api.json': serviceDashboard('payments-api', 'production'),
'checkout-svc.json': serviceDashboard('checkout-svc', 'production'),
'user-auth.json': serviceDashboard('user-auth', 'production'),
# ... 7 more services
}
# Build: jsonnet -J vendor/ -m output/ service-dashboard.jsonnet
# ─── Approach 3: Terraform ───
resource "grafana_dashboard" "payments" {
config_json = file("dashboards/payments-api.json")
folder = grafana_folder.production.id
}
# CI Pipeline (.github/workflows/grafana.yml)
# on PR: terraform plan → post diff as comment
# on merge: terraform apply → dashboards updatedInterview Tip
A junior engineer typically says 'export the JSON from the UI and put it in Git.' That works for one dashboard but does not scale. For a senior role, explain the three tiers: provisioning for simple file-based reload, Grafonnet for DRY dashboard generation using Jsonnet templates, and Terraform for state-managed, drift-detecting infrastructure-as-code. The key insight when asked about updating 10 dashboards at once is templating: with Grafonnet or a shared Jsonnet library, all 10 dashboards come from one template, so one change and one commit updates all 10. Bring up CI validation like jsonnet lint and terraform plan on pull requests, plus the auditability benefit -- every dashboard change has a PR, a reviewer, and a git SHA. This shows you have built platform-level observability tooling, not just hand-crafted a few dashboards.
◈ Architecture Diagram
GitOps Workflow for Grafana:
┌──────────┐ PR ┌──────────┐
│Developer │───────────►│ Git │
│ │ │ (main) │
│ edit │ review │ │
│ .jsonnet │◄───────────│ CI runs: │
│ or .tf │ approve │ lint │
└──────────┘ │ plan │
└────┬─────┘
│ merge
▼
┌─────────────────┐
│ CD Pipeline │
│ │
│ jsonnet build │
│ OR │
│ terraform apply │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Grafana │
│ │
│ 10 dashboards │
│ updated from │
│ 1 template │
└─────────────────┘
Template → 10 dashboards:
┌──────────────┐
│ svc-template │
│ .jsonnet │──► payments-api.json
│ │──► checkout-svc.json
│ │──► user-auth.json
│ │──► ... 7 more
│ 1 change = │
│ 10 updates │
└──────────────┘💬 Comments
Quick Answer
Symptom-based alerting fires on things users actually feel, like high error rates, slow responses, or SLO budget burn, instead of internal causes like high CPU or disk at 80%. It cuts alert noise dramatically because many internal causes map to just a few user-facing symptoms. You implement it with SLO-based burn rate alerts in Prometheus using multi-window, multi-burn-rate rules.
Detailed Answer
Think of it like a car dashboard. Cause-based alerting would mean separate warning lights for every internal part: fuel injector pressure, alternator voltage, coolant thermostat position, oxygen sensor reading. You would have 200 lights and no idea which ones matter. Symptom-based alerting gives you one light that says 'engine temperature high,' and the mechanic investigates the cause from there.
Traditional monitoring creates alerts for every possible internal state: CPU above 80%, disk above 85%, memory above 90%, Pod restarts above 3, queue depth above 1000. This leads to massive alert fatigue. A team with 50 microservices might have 500-plus alert rules, most of which fire for brief spikes that fix themselves. Engineers start ignoring alerts, and when a real outage happens, the critical signal is buried in noise.
Symptom-based alerting flips this around. You alert on what users experience: the error rate is burning through the SLO budget faster than sustainable, latency has crossed the SLO target, or availability has dropped below the threshold. These are called SLI-based alerts, where SLI stands for Service Level Indicator. If CPU is at 95% but the error rate is 0% and latency is normal, there is no user impact, so no alert is needed. If CPU is at 40% but the error rate is 5%, users are hurting, so you alert right away.
The best way to implement this is Google's multi-window, multi-burn-rate approach from the SRE book. You define an SLO such as 99.9% availability over 30 days, which gives you an error budget of 43.2 minutes of allowed downtime. Then you create burn rate alerts. A fast-burn alert fires when the error rate is consuming budget at 14.4 times the sustainable rate, meaning the entire monthly budget would be gone in 2 hours. This catches acute incidents. A slow-burn alert fires at 1 times the sustainable rate held over 3 days, catching gradual degradation. Each alert uses two time windows, a short one like 5 minutes and a long one like 1 hour, so a single brief spike does not trigger a false alarm.
In Prometheus, this translates to recording rules that calculate error ratios over multiple windows, plus alert rules that compare burn rates against thresholds. Grafana displays an SLO dashboard showing remaining error budget, burn rate trends, and alert status. The result: instead of 500 noisy alerts, you might have 10 to 20 SLO-based alerts across all services, each one actionable and tied to real user impact.
Code Example
# ─── SLO Definition ───
# Service: payments-api
# SLO: 99.9% availability (error budget: 0.1% or 43.2 min/month)
# ─── Recording Rules (prometheus-rules.yaml) ───
groups:
- name: payments-slo
rules:
# Error ratio over different windows
- record: payments:error_ratio:5m
expr: |
sum(rate(http_requests_total{job="payments-api",status=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="payments-api"}[5m]))
- record: payments:error_ratio:1h
expr: |
sum(rate(http_requests_total{job="payments-api",status=~"5.."}[1h]))
/
sum(rate(http_requests_total{job="payments-api"}[1h]))
- record: payments:error_ratio:6h
expr: |
sum(rate(http_requests_total{job="payments-api",status=~"5.."}[6h]))
/
sum(rate(http_requests_total{job="payments-api"}[6h]))
# ─── Alert Rules (burn rate) ───
# Fast burn: 14.4x budget consumption → page immediately
- alert: PaymentsSLOFastBurn
expr: |
payments:error_ratio:5m > (14.4 * 0.001)
and
payments:error_ratio:1h > (14.4 * 0.001)
for: 2m
labels:
severity: critical
annotations:
summary: "Payments API burning error budget 14x too fast"
description: "At this rate, monthly budget exhausted in 2 hours"
# Slow burn: 1x sustained → ticket (not page)
- alert: PaymentsSLOSlowBurn
expr: |
payments:error_ratio:6h > (1 * 0.001)
and
payments:error_ratio:3d > (1 * 0.001)
for: 30m
labels:
severity: warning
annotations:
summary: "Payments API slowly burning error budget"
description: "Gradual degradation — investigate this week"Interview Tip
A junior engineer typically says 'alert on errors and latency' without explaining why that is better than alerting on CPU or disk. For a senior role, describe the mindset shift: cause-based alerts like high CPU or full disk create noise because not every internal cause actually affects users. Symptom-based alerts only fire when users are impacted, measured by SLIs against SLOs. The key implementation detail is multi-window, multi-burn-rate: fast burn at 14.4x over 5 minutes AND 1 hour catches acute incidents, slow burn at 1x over 6 hours AND 3 days catches gradual degradation. Both windows must fire together to prevent false positives from single spikes. If you can explain error budgets (99.9% means 43.2 minutes of allowed downtime per month), burn rate math (14.4x means the monthly budget would be used up in 2 hours), and the two-tier severity model (fast burn pages you, slow burn creates a ticket), you show SRE maturity that most candidates cannot match.
◈ Architecture Diagram
Cause-Based (noisy): Symptom-Based (actionable): ┌────────────────────┐ ┌────────────────────┐ │ CPU > 80% PAGE │ │ │ │ Disk > 85% PAGE │ │ Error rate > SLO │ │ Memory > 90% PAGE │ │ burn rate? │ │ Restarts > 3 PAGE │ │ │ │ Queue > 1000 PAGE │ │ YES → PAGE │ │ Latency spike PAGE │ │ NO → silence │ └────────────────────┘ └────────────────────┘ 500+ alerts, most noise 10-20 alerts, all real Multi-Window Burn Rate: Error Budget: 43.2 min/month (99.9% SLO) ┌─── Fast Burn ──────────────────────┐ │ 14.4x burn rate │ │ 5min window AND 1hr window │ │ → exhausts budget in 2 hours │ │ → PAGE immediately │ └────────────────────────────────────┘ ┌─── Slow Burn ──────────────────────┐ │ 1x burn rate │ │ 6hr window AND 3day window │ │ → exhausts budget in 30 days │ │ → ticket, investigate this week │ └────────────────────────────────────┘
💬 Comments
Quick Answer
Prometheus writes new samples into an in-memory head block and protects them with a write-ahead log (WAL), then compacts them into durable two-hour blocks on disk. If disk space, cardinality, or retention settings are wrong, the WAL and head chunks can eat up all storage or memory before normal cleanup kicks in.
Detailed Answer
Think of a busy warehouse that receives packages all day. New boxes first land in a fast intake area, while a clerk writes every arrival into a paper ledger in case the power fails. Every few hours, the intake area is sorted into sealed pallets and moved to long-term shelves. Prometheus TSDB works the same way: fresh samples are kept hot for fast queries, protected by a write-ahead log, and later packed into efficient on-disk blocks.
In Prometheus, TSDB stands for time series database. It is the local storage engine that keeps metric samples organized by metric name and labels. Prometheus is intentionally a single-node local database, not a clustered durable store. That design keeps ingestion and querying simple, but it puts the burden on operators to size local disk, memory, and retention correctly. The local TSDB is great for recent operational data, but high availability and long-term retention typically require replicas, remote write, Thanos, Mimir, or another system layered on top.
The write path starts when a scrape returns samples. Prometheus checks the labels, appends each sample to the head block in memory, and writes a record to the WAL so a restart can replay recent data. The active head block covers the most recent samples and periodically gets frozen into an immutable block, usually covering a two-hour window. Older blocks are merged together in the background through a process called compaction. Index files map label sets to chunk data, tombstones record deletions, and retention cleanup removes blocks that have fully expired.
In production, head data and WAL data are where the pressure peaks. Operators watch active series count, ingestion rate, WAL fsync failures, checkpoint duration, available disk, compaction failures, and query latency. Retention based on time alone can be misleading when a team suddenly adds a high-cardinality label like customer_id. Size-based retention should leave headroom because the WAL and head chunks still need space while background cleanup catches up. Prometheus expects local POSIX storage; network filesystems are a reliability risk for the TSDB.
The gotcha that catches people off guard is that retention cleanup is block-oriented and delayed, but the outage is often immediate. If the disk fills because the WAL is growing or the head block is huge, lowering retention from 30 days to 15 days may not save the node right away. Deleting series through the admin API writes tombstones first and does not instantly shrink chunk files. Removing WAL files can let Prometheus start again, but you lose recent samples. Experienced operators first stop the source of excess cardinality, then recover disk, then tune retention and remote storage to prevent it from happening again.
Code Example
promtool tsdb analyze /var/lib/prometheus # Summarizes high-cardinality metrics and label pairs inside the local TSDB. promtool tsdb list /var/lib/prometheus # Lists persisted blocks so you can see block ranges and compaction history. curl -s 'http://prometheus:9090/api/v1/query?query=prometheus_tsdb_head_series' # Reads the number of active in-memory series currently pressuring the head block. curl -s 'http://prometheus:9090/api/v1/query?query=prometheus_tsdb_wal_truncations_failed_total' # Checks whether WAL cleanup is failing and leaving old segments behind. curl -s 'http://prometheus:9090/api/v1/query?query=prometheus_tsdb_compactions_failed_total' # Checks whether block compaction is failing and preventing normal storage cleanup. prometheus --config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/var/lib/prometheus --storage.tsdb.retention.time=15d --storage.tsdb.retention.size=80GB # Starts Prometheus with explicit time and size retention so the first limit reached wins.
Interview Tip
A junior engineer typically answers that Prometheus stores metrics on disk, which is true but surface-level. For a senior role, the interviewer wants to know if you understand the write path and the failure modes. Walk through the difference between the hot head data in memory, WAL replay safety on restart, persisted immutable blocks, tombstones from deletions, and background compaction. Then connect those internals to operational decisions: avoid high-cardinality labels, keep disk headroom above what retention alone suggests, never put the TSDB on unreliable network storage, and understand that changing retention settings may not provide immediate relief when the disk is already full.
◈ Architecture Diagram
┌──────────┐
│ Scrape │
└────┬─────┘
↓
┌──────────┐ ┌──────────┐
│ Head │←────│ WAL │
└────┬─────┘ └──────────┘
↓
┌──────────┐
│ Block 2h │
└────┬─────┘
↓
┌──────────┐
│ Compact │
└────┬─────┘
↓
┌──────────┐
│ Retain │
└──────────┘💬 Comments
Quick Answer
Prometheus reuses the newest sample only if it falls within the lookback window, which defaults to 5 minutes. When a target or metric disappears, Prometheus writes a staleness marker so queries stop returning the old value instead of silently carrying it forever.
Detailed Answer
Think of a train station display board. If the 8:10 train reported its location two minutes ago, the board can still show a useful last-known position. If the train has not reported for an hour, showing that old position would mislead passengers. Prometheus has the same problem with metrics: a recent sample is fine to use at query time, but an old sample should eventually disappear so graphs and alerts do not pretend the system is healthy.
PromQL, the Prometheus query language, evaluates instant queries at a single timestamp and range queries at many evenly spaced timestamps. For each evaluation timestamp, Prometheus looks backward for the newest sample inside the lookback window. The default lookback is 5 minutes, and it is configurable. This lets queries work even when scrapes do not land exactly on graph step boundaries. Without this behavior, normal scrape timing jitter would create broken graphs and unreliable aggregations.
Staleness adds another layer. If a target scrape no longer returns a series that previously existed, or if service discovery removes a target entirely, Prometheus can write a staleness marker for that time series. After that marker, instant queries no longer return the old value for that series. This prevents stale readings from being treated as current values in aggregations like sum, avg, or alert expressions. If fresh samples later arrive for the same label set, the series simply reappears.
Production alerting gets subtle here. An alert like up == 0 catches failed scrapes where the target is still known but unreachable. However, it may not catch a target that vanished from service discovery, because there may be no up series left to evaluate. For detecting missing services, absent() or inventory-based alerts are usually needed. Engineers also tune scrape_interval, scrape_timeout, evaluation_interval, and the alert for duration so brief network hiccups do not page people while true disappearances still get caught quickly.
The experienced gotcha is that a graph can look flat or empty for different reasons. A flat line may mean Prometheus is carrying a recent last sample inside the lookback window, while an empty graph may mean the series went stale, not that the value became zero. Exporters that attach their own timestamps can behave differently and may keep the last value visible until lookback expires. A common band-aid is using or vector(0) everywhere, which makes dashboards look tidy but hides missing telemetry. Senior engineers learn to distinguish between zero, missing, stale, and failed-scrape states explicitly rather than papering over the differences.
Code Example
curl -G 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=up{job="payments-api"}' # Checks whether Prometheus still sees the payments-api target and whether the latest scrape succeeded.
curl -G 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=absent(up{job="payments-api"})' # Detects the case where the target disappeared from service discovery and no up series exists.
curl -G 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=max_over_time(up{job="payments-api"}[10m])' # Shows whether the target was present at any point during the last 10 minutes.
curl -G 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=time() - timestamp(up{job="payments-api"})' # Measures how old the newest up sample is for the target.
promtool check rules /etc/prometheus/rules/payments-availability.yml # Validates alert rules before reloading them into Prometheus.Interview Tip
A junior engineer typically answers that `up == 0` means a service is down, but there are actually several different failure states the interviewer wants you to separate. Talk through the difference between a failed scrape where the target is still known, a target that has been removed from service discovery entirely, a stale series carrying an old value, and a genuine zero value. Explain how lookback protects normal queries from scrape jitter, how staleness markers prevent old values from being reused indefinitely, and why `absent()` is needed when service discovery removes a target altogether. That answer shows you can design alerts that catch genuinely missing telemetry without creating noisy false alarms.
◈ Architecture Diagram
┌──────────┐
│ Target │
└────┬─────┘
↓ scrape
┌──────────┐
│ Sample │
└────┬─────┘
↓ query
┌──────────┐
│ Lookback │
└────┬─────┘
↓
┌──────────┐ ┌──────────┐
│ Present │ │ Stale │
└────┬─────┘ └────┬─────┘
↓ ↓
┌──────────┐ ┌──────────┐
│ Alert │ │ Absent │
└──────────┘ └──────────┘💬 Comments
Quick Answer
Use histograms when you need to aggregate percentiles across many instances and tie them to SLOs. Classic histograms need explicit bucket boundaries, native histograms reduce that manual work, and summaries calculate percentiles inside the app but cannot be safely combined across replicas.
Detailed Answer
Think of measuring checkout wait times by placing customers into labeled bins: under 100 ms, under 300 ms, under 1 second, and so on. If the bins are chosen around the thresholds the business actually cares about, the data is useful. If every bin is too wide, too narrow, or missing the SLO boundary, the final percentile looks precise but answers the wrong question. Prometheus histograms are that binning system for measurements like request duration.
A classic Prometheus histogram exposes cumulative bucket counters using the le label, which stands for less than or equal, plus _sum and _count series. Prometheus calculates percentiles using the histogram_quantile() function over rates of those buckets. The big advantage of this design is that you can aggregate across pods, nodes, clusters, or jobs before calculating the percentile, which is why histograms are the go-to for distributed services. The cost is extra time series: each bucket boundary creates another series for every label combination.
Native histograms change the storage model by representing many bucket spans more compactly and letting Prometheus handle histogram samples directly. They reduce some of the pain of choosing bucket boundaries manually and support more flexible percentile exploration. However, they require compatible Prometheus settings, client libraries, remote write backends, and query paths, so you need to check the full chain before adopting them. Summaries are a different animal: they compute selected quantiles inside each application process. That can be useful for a single process, but averaging p95 values across replicas is statistically wrong because each process saw a different number and shape of requests.
The query path matters for getting correct results. For classic histograms, you typically apply rate() to _bucket counters, aggregate with sum by (le, service) or similar, then call histogram_quantile(). The le label must survive until the quantile function runs because it represents the bucket boundary. For SLO checks like seeing what fraction of requests finish under 300 ms, having an exact bucket at that boundary makes the calculation simple and reliable. For Apdex-style scores, you need buckets at both the satisfied and tolerated thresholds.
The gotcha is that histogram percentiles are estimates, and how good the estimate is depends entirely on where you place the buckets. A p99 alert built on buckets of 100 ms, 1 second, and 10 seconds cannot accurately tell the difference between 1.2 seconds and 8 seconds. Another common mistake is averaging per-pod p95 values in Grafana, which gives equal weight to quiet pods and busy pods. Experienced engineers pick bucket boundaries around the SLO thresholds users care about, keep labels low-cardinality, aggregate buckets before computing quantiles, and verify that the remote storage path preserves the histogram type they depend on.
Code Example
curl -G 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket{job="payments-api"}[5m])))' # Computes fleet-wide p95 latency from classic histogram buckets after aggregating by bucket boundary.
curl -G 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=sum(rate(http_request_duration_seconds_bucket{job="payments-api",le="0.3"}[5m])) / sum(rate(http_request_duration_seconds_count{job="payments-api"}[5m]))' # Calculates the fraction of payments-api requests completed within the 300 ms SLO bucket.
curl -G 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=sum(rate(http_request_duration_seconds_sum{job="payments-api"}[5m])) / sum(rate(http_request_duration_seconds_count{job="payments-api"}[5m]))' # Calculates average latency from histogram sum and count without using quantile math.
promtool check rules /etc/prometheus/rules/payments-latency-slo.yml # Validates histogram-based recording and alerting rules before deployment.Interview Tip
A junior engineer typically answers that histograms are for percentiles, which is correct but incomplete. For a senior role, the interviewer wants to see statistical and operational maturity. Explain why summaries cannot be aggregated across replicas, since averaging quantiles from different pods is mathematically invalid. Explain why classic histogram bucket boundaries need to sit near SLO thresholds, and why `histogram_quantile()` should run after you aggregate bucket rates, not before. Also bring up the storage trade-off: more buckets and labels mean more series, and cardinality can spiral. Native histograms are promising but a senior answer checks client library, server, and remote storage compatibility before rolling them out in production.
◈ Architecture Diagram
┌──────────┐
│ Request │
└────┬─────┘
↓
┌──────────┐
│ Buckets │
└────┬─────┘
↓
┌──────────┐
│ Rate │
└────┬─────┘
↓
┌──────────┐
│ Sum by le│
└────┬─────┘
↓
┌──────────┐
│ p95 │
└──────────┘💬 Comments
Quick Answer
Remote write tails the Prometheus WAL into per-destination queues, shards the work across parallel senders, batches samples, and retries on failure. If queues fill up, Prometheus stops reading from the WAL for that destination. If the receiver stays down too long, unsent samples can be lost as WAL data gets compacted away.
Detailed Answer
Think of a shipping dock that sends packages from a factory to a central warehouse. The factory keeps making boxes, workers load them into several truck lanes, and sometimes the warehouse slows down. If the lanes fill up, boxes pile up at the dock. If the warehouse stays closed for hours, the factory has to choose between halting intake, using more dock space, or eventually throwing away boxes it can no longer hold.
Prometheus remote write is that shipping dock for metrics. Prometheus first ingests samples locally through its normal scrape and WAL path. A remote write component then reads from the WAL, maps internal series IDs to their label sets, queues samples, and sends compressed HTTP requests to the configured remote endpoint. That endpoint might be Grafana Mimir, Thanos Receive, Cortex, VictoriaMetrics, or a managed cloud service. Remote write is not a magic way to backfill historical data; it is mainly a streaming replication path from the local ingestion flow.
Backpressure shows up when the remote endpoint is slow, returning errors, rate-limiting, or totally unreachable. Prometheus uses shards, which are parallel sending workers, to improve throughput. Each shard has an in-memory queue with a capacity limit and a maximum batch size. Failed requests get retried with exponential backoff. Prometheus can automatically adjust the shard count based on the incoming sample rate and how long sends are taking. But once queues fill up, reading from the WAL for that remote write target is blocked, and pending samples start piling up.
In production, the key metrics to watch are pending samples, failed samples, retried samples, send batch duration, current shard count, and queue capacity. Tuning usually starts with the receiver side: confirm it is healthy, not throttling, and not rejecting samples due to tenant limits or bad labels. Then tune Prometheus settings like max_samples_per_send, capacity, max_shards, and backoff values. Capacity should generally be several times the batch size, but setting it too high increases Prometheus memory usage. Write relabeling can drop expensive or unnecessary samples before they even leave Prometheus.
The gotcha is that cranking every knob up can make the outage worse. More shards can overwhelm a backend that is trying to recover. More queue capacity can cause Prometheus memory pressure, especially during high series churn because remote write caches series labels. Another gotcha involves the two-hour WAL window: if remote write stays blocked longer than the WAL can hold unsent data, samples get lost when the WAL is compacted. Senior engineers treat remote write tuning as end-to-end flow control, not just a matter of making the queue bigger.
Code Example
remote_write: # Sends locally ingested samples to a central backend such as Mimir or Thanos Receive.
- url: https://mimir-write.monitoring.svc/api/v1/push # Points Prometheus at the remote write receiver endpoint.
name: payments-mimir # Gives this remote write queue a stable name in metrics and logs.
remote_timeout: 30s # Bounds each send request so slow receivers do not hang workers forever.
queue_config: # Controls memory queues and parallel send workers for this remote write target.
max_samples_per_send: 5000 # Sends larger batches to improve throughput when the receiver supports them.
capacity: 30000 # Keeps per-shard capacity about six times the batch size to absorb short slowdowns.
max_shards: 10 # Caps parallelism so Prometheus does not overload the central backend during recovery.
min_shards: 2 # Starts with two workers so the queue can drain promptly after restart.
min_backoff: 1s # Waits at least one second before retrying a failed send.
max_backoff: 30s # Prevents retry storms by backing off repeated failures.
write_relabel_configs: # Drops samples before remote write to reduce bandwidth and receiver load.
- source_labels: [__name__] # Selects samples by metric name before deciding whether to send them.
regex: 'go_.*' # Matches noisy runtime metrics that the central backend does not need.
action: drop # Drops matching samples from remote write while keeping local scrape data.Interview Tip
A junior engineer typically answers that remote write sends metrics to long-term storage, which is true but skips the interesting parts. For a senior role, the interviewer wants to hear about the queuing model and what happens when things go wrong. Walk through WAL tailing, series label caching, shards, batch size, capacity, retries, and backoff. Then connect those details to how you would respond during an actual incident: check receiver health first, watch the pending and retry metrics, avoid blindly increasing shards because that can hammer a recovering backend, and use write relabeling to shed non-essential load. Mentioning that long receiver outages can still lose unsent data shows you understand remote write is resilient but not a bottomless buffer.
◈ Architecture Diagram
┌──────────┐
│ WAL │
└────┬─────┘
↓
┌──────────┐
│ Queue │
└────┬─────┘
↓
┌──────────┐
│ Shards │
└────┬─────┘
↓
┌──────────┐
│ Receiver │
└────┬─────┘
↓
┌──────────┐
│ Object │
└──────────┘💬 Comments
Quick Answer
Alertmanager groups related alerts, deduplicates notifications, routes them to the right receiver, silences planned noise, and inhibits lower-level alerts when a parent alert explains them. In HA mode, Prometheus should send alerts directly to every Alertmanager peer, not through a load balancer.
Detailed Answer
Think of a hospital emergency department during a city-wide power failure. Thousands of alarms pour in from buildings, traffic lights, and elevators. Operators do not want a separate phone call for each alarm. They want one grouped incident per affected area, with enough detail to know which buildings still need help. Alertmanager is that dispatch layer for Prometheus alerts.
Prometheus evaluates alerting rules and sends firing or resolved alerts to Alertmanager over HTTP. Alertmanager then groups alerts by chosen labels, routes each group through a routing tree to the right receiver (Slack, PagerDuty, email), deduplicates repeated notifications, applies silences for planned maintenance, and applies inhibitions when one alert makes another redundant. For example, if a ClusterDown alert is firing, an inhibition rule can suppress thousands of pod-level alerts from that same cluster because they are all symptoms of the same root cause.
Grouping is label-driven. The group_by setting picks which labels define a notification group. group_wait delays the first notification briefly so related alerts can arrive together. group_interval controls how often new alerts get added to an existing group. repeat_interval controls how frequently unresolved alerts are re-sent. Inhibition rules compare a source alert against target alerts using matchers and equality labels. Silences use matchers and time windows, and are usually created through the Alertmanager UI or API during maintenance windows.
For high availability, multiple Alertmanager instances form a cluster and share notification state through a gossip protocol. Prometheus should be configured with all Alertmanager peers listed as targets. The Prometheus docs warn against putting a load balancer between Prometheus and Alertmanager because each Prometheus instance needs to deliver alerts to the full cluster so deduplication and state replication work correctly. Teams also set external labels like cluster, region, and replica carefully so Alertmanager can tell independent environments apart while still deduplicating HA Prometheus replicas.
The gotcha is that label design can either flood your team or hide a real outage. If group_by includes pod, every single pod failure during a deployment becomes a separate page. If it only groups by alertname, unrelated production and staging incidents might collapse into one notification. Inhibition can be dangerous too -- if the source alert is too broad or fires too easily, it can silence real alerts. Senior engineers test alert routes with sample payloads, keep grouping labels tied to ownership and blast radius, and regularly review active silences to make sure planned maintenance windows have not turned into black holes for real incidents.
Code Example
alerting: # Configures where Prometheus sends evaluated alerts.
alertmanagers: # Lists Alertmanager targets for alert delivery.
- static_configs: # Uses explicit peer targets instead of a load-balanced single endpoint.
- targets: ['alertmanager-0:9093','alertmanager-1:9093','alertmanager-2:9093'] # Sends alerts directly to every HA Alertmanager peer.
route: # Defines the root Alertmanager routing tree.
receiver: sre-pager # Sends unmatched production alerts to the SRE paging receiver.
group_by: ['cluster','namespace','alertname'] # Groups by blast radius without grouping unrelated clusters together.
group_wait: 30s # Waits briefly so related alerts from the same incident can arrive together.
group_interval: 5m # Controls how often new alerts are added to an existing notification group.
repeat_interval: 4h # Prevents repeated pages for the same unresolved alert group.
inhibit_rules: # Suppresses noisy child alerts when a parent outage alert is already firing.
- source_matchers: ['alertname="ClusterDown"'] # Uses the cluster-level outage alert as the inhibition source.
target_matchers: ['severity="warning"'] # Suppresses lower-severity warning alerts during the parent outage.
equal: ['cluster'] # Applies inhibition only inside the same cluster label value.Interview Tip
A junior engineer typically says Alertmanager sends notifications and leaves it at that. To give a strong answer, walk through the full alert flow: grouping, deduplication, routing trees, silences, inhibition, and HA peer behavior. Then talk about label strategy -- include enough labels to preserve ownership and blast radius, but not so many that every pod generates its own page. Mention that Prometheus must target all Alertmanager peers directly in HA mode, not through a load balancer, because the cluster needs every alert delivered to every member for proper deduplication. Finally, explain that you review silences regularly to make sure maintenance windows do not accidentally swallow real incidents. That shows the interviewer you have operated alerting during real multi-service outages.
◈ Architecture Diagram
┌──────────┐
│ Rules │
└────┬─────┘
↓
┌──────────┐
│ Alerts │
└────┬─────┘
↓
┌──────────┐
│ Group │
└────┬─────┘
↓
┌──────────┐ ┌──────────┐
│ Inhibit │←────│ Silence │
└────┬─────┘ └──────────┘
↓
┌──────────┐
│ Route │
└────┬─────┘
↓
┌──────────┐
│ Pager │
└──────────┘💬 Comments
Quick Answer
Prometheus is an open-source monitoring toolkit that actively pulls metrics from HTTP endpoints (/metrics) on a schedule, instead of waiting for apps to push data. This gives Prometheus control over scrape timing and makes it easy to tell when a target is down.
Detailed Answer
Think of Prometheus like a health inspector who visits restaurants on a schedule instead of waiting for restaurants to mail in their own reports. The inspector decides when to visit, what to check, and can immediately tell if a restaurant has closed because nobody answers the door. This pull-based approach is the opposite of push-based systems like StatsD where applications send their own metrics to a collector.
Prometheus started at SoundCloud in 2012 and is now a graduated CNCF project built for cloud-native environments. At its core, Prometheus sends HTTP GET requests to /metrics endpoints on your applications and infrastructure at regular intervals (the scrape interval, usually 15 seconds to 1 minute). Each target exposes its current metric values in a standard text format. Prometheus collects these values, timestamps them, and stores them in its local time series database (TSDB).
To find what to scrape, Prometheus uses service discovery. In Kubernetes, it automatically discovers pods, services, and endpoints through the Kubernetes API. The discovery pipeline works in stages: first, targets are found through service discovery configs. Then relabeling rules filter and modify target labels before scraping. Finally, metric relabeling can transform or drop metrics after scraping. This automation means you never need to maintain static lists of IP addresses in a world where containers come and go constantly.
The pull model has big practical advantages. You can run Prometheus on your laptop and point it at staging endpoints for debugging. Your applications do not need to know where Prometheus lives -- they just expose /metrics. Load is predictable because Prometheus controls when it scrapes. The main limitation is that Prometheus must be able to reach your targets over the network, which can be tricky with firewalls or NAT. For short-lived batch jobs that finish before Prometheus can scrape them, there is the Pushgateway as a workaround.
A common mistake is confusing scrape interval with evaluation interval. Scrape interval is how often Prometheus fetches metrics from targets. Evaluation interval is how often it runs recording rules and alerting rules. Setting the scrape interval too low (like 5 seconds) can overwhelm both Prometheus and your targets with too many HTTP requests. Another pitfall is not watching scrape duration -- if a target takes 10 seconds to respond and your scrape interval is 15 seconds, you have very little headroom before timeouts start cascading.
Code Example
# prometheus.yml - Main Prometheus configuration
global:
scrape_interval: 30s # How often to scrape targets globally
evaluation_interval: 30s # How often to evaluate rules
scrape_timeout: 10s # Timeout for each scrape request
scrape_configs:
# Job to scrape the payments-api service
- job_name: 'payments-api' # Label added to all metrics from this job
metrics_path: '/metrics' # HTTP path to scrape (default: /metrics)
scheme: 'http' # Protocol to use (http or https)
scrape_interval: 15s # Override global interval for this job
static_configs:
- targets: # List of host:port to scrape
- 'payments-api-01.prod:8080' # First instance of payments-api
- 'payments-api-02.prod:8080' # Second instance of payments-api
labels:
environment: 'production' # Custom label added to all metrics
team: 'payments' # Team ownership label
# Job using Kubernetes service discovery
- job_name: 'checkout-service' # Scrape checkout pods in Kubernetes
kubernetes_sd_configs:
- role: pod # Discover targets from Kubernetes pods
namespaces:
names: ['checkout-ns'] # Only look in the checkout namespace
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] # Check annotation
action: keep # Only scrape pods with annotation = true
regex: 'true' # Match value of the annotation
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port] # Read port annotation
action: replace # Replace the target port
target_label: __address__ # Rewrite the scrape address
regex: (.+) # Capture the port value
replacement: '${1}:${2}' # Set address:portInterview Tip
A junior engineer typically describes Prometheus as just a monitoring tool without explaining the pull model or why it matters. To stand out, explain the pull-vs-push tradeoff clearly: pulling gives the monitoring system control over load and makes it trivial to detect down targets (no response means the target is down), while push-based systems require targets to know where to send data and cannot tell the difference between no data and a healthy-but-idle target. Mention that Prometheus uses the Pushgateway as a workaround for short-lived batch jobs. Also bring up Kubernetes service discovery -- it eliminates static target lists, which is essential when container IPs change constantly. If you can discuss scrape interval vs evaluation interval and explain why setting scrape interval too low is dangerous, that shows real operational experience.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Prometheus Pull-Based Model │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ HTTP GET /metrics ┌────────────────┐ │ │ │ │ ──────────────────────→ │ payments-api │ │ │ │ │ ←────────────────────── │ :8080/metrics │ │ │ │ │ metric samples └────────────────┘ │ │ │ │ │ │ │ Prometheus │ HTTP GET /metrics ┌────────────────┐ │ │ │ Server │ ──────────────────────→ │ checkout-svc │ │ │ │ │ ←────────────────────── │ :9090/metrics │ │ │ │ ┌───────────┐ │ metric samples └────────────────┘ │ │ │ │ TSDB │ │ │ │ │ │ Storage │ │ HTTP GET /metrics ┌────────────────┐ │ │ │ └───────────┘ │ ──────────────────────→ │ node-exporter │ │ │ │ │ ←────────────────────── │ :9100/metrics │ │ │ └─────────────────┘ metric samples └────────────────┘ │ │ │ │ │ │ Service Discovery │ │ ↓ │ │ ┌─────────────────┐ │ │ │ Kubernetes API │ → Discovers pods, services, endpoints │ │ │ Consul / DNS │ → Relabeling filters targets │ │ │ File SD │ → Static configs as fallback │ │ └─────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
PromQL is Prometheus's query language for filtering, aggregating, and transforming time series data. You use label selectors to pick metrics, rate() to calculate per-second rates from counters, and histogram_quantile() to get percentiles from histogram buckets.
Detailed Answer
Think of PromQL like a spreadsheet formula language designed specifically for time-stamped data. Just as Excel lets you filter rows, apply SUM or AVERAGE, and combine cells, PromQL lets you filter metrics by labels, apply math functions over time windows, and aggregate across dimensions. The difference is that every value in PromQL has a time component attached to it.
PromQL works with two main data types: instant vectors (a set of time series, each with one sample at a single point in time) and range vectors (a set of time series, each with multiple samples over a time window). Selectors are how you choose which metrics to work with. A simple selector like http_requests_total grabs all time series with that name. Adding label matchers narrows it down: http_requests_total{service="payments-api", status_code=~"5.."} selects only 5xx errors for the payments service. The =~ operator does regex matching, while != and !~ provide negative matching.
Under the hood, when Prometheus runs a PromQL query, it first resolves the selector against its TSDB index, which maps label combinations to time series. For range vector selectors like http_requests_total[5m], it fetches all samples from the last 5 minutes for each matched series. Functions then operate on these vectors. The rate() function is essential for counters -- it calculates the per-second average rate of increase over a range vector, automatically handling counter resets (when a process restarts and the counter goes back to zero). The irate() function uses only the last two data points for a more responsive but noisier result. The histogram_quantile() function takes a quantile value between 0 and 1 and a histogram metric to estimate percentiles -- for example, the 99th percentile latency.
In production, PromQL powers both dashboards and alerting rules. A well-designed alert might use rate(http_requests_total{status_code=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.01 to fire when the error rate crosses 1%. Recording rules let you pre-compute expensive queries and store the results as new time series, so dashboards load fast instead of recalculating on every refresh. Aggregation operators like sum, avg, max, and min combined with by or without clauses let you roll up metrics -- for example, summing request rates across all pods but keeping the service label.
A common mistake is using rate() on gauges instead of counters -- rate() assumes values only go up and produces garbage on gauges. Use deriv() for gauges instead. Another pitfall is using too short a range with rate(): you need at least two samples in the window, so rate(metric[15s]) with a 15-second scrape interval might return nothing. The rule of thumb is to use a range at least 4 times your scrape interval. For histogram_quantile(), remember that results are estimates based on bucket boundaries. If your buckets are spaced far apart (like 0.1, 1, 10 seconds), the interpolation between them will be inaccurate for values in the gaps.
Code Example
# PromQL Examples for monitoring payments-api and checkout-service
# --- Instant Vector Selectors ---
# Select all HTTP request counters for payments-api in production
http_requests_total{service="payments-api", environment="production"}
# Select 5xx errors using regex matching on status code
http_requests_total{service="payments-api", status_code=~"5.."}
# Negative match - all status codes except 200
http_requests_total{service="checkout-service", status_code!="200"}
# --- Range Vector Selectors and rate() ---
# Per-second request rate over the last 5 minutes (use with counters)
rate(http_requests_total{service="payments-api"}[5m])
# Instantaneous rate using last two data points (noisier but faster)
irate(http_requests_total{service="payments-api"}[5m])
# Error rate as a percentage
(
sum(rate(http_requests_total{service="payments-api", status_code=~"5.."}[5m])) # Total 5xx rate
/
sum(rate(http_requests_total{service="payments-api"}[5m])) # Total request rate
) * 100 # Convert to percentage
# --- Aggregation Operators ---
# Total request rate per service (sum across all pods and instances)
sum by (service) (rate(http_requests_total[5m]))
# Average memory usage per namespace
avg by (namespace) (container_memory_usage_bytes{container!="POD"})
# Top 5 pods by CPU usage
topk(5, sum by (pod) (rate(container_cpu_usage_seconds_total[5m])))
# --- histogram_quantile() for latency percentiles ---
# 99th percentile request duration for checkout-service
histogram_quantile(
0.99, # Target quantile (99th percentile)
sum by (le) ( # Must group by 'le' (bucket boundary)
rate(http_request_duration_seconds_bucket{service="checkout-service"}[5m]) # Rate of bucket counts
)
)
# 50th percentile (median) with service label preserved
histogram_quantile(
0.50, # Median latency
sum by (le, service) ( # Keep service label in result
rate(http_request_duration_seconds_bucket[5m]) # Rate across all services
)
)
# --- Recording Rule (prometheus-rules.yml) ---
# Pre-compute expensive queries to speed up dashboards
groups:
- name: payments_api_rules # Rule group name
interval: 30s # Evaluation interval
rules:
- record: service:http_request_rate:5m # New metric name
expr: sum by (service) (rate(http_requests_total[5m])) # PromQL expression to evaluate
- record: service:http_error_rate_percent:5m # Pre-computed error rate
expr: | # Multi-line expression
sum by (service) (rate(http_requests_total{status_code=~"5.."}[5m]))
/ sum by (service) (rate(http_requests_total[5m])) * 100Interview Tip
A junior engineer typically writes basic PromQL selectors but gets tripped up on rate() and histogram_quantile() nuances. Show depth by explaining why rate() needs a range vector (it computes the change divided by the time range across multiple samples), why you should never use rate() on a gauge, and the 4x-scrape-interval rule for range duration. For histogram_quantile(), explain that it interpolates within bucket boundaries, so bucket sizing directly affects accuracy. Mention that recording rules should pre-compute expensive aggregations to reduce query load when many engineers open the same dashboard during an incident. Interviewers like hearing about real mistakes -- for example, using irate() in alert rules is risky because it only uses two data points and can miss spikes that rate() would catch.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────┐
│ PromQL Query Execution Flow │
├──────────────────────────────────────────────────────────────┤
│ │
│ Query: rate(http_requests_total{service="payments"}[5m]) │
│ │
│ Step 1: Selector Resolution │
│ ┌────────────────────────────┐ │
│ │ TSDB Index Lookup │ │
│ │ metric: http_requests_total│ │
│ │ service = "payments" │ → Matches 4 time series │
│ └────────────┬───────────────┘ │
│ ↓ │
│ Step 2: Range Vector Fetch [5m] │
│ ┌────────────────────────────┐ │
│ │ Series A: pod-1 [samples] │ → 10 samples over 5 min │
│ │ Series B: pod-2 [samples] │ → 10 samples over 5 min │
│ │ Series C: pod-3 [samples] │ → 10 samples over 5 min │
│ │ Series D: pod-4 [samples] │ → 10 samples over 5 min │
│ └────────────┬───────────────┘ │
│ ↓ │
│ Step 3: Apply rate() Function │
│ ┌────────────────────────────┐ │
│ │ rate = (last - first) │ │
│ │ / time_range │ → Per-second increase │
│ │ Handles counter resets │ → Detects value decreases │
│ └────────────┬───────────────┘ │
│ ↓ │
│ Result: Instant Vector │
│ ┌────────────────────────────┐ │
│ │ {pod="pod-1"} → 45.2 req/s│ │
│ │ {pod="pod-2"} → 38.7 req/s│ │
│ │ {pod="pod-3"} → 51.1 req/s│ │
│ │ {pod="pod-4"} → 42.9 req/s│ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Recording rules pre-compute expensive PromQL queries and save the results as new time series, making dashboards load faster. Alerting rules check PromQL conditions at regular intervals and fire alerts to Alertmanager when conditions stay true for a set duration.
Detailed Answer
Think of recording rules like a restaurant that preps ingredients before the dinner rush. Instead of chopping vegetables from scratch for every order, the kitchen pre-chops during quiet hours. Recording rules pre-compute expensive PromQL queries on a schedule so dashboards load instantly. Alerting rules are like a smoke detector: they continuously check a condition and sound the alarm when something crosses a threshold for long enough to be a real problem.
Both types of rules are defined in YAML files and loaded by Prometheus through the rule_files config. They are organized into rule groups, where each group has a name and an optional evaluation interval. Recording rules have a record field (the name of the new metric to create) and an expr field (the PromQL expression to evaluate). The naming convention follows the pattern level:metric:operations -- for example, namespace:http_requests_total:rate5m tells you the aggregation level, the base metric, and what operation was applied. Alerting rules have an alert field (the alert name), an expr field, an optional for duration, labels to attach, and annotations for human-readable descriptions.
Under the hood, Prometheus evaluates rules within each group sequentially but can run multiple groups in parallel. The evaluation interval defaults to the global setting but can be overridden per group. For recording rules, each evaluation writes a new sample to the TSDB with the current timestamp. For alerting rules, the evaluation produces one of three states: inactive (the expression returned nothing), pending (the expression matched but the for duration has not passed yet), or firing (the expression has been true for at least the for duration). When an alert hits firing state, Prometheus sends it to all configured Alertmanagers.
In production, recording rules are essential for scaling dashboards. Without them, 50 engineers opening the same Grafana dashboard during an incident would each trigger the same expensive aggregation 50 times per refresh. Recording rules compute it once and store the result. A common pattern is building a pyramid: raw metrics get aggregated into per-service rates, then those rates get aggregated into per-team totals. For alerting, the for clause is critical -- it prevents false alarms from momentary spikes. A for: 5m clause means the condition must be continuously true for 5 minutes before the alert fires.
A key gotcha with recording rules is circular dependencies. If rule A depends on the output of rule B, both must be in the same group with B listed first, because rules within a group run sequentially. Across groups, evaluation order is not guaranteed. For alerting rules, a common mistake is leaving out the for clause entirely, which causes alerts to fire on every brief spike. Another pitfall is hardcoding values in annotations instead of using template variables. Always include {{ $labels.instance }} and {{ $value }} in your annotation templates so on-call engineers can immediately see which target is affected and how bad it is.
Code Example
# prometheus-rules.yml - Recording and Alerting Rules
# Loaded via: rule_files: ['prometheus-rules.yml'] in prometheus.yml
groups:
# Recording rules for payments-api performance metrics
- name: payments_api_recording_rules # Group name for organization
interval: 30s # Evaluate every 30 seconds
rules:
# Pre-compute per-service request rate
- record: service:http_requests_total:rate5m # New time series name (level:metric:operation)
expr: > # PromQL expression to evaluate
sum by (service, environment) (
rate(http_requests_total[5m]) # Rate of requests over 5 minutes
)
labels:
aggregated_by: "recording_rule" # Custom label to identify pre-computed metrics
# Pre-compute error rate percentage
- record: service:http_error_rate:ratio_rate5m # Error ratio as a recording rule
expr: > # Avoids expensive division in dashboards
sum by (service) (rate(http_requests_total{status_code=~"5.."}[5m]))
/
sum by (service) (rate(http_requests_total[5m]))
# Pre-compute p99 latency per service
- record: service:http_request_duration:p99_5m # 99th percentile latency
expr: > # histogram_quantile is expensive at query time
histogram_quantile(0.99,
sum by (le, service) (
rate(http_request_duration_seconds_bucket[5m])
)
)
# Alerting rules for checkout-service SLOs
- name: checkout_service_alerts # Alerting rule group
rules:
# Alert when error rate exceeds 1% for 5 minutes
- alert: CheckoutHighErrorRate # Alert name (PascalCase convention)
expr: > # PromQL condition to evaluate
service:http_error_rate:ratio_rate5m{service="checkout-service"} > 0.01
for: 5m # Must be true for 5 min before firing
labels:
severity: critical # Routing label for Alertmanager
team: checkout # Team responsible for this alert
annotations:
summary: "High error rate on checkout-service" # Short description
description: > # Detailed description with templates
Error rate is {{ $value | humanizePercentage }}
for {{ $labels.service }} in {{ $labels.environment }}.
runbook_url: "https://wiki.internal/runbooks/checkout-errors" # Link to remediation steps
# Alert when p99 latency exceeds 2 seconds
- alert: CheckoutHighLatency # Latency SLO violation alert
expr: > # Use pre-computed recording rule
service:http_request_duration:p99_5m{service="checkout-service"} > 2.0
for: 10m # Longer for-clause to reduce noise
labels:
severity: warning # Warning severity, not critical
team: checkout # Ownership label
annotations:
summary: "P99 latency exceeds 2s on checkout-service"
description: > # Include actual value for quick triage
P99 latency is {{ $value | humanizeDuration }}
for {{ $labels.service }}.Interview Tip
A junior engineer typically knows alerts exist but cannot explain the difference between recording and alerting rules, or why the for clause matters. Show that you understand the operational side: recording rules reduce query load on dashboards, which is critical when 50 engineers open the same Grafana dashboard during an incident. Explain the three alert states -- inactive, pending, and firing -- and why pending exists (it is the for duration countdown that prevents alert flapping from brief spikes). Mention the naming convention for recording rules (level:metric:operations) and stress that annotations should always use Go template variables like {{ $value }} and {{ $labels.instance }} so alerts are immediately actionable without engineers having to open a dashboard to figure out what is going on.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Recording Rules vs Alerting Rules │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Recording Rules │ │ │ │ │ │ │ │ Expensive PromQL ──→ Evaluate every 30s │ │ │ │ expr ──→ Store as new metric │ │ │ │ in TSDB │ │ │ │ │ │ │ │ sum(rate(http_total[5m])) → service:http:rate5m │ │ │ │ [complex query] [pre-computed] │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Alerting Rules │ │ │ │ │ │ │ │ PromQL expr ──→ Evaluate ──→ State Machine │ │ │ │ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ INACTIVE │──→│ PENDING │──→│ FIRING │ │ │ │ │ │ expr=∅ │ │ expr=true│ │ for:5m │ │ │ │ │ └──────────┘ │ timer<5m │ │ elapsed │ │ │ │ │ ↑ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ │ │ │ │ └──expr=false──┘ ↓ │ │ │ │ ┌────────────┐ │ │ │ │ │Alertmanager│ │ │ │ │ │ routing │ │ │ │ │ └────────────┘ │ │ │ └──────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Prometheus TSDB keeps recent data in an in-memory head block for fast access, protects it with a write-ahead log (WAL) for crash recovery, and periodically compacts it into compressed on-disk blocks organized in 2-hour chunks. Each block has an index, compressed chunk files, and metadata.
Detailed Answer
Imagine a library that organizes books by when they were written, not by author or title. Recent books sit on a desk for quick access (the in-memory head block). Every two hours, the librarian packs the desk books into sealed boxes (on-disk blocks), compresses them, and shelves them in the archive. A journal (the WAL, or write-ahead log) records every new book as it arrives so that if the librarian is interrupted, they can replay the journal and recover. This is essentially how Prometheus TSDB works.
Prometheus TSDB is a custom-built time series database optimized for the append-heavy, label-indexed workload of metrics. Each unique combination of metric name and labels forms a time series, identified by a numeric series ID. The database has two main parts: the head block (in-memory, mutable) containing the most recent 2+ hours of data, and persistent blocks (on-disk, immutable) containing older data. When a sample arrives, it goes to the head block and the WAL simultaneously. The WAL is a sequential log that ensures no data loss if Prometheus crashes -- on restart, it replays the WAL to rebuild the head block.
Each on-disk block is a directory with three key files. The index file maps label sets to series IDs and provides posting lists for fast label-based lookups (similar to a search engine's inverted index). Chunk files store the actual timestamp-value pairs compressed using Gorilla encoding -- double-delta encoding for timestamps and XOR encoding for values, achieving roughly 1.37 bytes per sample. A meta.json file records the block's time range and statistics. The head block uses the same chunk format but keeps everything in memory. When the head block covers more than 2 hours of data, it gets compacted into an on-disk block. Background compaction then merges smaller blocks into larger ones (up to 31 hours by default), reducing block count and improving query performance.
In production, understanding these internals helps with capacity planning. Storage size depends on three things: number of active time series (cardinality), scrape interval (samples per series per day), and retention period. A rough formula: disk_size = num_series x samples_per_day x bytes_per_sample x retention_days. With 1 million active series at a 15-second scrape interval and 15-day retention, expect about 120 GB of disk. The flags --storage.tsdb.retention.time and --storage.tsdb.retention.size let you cap storage. Remote write can offload long-term data to systems like Thanos or Mimir.
The biggest gotcha is high cardinality -- creating time series with labels like user IDs, request IDs, or IP addresses explodes the index size and can crash Prometheus. The index grows proportionally with unique label combinations. Another pitfall is ignoring WAL size. If Prometheus cannot compact fast enough (usually due to CPU or disk I/O saturation), the WAL grows without bound and can fill the disk. The metric prometheus_tsdb_wal_segment_current shows the current WAL segment number, and a rapidly increasing value means compaction is falling behind.
Code Example
# TSDB Storage Configuration in prometheus.yml startup flags
# Typical production launch command for Prometheus
# prometheus-deployment.yaml (Kubernetes)
apiVersion: apps/v1 # Kubernetes API version
kind: StatefulSet # StatefulSet for stable storage
metadata:
name: prometheus-server # Name of the StatefulSet
namespace: monitoring # Dedicated monitoring namespace
spec:
serviceName: prometheus # Headless service name
replicas: 1 # Single replica (HA via Thanos)
template:
spec:
containers:
- name: prometheus # Prometheus container
image: prom/prometheus:v2.51.0 # Pin to specific version
args:
- '--config.file=/etc/prometheus/prometheus.yml' # Config file path
- '--storage.tsdb.path=/prometheus/data' # TSDB data directory
- '--storage.tsdb.retention.time=15d' # Keep data for 15 days
- '--storage.tsdb.retention.size=100GB' # Max disk usage cap
- '--storage.tsdb.min-block-duration=2h' # Head block compaction interval
- '--storage.tsdb.max-block-duration=31h' # Max compacted block size
- '--storage.tsdb.wal-compression' # Enable WAL compression
- '--web.enable-lifecycle' # Allow reload via HTTP POST
volumeMounts:
- name: prometheus-storage # Mount persistent volume
mountPath: /prometheus/data # TSDB data directory
volumeClaimTemplates:
- metadata:
name: prometheus-storage # PVC name
spec:
accessModes: ['ReadWriteOnce'] # Single-node access
storageClassName: gp3 # Use gp3 SSD storage class
resources:
requests:
storage: 150Gi # Provision 150GB for TSDB
---
# PromQL queries to monitor TSDB health
# Number of active time series (cardinality)
prometheus_tsdb_head_series # Current active series count
# Rate of new series being created (cardinality growth)
rate(prometheus_tsdb_head_series_created_total[1h]) # Series creation rate
# WAL size and compaction health
prometheus_tsdb_wal_segment_current # Current WAL segment number
prometheus_tsdb_compactions_total # Total compactions completed
prometheus_tsdb_compaction_duration_seconds # Time spent compacting
# Storage size on disk
prometheus_tsdb_storage_blocks_bytes # Total block storage in bytes
prometheus_tsdb_head_chunks_storage_size_bytes # Head block memory usageInterview Tip
A junior engineer typically says Prometheus stores data on disk without knowing about the head block, WAL, or compaction. Walk the interviewer through the write path: a sample arrives, gets written to both the in-memory head block and the WAL at the same time, the head block is periodically compacted into an immutable on-disk block, and background compaction merges small blocks into larger ones. Mention Gorilla encoding (roughly 1.37 bytes per sample) to show you understand the compression. The most important production insight is cardinality: every unique label combination creates a new time series, and metrics with user-level labels can create millions of series, causing out-of-memory crashes. Knowing the TSDB health metrics like prometheus_tsdb_head_series and WAL segment count proves you have actually operated Prometheus under real load.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────┐ │ Prometheus TSDB Architecture │ ├──────────────────────────────────────────────────────────────┤ │ │ │ Incoming Sample │ │ │ │ │ ├──────────────────────┐ │ │ ↓ ↓ │ │ ┌──────────┐ ┌──────────┐ │ │ │ WAL │ │ Head │ │ │ │ (Write │ │ Block │ ← In-memory, mutable │ │ │ Ahead │ │ (0-2h) │ Recent samples │ │ │ Log) │ │ │ │ │ │ Crash │ └────┬─────┘ │ │ │ Recovery │ │ Compaction (every 2h) │ │ └──────────┘ ↓ │ │ ┌──────────────┐ │ │ │ On-Disk │ ← Immutable blocks │ │ │ Block (2h) │ │ │ │ ┌──────────┐ │ │ │ │ │ index │ │ ← Label→Series mapping │ │ │ ├──────────┤ │ │ │ │ │ chunks │ │ ← Gorilla-compressed │ │ │ ├──────────┤ │ samples (~1.37 B/s) │ │ │ │meta.json │ │ ← Time range, stats │ │ │ └──────────┘ │ │ │ └──────┬───────┘ │ │ │ Background Compaction │ │ ↓ │ │ ┌──────────────┐ │ │ │ Merged │ ← Up to 31h duration │ │ │ Block │ Reduces block count │ │ └──────────────┘ │ │ │ │ │ ↓ │ │ ┌──────────────┐ │ │ │ Retention │ ← Delete blocks older │ │ │ Cleanup │ than retention.time │ │ └──────────────┘ │ └──────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
The Prometheus Operator manages Prometheus instances through Kubernetes CRDs like Prometheus, ServiceMonitor, and PrometheusRule. ServiceMonitor automatically discovers scrape targets by matching Kubernetes Service labels, so you never have to manually edit prometheus.yml when services are added or removed.
Detailed Answer
Imagine you hire a property manager for your apartment building. Instead of personally meeting each new tenant and updating the paperwork, you tell the manager: anyone who moves into floors 3 through 5 gets set up with water, electricity, and parking. The property manager (Prometheus Operator) watches for new tenants (services), matches them against your rules (ServiceMonitors), and handles all the configuration automatically. You never edit the master list by hand.
The Prometheus Operator is a Kubernetes controller that manages Prometheus deployments and their configuration through Custom Resource Definitions (CRDs). It was originally created by CoreOS and is the backbone of the popular kube-prometheus-stack Helm chart. The Operator watches for changes to its CRDs and keeps the actual state in sync with what you declared. The main CRDs are: Prometheus (defines a Prometheus instance), ServiceMonitor (defines which Kubernetes Services to scrape), PodMonitor (scrapes pods directly without needing a Service), PrometheusRule (defines recording and alerting rules), and Alertmanager (manages Alertmanager instances).
When you create a ServiceMonitor, the Operator reads its spec, which includes a selector (label matcher for Kubernetes Services), namespaceSelector (which namespaces to look in), and endpoints (port names, paths, and scrape intervals). The Operator then generates the equivalent Prometheus scrape_config YAML and loads it into the Prometheus pod. The important part is that the Prometheus CRD has a serviceMonitorSelector field that controls which ServiceMonitors it picks up -- if your ServiceMonitor labels do not match this selector, Prometheus will silently ignore it. The Operator also supports serviceMonitorNamespaceSelector to limit which namespaces are watched.
In production, the Prometheus Operator is the standard way to run Prometheus on Kubernetes. Teams create ServiceMonitors alongside their application deployments, often in the same Helm chart. This means the team deploying payments-api also defines how it gets monitored. The Operator supports advanced features like sharding (spreading targets across multiple Prometheus instances), Thanos sidecar integration for long-term storage, and automatic TLS for scrape endpoints. It also handles version upgrades, resource management, and persistent volumes for TSDB storage.
The most common gotcha is the label mismatch problem: you create a ServiceMonitor, but Prometheus never scrapes your targets. This happens because the Prometheus CRD's serviceMonitorSelector does not match your ServiceMonitor's labels. The kube-prometheus-stack Helm chart defaults to requiring release: kube-prometheus-stack as a label, so every ServiceMonitor must include it. Debug by running kubectl get prometheus -o yaml and checking the serviceMonitorSelector field. Another pitfall is port name mismatches -- the ServiceMonitor endpoints.port field must match the port name in the Kubernetes Service spec, not the container port number.
Code Example
# service-monitor.yaml - ServiceMonitor for payments-api
# Tells Prometheus Operator to scrape the payments-api Service
apiVersion: monitoring.coreos.com/v1 # Prometheus Operator CRD API group
kind: ServiceMonitor # CRD kind for service-level scraping
metadata:
name: payments-api-monitor # ServiceMonitor resource name
namespace: payments-ns # Namespace where this resource lives
labels:
release: kube-prometheus-stack # MUST match Prometheus serviceMonitorSelector
team: payments # Team ownership label
spec:
selector: # Select which Kubernetes Services to scrape
matchLabels:
app: payments-api # Match Services with this label
namespaceSelector: # Which namespaces to find Services in
matchNames:
- payments-ns # Only look in payments-ns namespace
endpoints: # How to scrape the matched Services
- port: metrics # Must match Service port NAME, not number
path: /metrics # HTTP path to scrape
interval: 30s # Scrape interval for this target
scrapeTimeout: 10s # Timeout per scrape request
metricRelabelings: # Transform metrics after scraping
- sourceLabels: [__name__] # Filter by metric name
regex: 'go_.*' # Match all Go runtime metrics
action: drop # Drop them to reduce cardinality
---
# payments-api-service.yaml - The Kubernetes Service being monitored
apiVersion: v1 # Core API version
kind: Service # Kubernetes Service resource
metadata:
name: payments-api # Service name
namespace: payments-ns # Same namespace as ServiceMonitor
labels:
app: payments-api # Label that ServiceMonitor selector matches
spec:
selector:
app: payments-api # Select pods with this label
ports:
- name: http # Application traffic port
port: 8080 # Service port
targetPort: 8080 # Container port
- name: metrics # Metrics port - matches ServiceMonitor endpoint
port: 9090 # Prometheus metrics port
targetPort: 9090 # Container metrics port
---
# prometheus-rule.yaml - PrometheusRule CRD for alerting rules
apiVersion: monitoring.coreos.com/v1 # Prometheus Operator CRD
kind: PrometheusRule # CRD for recording/alerting rules
metadata:
name: payments-api-alerts # Rule resource name
namespace: payments-ns # Namespace
labels:
release: kube-prometheus-stack # Must match Prometheus ruleSelector
spec:
groups:
- name: payments-api.rules # Rule group name
rules:
- alert: PaymentsAPIDown # Alert when payments-api is unreachable
expr: up{job="payments-api"} == 0 # Scrape target is down
for: 2m # Must be down for 2 minutes
labels:
severity: critical # Critical alert
annotations:
summary: "payments-api instance {{ $labels.instance }} is down"Interview Tip
A junior engineer typically installs kube-prometheus-stack via Helm but cannot explain why their ServiceMonitor does not work. The number one debugging question in interviews is: I created a ServiceMonitor but Prometheus is not scraping my service -- what went wrong? Walk through the checklist step by step: (1) Does the ServiceMonitor have the correct release label matching the Prometheus CRD's serviceMonitorSelector? (2) Does the ServiceMonitor's selector match the Kubernetes Service labels? (3) Does the endpoint port name match the Service port name, not the port number? (4) Is the namespaceSelector correct? Show that you know how to verify each step -- kubectl get prometheus -o yaml to inspect the selector, and the Prometheus /targets page to see if the target shows up at all. That systematic debugging approach tells the interviewer you have actually troubleshot this in the real world.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Prometheus Operator + ServiceMonitor Flow │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────┐ watches ┌──────────────────┐ │ │ │ Prometheus │ ──────────────────→ │ ServiceMonitor │ │ │ │ Operator │ │ (CRD) │ │ │ │ │ watches ┌──────────────────┐ │ │ │ Reconciliation │ ──────────────────→ │ PrometheusRule │ │ │ │ Loop │ │ (CRD) │ │ │ └────────┬─────────┘ └──────────────────┘ │ │ │ │ │ │ Generates scrape config │ │ ↓ │ │ ┌──────────────────┐ │ │ │ Prometheus │ ServiceMonitor.selector │ │ │ StatefulSet │ │ │ │ │ │ ↓ │ │ │ scrape_configs │ ┌──────────────┐ ┌──────────────┐ │ │ │ (auto-generated)│────→│ K8s Service │────→│ Pod │ │ │ │ │ │ app:payments │ │ /metrics │ │ │ └──────────────────┘ │ port:metrics │ │ :9090 │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ Label Matching Chain: │ │ ┌────────────┐ selector ┌────────────┐ selector │ │ │ Prometheus │ ────────────→ │ Service │ ────────────→ │ │ │ CRD │ Monitor │ Monitor │ K8s Service │ │ │ svcMonitor │ Selector │ labels: │ labels: │ │ │ Selector: │ │ release: │ app: │ │ │ release:x │ │ x │ payments-api │ │ └────────────┘ └────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Alertmanager receives alerts from Prometheus, groups related ones by labels, routes them to the right receiver (Slack, PagerDuty, email) using a routing tree, and supports silences to temporarily mute notifications. Grouping reduces noise by batching alerts that share the same labels into one notification.
Detailed Answer
Think of Alertmanager as a hospital triage system. Patients (alerts) arrive and are grouped by condition. Cardiac cases go to cardiology, broken bones go to orthopedics (routing rules). If the hospital is doing planned maintenance on radiology machines, they put up a sign saying ignore false alarms from 2am to 4am (silences). The triage nurse does not page the doctor twice for the same patient (deduplication), and waits a few minutes to batch patients arriving together (group_wait).
Alertmanager is a separate process that receives alerts from one or more Prometheus servers through its /api/v2/alerts endpoint. Its configuration defines receivers (notification channels like Slack or PagerDuty), a routing tree (which alerts go where), inhibition rules (suppress certain alerts when others are already firing), and templates for formatting notifications. The routing tree starts with a root route that has a default receiver. Child routes match on alert labels using matchers. Routes are evaluated top to bottom, and the first matching child wins unless continue: true is set, which lets evaluation continue to the next sibling.
Grouping is Alertmanager's most important noise reduction feature. When group_by is set to something like [service, environment], all alerts with the same service and environment label values get bundled into a single notification. Three timing settings control notification behavior: group_wait is how long to wait for more alerts before sending the first notification for a new group (default 30 seconds), group_interval is the minimum time between updates to an existing group when new alerts arrive (default 5 minutes), and repeat_interval is how long to wait before resending an unresolved alert (default 4 hours). Getting these right is the difference between a useful alert system and one that either floods your phone or misses real problems.
In production, a well-designed routing tree mirrors your organization's on-call structure. Critical payment alerts go to PagerDuty for immediate paging. Warning-level alerts for batch jobs go to a Slack channel. Silences are created through the Alertmanager UI or API and match alerts by label matchers -- they are essential during deployments and maintenance windows. Inhibition rules automatically suppress downstream alerts: when the entire cluster is unreachable (KubeAPIDown), you do not want 500 pod alerts flooding the channel. The inhibition rule says if KubeAPIDown is firing, suppress all alerts with the same cluster label.
A common mistake is setting group_by to too many labels, like [service, pod, instance]. This creates one notification per pod, which defeats the purpose of grouping. On the other hand, the special value group_by: ['...'] groups nothing -- every alert becomes its own group. Another pitfall is setting repeat_interval too low, causing alert fatigue from constant re-notifications for chronic issues. The sweet spot is usually 4 to 12 hours. Engineers also forget that silences expire. If you create a 1-hour silence for a deployment that takes 2 hours, alerts will resume halfway through. Always add generous padding to silence durations.
Code Example
# alertmanager.yml - Alertmanager configuration
global:
resolve_timeout: 5m # Mark alert as resolved if not re-received in 5m
slack_api_url: 'https://hooks.slack.com/services/T00/B00/xxx' # Default Slack webhook
pagerduty_url: 'https://events.pagerduty.com/v2/enqueue' # PagerDuty events API
# Notification templates
templates:
- '/etc/alertmanager/templates/*.tmpl' # Path to custom notification templates
# Routing tree - determines which alerts go to which receivers
route:
receiver: 'slack-default' # Default receiver if no child route matches
group_by: ['alertname', 'service'] # Group alerts by these labels
group_wait: 30s # Wait 30s for more alerts before first notification
group_interval: 5m # Wait 5m between updates to existing groups
repeat_interval: 4h # Re-send unresolved alerts every 4 hours
routes:
# Critical payment alerts go to PagerDuty immediately
- match:
severity: critical # Match alerts with severity=critical
team: payments # AND team=payments
receiver: 'pagerduty-payments' # Route to PagerDuty
group_wait: 10s # Shorter wait for critical alerts
repeat_interval: 1h # Re-page every hour if unresolved
continue: false # Stop matching after this route
# All critical alerts (non-payments) go to PagerDuty general
- match:
severity: critical # Match any critical alert
receiver: 'pagerduty-general' # General on-call PagerDuty
group_wait: 15s # Quick notification for critical
# Warning alerts go to team-specific Slack channels
- match:
severity: warning # Match warning-level alerts
receiver: 'slack-default' # Default Slack channel
routes:
- match:
team: checkout # Checkout team warnings
receiver: 'slack-checkout' # Team-specific Slack channel
- match:
team: payments # Payments team warnings
receiver: 'slack-payments' # Payments Slack channel
# Inhibition rules - suppress alerts when others are firing
inhibit_rules:
- source_match: # When this alert is firing...
alertname: 'KubeAPIDown' # Kubernetes API server is down
target_match_re: # ...suppress these alerts
alertname: 'Kube.*' # All Kubernetes-related alerts
equal: ['cluster'] # Only if cluster label matches
- source_match: # When critical alert is firing...
severity: 'critical' # For a specific service
target_match:
severity: 'warning' # Suppress warning alerts
equal: ['alertname', 'service'] # For the same alert and service
# Receivers - notification channel configurations
receivers:
- name: 'slack-default' # Default Slack receiver
slack_configs:
- channel: '#alerts-general' # Slack channel name
send_resolved: true # Notify when alert resolves
title: '{{ .GroupLabels.alertname }}' # Alert name as title
text: >- # Notification body template
{{ range .Alerts }}
*{{ .Labels.service }}* - {{ .Annotations.summary }}
{{ end }}
- name: 'pagerduty-payments' # PagerDuty for payments team
pagerduty_configs:
- service_key: 'payments-service-key-xxx' # PagerDuty integration key
severity: '{{ .GroupLabels.severity }}' # Map to PD severity
description: '{{ .CommonAnnotations.summary }}' # Alert summary
- name: 'slack-checkout' # Checkout team Slack channel
slack_configs:
- channel: '#checkout-alerts' # Team-specific channel
send_resolved: true # Send resolution notifications
- name: 'slack-payments' # Payments team Slack channel
slack_configs:
- channel: '#payments-alerts' # Team-specific channel
send_resolved: true # Send resolution notifications
- name: 'pagerduty-general' # General on-call PagerDuty
pagerduty_configs:
- service_key: 'general-oncall-key-xxx' # General integration keyInterview Tip
A junior engineer typically sets up a single Slack receiver and calls it done, without understanding routing trees, grouping, or inhibition. Show operational maturity by explaining the three timing knobs: group_wait batches the initial burst of alerts, group_interval paces updates to existing groups, and repeat_interval prevents spam for chronic unresolved issues. Talk about inhibition rules as a noise reduction strategy -- when the cluster API is down, suppressing all downstream alerts keeps the on-call engineer focused on the root cause instead of drowning in symptoms. For silences, explain that they are temporary, label-matched, created during maintenance windows, and should always have a comment explaining who created them and why. Interviewers value candidates who can design routing trees that map to team ownership and severity escalation, because it shows you have thought about alerting as a system, not just a checkbox.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Alertmanager Routing Tree │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ Prometheus ──→ POST /api/v2/alerts ──→ Alertmanager │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Root Route │ │ │ │ receiver: slack-default │ │ │ │ group_by: [alertname, service] │ │ │ │ │ │ │ │ ├── severity=critical AND team=payments │ │ │ │ │ └── receiver: pagerduty-payments │ │ │ │ │ │ │ │ │ ├── severity=critical │ │ │ │ │ └── receiver: pagerduty-general │ │ │ │ │ │ │ │ │ └── severity=warning │ │ │ │ ├── team=checkout │ │ │ │ │ └── receiver: slack-checkout │ │ │ │ └── team=payments │ │ │ │ └── receiver: slack-payments │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ Grouping Timeline: │ │ ┌────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Alert │→ │group_wait│→ │ 1st Notify │→ │group_interval│ │ │ │ Arrives│ │ (30s) │ │ (batch sent) │ │ (5m) │ │ │ └────────┘ └──────────┘ └──────────────┘ └──────┬───────┘ │ │ │ │ │ ┌───────↓───────┐ │ │ │repeat_interval│ │ │ │ (4h) │ │ │ │ re-send if │ │ │ │ unresolved │ │ │ └───────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Prometheus has four metric types. Counter only goes up (total requests). Gauge goes up and down (current memory). Histogram sorts values into buckets for percentile math across instances. Summary calculates percentiles inside the app but cannot be safely combined across replicas.
Detailed Answer
Think of these four metric types like different measuring tools. A counter is like a car odometer -- it only goes up, and it resets to zero when the car is new (process restart). A gauge is like a speedometer -- it moves up and down to show the current state. A histogram is like a teacher sorting exam scores into grade buckets (A, B, C, D, F) -- you know how many scores fell into each range. A summary is like having a statistician who continuously calculates the median and 90th percentile of those scores on the fly.
Counters are the most common type. They track cumulative totals that only increase or reset to zero on restart. Examples include http_requests_total, errors_total, and bytes_sent_total. You almost always use rate() or increase() with counters because the raw number is meaningless on its own -- it depends on when the process started. Gauges show a snapshot of a current value that can move up or down: memory usage, temperature, active connections, queue depth. Unlike counters, gauges are directly useful as-is -- you can use max_over_time(), min_over_time(), or avg_over_time() to track how they change.
Histograms and summaries both measure distributions (usually latencies), but they work in very different ways. A histogram tells the application to count how many observations fall into preset buckets. For example, http_request_duration_seconds with buckets [0.01, 0.05, 0.1, 0.5, 1, 5] creates a counter for each bucket boundary using the _bucket metric with le (less than or equal) labels. It also creates _count (total observations) and _sum (sum of all observed values). You must set bucket boundaries when you write the code, and changing them requires a redeploy. A summary calculates percentiles (like p50, p90, p99) inside each application process using a streaming algorithm. It gives you ready-made percentile values, but you cannot combine them across instances -- averaging p99 values from different pods gives a statistically wrong result.
In production, histograms are almost always the better choice for latency measurement. The big advantage is that histogram_quantile() can combine data across all your instances -- you get the true overall p99 for your service, not just per-pod numbers. Summaries cannot do this. However, histograms need thoughtful bucket selection. If your API has a p99 of 200ms but your smallest bucket is 1 second, the calculated percentile will be wrong because Prometheus has to guess between wide bucket gaps. The default Prometheus client buckets (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10) work well for typical HTTP latencies. Watch out for cardinality with histograms -- each label combination creates buckets_count + 2 time series.
A critical mistake is applying rate() to a gauge -- it gives nonsensical results because rate() assumes the value only goes up. Similarly, looking at a counter's raw value is almost never useful. Another common error is creating histograms with too many buckets or too many label dimensions, causing a cardinality explosion. With 20 buckets and 5 label dimensions each having 10 values, one histogram metric creates 20 x 10^5 = 2 million time series. Also remember that _bucket values are cumulative: le="0.5" counts ALL observations at or below 0.5 seconds, not just those between 0.25 and 0.5.
Code Example
# Prometheus client instrumentation in Python (prometheus_client library)
# Application: payments-api
from prometheus_client import Counter, Gauge, Histogram, Summary # Import all 4 metric types
from prometheus_client import start_http_server # Expose /metrics endpoint
import time # For timing operations
import random # For demo values
# --- COUNTER: Monotonically increasing value ---
# Tracks total number of payment transactions processed
payment_transactions_total = Counter(
'payment_transactions_total', # Metric name
'Total number of payment transactions', # Help text
['method', 'status', 'currency'] # Label names
)
# Increment the counter
payment_transactions_total.labels(
method='credit_card', # Payment method label
status='success', # Transaction status
currency='USD' # Currency label
).inc() # Increment by 1
# --- GAUGE: Value that goes up and down ---
# Tracks current number of in-flight payment requests
active_payment_requests = Gauge(
'active_payment_requests', # Metric name
'Number of currently active payment requests', # Help text
['service'] # Label names
)
# Use as context manager for automatic inc/dec
active_payment_requests.labels(service='checkout-service').inc() # Request started
active_payment_requests.labels(service='checkout-service').dec() # Request completed
active_payment_requests.labels(service='checkout-service').set(42) # Set to specific value
# --- HISTOGRAM: Observations in configurable buckets ---
# Tracks payment processing latency distribution
payment_duration_seconds = Histogram(
'payment_duration_seconds', # Metric name
'Time to process a payment transaction', # Help text
['payment_gateway'], # Label names
buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0] # Custom bucket boundaries
)
# Observe a value - automatically increments the correct bucket
payment_duration_seconds.labels(payment_gateway='stripe').observe(0.234) # 234ms payment
# Use as a decorator to time a function
@payment_duration_seconds.labels(payment_gateway='stripe').time() # Auto-measures duration
def process_payment():
time.sleep(random.uniform(0.01, 0.5)) # Simulate payment processing
# --- SUMMARY: Client-side calculated quantiles ---
# Tracks payment validation latency with pre-calculated quantiles
payment_validation_seconds = Summary(
'payment_validation_seconds', # Metric name
'Time to validate a payment request', # Help text
['validation_type'] # Label names
)
payment_validation_seconds.labels(validation_type='fraud_check').observe(0.045) # 45ms
# --- PromQL queries for each metric type ---
# COUNTER: Always use rate() or increase()
# rate(payment_transactions_total{status="success"}[5m]) # Per-second success rate
# increase(payment_transactions_total{status="failed"}[1h]) # Failed transactions in 1 hour
# GAUGE: Use directly or with aggregation over time
# active_payment_requests # Current value
# max_over_time(active_payment_requests[1h]) # Peak in last hour
# HISTOGRAM: Use histogram_quantile() with _bucket metric
# histogram_quantile(0.99, rate(payment_duration_seconds_bucket[5m])) # p99 latency
# SUMMARY: Read quantiles directly (cannot aggregate across instances)
# payment_validation_seconds{quantile="0.99"} # p99 from single instance
if __name__ == '__main__':
start_http_server(9090) # Expose metrics on port 9090Interview Tip
A junior engineer typically lists all four types but cannot explain when to pick each one or why histograms usually beat summaries. The key point is aggregation: histogram buckets can be combined across instances using histogram_quantile(), while summary percentiles cannot -- you cannot meaningfully average p99 values from different pods. That makes histograms the default choice for latency in microservices. Bring up the cardinality cost: each bucket creates a separate time series, so 15 buckets with 3 label dimensions of 10 values each produces 15,000 series from one metric. Always mention that counters need rate() and gauges should never use rate(). Interviewers sometimes ask what function measures the rate of change of temperature -- the answer is deriv() for gauges, not rate().
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐
│ Four Prometheus Metric Types │
├──────────────────────────────────────────────────────────────────┤
│ │
│ COUNTER (monotonically increasing) │
│ ┌──────────────────────────────────┐ │
│ │ Value │ │
│ │ ↑ ╱╱╱╱╱ │ Use: rate(), increase() │
│ │ │ ╱╱ │ Ex: http_requests_total │
│ │ │ ╱╱ ← reset │ │
│ │ │ ╱╱ ╱╱╱╱ │ │
│ │ │╱╱ ╱╱ │ │
│ │ └──────────────→ Time │ │
│ └──────────────────────────────────┘ │
│ │
│ GAUGE (goes up and down) │
│ ┌──────────────────────────────────┐ │
│ │ Value ╱╲ │ │
│ │ ↑ ╱ ╲ ╱╲ │ Use: direct, avg/max/min │
│ │ │ ╱ ╲╱ ╲ │ Ex: memory_usage_bytes │
│ │ │ ╱ ╲╱╲ │ │
│ │ └──────────────→ Time │ │
│ └──────────────────────────────────┘ │
│ │
│ HISTOGRAM (bucket-based distribution) │
│ ┌──────────────────────────────────┐ │
│ │ Count │ Exposes: _bucket, _count │
│ │ ↑ ██ │ _sum │
│ │ │ ██ ██ │ Use: histogram_quantile() │
│ │ │ ██ ██ ██ │ Aggregatable: YES │
│ │ │ ██ ██ ██ ██ │ │
│ │ │ ██ ██ ██ ██ ██ │ │
│ │ └──────────────→ Bucket (le) │ │
│ │ 0.1 0.5 1.0 2.5 5.0 │ │
│ └──────────────────────────────────┘ │
│ │
│ SUMMARY (client-side quantiles) │
│ ┌──────────────────────────────────┐ │
│ │ Outputs pre-calculated: │ Exposes: {quantile=...} │
│ │ quantile=0.5 → 0.042s │ _count, _sum │
│ │ quantile=0.9 → 0.089s │ Aggregatable: NO │
│ │ quantile=0.99 → 0.234s │ (cannot avg quantiles) │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
kube-state-metrics watches the Kubernetes API and exposes object-level info like deployment replicas, pod status, and resource requests. node-exporter runs on every node and exposes hardware metrics like CPU, memory, disk, and network. Together they give you complete cluster visibility when Prometheus scrapes them.
Detailed Answer
Think of monitoring a factory. kube-state-metrics is like the floor manager who tracks the status of every work order: how many assembly lines are running, which ones are behind schedule, which workers are idle. node-exporter is like the building engineer who monitors the physical plant: power consumption, HVAC temperature, water pressure, and structural integrity. You need both perspectives -- the logical work status and the physical infrastructure health -- to understand what is really going on.
kube-state-metrics (KSM) is a service that connects to the Kubernetes API server and turns Kubernetes object states into Prometheus metrics. It does not instrument applications or nodes -- it purely converts the API's data into time series. For example, it exposes kube_deployment_spec_replicas (how many replicas you asked for), kube_deployment_status_replicas_available (how many are actually available), kube_pod_status_phase (whether a pod is Pending, Running, Succeeded, Failed, or Unknown), and kube_pod_container_resource_requests (CPU/memory requests). KSM runs as a single deployment (or sharded for large clusters) and exposes metrics on port 8080. It puts zero load on individual nodes or pods.
node-exporter is a Prometheus exporter that runs as a DaemonSet on every Kubernetes node, exposing hardware and OS metrics. It reads from /proc and /sys on Linux to report things like node_cpu_seconds_total (CPU usage per core per mode), node_memory_MemAvailable_bytes (available memory), node_filesystem_avail_bytes (disk space), node_network_receive_bytes_total (network traffic), and node_disk_io_time_seconds_total (disk I/O). Each node-exporter instance exposes its local node's metrics on port 9100. On Kubernetes, it is deployed as a DaemonSet with hostPID, hostNetwork, and volume mounts to /proc and /sys.
In production, combining both exporters unlocks powerful correlation queries. For example, you can spot resource contention by comparing kube_pod_container_resource_requests_cpu_cores (what pods asked for) against node_cpu_seconds_total (what the node is actually using). You can build utilization dashboards showing requested versus actual versus capacity for every node. The kube-prometheus-stack Helm chart deploys both exporters along with Prometheus, Alertmanager, and pre-built Grafana dashboards and alert rules. Common alerts include KubePodNotReady (pods stuck in a non-ready state using KSM metrics), NodeMemoryHighUtilization (node running low on memory using node-exporter metrics), and KubeDeploymentReplicasMismatch (desired versus actual replicas using KSM).
A key gotcha with kube-state-metrics is that it does not show actual resource usage -- it only reports what was requested and what the limits are. To see real CPU and memory consumption, you need metrics-server or cAdvisor (container_cpu_usage_seconds_total, container_memory_usage_bytes), which are built into the kubelet. Another pitfall is node-exporter's filesystem metrics: by default, it mounts the host's /proc and /sys into the container, but if those mounts are set up wrong, metrics will reflect the container's filesystem instead of the host's. On managed Kubernetes services (EKS, GKE, AKS), some kernel-level metrics may be blocked by the cloud provider's security boundaries.
Code Example
# kube-state-metrics DaemonSet deployment
# Usually deployed via kube-prometheus-stack Helm chart
# --- Key kube-state-metrics PromQL queries ---
# Pods not in Running phase for checkout-service
kube_pod_status_phase{namespace="checkout-ns", phase!="Running"} == 1 # Non-running pods
# Deployment replica mismatch (desired vs available)
kube_deployment_spec_replicas{deployment="payments-api"}
!= # Compare desired
kube_deployment_status_replicas_available{deployment="payments-api"} # vs available
# Container restart count (CrashLoopBackOff detection)
rate(kube_pod_container_status_restarts_total{namespace="payments-ns"}[15m]) > 0 # Restarting containers
# CPU requests vs limits ratio (over-provisioning detection)
sum by (namespace) (kube_pod_container_resource_requests{resource="cpu"}) # Total CPU requested
/ # Divided by
sum by (namespace) (kube_pod_container_resource_limits{resource="cpu"}) # Total CPU limits
# --- Key node-exporter PromQL queries ---
# CPU utilization per node (excluding idle)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) # CPU busy %
# Memory utilization per node
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 # Memory used %
# Disk space utilization per mountpoint
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) * 100 # Disk used %
# Network throughput per interface
rate(node_network_receive_bytes_total{device!~"lo|veth.*"}[5m]) * 8 # Receive bits/sec
rate(node_network_transmit_bytes_total{device!~"lo|veth.*"}[5m]) * 8 # Transmit bits/sec
# --- Alerting rules combining both exporters ---
# prometheus-rules.yaml
groups:
- name: kubernetes_cluster_alerts # Rule group name
rules:
# KSM alert: Deployment replicas mismatch
- alert: KubeDeploymentReplicasMismatch # Alert name
expr: > # PromQL expression
kube_deployment_spec_replicas
!= kube_deployment_status_replicas_available # Desired != actual
for: 10m # Must persist for 10 minutes
labels:
severity: warning # Warning severity
annotations:
summary: > # Human-readable summary
Deployment {{ $labels.deployment }} in
{{ $labels.namespace }} has replica mismatch
# Node-exporter alert: High memory usage on node
- alert: NodeMemoryHighUtilization # Alert name
expr: > # PromQL expression
(1 - node_memory_MemAvailable_bytes
/ node_memory_MemTotal_bytes) * 100 > 90 # Over 90% memory used
for: 5m # Must persist for 5 minutes
labels:
severity: critical # Critical severity
annotations:
summary: > # Include node in summary
Node {{ $labels.instance }} memory usage
is {{ $value | humanize }}%
# Combined alert: Pods using more than requested
- alert: PodCPUThrottling # Pod being throttled
expr: > # Compare actual vs requested
rate(container_cpu_cfs_throttled_seconds_total[5m])
/ rate(container_cpu_cfs_periods_total[5m]) > 0.25 # Throttled > 25% of time
for: 15m # Persistent throttling
labels:
severity: warning # Warning to review requests
annotations:
summary: "Pod {{ $labels.pod }} CPU throttled {{ $value | humanizePercentage }}"Interview Tip
A junior engineer typically mixes up kube-state-metrics with metrics-server or cAdvisor. Make the distinction clear: kube-state-metrics exposes Kubernetes OBJECT state (deployment replicas, pod phases, resource requests/limits) by watching the API server -- it tells you what Kubernetes thinks is happening. node-exporter exposes NODE-level hardware metrics (CPU, memory, disk, network) -- it tells you what the physical machine is doing. cAdvisor (built into kubelet) exposes CONTAINER-level resource usage -- it tells you what each container is actually consuming. You need all three layers for full observability. A strong answer explains how to combine them: use kube-state-metrics to detect replica mismatches, node-exporter to find hot nodes, and cAdvisor to identify resource-hungry containers.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Kubernetes Monitoring Stack Architecture │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ Prometheus Server │ │ │ │ Scrapes all exporters on schedule │ │ │ └────┬──────────────────┬──────────────────┬─────────────────┘ │ │ │ │ │ │ │ ↓ ↓ ↓ │ │ ┌──────────┐ ┌────────────┐ ┌───────────────┐ │ │ │ kube- │ │ node- │ │ cAdvisor │ │ │ │ state- │ │ exporter │ │ (kubelet) │ │ │ │ metrics │ │ DaemonSet │ │ │ │ │ ├──────────┤ ├────────────┤ ├───────────────┤ │ │ │ Watches │ │ Reads from │ │ Per-container │ │ │ │ K8s API │ │ /proc /sys │ │ resource │ │ │ │ │ │ on each │ │ usage │ │ │ │ Exposes: │ │ node │ │ │ │ │ │ -replicas│ │ │ │ Exposes: │ │ │ │ -pod │ │ Exposes: │ │ -container_ │ │ │ │ phase │ │ -node_cpu │ │ cpu_usage │ │ │ │ -resource│ │ -node_mem │ │ -container_ │ │ │ │ requests│ │ -node_disk │ │ memory_usage │ │ │ │ -deploy │ │ -node_net │ │ -container_ │ │ │ │ status │ │ │ │ fs_usage │ │ │ └──────────┘ └────────────┘ └───────────────┘ │ │ │ │ │ │ │ ↓ ↓ ↓ │ │ ┌──────────┐ ┌────────────┐ ┌───────────────┐ │ │ │ K8s API │ │ Node 1 │ │ Pod/Container│ │ │ │ Server │ │ Node 2 │ │ Runtime │ │ │ │ (source) │ │ Node 3 │ │ (source) │ │ │ └──────────┘ └────────────┘ └───────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Thanos uploads Prometheus TSDB blocks to object storage for long-term retention and provides a global query layer that fans out across multiple Prometheus instances. Architects must size the Store Gateway for index caching, configure compaction for downsampling, and watch query latency, storage costs, and compaction lag.
Detailed Answer
Think of a library system across a city. Each branch library (Prometheus instance) keeps recent books on its shelves, but older books go to a central warehouse (object storage). When a researcher wants to search across all branches and the warehouse at the same time, a central catalog system (Thanos Querier) knows where every book is and fetches it from the right place. Thanos does exactly this for Prometheus metrics.
Thanos was built to fix two basic Prometheus limitations: local storage is not durable (if the node dies, metrics are lost), and a single Prometheus can only query its own data. In a multi-cluster environment with 15 Kubernetes clusters, each running its own Prometheus, there is no built-in way to query across all clusters or keep metrics beyond the local retention period (typically 15-30 days). Thanos adds a sidecar to each Prometheus that uploads completed TSDB blocks to object storage (S3, GCS, or Azure Blob), a Store Gateway that serves historical blocks from object storage, a Querier that merges results from sidecars and Store Gateways, and a Compactor that downsamples and compacts blocks for faster long-range queries.
Here is the detailed flow. Prometheus writes 2-hour TSDB blocks to local disk. The Thanos Sidecar watches the Prometheus data directory and uploads completed blocks to the object storage bucket. Each block contains a meta.json describing its time range, labels, and resolution. The Querier implements the Prometheus HTTP API and receives PromQL queries. It fans out to all connected StoreAPI endpoints -- sidecars for recent data and Store Gateways for historical data -- deduplicates overlapping series using external labels, and returns merged results. The Compactor runs as a singleton (meaning exactly one instance), downloading blocks from object storage, merging overlapping blocks, creating downsampled versions at 5-minute and 1-hour resolutions, and re-uploading the compacted blocks. This cuts storage costs and speeds up queries over long time ranges.
At production scale, the Store Gateway is the most resource-hungry component because it must cache block index headers in memory to answer queries quickly. A cluster with 500 million active time series and 1 year of retention may have hundreds of thousands of blocks, requiring Store Gateway instances with 32-64 GB of memory for index caching. The Compactor must keep up with block production -- if it falls behind, queries over historical data slow down because the Querier has to open many small blocks instead of a few large ones. Architects should watch thanos_compact_group_compactions_failures_total, thanos_store_bucket_cache_hits_total, thanos_query_store_api_duration_seconds, and overall object storage bucket size and cost.
The sneaky gotcha is that Thanos deduplication depends on consistent external labels. If two Prometheus instances scrape the same targets but have different or missing external labels, the Querier cannot deduplicate correctly and returns duplicate series that produce wrong aggregation results. Another trap is that the Compactor is a singleton -- running two Compactors against the same bucket causes data corruption because they fight over the same blocks. Teams using Thanos Compactor must guarantee exactly-one behavior, typically via a Kubernetes Deployment with replicas: 1 and a PodDisruptionBudget that prevents eviction during compaction.
Code Example
# Deploy Thanos Sidecar alongside Prometheus using Helm values
# values-prometheus.yaml for kube-prometheus-stack
# prometheus:
# prometheusSpec:
# replicas: 2 # HA Prometheus pair in each cluster
# retention: 6h # Short local retention since Thanos handles long-term
# externalLabels:
# cluster: payments-prod-us-east # Unique label for deduplication
# region: us-east-1 # Region label for filtering queries
# thanos:
# objectStorageConfig:
# existingSecret:
# name: thanos-objstore-config # Secret containing S3 bucket config
# key: objstore.yml # Key within the secret
# thanos-objstore-config secret content
# objstore.yml:
# type: S3
# config:
# bucket: company-thanos-metrics-prod
# endpoint: s3.us-east-1.amazonaws.com
# region: us-east-1
# Deploy Thanos Querier that connects to all cluster sidecars and store gateways
kubectl apply -f thanos-querier.yaml
# thanos-querier.yaml
apiVersion: apps/v1 # Stable Deployment API
kind: Deployment # Manages the Querier replicas
metadata:
name: thanos-querier # Central query component
namespace: monitoring # Observability namespace
spec:
replicas: 3 # Three replicas for high availability
selector:
matchLabels:
app: thanos-querier # Pod selector
template:
metadata:
labels:
app: thanos-querier # Label for Service discovery
spec:
containers:
- name: querier # Thanos Querier container
image: quay.io/thanos/thanos:v0.36.1 # Pinned Thanos version
args:
- query # Run in query mode
- --grpc-address=0.0.0.0:10901 # gRPC address for other components
- --http-address=0.0.0.0:9090 # HTTP address for PromQL API
- --endpoint=dnssrv+_grpc._tcp.thanos-sidecar.monitoring.svc # Discover sidecars via DNS SRV
- --endpoint=dnssrv+_grpc._tcp.thanos-store.monitoring.svc # Discover store gateways via DNS SRV
- --query.replica-label=prometheus_replica # Deduplicate HA Prometheus pairs
ports:
- containerPort: 9090 # HTTP port for Grafana and API queries
name: http # Port name for Service
- containerPort: 10901 # gRPC port for inter-component communication
name: grpc # Port name for Service
# Query across all clusters for the payment API error rate
# curl http://thanos-querier:9090/api/v1/query --data-urlencode \
# 'query=sum(rate(http_requests_total{service="payments-api",code=~"5.."}[5m])) by (cluster)'Interview Tip
A junior engineer typically says Thanos stores metrics in S3 and stops there. For a senior or architect role, the interviewer wants to hear component-level architecture and real operational experience. Walk through the data flow: Prometheus writes blocks, Sidecar uploads them, Store Gateway serves historical data, Querier fans out and deduplicates, and Compactor must run as a singleton or you get data corruption. A mature answer also covers Store Gateway memory sizing for index caching, compaction lag monitoring, downsampling resolutions (raw, 5m, 1h), and the failure mode where inconsistent external labels cause duplicate series in global queries. Showing you understand these details proves you have operated Thanos at scale, not just installed it.
◈ Architecture Diagram
┌──────────┐ ┌──────────┐
│Prometheus│ │Prometheus│
│Cluster A │ │Cluster B │
└────┬─────┘ └────┬─────┘
│ Sidecar │ Sidecar
↓ ↓
┌─────────────────────────┐
│ Object Storage (S3) │
└────────────┬────────────┘
│
┌───────┴───────┐
↓ ↓
┌──────────┐ ┌──────────┐
│Store GW │ │Compactor │
└────┬─────┘ └──────────┘
│
┌────┴─────┐
│ Querier │
└──────────┘💬 Comments
Quick Answer
Hierarchical federation pulls pre-aggregated metrics from lower-level Prometheus instances into a global one for fleet-wide dashboards. Cross-service federation pulls specific metrics from another team's Prometheus. Use federation for aggregated views or targeted sharing, but pick Thanos or remote write when you need full-resolution global queries or long-term retention.
Detailed Answer
Think of a news organization. Hierarchical federation is like regional bureaus sending headline summaries to the national desk -- the national editor gets the big picture without reading every local article. Cross-service federation is like the sports desk borrowing a specific stat from the finance desk's data feed. In both cases, only selected information flows upward or sideways, not everything.
Prometheus federation uses the /federate endpoint to expose a subset of metrics from one Prometheus instance so another Prometheus can scrape them. In hierarchical federation, a global Prometheus sits above multiple datacenter or cluster-level Prometheus instances. Each lower-level instance runs recording rules that pre-aggregate raw metrics into summary time series -- for example, computing the 99th percentile request latency per service every minute. The global Prometheus scrapes only these aggregated metrics from the /federate endpoint of each lower-level instance, giving it a fleet-wide view without ingesting the raw per-pod or per-instance metrics.
Under the hood, the /federate endpoint accepts match[] parameters that filter which metrics are exposed. The global Prometheus configures a scrape job with honor_labels: true to keep the original labels from the source instances, and metrics_path: /federate with params that specify the match expressions. Cross-service federation uses the same mechanism but horizontally: the payments team's Prometheus scrapes specific metrics like http_requests_total or circuit_breaker_state from the user-auth team's Prometheus to monitor a critical dependency. The match expression is narrow, pulling only the exact metrics needed rather than the entire dataset.
At production scale, hierarchical federation works well when the global Prometheus only needs aggregated views -- overall error rates, cluster-level resource utilization, SLO compliance percentages. It falls short when engineers need to drill into full-resolution metrics for incident investigation because the global instance only has pre-aggregated data. That is where Thanos or remote write to a central TSDB like Cortex, Mimir, or VictoriaMetrics becomes necessary. Remote write pushes all raw metrics from every Prometheus to a central store, enabling full-resolution global queries at the cost of more storage and ingestion infrastructure. Pick hierarchical federation for cost-effective fleet-wide dashboards, cross-service federation for targeted dependency monitoring with minimal coupling, and Thanos or remote write when incident responders need full-resolution cross-cluster queries.
The sneaky gotcha is that federation creates a scrape-interval dependency. The global Prometheus scrapes the /federate endpoint at its own interval (typically 60 seconds), but the source metrics were produced at the source's scrape interval (typically 15-30 seconds). This creates staleness windows where the global view lags behind reality. If the global scrape interval is longer than twice the source's recording rule evaluation interval, data points can be missed entirely. Another trap is that /federate is expensive for the source Prometheus -- serving thousands of metrics via /federate on every scrape adds CPU and memory pressure to the source. Teams should use recording rules to keep the number of series exposed via /federate small rather than using broad match expressions that pull raw metrics.
Code Example
# Recording rule on the cluster-level Prometheus to pre-aggregate for federation
# rules/payments-aggregation.yaml
apiVersion: monitoring.coreos.com/v1 # Prometheus Operator CRD
kind: PrometheusRule # Defines recording and alerting rules
metadata:
name: payments-federation-rules # Rules for federation aggregation
namespace: monitoring # Monitoring namespace
spec:
groups:
- name: payments-federation # Rule group name
interval: 30s # Evaluate every 30 seconds
rules:
- record: cluster:http_request_duration_seconds:p99 # Pre-aggregated p99 latency
expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service="payments-api"}[5m])) by (le, cluster))
- record: cluster:http_requests_total:rate5m # Pre-aggregated request rate
expr: sum(rate(http_requests_total{service="payments-api"}[5m])) by (cluster, code)
- record: cluster:up:ratio # Availability ratio across all targets
expr: count(up{job="payments-api"} == 1) / count(up{job="payments-api"})
# Global Prometheus scrape config for hierarchical federation
# prometheus-global.yaml (scrape_configs section)
# scrape_configs:
# - job_name: 'federate-us-east'
# honor_labels: true # Preserve original labels from source Prometheus
# metrics_path: '/federate' # Use the federation endpoint
# params:
# 'match[]': # Filter to only pull pre-aggregated metrics
# - '{__name__=~"cluster:.*"}' # Match all cluster-level recording rules
# static_configs:
# - targets:
# - 'prometheus-us-east.monitoring.svc:9090' # US East cluster Prometheus
# labels:
# source_cluster: us-east-prod # Label identifying the source
# - job_name: 'federate-eu-west'
# honor_labels: true # Preserve original labels from source
# metrics_path: '/federate' # Federation endpoint
# params:
# 'match[]':
# - '{__name__=~"cluster:.*"}' # Same filter pattern
# static_configs:
# - targets:
# - 'prometheus-eu-west.monitoring.svc:9090' # EU West cluster Prometheus
# labels:
# source_cluster: eu-west-prod # Source identification label
# Cross-service federation: payments team scrapes auth service circuit breaker status
# Added to the payments team's Prometheus scrape config
# - job_name: 'cross-federate-auth'
# honor_labels: true
# metrics_path: '/federate'
# params:
# 'match[]':
# - 'circuit_breaker_state{service="user-auth-service"}'
# - 'http_requests_total{service="user-auth-service",code=~"5.."}'
# static_configs:
# - targets: ['prometheus-auth.monitoring.svc:9090']Interview Tip
A junior engineer typically says federation lets one Prometheus scrape another, and stops there. For a senior or architect role, the interviewer wants you to compare architectural patterns and explain their limits. Walk through hierarchical federation (aggregated fleet views built on recording rules) versus cross-service federation (targeted dependency monitoring with narrow match expressions), explain when each fits, and clarify why Thanos or remote write is needed for full-resolution global queries. A mature answer also covers the staleness window caused by mismatched scrape intervals, the CPU cost of serving /federate endpoints, and the strategy of using recording rules to keep the series count small rather than pulling raw metrics through federation.
◈ Architecture Diagram
┌─────────────────────────┐
│ Global Prometheus │
│ (aggregated view) │
└────┬──────────────┬─────┘
│ /federate │ /federate
┌────┴─────┐ ┌────┴─────┐
│Cluster A │ │Cluster B │
│Prometheus│ │Prometheus│
│rec. rules│ │rec. rules│
└────┬─────┘ └────┬─────┘
│ scrape │ scrape
┌────┴─────┐ ┌────┴─────┐
│ Targets │ │ Targets │
└──────────┘ └──────────┘💬 Comments
Quick Answer
Thanos Sidecar is pull-based: it uploads completed Prometheus TSDB blocks to object storage and serves recent data via StoreAPI. Thanos Receive is push-based: Prometheus remote-writes metrics to a stateful Receive cluster that replicates and uploads blocks. Pick Sidecar for simpler Kubernetes-native setups, and Receive when Prometheus cannot reach object storage directly or you need multi-tenancy.
Detailed Answer
Think of two ways to archive office documents. The Sidecar approach is like each department scanning its own files and uploading them to cloud storage -- simple and decentralized, but each department needs cloud access. The Receive approach is like all departments sending their files to a central mailroom that handles scanning, copies, and archiving -- more infrastructure in the middle, but departments need no cloud access and the mailroom can sort files by department.
Thanos Sidecar runs as a container alongside each Prometheus Pod. It watches the Prometheus data directory for completed TSDB blocks (every 2 hours) and uploads them to object storage. It also exposes a StoreAPI gRPC endpoint that the Querier uses to access recent data still in Prometheus's local TSDB. This model is pull-based: Prometheus writes to local disk as usual, and the Sidecar pulls completed blocks into object storage. The main advantages are simplicity (no extra stateful components), tight coupling with the Prometheus lifecycle, and the ability to serve recent data with zero extra latency since it reads directly from Prometheus's local TSDB.
Thanos Receive implements the Prometheus remote write API. Prometheus instances are configured with remote_write to send metrics to a Receive cluster. Receive ingests the data, applies tenant labels, replicates across Receive instances for durability (configurable replication factor, typically 3), and writes TSDB blocks to local disk before uploading to object storage. The Receive cluster is stateful and must be carefully sized for ingestion throughput, disk IOPS, and memory. It serves both recent and locally-stored historical data via StoreAPI.
At production scale, the decision depends on your infrastructure and organizational needs. Sidecar is the go-to pattern when Prometheus runs in Kubernetes with direct access to object storage (S3, GCS), when the team wants minimal extra infrastructure, and when HA is handled by running duplicate Prometheus pairs with the Querier deduplicating via external labels. Receive is the better choice when Prometheus runs in edge locations, on-premise datacenters, or environments where direct object storage access is blocked by network policy or compliance rules. Receive also enables multi-tenancy: each tenant's metrics can be routed to specific Receive instances with tenant-level resource limits and retention. The Receive hashring distributes incoming series across instances based on tenant and metric labels, allowing horizontal scaling of ingestion.
The sneaky gotcha with Sidecar is the 2-hour upload delay -- completed TSDB blocks are only uploaded after Prometheus compacts them, so there is a window where data exists only on Prometheus's local disk. If the Prometheus Pod crashes before upload, that block can be lost (though HA pairs reduce this risk). With Receive, the gotcha is operational complexity: Receive is a stateful distributed system that requires careful hashring configuration, anti-affinity scheduling, and monitoring of replication lag. If a Receive instance falls behind on ingestion, back-pressure can cause Prometheus remote write queues to grow, eventually dropping data. Architects must size Receive for peak ingestion rate plus a 30 percent buffer, and watch thanos_receive_write_failures_total and remote_storage_queue_highest_sent_timestamp_seconds.
Code Example
# Sidecar pattern: Prometheus with Thanos Sidecar in Kubernetes
# kube-prometheus-stack Helm values
# prometheus:
# prometheusSpec:
# replicas: 2 # HA pair for redundancy
# retention: 4h # Short retention since Sidecar handles long-term
# externalLabels:
# cluster: payments-prod # Unique cluster label for deduplication
# thanos:
# image: quay.io/thanos/thanos:v0.36.1 # Sidecar image
# objectStorageConfig:
# existingSecret:
# name: thanos-s3-config # S3 bucket configuration
# key: objstore.yml
# Receive pattern: Prometheus remote-writes to Thanos Receive
# prometheus-remote-write.yaml (Prometheus config)
# remote_write:
# - url: http://thanos-receive.monitoring.svc:19291/api/v1/receive
# headers:
# THANOS-TENANT: payments # Multi-tenant header for isolation
# queue_config:
# capacity: 10000 # Buffer capacity before dropping
# max_shards: 30 # Parallel write shards
# min_shards: 3 # Minimum active shards
# max_samples_per_send: 5000 # Batch size per remote write request
# Thanos Receive StatefulSet
apiVersion: apps/v1 # Stable StatefulSet API
kind: StatefulSet # Stateful for persistent storage
metadata:
name: thanos-receive # Receive component
namespace: monitoring # Observability namespace
spec:
replicas: 3 # Three instances for replication factor 3
serviceName: thanos-receive # Headless service for peer discovery
selector:
matchLabels:
app: thanos-receive # Pod selector
template:
metadata:
labels:
app: thanos-receive # Label for Service and Querier discovery
spec:
containers:
- name: receive # Thanos Receive container
image: quay.io/thanos/thanos:v0.36.1 # Pinned version
args:
- receive # Run in receive mode
- --grpc-address=0.0.0.0:10901 # gRPC for Querier StoreAPI
- --http-address=0.0.0.0:10902 # HTTP for health and metrics
- --remote-write.address=0.0.0.0:19291 # Remote write ingestion endpoint
- --receive.replication-factor=3 # Replicate to all 3 instances
- --receive.hashrings-file=/etc/thanos/hashring.json # Hashring config
- --tsdb.path=/data/receive # Local TSDB storage path
- --tsdb.retention=6h # Keep blocks locally before upload
- --objstore.config-file=/etc/thanos/objstore.yml # S3 upload config
ports:
- containerPort: 19291 # Remote write ingestion port
name: remote-write # Port name
- containerPort: 10901 # gRPC StoreAPI port
name: grpc # Port name
volumeMounts:
- name: data # Persistent volume for TSDB blocks
mountPath: /data/receive # Mount path matching tsdb.path
volumeClaimTemplates:
- metadata:
name: data # PVC name for each replica
spec:
accessModes: [ReadWriteOnce] # Single-node access
resources:
requests:
storage: 100Gi # Storage for local TSDB blocks before uploadInterview Tip
A junior engineer typically says Thanos adds long-term storage to Prometheus without distinguishing how the data gets there. For a senior or architect role, the interviewer wants to hear the pull-versus-push decision and its real-world consequences. Explain how Sidecar uploads completed TSDB blocks (pull model) versus Receive ingesting remote write data (push model), why Sidecar has a 2-hour data loss window if the Pod crashes, why Receive requires hashring management and careful sizing for ingestion throughput, and when each pattern fits. A mature answer also covers Receive's multi-tenancy capabilities, the remote write queue back-pressure mechanism, and which monitoring metrics tell you Receive is falling behind on ingestion.
◈ Architecture Diagram
┌── Sidecar Pattern ──┐ ┌── Receive Pattern ──┐ │ │ │ │ │ ┌────────┐ │ │ ┌────────┐ │ │ │Prom │ │ │ │Prom │ │ │ │+ Sidecar──→ S3 │ │ │ │──remote │ │ └────────┘ upload │ │ └───┬────┘ write │ │ (pull) │ │ ↓ (push) │ │ │ │ ┌────────┐ │ │ │ │ │Receive │──→ S3 │ │ │ │ │(x3 HA) │ │ │ │ │ └────────┘ │ └─────────────────────┘ └─────────────────────┘
💬 Comments
Quick Answer
High-cardinality PromQL queries should filter early, avoid regex on high-cardinality labels, and use recording rules to pre-compute expensive expressions. For alerting, use for durations to avoid flapping, base alerts on recording rules for consistency, and set query timeouts so one runaway dashboard cannot consume all Prometheus CPU.
Detailed Answer
Think of a city tax office processing returns. If every clerk individually calculates the city's total revenue by reading every single return, the office grinds to a halt. Instead, each department pre-computes its subtotal (recording rule), and the summary report (dashboard query) just adds up the subtotals. PromQL optimization follows the same idea -- pre-compute expensive aggregations so that real-time queries stay cheap.
High cardinality in Prometheus means having many unique label combinations for a single metric. A metric like http_request_duration_seconds with labels for service, method, path, status_code, pod, and customer_id can produce millions of unique time series. When a PromQL query touches all of these series -- for example, histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) without any label filter -- Prometheus must load, decode, and process every matching series from its TSDB. This can eat gigabytes of memory and minutes of CPU, blocking other queries and potentially crashing Prometheus with out-of-memory errors.
Internally, Prometheus processes PromQL in stages: label matching loads the posting lists (inverted index entries) for each label matcher, then the storage engine fetches the actual sample data for the matched series, and the query engine evaluates the expression. The key optimization point is label matching -- narrow matchers like {service="payments-api",path=~"/api/v2/orders.*"} shrink the posting list intersection dramatically compared to {service=~".*"}. Recording rules evaluate expensive queries at a fixed interval and store the result as a new time series. Instead of every dashboard panel and alert rule independently computing histogram_quantile across all pods, a recording rule computes it once per evaluation interval and stores it as service:http_request_duration_seconds:p99, which later queries read as a simple series lookup.
At production scale, architects should build a recording rule hierarchy: L1 recording rules aggregate raw per-pod metrics into per-service metrics, L2 rules compute rates and percentiles from L1 results, and L3 rules compute SLO burn rates from L2 results. Alert rules should reference recording rules rather than raw metrics so that alerts and dashboards always see identical values. The for duration in alerting rules should be at least 2-3 evaluation intervals to absorb scrape gaps and brief spikes -- a 1-minute for duration with a 30-second evaluation interval means the condition must be true for 2-3 consecutive evaluations before it fires. Query timeout (--query.timeout flag) and maximum sample limits (--query.max-samples) protect Prometheus from runaway queries that could take down the entire monitoring stack.
The sneaky gotcha is that recording rules themselves can cause high cardinality if the aggregation does not actually reduce dimensions. A recording rule that preserves the pod label still produces one series per pod, which could be thousands. The rule should aggregate by service, environment, and cluster -- labels that shrink to tens or hundreds of series -- not by pod or instance. Another trap is rate() and histogram_quantile() behaving differently than expected when the recording rule interval does not align with the rate window. If the interval is 120 seconds but the rate window is 30s, some data points may be missed because the lookback window is shorter than the gap between evaluations.
Code Example
# Recording rules that pre-compute expensive queries for dashboards and alerts
# rules/payments-recording.yaml
apiVersion: monitoring.coreos.com/v1 # Prometheus Operator CRD
kind: PrometheusRule # Recording and alerting rules
metadata:
name: payments-recording-rules # Rule set name
namespace: monitoring # Monitoring namespace
spec:
groups:
- name: payments-sli-recording # Service Level Indicator recording rules
interval: 30s # Evaluate every 30 seconds
rules:
# L1: Aggregate raw per-pod request rate into per-service rate
- record: service:http_requests_total:rate5m
expr: sum(rate(http_requests_total{service="payments-api"}[5m])) by (service, code)
# L2: Compute error ratio from L1 results (cheap query)
- record: service:http_error_ratio:rate5m
expr: |
sum(service:http_requests_total:rate5m{code=~"5.."}) by (service)
/
sum(service:http_requests_total:rate5m) by (service)
# L2: Pre-compute p99 latency aggregated by service, not by pod
- record: service:http_request_duration_seconds:p99
expr: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{service="payments-api"}[5m])) by (le, service)
)
# L3: SLO burn rate for error budget alerting
- record: service:slo_error_budget_burn_rate:rate1h
expr: |
service:http_error_ratio:rate5m{service="payments-api"}
/
0.001
- name: payments-alerts # Alerting rules referencing recording rules
rules:
- alert: PaymentsHighErrorRate
# Reference the recording rule instead of computing from raw metrics
expr: service:http_error_ratio:rate5m{service="payments-api"} > 0.01
for: 5m # Must be true for 5 minutes to avoid flapping on transients
labels:
severity: critical # Routing label for Alertmanager
team: payments # Team ownership label
annotations:
summary: 'Payments API error rate above 1% for 5 minutes'
runbook: 'https://runbooks.company.com/payments/high-error-rate'
- alert: PaymentsSLOBurnRateHigh
# Alert when error budget is burning 10x faster than sustainable
expr: service:slo_error_budget_burn_rate:rate1h{service="payments-api"} > 10
for: 2m # Short window because burn rate already smooths noise
labels:
severity: warning
team: payments
# Prometheus configuration flags to protect against query-induced outages
# --query.timeout=2m # Kill queries running longer than 2 minutes
# --query.max-samples=50000000 # Limit samples per query to 50 million
# --query.lookback-delta=5m # Staleness lookback for absent dataInterview Tip
A junior engineer typically says recording rules pre-compute queries, which is correct but shallow. For a senior or architect role, the interviewer wants to hear about cardinality management strategy and production safety patterns. Explain why aggregation must reduce dimensionality (aggregate by service, not by pod), how a recording rule hierarchy (L1 raw aggregation, L2 rates and percentiles, L3 SLO burn rates) prevents redundant computation, why alert rules should reference recording rules for consistency, and how query.timeout and query.max-samples protect Prometheus from dashboard-induced outages. A mature answer also covers the rate window versus evaluation interval alignment trap, the for duration strategy to prevent alert flapping, and the cost of regex label matchers on high-cardinality labels.
◈ Architecture Diagram
┌──────────┐
│Raw Metric│
│per-pod │
└────┬─────┘
↓ L1 rule
┌──────────┐
│Aggregated│
│per-service│
└────┬─────┘
↓ L2 rule
┌──────────┐
│Rates + │
│Quantiles │
└────┬─────┘
↓ L3 rule
┌──────────┐
│SLO Burn │
│Rate │
└────┬─────┘
↓
┌──────────┐
│Alert Rule│
└──────────┘💬 Comments
Quick Answer
The Prometheus Operator uses CRDs like Prometheus, ServiceMonitor, and PodMonitor to manage monitoring config declaratively. ServiceMonitors auto-discover scrape targets by matching Kubernetes Service labels, so nobody has to edit prometheus.yml by hand. To scale across hundreds of microservices, use label conventions, namespace-scoped ServiceMonitors, and sharded Prometheus instances.
Detailed Answer
Think of a phone system that automatically adds new employees to the directory when they join a department. Instead of the IT team manually programming each extension, the system watches the HR database and configures the phone switch whenever a new hire appears. The Prometheus Operator works the same way -- it watches Kubernetes for ServiceMonitor objects and automatically reconfigures Prometheus scrape targets without anyone editing prometheus.yml.
The Prometheus Operator is a Kubernetes controller that manages Prometheus instances, Alertmanager clusters, and their configurations as native Kubernetes resources. Instead of maintaining a prometheus.yml file with static scrape targets, teams create ServiceMonitor CRDs (Custom Resource Definitions) that declare which Kubernetes Services should be scraped, which port and path to use, and what relabeling to apply. The Operator watches these CRDs and generates the corresponding scrape configuration, injects it into the Prometheus StatefulSet, and triggers a configuration reload -- all without restarting Prometheus or requiring manual work.
Under the hood, the Operator reconciliation loop runs continuously. When a new ServiceMonitor is created in any namespace, the Operator checks whether it matches the serviceMonitorSelector on any Prometheus CR (Custom Resource). If it matches, the Operator regenerates the Prometheus configuration by combining all matching ServiceMonitors into scrape_configs, updates the configuration Secret mounted into the Prometheus Pod, and sends a SIGHUP to Prometheus to reload. For target discovery, the generated scrape config uses kubernetes_sd_config with role: endpoints, which discovers all Pods behind the matched Service and adds them as scrape targets. Relabeling rules from the ServiceMonitor are translated into relabel_configs that filter, rename, or transform labels before ingestion.
At production scale with 200+ microservices, architects need several patterns. First, establish a label convention: every Service that exposes metrics must carry a label like monitoring: enabled with annotations for the metrics port and path. Second, use namespace-scoped ServiceMonitors owned by application teams rather than one central ServiceMonitor that tries to match everything -- this spreads ownership and prevents one team's misconfigured ServiceMonitor from breaking another team's monitoring. Third, when a single Prometheus cannot handle the ingestion rate (typically above 500,000 samples/second), use Prometheus sharding via the shards field in the Prometheus CR, which runs multiple Prometheus instances that each scrape a deterministic subset of targets using hashmod relabeling. Fourth, use separate PodMonitors for short-lived workloads like Jobs and CronJobs that do not have Services.
The sneaky gotcha is the serviceMonitorSelector on the Prometheus CR. By default, the Operator configures Prometheus to only discover ServiceMonitors that match specific labels. If a team creates a ServiceMonitor in a new namespace but forgets to add the required label (like release: kube-prometheus-stack), Prometheus never scrapes their targets and they assume monitoring is broken. Another trap is that ServiceMonitors generate scrape configs with kubernetes_sd_config, which requires the Prometheus Pod's service account to have RBAC permissions to list and watch Services, Endpoints, and Pods across the namespaces being monitored. Missing RBAC silently results in zero targets discovered.
Code Example
# ServiceMonitor for the payments-api service — owned by the payments team
apiVersion: monitoring.coreos.com/v1 # Prometheus Operator CRD
kind: ServiceMonitor # Declares a scrape target configuration
metadata:
name: payments-api # ServiceMonitor name matching the service
namespace: payments # Namespace where the service runs
labels:
release: kube-prometheus-stack # Required label matching Prometheus serviceMonitorSelector
team: payments # Team ownership label for filtering
spec:
selector:
matchLabels:
app: payments-api # Matches the Kubernetes Service labels
namespaceSelector:
matchNames:
- payments # Only look for Services in the payments namespace
endpoints:
- port: metrics # Named port on the Service (must match Service port name)
path: /metrics # HTTP path to scrape for Prometheus metrics
interval: 15s # Scrape every 15 seconds
scrapeTimeout: 10s # Timeout for each scrape request
metricRelabelings:
- sourceLabels: [__name__] # Filter high-cardinality debug metrics
regex: 'go_gc_.*' # Match Go garbage collection internal metrics
action: drop # Drop these to reduce storage and cardinality
# Prometheus CR with sharding for large-scale deployments
apiVersion: monitoring.coreos.com/v1 # Prometheus Operator CRD
kind: Prometheus # Manages a Prometheus StatefulSet
metadata:
name: platform-prometheus # Central platform Prometheus instance
namespace: monitoring # Monitoring namespace
spec:
replicas: 2 # HA pair within each shard
shards: 3 # Split targets across 3 shards for horizontal scaling
retention: 24h # Short retention with Thanos for long-term
resources:
requests:
cpu: "2" # CPU request for each Prometheus replica
memory: 8Gi # Memory request for TSDB and query processing
limits:
memory: 12Gi # Memory limit to prevent OOM killing the node
serviceMonitorSelector:
matchLabels:
release: kube-prometheus-stack # Only discover ServiceMonitors with this label
serviceMonitorNamespaceSelector: {} # Discover ServiceMonitors in all namespaces
podMonitorSelector:
matchLabels:
release: kube-prometheus-stack # Same selector pattern for PodMonitors
externalLabels:
cluster: payments-prod-us-east # Cluster identification for Thanos
# Verify which targets Prometheus discovered from ServiceMonitors
kubectl port-forward -n monitoring svc/platform-prometheus 9090:9090
# Open http://localhost:9090/targets to see all discovered scrape targets
# Check ServiceMonitor reconciliation status
kubectl get servicemonitors -A -l release=kube-prometheus-stackInterview Tip
A junior engineer typically says ServiceMonitors tell Prometheus what to scrape, which is true but surface-level. For a senior or architect role, the interviewer wants to hear about operational scale patterns and common failure modes. Explain how the Operator reconciliation loop generates scrape configs from ServiceMonitor CRDs, why serviceMonitorSelector label matching is the most common source of silent monitoring gaps, how namespace-scoped ServiceMonitors let application teams own their monitoring, and when Prometheus sharding via the shards field becomes necessary. A mature answer also covers the RBAC requirements for cross-namespace service discovery, metricRelabelings for cardinality control, and the pattern of using PodMonitors for short-lived workloads that do not have Services.
◈ Architecture Diagram
┌──────────┐
│ Team CR │
│ServiceMon│
└────┬─────┘
│ watch
┌────┴─────┐
│ Operator │
│reconcile │
└────┬─────┘
│ generate
┌────┴─────┐
│prometheus│
│ .yml │
└────┬─────┘
│ reload
┌────┴─────┐
│Prometheus│
│ scrape │
└────┬─────┘
│
┌────┴─────┐
│ Targets │
└──────────┘💬 Comments
Quick Answer
Multi-window burn rate alerting fires when the error rate burns through the error budget faster than expected across both a long window (1h) and a short window (5m). This reduces alert noise compared to static thresholds by only alerting when the burn rate is sustained enough to exhaust the budget within the SLO period.
Detailed Answer
Think of a car's fuel gauge. A static threshold alert says 'warn at 25% fuel' — but that ignores whether you are on a highway burning fuel fast or parked with the engine off. Multi-window burn rate is like saying 'warn when fuel consumption over the last hour would empty the tank before you reach the next gas station, AND you are still burning fast right now.' This catches real problems while ignoring brief spikes.
SLO-based alerting starts with defining an error budget. If your SLO is 99.9% availability over 30 days, your error budget is 0.1% — about 43 minutes of downtime. The burn rate is how fast you are consuming this budget. A burn rate of 1x means you will exactly exhaust the budget by the end of the period. A burn rate of 14.4x means you will exhaust the 30-day budget in just 2 days.
Multi-window burn rate uses two windows to reduce false positives. The long window (typically 1 hour) detects sustained error rates that threaten the budget. The short window (typically 5 minutes) confirms the problem is still happening right now. Both conditions must be true for the alert to fire. This prevents alerting on brief spikes that self-resolve (short window would not fire) and on historical errors that have already been fixed (long window shows the past, short window confirms the present). Google's SRE book recommends multiple severity tiers: 14.4x burn rate over 1h/5m for critical (page), 6x over 6h/30m for warning (ticket).
At production scale, teams define recording rules that pre-compute error ratios for each SLI at multiple windows. The error ratio is calculated as rate(http_requests_total{status=~"5.."}[window]) / rate(http_requests_total[window]). Recording rules at 5m, 30m, 1h, and 6h windows avoid expensive queries at alert evaluation time. Grafana dashboards show the remaining error budget as a percentage, making it visual whether the team can ship features or must focus on reliability.
The non-obvious gotcha is that burn rate alerts assume a uniform error distribution, which rarely matches reality. A 5-minute outage that burns 10% of the monthly budget followed by 29 days of perfect operation is very different from a constant 0.1% error rate. Teams should complement burn rate alerts with absolute threshold alerts for catastrophic failures (error rate > 50% for 1 minute) that would cause immediate user impact regardless of the monthly budget.
Code Example
# Recording rules for multi-window error ratios
# prometheus-rules.yaml
groups:
- name: slo-payments-api
rules:
# 5-minute error ratio (short window)
- record: payments_api:error_ratio:5m
expr: |
sum(rate(http_requests_total{service="payments-api",status=~"5.."}[5m]))
/
sum(rate(http_requests_total{service="payments-api"}[5m]))
# 1-hour error ratio (long window)
- record: payments_api:error_ratio:1h
expr: |
sum(rate(http_requests_total{service="payments-api",status=~"5.."}[1h]))
/
sum(rate(http_requests_total{service="payments-api"}[1h]))
# Multi-window burn rate alert (14.4x = exhausts 30-day budget in 2 days)
- alert: PaymentsAPIHighBurnRate
expr: |
payments_api:error_ratio:1h > (14.4 * 0.001)
and
payments_api:error_ratio:5m > (14.4 * 0.001)
for: 2m
labels:
severity: critical
annotations:
summary: "payments-api burning error budget at 14.4x rate"Interview Tip
A junior engineer typically sets static error rate thresholds like 'alert if 5xx > 1%', but for a senior SRE role, the interviewer is actually looking for SLO-driven alerting that connects to business impact. Explain the error budget concept (99.9% = 43 min/month), how burn rate measures budget consumption speed, why multi-window (long + short) reduces noise, and the severity tiers (14.4x for page, 6x for ticket). Mentioning recording rules for performance, Grafana budget dashboards, and the gotcha about non-uniform error distribution shows mature SRE practice.
💬 Comments
Quick Answer
OpenTelemetry provides a unified SDK that injects trace IDs into metrics, logs, and traces. Grafana connects to Prometheus (metrics), Loki (logs), and Tempo (traces) and uses exemplars and trace-to-log links to jump between signals. A trace ID in a log line links directly to the distributed trace showing the full request path.
Detailed Answer
Think of investigating a crime scene. Fingerprints (metrics) tell you something happened. Witness statements (logs) describe what happened. Security camera footage (traces) shows exactly who went where and when. Each clue is useful alone, but correlating them — the fingerprint matches the person on camera who matches the witness description — solves the case. Observability correlation works the same way.
OpenTelemetry is the CNCF standard for collecting and exporting telemetry data. A single OpenTelemetry SDK instrumented in your application generates metrics, logs, and traces with shared context. The key correlation mechanism is the trace ID — a unique identifier generated when a request enters the system. This trace ID is automatically injected into every log line, every metric exemplar, and every trace span generated during that request's lifecycle across all microservices.
The Grafana stack provides the visualization and correlation layer. Prometheus stores metrics (request rate, error rate, latency histograms). Loki stores logs (structured log lines with trace IDs as labels). Tempo stores traces (distributed spans showing the full request journey). Grafana connects to all three and provides cross-signal navigation: click an anomalous point on a latency chart, see the exemplar trace ID, jump to the full trace in Tempo, then click any span to see the logs for that service during that request. This trace-to-metric-to-log correlation turns a 30-minute investigation into a 3-minute one.
At production scale, teams must manage cardinality and cost. Metrics should use bounded label values (HTTP method, status code category) not unbounded ones (user ID, request ID). Logs should be structured JSON with consistent fields across services. Traces should use head-based or tail-based sampling to reduce storage — capturing 100% of traces is prohibitively expensive at high throughput, but capturing 100% of error traces and 1% of success traces provides good coverage.
The non-obvious gotcha is that correlation only works when all three signals share the same trace ID format and propagation context. If one service uses W3C TraceContext and another uses B3 propagation, the trace breaks. Teams must standardize on one propagation format across all services and ensure the OpenTelemetry SDK is configured consistently. Also, log-to-trace correlation requires the trace ID to be a searchable field in Loki — adding it as a label enables fast lookups but increases cardinality, while using structured metadata keeps cardinality low but requires full-text search.
Code Example
# OpenTelemetry Collector configuration for K8s
# otel-collector-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-collector-config
namespace: observability
data:
config.yaml: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317 # Receives OTLP gRPC from applications
http:
endpoint: 0.0.0.0:4318 # Receives OTLP HTTP from applications
processors:
batch:
timeout: 5s # Batch telemetry for efficiency
exporters:
prometheus:
endpoint: 0.0.0.0:8889 # Expose metrics for Prometheus scraping
loki:
endpoint: http://loki.observability:3100/loki/api/v1/push # Send logs to Loki
otlp/tempo:
endpoint: tempo.observability:4317 # Send traces to Tempo
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [batch]
exporters: [loki]
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/tempo]Interview Tip
A junior engineer typically describes metrics, logs, and traces as separate systems, but for a senior role, the interviewer is actually looking for correlation strategy. Explain how OpenTelemetry injects trace IDs across all three signals, how Grafana connects Prometheus+Loki+Tempo for cross-signal navigation, and how exemplars link metrics to traces. Mention sampling strategies (100% errors, 1% success) for cost control, and the propagation format consistency requirement (W3C TraceContext vs B3). Describing a real debugging workflow — metric anomaly → exemplar → trace → span logs — demonstrates that you use observability, not just set it up.
💬 Comments
Quick Answer
The RED method monitors three signals per service: Rate (requests per second), Errors (failed requests per second or error percentage), and Duration (latency distribution, especially p50/p95/p99). Each microservice gets a row on the dashboard with these three panels, providing at-a-glance health visibility.
Detailed Answer
Think of monitoring a restaurant. Rate is how many customers you serve per hour. Errors is how many meals are sent back. Duration is how long customers wait for their food. If you track these three numbers for every section of the restaurant (bar, dining room, takeout), you know exactly where problems are and how severe they are. The RED method applies this same thinking to microservices.
The RED method was popularized by Tom Wilkie (Grafana Labs) as a simplification of Google's Four Golden Signals. While the Golden Signals include Latency, Traffic, Errors, and Saturation, RED focuses on the three most actionable signals for request-driven services. Rate tells you the traffic volume and whether it matches expectations. Errors tell you if something is broken. Duration tells you if something is slow. Together, they answer the question every on-call engineer asks: is the service healthy, and if not, what is wrong?
Implementation in Grafana uses Prometheus queries against standard HTTP metrics. Rate is computed as sum(rate(http_requests_total[5m])) by service. Error rate is sum(rate(http_requests_total{status=~"5.."}[5m])) divided by total rate. Duration uses histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) for p99 latency. Each service gets a dashboard row with three panels: a rate graph (requests/sec over time), an error percentage graph (% 5xx over time), and a latency heatmap or multi-quantile graph (p50, p95, p99 over time).
At production scale, dashboard design matters. Use template variables so one dashboard works for all services by selecting a service from a dropdown. Add annotations for deployments so you can visually correlate metric changes with code releases. Use the repeat-by-variable feature to automatically create rows for each service. Set meaningful Y-axis ranges — auto-scaling can hide problems when error rates go from 0.01% to 0.1% because the scale adjusts to make it look flat.
The non-obvious gotcha is that RED works well for request-driven services but not for batch jobs, queues, or databases. For those, the USE method (Utilization, Saturation, Errors) is more appropriate. A payments API gets RED; the Kafka consumer gets USE (CPU utilization, queue depth saturation, processing errors). Many teams make the mistake of applying RED to everything, then wondering why their database dashboard does not show useful information. Use RED for services that handle requests and USE for infrastructure components.
Code Example
# Grafana dashboard panel queries for RED method
# Panel 1: Request Rate (requests per second)
# PromQL: sum(rate(http_requests_total{service="payments-api"}[5m])) # Total RPS for payments-api
# Panel 2: Error Rate (percentage of 5xx responses)
# PromQL: 100 * sum(rate(http_requests_total{service="payments-api",status=~"5.."}[5m])) / sum(rate(http_requests_total{service="payments-api"}[5m])) # Error percentage
# Panel 3: Latency Distribution (p50, p95, p99)
# PromQL for p99: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service="payments-api"}[5m])) by (le)) # 99th percentile latency
# PromQL for p95: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{service="payments-api"}[5m])) by (le)) # 95th percentile latency
# PromQL for p50: histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{service="payments-api"}[5m])) by (le)) # Median latency
# Grafana dashboard JSON model snippet for template variable
# "templating": {
# "list": [{
# "name": "service",
# "query": "label_values(http_requests_total, service)",
# "type": "query"
# }]
# }Interview Tip
A junior engineer typically creates dashboards with random metrics, but for a senior role, the interviewer is actually looking for a structured monitoring methodology. Explain the RED method (Rate, Errors, Duration) for request-driven services and when to use USE method instead (for infrastructure). Describe the PromQL queries for each signal, how template variables make one dashboard work for all services, and why deployment annotations matter for correlation. Mentioning that RED does not apply to queues or databases (use USE instead) shows you understand the methodology rather than just following a tutorial.
💬 Comments
Quick Answer
Key cluster health metrics include node_cpu_seconds_total and node_memory_MemAvailable_bytes for node resources, kube_pod_container_status_restarts_total for pod stability, kube_node_status_condition for node readiness, and etcd_server_leader_changes_seen_total for control plane stability. Alert on node NotReady, high restart counts, and persistent pending pods.
Detailed Answer
Think of monitoring a factory floor. You check the building infrastructure (nodes), the assembly lines (pods), the power supply (control plane), and the shipping dock (networking). Each level has its own vital signs, and a problem at one level cascades to others. A power outage (control plane issue) affects all assembly lines (pods), but a single broken machine (crashed pod) only affects one product.
Kubernetes cluster health metrics come from three sources. Node metrics from node-exporter measure hardware and OS resources: CPU usage, memory available, disk space, network throughput. Kubernetes state metrics from kube-state-metrics report the desired vs actual state of Kubernetes objects: pod phase, deployment replicas, node conditions. Control plane metrics from the API server, scheduler, and etcd report the health of cluster management components.
The critical metrics organized by layer are: Nodes — node_cpu_seconds_total (CPU usage per core), node_memory_MemAvailable_bytes (available memory), node_filesystem_avail_bytes (disk space), kube_node_status_condition (Ready/NotReady). Pods — kube_pod_container_status_restarts_total (restart count), kube_pod_status_phase (Pending/Running/Failed), container_memory_working_set_bytes (actual memory usage vs limits). Control plane — apiserver_request_total (API server request rate and errors), etcd_server_leader_changes_seen_total (leader elections indicate instability), scheduler_pending_pods (pods waiting for a node).
At production scale, alert on symptoms that indicate user impact: nodes in NotReady for more than 5 minutes, pods with more than 5 restarts in 15 minutes, pending pods for more than 10 minutes (scheduling failure), API server error rate above 1%, and etcd leader changes more than 3 in an hour. Avoid alerting on individual pod CPU usage — it fluctuates naturally. Instead, alert on the ratio of usage to request, which indicates right-sizing problems.
The non-obvious gotcha is the difference between container_memory_usage_bytes and container_memory_working_set_bytes. The usage metric includes filesystem cache that the kernel can reclaim, so it often looks higher than the actual limit. The working_set metric reflects non-reclaimable memory and is what the OOM killer uses. Alerting on usage_bytes causes false alarms; alert on working_set_bytes approaching the memory limit instead.
Code Example
# Key Prometheus alert rules for cluster health
groups:
- name: kubernetes-cluster-health
rules:
# Node not ready for 5 minutes
- alert: KubeNodeNotReady
expr: kube_node_status_condition{condition="Ready",status="true"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Node {{ $labels.node }} is NotReady"
# Pod restarting frequently
- alert: KubePodCrashLooping
expr: increase(kube_pod_container_status_restarts_total[15m]) > 5
for: 5m
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.pod }} restarted {{ $value }} times in 15m"
# Pods stuck pending (scheduling failure)
- alert: KubePodPending
expr: kube_pod_status_phase{phase="Pending"} == 1
for: 10m
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.pod }} stuck in Pending for 10 minutes"
# Memory approaching limit (OOM risk)
- alert: ContainerMemoryNearLimit
expr: container_memory_working_set_bytes / container_spec_memory_limit_bytes > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }} memory at {{ $value | humanizePercentage }} of limit"Interview Tip
A junior engineer typically lists random Kubernetes metrics, but for a senior role, the interviewer is actually looking for a layered monitoring approach. Organize your answer by layer: nodes (CPU, memory, disk), pods (restarts, phase, memory working set), and control plane (API server errors, etcd leader changes, pending pods). Explain the difference between container_memory_usage_bytes and container_memory_working_set_bytes — only the working set reflects OOM risk. Mentioning symptom-based alerting (node NotReady, pod crash looping) rather than cause-based alerting (CPU spike) shows SRE maturity.
💬 Comments
Quick Answer
Error rate: `sum(rate(http_requests_total{status=~'5..'}[5m])) / sum(rate(http_requests_total[5m]))`. Burn rate alerts use multiple time windows to detect SLO violations early without alerting on brief spikes.
Detailed Answer
``promql sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) ``
1. Too sensitive: Brief 2-minute spikes trigger pages that resolve before you investigate 2. Not SLO-aware: A 1% error rate for 5 minutes is very different from 1% for 5 hours
Burn rate measures how fast you're consuming your error budget relative to the SLO period.
If your SLO is 99.9% over 30 days, your error budget is 0.1% × 30 days = 43.2 minutes of downtime. - Burn rate 1 = consuming budget at exactly the rate to exhaust it in 30 days - Burn rate 14.4 = consuming budget so fast it'll be gone in 2 days - Burn rate 6 = gone in 5 days
Use two windows per alert: a long window (catches sustained issues) and a short window (ensures issue is still happening):
| Severity | Burn Rate | Long Window | Short Window | Budget Consumed | |----------|-----------|-------------|--------------|------------------| | Page | 14.4x | 1 hour | 5 minutes | 2% in 1h | | Page | 6x | 6 hours | 30 minutes | 5% in 6h | | Ticket | 3x | 1 day | 2 hours | 10% in 1d | | Ticket | 1x | 3 days | 6 hours | 10% in 3d |
Code Example
# PromQL: Multi-window burn rate alert
# SLO: 99.9% (error budget = 0.001)
# 1-hour burn rate
(
sum(rate(http_requests_total{status=~"5.."}[1h]))
/
sum(rate(http_requests_total[1h]))
) / 0.001 # divide by error budget to get burn rate
# Alert rule: Page if burn rate > 14.4 over 1h AND > 14.4 over 5m
groups:
- name: slo-burn-rate
rules:
- alert: HighBurnRate_Page
expr: |
(
sum(rate(http_requests_total{status=~"5.."}[1h]))
/ sum(rate(http_requests_total[1h]))
) / 0.001 > 14.4
and
(
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m]))
) / 0.001 > 14.4
labels:
severity: pageInterview Tip
This is a Google SRE must-know topic. Explain the math: why 14.4x burn rate = budget exhausted in 2 days (30 days / 14.4 ≈ 2.08 days). The multi-window approach (long + short) eliminates both false positives (brief spikes) and stale alerts (issue already resolved).
💬 Comments
Quick Answer
Control cardinality at the source with label guidelines, use recording rules for pre-aggregation, implement tiered storage (hot/warm/cold), and set per-team cardinality budgets.
Detailed Answer
Each unique combination of metric name + label values creates a time series. A metric with labels {service, endpoint, status, method, instance} across 500 services × 50 endpoints × 5 statuses × 4 methods × 20 instances = 1 billion series. Prometheus can handle ~10M active series before performance degrades.
1. Label guidelines: Ban unbounded labels (user_id, request_id, IP addresses). Publish allowed label values. 2. Metric naming conventions: Require teams to register new metrics via PR review 3. Client-side aggregation: Aggregate in-process before exposing to scrape. Don't export per-request metrics. 4. Histogram buckets: Use default buckets unless justified. Each bucket is a separate series.
1. Recording rules: Pre-aggregate high-cardinality metrics into lower-cardinality summaries 2. Relabeling: Drop unnecessary labels at scrape time using metric_relabel_configs 3. Tiered storage: Thanos/Cortex/Mimir with: - Hot: 2 hours in-memory (Prometheus) - Warm: 30 days in block storage with compaction - Cold: 1 year in object storage (S3) with downsampling (5m, 1h)
- Per-team cardinality budgets enforced by admission webhook or Mimir tenant limits - Prom-label-proxy to enforce label matchers (teams can only query their own metrics) - Monitor cardinality itself: count({__name__=~".+"}) by (job) - Set per-tenant series limits in Mimir: max_global_series_per_user
Prometheus (per-cluster) → Remote write → Mimir (centralized, multi-tenant) → Grafana With Grafana Loki for logs and Tempo for traces, correlated via exemplars and trace IDs.
Code Example
# Recording rule to reduce cardinality
groups:
- name: aggregated-metrics
interval: 30s
rules:
- record: service:http_requests:rate5m
expr: sum(rate(http_requests_total[5m])) by (service, status)
- record: service:http_latency:p99
expr: histogram_quantile(0.99, sum(rate(http_duration_bucket[5m])) by (le, service))
# Drop high-cardinality labels at scrape
scrape_configs:
- job_name: my-service
metric_relabel_configs:
- source_labels: [__name__]
regex: 'http_request_duration_seconds_bucket'
action: drop # Use recording rule instead
- regex: 'request_id|trace_id'
action: labeldropInterview Tip
Cardinality management is the #1 operational challenge of metrics at scale. Lead with prevention (label guidelines, metric registration) before talking about technical solutions. Mention specific numbers (10M series limit, storage tiers) to show you've operated this at scale.
💬 Comments
Quick Answer
Prometheus periodically scrapes an HTTP endpoint (usually /metrics) exposed by each target, rather than having applications push metrics to it. This makes service health directly observable (a failed scrape is itself a signal), simplifies target lifecycle management via service discovery, and avoids a central ingestion bottleneck that a push model would require for backpressure and fan-in.
Detailed Answer
In the pull model, Prometheus's scrape config defines a set of targets (statically or via service discovery — Kubernetes, Consul, EC2, file_sd, etc.), and the server initiates an HTTP GET against each target's metrics endpoint on a fixed interval (commonly 15s-60s). The target's client library (e.g. client_golang, client_python) exposes current metric values in the Prometheus text exposition format or the newer OpenMetrics format.
This has several practical consequences interviewers probe for: (1) you can scrape from your laptop to debug a target directly with curl, since the target doesn't need to know Prometheus exists; (2) a target that's down simply fails to be scraped, which Prometheus surfaces as the synthetic up metric going to 0 — a push model needs a separate heartbeat/dead-man's-switch mechanism to detect this; (3) Prometheus, not the application, controls cardinality and scrape frequency, which caps the blast radius of a runaway metrics emitter; (4) short-lived batch jobs don't fit this model well, which is exactly why the Pushgateway exists as an explicit escape hatch — metrics are pushed to the gateway, which Prometheus then scrapes like any other target.
The tradeoff: pull doesn't work well through NAT/firewalls without extra plumbing (hence remote_write and Pushgateway), and very large fleets need service discovery to keep the target list current rather than static configs.
Interview Tip
If asked "why pull instead of push," lead with the `up` metric and target discoverability — that's the answer that shows you've actually operated Prometheus, not just read the docs.
💬 Comments
Context
A platform team runs Kubernetes in multiple regional clusters, each with its own Prometheus instance scraping local workloads. Engineers need a single dashboard and alerting view across all regions, plus retention well beyond what local disk on each Prometheus can hold, without giving up per-cluster Prometheus's simplicity and independence.
Problem
Each cluster's Prometheus only knows about its own local targets and has limited local retention (a few weeks at most, bounded by disk). There's no way to run a single PromQL query that spans all regions, and comparing cross-region SLOs requires manually opening multiple dashboards and eyeballing them side by side. A single centralized Prometheus scraping everything directly isn't viable across regions due to network latency, scrape reliability across WAN links, and the cardinality of combining every cluster's metrics into one instance.
Solution
Deploy a Thanos Sidecar alongside each existing regional Prometheus (no changes to how each Prometheus scrapes locally), which continuously uploads TSDB blocks to a shared object storage bucket (S3-compatible) for long-term retention and also exposes a StoreAPI for the sidecar's own local data. A central Thanos Querier is configured to discover all regional Sidecars (and a Store Gateway serving the historical object-storage data), giving a single PromQL endpoint that transparently merges live and historical data across every region. Grafana points at the Thanos Querier instead of any individual Prometheus.
Commands
thanos sidecar --prometheus.url=http://localhost:9090 --objstore.config-file=bucket.yml
thanos query --store=sidecar-region-a:10901 --store=sidecar-region-b:10901 --store=store-gateway:10901
thanos compact --data-dir=/data --objstore.config-file=bucket.yml
Outcome
Engineers query and build dashboards against one endpoint regardless of which region the underlying data originated from, with retention extended from weeks to over a year via object storage, at a fraction of the cost of equivalent block storage on each Prometheus instance. Each regional Prometheus continues operating independently, so a Thanos component outage doesn't affect local scraping or local alerting for that region.
Lessons Learned
Thanos's additive, sidecar-based design was the key factor that made adoption low-risk — teams could roll it out cluster-by-cluster without re-architecting existing Prometheus deployments or migrating scrape configs. The main operational cost that's easy to underestimate upfront is the Compactor component, which needs careful resource sizing and monitoring of its own, since a stalled compaction directly degrades long-term query performance and can silently balloon object storage costs if downsampling isn't running correctly.
💬 Comments
Context
A ride-hailing-scale engineering organization (the kind of setup described in Cortex's own adoption case studies, e.g. Gojek) had grown to hundreds of internal services, each wanting Prometheus-style monitoring, with platform engineering fielding constant requests to provision and maintain per-team Prometheus instances.
Problem
Running one Prometheus per team doesn't scale operationally — every instance needs its own capacity planning, upgrade cycle, HA pair, and dashboard/alerting wiring, and teams have no easy way to query across each other's data for platform-wide reliability views. A single giant shared Prometheus instance hits the same cardinality and blast-radius problems described in other single-instance incidents at this scale.
Solution
Adopt Cortex as a horizontally scalable, multi-tenant remote_write backend: every team's existing Prometheus (or a thin per-team Prometheus purely for local scraping) is configured with remote_write pointing at Cortex, tagged with a tenant ID. Cortex's distributor, ingester, querier, and compactor microservices handle ingestion, storage in object storage, and query fan-out, giving each tenant logically isolated data with a shared, centrally-operated backend. Teams query through the same PromQL interface they already know.
Commands
remote_write:
- url: https://cortex.internal/api/v1/push
headers:
X-Scope-OrgID: team-paymentscortex --target=distributor,ingester,querier,compactor -config.file=cortex.yaml
Outcome
Platform engineering now operates one scalable backend instead of many bespoke Prometheus instances, onboarding a new team is a remote_write config change rather than provisioning new infrastructure, and cross-team/platform-wide dashboards become possible because all data lives in one multi-tenant system with consistent retention and availability guarantees.
Lessons Learned
The distributed microservice architecture (distributors, ingesters, queriers, compactor) that gives Cortex its horizontal scalability also means meaningfully more operational complexity than a single Prometheus binary — teams adopting it should budget for learning to operate a small distributed system, not just "a bigger Prometheus." The remote_write model being push-based (versus Thanos's original pull-based sidecar model) was specifically valuable for the monitoring-as-a-service use case, since it doesn't require the central platform team to reach into every team's network to scrape them.
💬 Comments
Context
A mid-size engineering org has one Prometheus per product team, each with its own SLO recording rules already computing burn-rate and availability metrics locally. Leadership wants a single company-wide dashboard showing every team's SLO status, but the platform team is wary of the storage and cardinality cost of centralizing every team's raw metrics into one place just to show a handful of rollup numbers.
Problem
Copying every team's full metric set into a central system (via remote_write or federation of raw metrics) would recreate the same cardinality and cost problems at a higher level, for the sake of displaying maybe a dozen SLO numbers per team. What's actually needed is much smaller: just the already-computed recording-rule outputs, not the underlying raw request/latency histograms.
Solution
Each team's Prometheus already computes SLO recording rules locally (e.g. job:http_errors:burnrate5m, job:availability:ratio30d). A central "global" Prometheus is configured to federate only those recording-rule metric names from each team's /federate endpoint using a match[] selector, explicitly excluding raw high-cardinality metrics. The global Prometheus's own retention and dashboards are then built purely on this small, pre-aggregated federated dataset.
Commands
curl -G http://team-prometheus:9090/federate --data-urlencode 'match[]={__name__=~"job:.*:burnrate.*"}'scrape_configs:
- job_name: federate
honor_labels: true
metrics_path: /federate
params:
match[]:
- '{__name__=~"job:.*:burnrate.*|job:.*:ratio.*"}'
static_configs:
- targets: ['team-a-prometheus:9090', 'team-b-prometheus:9090']Outcome
The company-wide SLO dashboard shows real-time, per-team burn-rate and availability numbers with a federated dataset that's orders of magnitude smaller than each team's full raw metric set, and each team retains full ownership and independent operation of their own Prometheus and raw data. The cost and cardinality of the global view stays roughly constant regardless of how much raw metric volume any individual team generates.
Lessons Learned
Federation earns its keep specifically when what you need centrally is a small, pre-aggregated slice of data — the mistake teams make is trying to federate broad or raw metric sets 'just in case,' which reintroduces the exact cardinality and storage problems federation was meant to avoid. Recording rules are the right unit to federate, not raw series; if a future need arises for full-fidelity cross-team querying, that's a signal to add remote_write/Thanos/Cortex alongside federation, not to expand what's being federated.
💬 Comments
Symptom
PagerDuty opened 312 alerts for one API latency incident. Alertmanager grouped poorly because every pod and path label created a distinct alert instance.
Root Cause
The rule copied a dashboard query that preserved pod, instance, path, and status labels. Dashboards benefit from detail; paging alerts need aggregation. When one dependency slowed down, hundreds of label combinations crossed the threshold and Alertmanager could not present a single service-level incident. The underlying issue was a combination of factors that individually seemed harmless but together created a cascading failure. The monitoring that should have caught this early was either missing or configured with thresholds too high to trigger before user impact. The on-call engineer initially pursued the wrong hypothesis because the symptoms resembled a different, more common failure mode. By the time the real root cause was identified, the blast radius had expanded beyond the original service to affect downstream dependencies. This incident exposed a gap in the team's runbooks and highlighted the need for better correlation between metrics, logs, and traces during diagnosis.
Diagnosis Steps
Solution
Aggregate paging alerts by service, namespace, team, and severity. Keep high-cardinality labels in dashboards and annotations. Update Alertmanager grouping to match ownership labels.
Commands
promtool check rules alerts.yaml
kubectl rollout restart deploy/prometheus-server -n monitoring
Prevention
Review alert label sets in code review. Track alerts per incident. Use multi-window burn-rate alerts for critical SLOs instead of raw pod-level thresholds.
◈ Architecture Diagram
┌──────────┐
│ Trigger │
└────┬─────┘
↓
┌──────────┐
│ Incident │
└────┬─────┘
↓
┌──────────┐
│ Verify │
└──────────┘💬 Comments
Symptom
Overnight, a deploy added a new label to an existing request counter. Head series count tripled within hours. Prometheus's memory usage climbed steadily until the process was OOM-killed by the kernel, and it restarted into a multi-minute WAL replay. Dashboards showed gaps or "no data" during the replay, and — critically — no alerts fired for an unrelated, genuinely ongoing service outage during the same window, because the Prometheus evaluating those alert rules was down.
Error Message
level=error msg="opening storage failed" err="..."; container OOMKilled (exit code 137); prometheus_tsdb_head_series climbing without plateau in the metrics that were still queryable via a secondary instance
Root Cause
A new label was added to a high-traffic request counter as part of an unrelated feature deploy. Because the label's value set was effectively unbounded (a per-request or per-session identifier), every unique value created a brand-new time series in Prometheus's head block. Each series carries fixed in-memory overhead independent of how often it's scraped, so total memory grew roughly linearly with the number of distinct label values seen, not with request volume — and kept growing as more distinct values appeared, until available memory was exhausted.
Diagnosis Steps
Solution
Add a metric_relabel_configs rule with action: labeldrop for the offending label so future scrapes stop recording it, then either wait for the old high-cardinality series to age out of retention or, if urgent, restart Prometheus after the relabel change to shed the bloated head block sooner. Increase available memory headroom temporarily if the instance needs to survive until the fix rolls out.
Commands
topk(10, count by (__name__)({__name__=~".+"}))count by (user_id) (http_requests_total)
kubectl logs <prometheus-pod> --previous | grep -i oom
Prevention
Require a lightweight cardinality review for any new label on an existing metric (bounded value sets only — no user/session/request identifiers), add alerting on prometheus_tsdb_head_series trending upward without plateauing, and run redundant Prometheus + Alertmanager pairs so one instance's OOM-restart cycle can't create a company-wide alerting blackout.
💬 Comments
Symptom
After a planned Prometheus version upgrade requiring a restart, dashboards across the org showed "no data" or stale data for roughly 8 minutes. On-call engineers initially suspected the upgrade itself had broken something, since the restart had been scheduled as low-risk.
Error Message
msg="Replaying WAL, this may take a while" duration=8m12s; readiness probe failing on /-/ready during this window
Root Cause
The Prometheus instance had accumulated a very large head block (tens of millions of active series) prior to the restart. On startup, Prometheus must replay its write-ahead log to rebuild the in-memory head block before serving queries or evaluating rules, and replay duration scales with head block size. The team had not previously measured how long a cold-start replay would take at current cardinality, so the operational impact of an otherwise routine restart was underestimated.
Diagnosis Steps
Solution
Wait out the replay (no way to skip it safely) and communicate to stakeholders that dashboards would recover automatically. Post-incident, split the single large Prometheus instance's scrape targets across two smaller instances by team/domain to reduce per-instance head block size and thus replay time.
Commands
curl -s http://localhost:9090/-/ready
prometheus_tsdb_head_series
prometheus_tsdb_wal_segment_current
Prevention
Periodically measure and track WAL replay time as an operational metric, treat it as a capacity signal (rising replay time indicates rising cardinality/risk), reduce per-instance cardinality via sharding, and always run redundant Prometheus pairs behind any restart-worthy maintenance so one instance's downtime doesn't create a monitoring blind spot.
💬 Comments
Symptom
During a period of frequent pod autoscaling and rolling deploys, several services showed intermittent gaps in their metrics — short periods where a service's dashboards had no data points even though the service itself was healthy and serving traffic.
Error Message
up{job="payments-api"} flapping between 1 and 0; scrape_duration_seconds spikes; context deadline exceeded (Client.Timeout exceeded while awaiting headers) in Prometheus's own logs for affected targetsRoot Cause
Kubernetes service discovery (kubernetes_sd_configs) was reacting to rapid pod churn during autoscaling events faster than Prometheus's scrape interval and timeout were tuned for. Newly-created pods were being added as scrape targets before their application containers were fully ready to serve the metrics endpoint, and terminating pods were sometimes still listed as targets briefly after they stopped responding, causing scrape timeouts that counted as failed scrapes (up=0) for that interval.
Diagnosis Steps
Solution
Add a relabel_configs keep rule that only includes pods with a 'ready' annotation/label set by the application once fully initialized, increase scrape_timeout modestly for high-churn jobs, and add a short preStop sleep so pods remain scrapeable briefly into their termination sequence.
Commands
up{job="payments-api"}scrape_duration_seconds{job="payments-api"}kubectl get events -n payments --sort-by=.lastTimestamp
Prevention
Standardize a readiness-gated scrape label across all services using Kubernetes service discovery, monitor scrape_samples_scraped and up transitions as a service-health signal in their own right (not just application-level ones), and load-test autoscaling events against the current Prometheus scrape configuration before assuming it will hold up at higher churn rates.
💬 Comments
Symptom
Prometheus's query API became slow to unresponsive for several minutes; Grafana dashboards across the team timed out loading; alert rule evaluation also began lagging behind its expected schedule.
Error Message
context deadline exceeded on /api/v1/query_range requests; prometheus_engine_query_duration_seconds p99 spiking; --query.max-concurrency limit reached, requests queuing
Root Cause
A newly-published Grafana dashboard used a very high-cardinality, unaggregated PromQL query (querying raw per-pod histogram buckets over a long time range without any recording rule) on a panel set to auto-refresh every 5 seconds, and the dashboard was pinned to a shared TV display, meaning it ran continuously with no user closing the tab. Combined with a few analysts independently running similarly expensive ad hoc range queries, the query engine's configured concurrency limit was saturated, queuing out other legitimate queries including those backing alert rule evaluation.
Diagnosis Steps
Solution
Fix the offending dashboard panel to query a recording rule instead of raw histogram buckets, lower its refresh rate, and temporarily raise --query.max-concurrency as a stopgap while the dashboard fix rolled out.
Commands
prometheus_engine_query_duration_seconds
rate(prometheus_rule_evaluation_duration_seconds_sum[5m])
prometheus_engine_queries_concurrent_max
Prevention
Require dashboards intended for auto-refreshing shared displays to be reviewed for query cost before publishing, precompute expensive/frequently-viewed aggregations as recording rules, and alert on prometheus_engine_query_duration_seconds and rule evaluation delay as leading indicators of query engine saturation before it becomes user-visible.
💬 Comments