Модуль: 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().
  • rate vs irate: 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: организационные лимиты на ряды, tsdb cli анализ, автоматический drop вредных лейблов.
  • Exemplars: привязка trace_id к точкам гистограммы → переход metric → trace из Grafana.
  • Scrape-тюнинг: sample_limit, target_limit, body size limits, staleness, согласование scrape_interval с rate-окнами и recording rules.