Модуль: Observability · Уровень: Middle+/Senior
TL;DR#
Prometheus — это TSDB + язык запросов PromQL + pull-модель сбора метрик: сервер сам ходит (scrape) по HTTP-эндпоинтам /metrics целей, обнаруженных через service discovery, и складывает числовые временные ряды. Метрика — это name{label="value"} → значение во времени; уникальная комбинация имени и набора лейблов = один временной ряд (series). Четыре типа: counter (монотонно растёт, мерим через rate()), gauge (произвольно колеблется), histogram (бакеты _bucket{le} + _sum + _count, перцентили считаются на сервере через histogram_quantile, агрегируется между инстансами), summary (квантили считает клиент, НЕ агрегируется). Главный враг senior’а — cardinality explosion: лейбл с неограниченным множеством значений (user_id, request_id, URL с ID) умножает число рядов и убивает память/диск. В Go используем prometheus/client_golang.
Теория#
Pull vs Push#
Prometheus сам опрашивает цели по scrape_interval (обычно 15–60s). Каждая цель отдаёт текущее состояние своих метрик на /metrics.
Почему pull:
- Prometheus знает, кто должен быть жив → может алертить на «цель не отвечает» (
up == 0). При push пропавший инстанс просто молчит — не отличишь от «всё ок». - Централизованный контроль частоты и таргетов, легко вручную дёрнуть
/metricsдля дебага. - Целям не нужно знать адрес Prometheus.
Когда pull не работает → Pushgateway: для эфемерных/batch-job, которые умирают раньше, чем их успеют заскрейпить. Job пушит метрики в Pushgateway, Prometheus скрейпит его. Pushgateway — НЕ для обычных сервисов (он не TTL-ит метрики, ряд «залипает» после смерти джобы; нет up-семантики; единая точка отказа).
# prometheus.yml
scrape_configs:
- job_name: payments
scrape_interval: 15s
kubernetes_sd_configs: [{ role: pod }] # service discovery
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"Service discovery (k8s, Consul, EC2, file_sd) динамически находит цели; relabel_configs фильтруют/переписывают лейблы ещё до скрейпа.
Четыре типа метрик#
| Тип | Семантика | Как читать | Агрегируется? |
|---|---|---|---|
| Counter | Только растёт (сброс при рестарте) | rate(), increase() | Да (sum) |
| Gauge | Растёт и падает | напрямую, avg/max/min | Да |
| Histogram | Распределение по бакетам | histogram_quantile() на серверной агрегации | Да (бакеты складываются) |
| Summary | Клиент-сайд квантили + sum/count | напрямую читаешь квантиль | Нет (квантили нельзя усреднять) |
Histogram vs Summary — ключевой senior-вопрос.
- Histogram экспортирует сырые бакеты:
http_duration_bucket{le="0.1"},le="0.5", …,le="+Inf", плюс_sumи_count. Перцентиль вычисляется на сервере в момент запроса черезhistogram_quantile(0.99, ...). Поскольку бакеты — это counters, их можно сложить между инстансами (sum by (le)), и только потом взять квантиль. Минус: точность зависит от выбора границ бакетов; квантиль интерполируется внутри бакета. - Summary считает квантили на клиенте в скользящем окне (φ-quantile, напр. p50/p90/p99). Минус фатальный: квантили нельзя агрегировать — нельзя усреднить p99 трёх инстансов и получить общий p99 (математически бессмысленно). Плюс: точные значения без зависимости от бакетов, дешевле читать. Используй summary только когда нужна точность per-instance и не нужна агрегация.
Правило: для распределённого сервиса почти всегда histogram, потому что нужна агрегация по инстансам.
Exposition format#
Текстовый формат, который отдаёт /metrics:
# HELP http_requests_total Total HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",code="200"} 102934
http_requests_total{method="POST",code="500"} 12
# HELP http_request_duration_seconds Request latency.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.1"} 8000
http_request_duration_seconds_bucket{le="0.5"} 9500
http_request_duration_seconds_bucket{le="+Inf"} 9600
http_request_duration_seconds_sum 4523.7
http_request_duration_seconds_count 9600# TYPE/# HELP— метаданные.- Histogram разворачивается в серию
_bucket(кумулятивные!le= «less or equal»), плюс_sumи_count. le="+Inf"обязателен и равен_count.
Naming conventions: unit в имени (_seconds, _bytes, _total для counters), base units (секунды, не миллисекунды), snake_case, имя описывает что меряем безотносительно лейблов.
client_golang#
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
reqTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests.",
},
[]string{"method", "code", "route"}, // ОГРАНИЧЕННЫЕ по множеству лейблы
)
reqDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Request latency.",
Buckets: prometheus.DefBuckets, // или кастом под SLO
},
[]string{"route"},
)
inFlight = promauto.NewGauge(prometheus.GaugeOpts{
Name: "http_in_flight_requests",
Help: "Current in-flight requests.",
})
)
func handler(w http.ResponseWriter, r *http.Request) {
inFlight.Inc()
defer inFlight.Dec()
start := time.Now()
// ... обработка, route — это ШАБЛОН пути, не raw URL
route := "/orders/:id"
reqDuration.WithLabelValues(route).Observe(time.Since(start).Seconds())
reqTotal.WithLabelValues(r.Method, "200", route).Inc()
}
func main() {
http.Handle("/metrics", promhttp.Handler()) // отдаёт default registry
http.ListenAndServe(":8080", nil)
}promautoрегистрирует в default registry автоматически; иначеprometheus.MustRegister(c).*Vec— семейство метрик, параметризованное лейблами;WithLabelValues(...)достаёт конкретный ряд.- Custom collector — реализуешь
prometheus.Collector(Describe/Collect), когда метрики собираются on-scrape из внешнего источника (например, опрос БД), а не инкрементятся в коде.
type dbCollector struct {
db *sql.DB
desc *prometheus.Desc
}
func (c *dbCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.desc }
func (c *dbCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue,
float64(c.db.Stats().OpenConnections))
}PromQL базово#
# Скорость запросов в секунду за 5м окно (counter -> rate)
rate(http_requests_total[5m])
# Error rate как доля
sum(rate(http_requests_total{code=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m]))
# p99 латенси из histogram, агрегированной по инстансам
histogram_quantile(0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket[5m])))
# Агрегации
sum by (route) (rate(http_requests_total[5m])) # схлопнуть всё кроме route
avg without (instance) (process_resident_memory_bytes)rate()— средняя скорость роста counter за окно (сглажено, для алертов/графиков).irate()— мгновенная (по двум последним точкам, дёргается, для дебага).increase()— абсолютный прирост за окно (=rate * window).histogram_quantile(φ, bucket_series)— перцентиль по бакетам.by/without— что оставить / что выкинуть из группировки.
Recording rules — предвычисление дорогих запросов в новый ряд по расписанию: для дашбордов/алертов, чтобы не считать тяжёлый PromQL каждый раз.
groups:
- name: slo
rules:
- record: job:http_error_rate:ratio5m
expr: sum(rate(http_requests_total{code=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))Cardinality explosion#
Cardinality = число уникальных временных рядов. Каждая метрика хранит по одному ряду на каждую уникальную комбинацию лейблов.
Формула: series = метрик × ∏(значений каждого лейбла).
Пример катастрофы: добавили лейбл user_id. 1M пользователей × 5 кодов × 10 роутов = 50M рядов на одну метрику. Каждый ряд — это память (head chunks), индекс, диск. TSDB падает по OOM.
Откуда берётся: user_id, email, request_id, session_id, trace_id, raw URL с ID, IP, timestamp, любое неограниченное множество.
Как бороться:
- Лейблы — только bounded множества: метод, код, шаблон роута (
/orders/:id, не/orders/12345), регион, тип ошибки. - Высококардинальные данные → в трейсы/логи, не в метрики.
- Мониторь
prometheus_tsdb_head_series,scrape_samples_post_metric_relabeling. metric_relabel_configsсaction: drop/labeldrop— отрезать вредные лейблы на скрейпе.- Cardinality budgeting: лимит рядов на сервис, ревью новых лейблов на code review.
Подводные камни / gotchas#
rate()/irate()работают только с counters и требуют ≥2 точек в окне; на gauge дают мусор. Для gauge используйdelta()/deriv().ratevsirate: rate сглаживает (для алертов), irate скачет по двум последним точкам (для дебага коротких всплесков). Не алертить на irate.- Counter reset: при рестарте инстанса counter обнуляется;
rate()/increase()это детектируют и корректируют автоматически — но только в пределах одного ряда, не суммируй counters руками. - Правило 4×: окно
rate([X])должно быть ≥ 4× scrape_interval, иначе пропуски точек дают дыры/нули. При 15s scrape — минимум[1m], безопаснее[5m]. В Grafana —$__rate_interval. histogram_quantileточен ровно настолько, насколько подобраны бакеты. p99 попавший в бакет[1s, +Inf]вернёт интерполяцию до бесконечности → бессмыслица. Бакеты надо ставить вокруг ожидаемой латенси и порога SLO.- Summary нельзя агрегировать между инстансами —
avg(http_duration{quantile="0.99"})математически неверен. Нужен histogram. - Cardinality убивает TSDB — самая частая прод-авария Prometheus.
- Staleness markers: если ряд исчез из скрейпа, Prometheus помечает его stale через ~5 мин — запросы перестают его возвращать (важно для алертов «исчезнувший таргет»).
- Не делай метрику на каждый запрос (
request_idв лейбле) — это путь к взрыву; метрика — это агрегат. scrape_timeout < scrape_intervalобязательно, иначе скрейпы наслаиваются.
Вопросы на собеседовании#
В: Почему Prometheus pull, а не push? Когда нужен Pushgateway?
О: Pull даёт up-семантику (видно, что таргет умер, а не просто молчит), централизованный контроль частоты/таргетов и ручной дебаг /metrics. Pushgateway — для эфемерных batch-jobs, которые завершаются раньше скрейпа. Для долгоживущих сервисов он антипаттерн: метрики залипают после смерти джобы, нет TTL и нет up.
В: В чём разница histogram и summary и почему для микросервисов берут histogram?
О: Histogram отдаёт сырые кумулятивные бакеты, перцентиль считается на сервере через histogram_quantile, и бакеты можно сложить между инстансами перед взятием квантиля → агрегируется. Summary считает квантили на клиенте, и их нельзя агрегировать (усреднять p99 нескольких инстансов бессмысленно). В распределённой системе нужна агрегация по подам → histogram. Цена histogram — зависимость точности от бакетов и больше рядов.
В: Что такое cardinality explosion и как предотвратить?
О: Это лавинообразный рост числа рядов из-за лейбла с неограниченным множеством значений (user_id, request_id, raw URL). Число рядов = произведение кардинальностей лейблов, легко доходит до десятков миллионов и кладёт TSDB по памяти. Лечение: лейблы только bounded (метод/код/route-шаблон/регион), высококардинальное в трейсы/логи, metric_relabel_configs drop, мониторинг tsdb_head_series, ревью лейблов.
В: Зачем rate() нужно окно минимум в 4 раза больше scrape interval?
О: Чтобы в окне гарантированно были ≥2–4 точки даже при пропущенных скрейпах. Если окно ≈ scrape_interval, единичный промах даёт дыры и нули в графике/алерте. При 15s scrape окно ≥1m. В Grafana для этого есть $__rate_interval, который сам подбирает безопасное окно.
В: Как посчитать p99 латенси, агрегированный по всем инстансам сервиса?
О: histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))). Сначала rate по бакетам, затем sum by (le) складывает бакеты всех инстансов (они counters — складываются), и только потом histogram_quantile. Порядок важен: квантиль берётся последним, по агрегированным бакетам.
В: Как Prometheus обрабатывает рестарт инстанса и обнуление counter?
О: rate()/increase() детектируют сброс (значение упало) и корректно учитывают его как продолжение роста, а не отрицательную скорость. Это работает в пределах одного ряда. Поэтому нельзя складывать сырые counters руками — нужно сначала rate на каждом ряде, потом sum.
В: Что такое recording rules и когда их применять? О: Предвычисленные по расписанию запросы, сохраняемые в новый ряд. Применяют для дорогих/часто используемых PromQL (SLI-ratio, агрегаты для дашбордов), чтобы снять нагрузку с query-time и ускорить дашборды/алерты, а также для стандартизации формул между командами.
На что копают на senior+#
- TSDB internals: head block (in-memory + WAL), 2-часовые блоки на диске, compaction, как кардинальность бьёт по памяти head-серий и индексу.
- HA и масштабирование: дублирование Prometheus + дедуп в Alertmanager; федерация (иерархический скрейп агрегатов); Thanos/Cortex/Mimir для long-term storage, global query view и горизонтального масштаба.
- Remote write/read: стриминг сэмплов во внешнее хранилище, тюнинг очередей, шардирование.
- Cardinality budgeting: организационные лимиты на ряды,
tsdbcli анализ, автоматический drop вредных лейблов. - Exemplars: привязка trace_id к точкам гистограммы → переход metric → trace из Grafana.
- Scrape-тюнинг:
sample_limit,target_limit, body size limits, staleness, согласованиеscrape_intervalс rate-окнами и recording rules.