Prometheus — Production Reference Guide
1. Architecture
graph LR
subgraph Targets
A["App /metrics"]
B[Node Exporter]
C[kube-state-metrics]
D[Blackbox Exporter]
end
subgraph Prometheus
SC[Scrape Engine]
TSDB[TSDB Storage]
PQ[PromQL Engine]
RM[Rule Manager]
end
subgraph Outputs
GF[Grafana]
AM[AlertManager]
RW[Remote Write]
end
A -->|pull /metrics| SC
B -->|pull /metrics| SC
C -->|pull /metrics| SC
D -->|pull /metrics| SC
SC --> TSDB
TSDB --> PQ
PQ --> GF
RM -->|eval rules| TSDB
RM -->|fire alerts| AM
TSDB -->|stream| RW
AM -->|notify| PagerDuty
AM -->|notify| Slack
Key insight: Prometheus is a pull-based system. It scrapes HTTP /metrics endpoints on a configured interval (default 15s). This inverts the model — targets don't push; Prometheus fetches.
2. Data Model
Every time-series is identified by:
metric_name{label1="value1", label2="value2"} <timestamp> <float64>
Example:
http_requests_total{method="GET", status="200", handler="/api/users"} 1627890123456 4821
The label set uniquely identifies a series. Two series differing by even one label value are entirely separate streams stored independently in TSDB.
2.1 Metric Types
Counter — monotonically increasing
http_requests_total{status="200"} 4821
http_requests_total{status="500"} 12
- Never decreases (except on process restart → reset)
- Always use
rate()orincrease()to get useful numbers - Math:
rate(http_requests_total[5m])= per-second average over 5 min window
rate = (last_value - first_value) / range_seconds
= (4821 - 4650) / 300
= 0.57 req/s
Gauge — current snapshot value
process_resident_memory_bytes 52428800
go_goroutines 42
node_load1 0.73
- Can go up or down
- Use directly: no
rate()needed - Math: just read the value, or use
delta()for change over time
delta(node_load1[10m]) → load change over last 10 minutes
Histogram — distribution of observations
http_request_duration_seconds_bucket{le="0.1"} 240
http_request_duration_seconds_bucket{le="0.5"} 890
http_request_duration_seconds_bucket{le="1.0"} 950
http_request_duration_seconds_bucket{le="+Inf"} 960
http_request_duration_seconds_count 960
http_request_duration_seconds_sum 143.7
- Buckets are cumulative (each
leincludes all smaller values) _count= total observations,_sum= total sum of all values- Math for p99:
histogram_quantile(0.99,
rate(http_request_duration_seconds_bucket[5m])
)
The function interpolates linearly within the bucket that contains the φ-th quantile.
Average latency:
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])
Summary — client-side pre-computed quantiles
rpc_duration_seconds{quantile="0.5"} 0.012
rpc_duration_seconds{quantile="0.9"} 0.034
rpc_duration_seconds{quantile="0.99"} 0.089
rpc_duration_seconds_count 1234
rpc_duration_seconds_sum 18.6
- Quantiles computed inside the client library — cannot be aggregated across instances
- Use histogram when you need to aggregate; use summary when you need accurate single-instance quantiles cheaply
- Cannot do
histogram_quantile()on summaries
3. TSDB Internals
Block Structure
graph TD
subgraph Head Block - RAM + WAL
WA[WAL - write-ahead log]
HC[Head Chunk - 2h window]
WA --> HC
end
subgraph Persistent Blocks - disk
B1["Block t0..t0+2h<br/>chunks/ index/ meta.json/ tombstones/"]
B2["Block t0+2h..t0+4h<br/>chunks/ index/ meta.json/ tombstones/"]
B3["Compacted Block<br/>spans 2..N blocks"]
end
HC -->|flush every 2h| B1
HC -->|flush every 2h| B2
B1 -->|compaction| B3
B2 -->|compaction| B3
How it works
Head block lives in memory. All incoming scrapes write to the WAL first (durability), then to in-memory chunks. After 2 hours, the head is flushed to a persistent block on disk.
Block layout on disk:
data/
01BKGV7JC0RY8A5WMZN4P/ ← ULID-named block directory
chunks/
000001 ← raw chunk data (128MB segments)
index ← series → chunk offset mapping
meta.json ← minTime, maxTime, stats
tombstones ← soft deletes
Compaction merges small adjacent blocks into larger ones, applies tombstones, and removes redundant chunks. Prometheus runs compaction automatically. Default retention is 15 days; TSDB deletes blocks outside the retention window entirely.
WAL replay: On crash, Prometheus replays the WAL to reconstruct the head block. WAL segments are 128MB by default; old segments are checkpointed and removed once the head is flushed.
4. PromQL — 12+ Real Queries
4.1 Basic Rate
rate(http_requests_total[5m])
Per-second request rate averaged over 5 minutes. The [5m] range must contain at least 2 samples; use a range ≥ 2× scrape interval.
4.2 P99 Latency via Histogram
histogram_quantile(
0.99,
sum by (le, service) (
rate(http_request_duration_seconds_bucket[5m])
)
)
Aggregates buckets across all pods of a service, then computes p99. Always aggregate the _bucket series with sum by (le, ...) before passing to histogram_quantile.
4.3 increase() vs rate()
-- increase: total count over the window (extrapolated)
increase(http_requests_total[1h])
-- rate: per-second rate (increase / window_seconds)
rate(http_requests_total[1h])
increase(v[d]) = rate(v[d]) * duration_seconds(d)
Use increase() for "how many in the last hour"; use rate() for "per-second throughput".
4.3b Follow-up: rate() vs irate()
rate() |
irate() |
|
|---|---|---|
| Samples used | All in window | Last 2 only |
| Smoothing | Yes — averages over window | No — instantaneous spike |
| Best for | Dashboards, alert rules | Detecting short bursts |
| Minimum window | 2× scrape_interval | Needs 2 recent samples |
Use rate() for almost everything. irate() only when you explicitly want to see momentary spikes that rate() would smooth away.
4.3c Follow-up: Why does histogram_quantile return approximate values?
histogram_quantile interpolates linearly within the bucket that contains the quantile. It cannot know where within that bucket the actual observations fall.
Buckets: le=0.5 → 890 observations, le=1.0 → 950 observations
p99 falls in the (0.5, 1.0] bucket.
The function assumes uniform distribution within that range → interpolates.
Actual p99 could be anywhere from 0.51 to 1.0 — impossible to know without raw data.
Accuracy = bucket granularity. Define buckets at your SLO boundaries:
// Tight around a 300ms SLO
prometheus.LinearBuckets(0.05, 0.05, 10) // 50ms steps up to 500ms
prometheus.ExponentialBuckets(0.01, 2, 12) // 10ms, 20ms, 40ms, 80ms...
For exact quantiles: use Summary (computed in client) — but summaries cannot be aggregated across instances. Use histogram when you need fleet-wide percentiles.
4.3d Follow-up: Staleness window (5 minutes)
If a target stops scraping, Prometheus marks its last sample stale after 5 × scrape_interval (default 5 min). Stale series are excluded from rate() and aggregations — they return no data rather than a stale value.
Why this matters for counter resets: when a process restarts (counter resets to 0), Prometheus detects the stale marker before the new samples arrive and handles the reset correctly — rate() does not return a negative value.
4.4 topk / bottomk
-- Top 5 endpoints by request rate
topk(5, rate(http_requests_total[5m]))
-- Bottom 3 pods by available memory
bottomk(3, container_memory_available_bytes)
topk(k, expr) returns the k highest time-series from the instant vector. Does not aggregate — returns individual series.
4.5 Aggregation: by / without
-- Total RPS per service (drop all labels except service)
sum by (service) (rate(http_requests_total[5m]))
-- Total RPS dropping only instance label
sum without (instance, pod) (rate(http_requests_total[5m]))
-- Error ratio per service
sum by (service) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (service) (rate(http_requests_total[5m]))
by keeps only listed labels. without drops listed labels and keeps the rest. Prefer by for clarity.
4.6 absent() — Alert on Missing Metrics
-- Fire if a job stops scraping entirely
absent(up{job="payment-service"})
-- Fire if no 200 responses seen in 5m
absent(rate(http_requests_total{status="200"}[5m]))
absent() returns a single element with value 1 if the selector matches nothing. Returns empty if the metric exists. Essential for "target down" and "metric disappeared" alerts.
4.7 predict_linear() — Disk Full Forecast
predict_linear(
node_filesystem_avail_bytes{mountpoint="/"}[1h],
4 * 3600
) < 0
Uses linear regression over the last 1h of data to predict the value 4 hours from now. Returns predicted bytes; < 0 means disk will be full. Pair with and to avoid false alerts on stable disks:
predict_linear(node_filesystem_avail_bytes[1h], 4*3600) < 0
and
(node_filesystem_avail_bytes / node_filesystem_size_bytes) < 0.2
4.8 changes() — Config Reload / Flapping Detection
-- How many times did a value change in 1h
changes(process_start_time_seconds[1h]) > 2
-- Detect pod restart storms
changes(kube_pod_status_ready[30m]) > 5
changes(v[d]) counts the number of times the value changed within the range. Useful to detect flapping or frequent restarts.
4.9 Average Request Duration
sum by (service) (rate(http_request_duration_seconds_sum[5m]))
/
sum by (service) (rate(http_request_duration_seconds_count[5m]))
Computes mean latency per service. Use only as a rough guide — mean hides tail latency. Always pair with p99.
4.10 CPU Saturation
-- CPU usage per pod (last 5m)
sum by (pod, namespace) (
rate(container_cpu_usage_seconds_total{container!=""}[5m])
)
-- Throttling ratio
sum by (pod) (rate(container_cpu_throttled_seconds_total[5m]))
/
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
4.11 Memory Saturation
-- Working set memory as fraction of limit
container_memory_working_set_bytes
/
container_spec_memory_limit_bytes > 0.9
4.12 Recording Rule Query — Pre-computed
-- This becomes a new metric: job:http_requests:rate5m
sum by (job) (rate(http_requests_total[5m]))
When stored as a recording rule, Prometheus pre-evaluates this on every rule interval and stores the result as a new metric. Dashboards then query job:http_requests:rate5m directly — much faster for high-cardinality sources.
PromQL Evaluation Flow
graph TD
Q[PromQL Query String] --> P["Parser<br/>AST construction"]
P --> A["Analyzer<br/>type check + label validation"]
A --> E[Evaluator]
E --> TS["TSDB Chunk Iterator<br/>fetch raw samples"]
TS --> F["Function Eval<br/>rate / histogram_quantile"]
F --> AG["Aggregation<br/>sum / by / without"]
AG --> R["Result Vector<br/>instant or range"]
R --> GF["Grafana / API caller"]
5. Scrape Configuration
global:
scrape_interval: 15s # default pull frequency
evaluation_interval: 15s # rule evaluation frequency
scrape_timeout: 10s
scrape_configs:
# --- Static targets ---
- job_name: payment-service
static_configs:
- targets:
- payment-svc:8080
- payment-svc-2:8080
labels:
env: production
region: us-east-1
# --- Kubernetes pod discovery ---
- job_name: k8s-pods
kubernetes_sd_configs:
- role: pod
namespaces:
names: [production, staging]
relabel_configs:
# Only scrape pods with annotation prometheus.io/scrape=true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"
# Use custom port from annotation
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: (.+)
replacement: "${1}"
# Copy pod name to label
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
# Copy namespace to label
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
# Drop pods in Terminating state
- source_labels: [__meta_kubernetes_pod_phase]
action: drop
regex: (Terminating|Succeeded|Failed)
# --- Kubernetes node discovery ---
- job_name: k8s-nodes
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
kubernetes_sd_configs:
- role: node
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
# --- Blackbox HTTP probing ---
- job_name: blackbox-http
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- https://api.example.com/health
- https://api.example.com/ready
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
Relabeling Actions
| Action | Effect |
|---|---|
keep |
Drop series where regex does NOT match |
drop |
Drop series where regex matches |
replace |
Rewrite target_label using regex + replacement |
labelmap |
Copy labels matching regex to new names |
labeldrop |
Remove labels matching regex from final set |
labelkeep |
Remove all labels NOT matching regex |
6. Recording Rules
# rules/recording.yaml
groups:
- name: http_aggregations
interval: 30s # override global evaluation_interval
rules:
# Pre-aggregate per-job request rate
- record: job:http_requests_total:rate5m
expr: |
sum by (job, status) (
rate(http_requests_total[5m])
)
# Pre-aggregate error ratio
- record: job:http_error_ratio:rate5m
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
# Pre-aggregate p99 latency per service
- record: job:http_request_duration_p99:rate5m
expr: |
histogram_quantile(0.99,
sum by (job, le) (
rate(http_request_duration_seconds_bucket[5m])
)
)
# CPU usage per namespace
- record: namespace:container_cpu_usage:rate5m
expr: |
sum by (namespace) (
rate(container_cpu_usage_seconds_total{container!=""}[5m])
)
# Memory working set per namespace
- record: namespace:container_memory_working_set:sum
expr: |
sum by (namespace) (
container_memory_working_set_bytes{container!=""}
)
Naming convention: level:metric:operation — e.g., job:http_requests_total:rate5m. This is the Prometheus community standard.
7. Alerting Rules — 5 Production Alerts
# rules/alerts.yaml
groups:
- name: production-alerts
rules:
# 1. High error rate (> 1% of traffic for 5 minutes)
- alert: HighErrorRate
expr: |
(
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
) > 0.01
for: 5m
labels:
severity: critical
team: platform
annotations:
summary: "High HTTP error rate on {{ $labels.job }}"
description: >
{{ $labels.job }} error rate is {{ $value | humanizePercentage }}
(threshold 1%) for 5 minutes.
runbook: https://wiki.example.com/runbooks/high-error-rate
# 2. High p99 latency (> 1s for 10 minutes)
- alert: HighP99Latency
expr: |
histogram_quantile(0.99,
sum by (job, le) (
rate(http_request_duration_seconds_bucket[5m])
)
) > 1.0
for: 10m
labels:
severity: warning
team: platform
annotations:
summary: "High p99 latency on {{ $labels.job }}"
description: >
p99 latency for {{ $labels.job }} is {{ $value | humanizeDuration }}
(threshold 1s).
runbook: https://wiki.example.com/runbooks/high-latency
# 3. Pod crash-looping (> 3 restarts in 15 minutes)
- alert: PodCrashLooping
expr: |
increase(kube_pod_container_status_restarts_total[15m]) > 3
for: 5m
labels:
severity: critical
team: platform
annotations:
summary: "Pod {{ $labels.pod }} is crash-looping"
description: >
Pod {{ $labels.namespace }}/{{ $labels.pod }} container
{{ $labels.container }} restarted {{ $value }} times in 15m.
runbook: https://wiki.example.com/runbooks/pod-crash-loop
# 4. Disk filling up (< 20% free, will fill in < 4h)
- alert: DiskFillingUp
expr: |
(
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/
node_filesystem_size_bytes
) < 0.2
and
predict_linear(
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[1h],
4 * 3600
) < 0
for: 15m
labels:
severity: warning
team: infra
annotations:
summary: "Disk {{ $labels.mountpoint }} filling on {{ $labels.instance }}"
description: >
Filesystem {{ $labels.mountpoint }} on {{ $labels.instance }}
is {{ $value | humanizePercentage }} free and will fill in < 4h.
runbook: https://wiki.example.com/runbooks/disk-filling
# 5. Prometheus target down
- alert: TargetDown
expr: up == 0
for: 2m
labels:
severity: critical
team: platform
annotations:
summary: "Target {{ $labels.instance }} is down"
description: >
Prometheus cannot scrape {{ $labels.job }}/{{ $labels.instance }}
for 2 minutes.
runbook: https://wiki.example.com/runbooks/target-down
Scrape → Alert Pipeline
sequenceDiagram
participant T as Target /metrics
participant SC as Scrape Engine
participant TSDB as TSDB
participant RM as Rule Manager
participant AM as AlertManager
participant PD as PagerDuty
SC->>T: GET /metrics every 15s
T-->>SC: text/plain metrics
SC->>TSDB: write samples
RM->>TSDB: eval alert expr every 15s
TSDB-->>RM: instant vector result
alt expr result non-empty
RM->>RM: set state = pending
RM->>RM: wait for: duration (5m)
RM->>AM: POST /alerts firing
AM->>AM: group + inhibit + silence
AM->>PD: send notification
else expr result empty
RM->>RM: set state = resolved
RM->>AM: POST /alerts resolved
end
8. Production Scenarios
8.1 High Cardinality Problem
What causes it:
High cardinality happens when a label has too many unique values — each unique combination creates a new time-series. TSDB stores each series independently; memory and CPU grow linearly with series count.
Common mistakes:
# BAD — user_id can have millions of unique values
http_requests_total{user_id="usr_abc123", path="/api/..."}
# BAD — request_id is unique per request
rpc_calls_total{request_id="req_8f7a3b2c"}
# BAD — full URL with query strings
http_requests_total{url="/search?q=golang&page=3&sort=date"}
# GOOD — normalize to a path template
http_requests_total{handler="/search"}
How to detect:
-- Top metrics by series count
topk(10, count by (__name__) ({__name__=~".+"}))
-- Total active series
prometheus_tsdb_head_series
-- Series created per minute (cardinality growth rate)
rate(prometheus_tsdb_head_series_created_total[5m])
-- Memory used by head block
prometheus_tsdb_head_chunks_storage_size_bytes
Check the Prometheus UI at http://prometheus:9090/tsdb-status for top metrics by series count and top label value pairs.
Fix:
- Drop high-cardinality labels at scrape time with
relabel_configs:
relabel_configs:
- source_labels: [__name__]
target_label: __tmp_metric_name
- regex: http_requests_total
source_labels: [__tmp_metric_name]
action: keep
metric_relabel_configs:
# Drop user_id label from all metrics
- action: labeldrop
regex: user_id
# Drop request_id label
- action: labeldrop
regex: request_id
- Use
metric_relabel_configs(post-scrape) to drop series before storage:
metric_relabel_configs:
- source_labels: [url]
regex: '/api/v\d+/users/[a-f0-9-]+'
target_label: url
replacement: '/api/v1/users/{id}'
8.2 Scrape Failure Debugging
Step 1 — Check up metric:
up{job="my-service"} == 0
Step 2 — Check Prometheus targets page: http://prometheus:9090/targets — shows last scrape error message.
Common errors and fixes:
| Error | Cause | Fix |
|---|---|---|
connection refused |
Port wrong or service down | Fix port in scrape config or restart service |
context deadline exceeded |
Scrape timeout too short | Increase scrape_timeout |
x509: certificate signed by unknown authority |
TLS cert not trusted | Add tls_config.ca_file or insecure_skip_verify: true |
401 Unauthorized |
Missing auth | Add bearer_token_file or basic_auth |
text format parsing errors |
Malformed metrics output | Fix instrumentation in the target |
Step 3 — Manual probe:
# Direct HTTP check
curl -v http://service:8080/metrics | head -50
# Check Prometheus can reach it (from Prometheus pod)
kubectl exec -n monitoring prometheus-0 -- \
curl -s http://payment-service:8080/metrics | head -20
8.3 Slow PromQL Queries
Symptoms: Grafana dashboards time out, query_log shows high latency.
Diagnosis:
-- Queries taking > 5s
prometheus_engine_query_duration_seconds{quantile="0.99"} > 5
-- Enable query log in prometheus.yml
-- global:
-- query_log_file: /var/log/prometheus/query.log
Common causes and fixes:
| Cause | Symptom | Fix |
|---|---|---|
| High cardinality aggregation | sum({__name__=~".+"}) scans all series |
Use specific selectors |
| Long range on high-churn metric | rate(v[1h]) on 100k series |
Reduce range or use recording rule |
| No label filtering | rate(http_requests_total[5m]) returns 50k series |
Add {job="x"} filter |
| Nested subqueries | max_over_time(rate(v[5m])[1h:1m]) |
Pre-compute with recording rule |
Fix pattern — recording rules:
# Replace slow dashboard query with pre-computed metric
- record: job:http_requests_total:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
# Dashboard now queries the tiny pre-computed series
# job:http_requests_total:rate5m instead of sum by (job) (rate(http_requests_total[5m]))
8.4 Federation vs Remote Write
graph TD
subgraph Federation
PL1["Prometheus Local 1<br/>cluster A"]
PL2["Prometheus Local 2<br/>cluster B"]
PG["Prometheus Global<br/>scrapes /federate"]
PL1 -->|/federate endpoint| PG
PL2 -->|/federate endpoint| PG
end
subgraph Remote Write
PR1["Prometheus 1<br/>cluster A"]
PR2["Prometheus 2<br/>cluster B"]
RW["Remote Storage<br/>Thanos / Cortex / Mimir"]
PR1 -->|stream samples| RW
PR2 -->|stream samples| RW
end
| Federation | Remote Write | |
|---|---|---|
| How | Global Prometheus scrapes /federate on local instances |
Each Prometheus streams samples to remote endpoint |
| Latency | One scrape interval behind | Near real-time (configurable queue) |
| Scalability | Limited — global becomes bottleneck | Scales horizontally (Thanos/Cortex/Mimir) |
| Data loss on restart | Possible — depends on scrape timing | WAL-backed queue survives restarts |
| Use case | Small multi-DC setups, aggregate pre-recorded metrics | Production multi-cluster, long-term storage |
| Query scope | Single global Prometheus | Unified query across all clusters |
Remote write config:
remote_write:
- url: https://thanos-receive.example.com/api/v1/receive
queue_config:
max_samples_per_send: 10000
capacity: 100000
max_shards: 30
write_relabel_configs:
# Only send metrics needed for long-term storage
- source_labels: [__name__]
regex: 'job:.*'
action: keep
Federation config (global Prometheus):
scrape_configs:
- job_name: federate
honor_labels: true
metrics_path: /federate
params:
match[]:
- '{job="payment-service"}'
- 'job:http_requests_total:rate5m'
static_configs:
- targets:
- prometheus-cluster-a:9090
- prometheus-cluster-b:9090
Quick Reference
Metric types: counter(rate) · gauge(direct) · histogram(quantile) · summary(client)
TSDB: 2h head → block flush → compaction → retention delete
PromQL: instant vector | range vector | scalar | string
Cardinality: series = unique label combinations — keep labels low-cardinality
Alerting: expr fires → pending → (for: duration) → firing → AlertManager
Recording: level:metric:operation naming, pre-compute expensive queries
Remote write: prefer over federation for production multi-cluster