17. Метрики и базовый monitoring

Лорд Кельвин однажды заметил: «Если вы не можете измерить то, о чем говорите, и выразить это в цифрах, ваше знание скудно и неудовлетворительно». В распределенных системах эта истина становится вопросом выживания.

Если логи можно сравнить с «черным ящиком» самолета, который расшифровывают уже после катастрофы, то метрики — это приборная панель в кабине пилота. Они показывают текущую высоту, скорость, температуру турбин и давление масла в реальном времени.

Метрики — это формализованный способ измерять поведение системы во времени. В отличие от логов, которые рассказывают что случилось, метрики показывают как система ведет себя под нагрузкой. В Go-экосистеме де-факто стандартом стала связка prometheus/client_golang + Pull-модель сбора. Правильная настройка метрик позволяет предсказывать сбои до их наступления, диагностировать утечки памяти и оптимизировать узкие места без гадания на кофе.

flowchart LR
    A["Go Сервис<br>накопление метрик в памяти"] -->|"HTTP GET /metrics"| B["Prometheus<br>скрейпер"]
    B -->|"Парсинг текста<br>экспозишн формат"| C["TSDB<br>хранение временных рядов"]
    C -->|"PromQL запросы"| D["Grafana<br>визуализация"]

    classDef srv fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    classDef prom fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#f8fafc;
    classDef tsdb fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    class A srv;
    class B,C,D prom;
  

1. Pull-модель и архитектура сбора

В классических push-системах (статистика, старые APM) клиент сам отправляет данные на сервер. Это создает проблемы с backpressure: если сервер метрик тормозит, клиент тратит CPU на буферизацию и retry, замедляя бизнес-логику.

Prometheus использует Pull-модель: сервер самостоятельно скрейпит HTTP-эндпоинт /metrics целевых сервисов с заданным интервалом (обычно 15-60 секунд).


2. Типы метрик и семантика

Выбор типа метрики диктует, как данные будут агрегироваться в TSDB и какие функции PromQL к ним применимы.

  • Counter: Только монотонно растет. Сбрасывается только при рестарте процесса. Используется для подсчета событий: количество запросов, ошибок, обработанных сообщений. В PromQL используется rate() или increase() для получения RPS.
  • Gauge: Может расти и падать. Отражает текущее состояние: количество активных горутин, использование памяти, длина очереди, температура CPU.
  • Histogram: Агрегирует значения в предопределенные бакеты. Идеален для измерения длительности запросов или размеров payload. Позволяет вычислять процентили через histogram_quantile().
  • Summary: Клиентский расчет процентилей. Дает точные значения, но не агрегируется по лейблам и не может быть усреднен. В Go-экосистеме почти всегда заменяется на Histogram из-за проблем с агрегацией в кластере.
Go
package metrics

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total number of HTTP requests",
    }, []string{"method", "handler", "status_code"})

    HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "Duration of HTTP requests in seconds",
        Buckets: prometheus.DefBuckets, // [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    }, []string{"method", "handler"})
)

3. Под капотом. Атомики, блокировки и кэш-линии

Клиентская библиотека prometheus/client_golang оптимизирована для работы в конкурентной среде, но ее устройство напрямую влияет на производительность сервиса.

Каждая метрика хранится в памяти процесса. При вызове .Inc() или .Observe():

  1. Библиотека находит нужный вектор по комбинации лейблов (использует sync.Map или мьютекс для поиска)
  2. Обновляет внутреннее значение с помощью sync/atomic
  3. Возвращает управление горутине

Для Histogram запись сложнее: библиотека вычисляет индекс бакета (часто через math.Log или бинарный поиск) и атомарно инкрементирует счетчик конкретного бакета. Это дороже, чем Counter.Inc(), но необходимо для процентилей.


4. Интеграция в HTTP-сервис

Идиоматичный способ добавить метрики — использовать http.Handler обертку или promhttp инструменты.

Go
package main

import (
    "net/http"
    "time"

    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func setupMetricsHandler(mux *http.ServeMux) {
    // Эндпоинт для Prometheus
    mux.Handle("/metrics", promhttp.HandlerFor(
        prometheus.DefaultGatherer,
        promhttp.HandlerOpts{
            EnableOpenMetrics: true, // Оптимизация для современных скрейперов
            ErrorLog:          customLogger{},
        },
    ))
}

func metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Создаем обертку для захвата статуса ответа
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(rw, r)

        duration := time.Since(start).Seconds()

        // Запись метрик (без блокировки основного потока)
        HTTPRequestsTotal.With(prometheus.Labels{
            "method":      r.Method,
            "handler":     r.URL.Path,
            "status_code": http.StatusText(rw.statusCode),
        }).Inc()

        HTTPRequestDuration.With(prometheus.Labels{
            "method":  r.Method,
            "handler": r.URL.Path,
        }).Observe(duration)
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

5. Опасность высокой кардинальности и GC-давление

Кардинальность метрики — это количество уникальных комбинаций лейблов. Это самая опасная метрика в production.

Go
// АНТИПАТТЕРН: Кардинальный взрыв
HTTPRequestsTotal.With(prometheus.Labels{
    "user_id":    userID,    // 1 000 000 уникальных пользователей
    "request_id": reqID,     // 10 000 000 уникальных запросов
}).Inc()

Каждая уникальная комбинация создает новый объект в памяти (запись в sync.Map или map, структура метрики). При высокой кардинальности:

  1. OOM в сервисе: Go-процесс расходует гигабайты RAM на хранение лейблов и значений.
  2. GC Pressure: Сборщик мусора вынужден сканировать миллионы живых объектов. Это увеличивает паузы STW (Stop-The-World) и снижает пропускную способность.
  3. Crash Prometheus: Скрейпер пытается загрузить миллионы временных рядов в свою TSDB, что приводит к OOM на стороне мониторинга.

6. Практические правила эксплуатации

  1. Лейблы должны быть bounded: Только известные конечные множества (method, status_code, service_name, handler). Никогда не используйте user_id, email, ip, uuid в лейблах.
  2. Именование: namespace_subsystem_name_unit (например, http_request_duration_seconds). Единицы измерения всегда в суффиксе.
  3. Goroutine/Memory метрики: Рантайм Go уже экспортирует go_goroutines, go_memstats_*. Не дублируйте их вручную.
  4. Таймауты на скрейпинге: Настройте scrape_timeout в Prometheus чуть больше времени выполнения /metrics, но меньше scrape_interval.

Итог

  1. Pull-модель Prometheus защищает бизнес-логику от backpressure и упрощает архитектуру мониторинга.
  2. Counter для событий, Gauge для состояния, Histogram для распределений. Summary в Go-стеке практически не используется.
  3. Запись метрик использует атомарные операции, но при высокой конкуренции вызывает contention на кэш-линиях CPU.
  4. Высокая кардинальность лейблов — главный враг стабильности. Она ведет к OOM сервиса и краху TSDB.
  5. /metrics endpoint должен быть легковесным и предсказуемым по времени выполнения.
  6. Метрики проектируются до написания кода: лейблы, имена и бакеты согласуются командой на этапе design review.

Следующая статья: 18. Rate limiting