Метрики Prometheus в Go: ошибки, которые взрывают память
Гистограмма с полем user_id в лейбле, счётчик внутри хендлера и /metrics на 40 мегабайт — разбираем, почему Go-сервис с Prometheus течёт по памяти и что с этим делать.

Сервис на Go работает месяц, потом начинает съедать по гигабайту RAM в сутки и падать по OOM. В pprof видно, что растёт не бизнес-логика, а внутренние структуры client_golang. Причина почти всегда одна: где-то в лейбл метрики попало значение с высокой кардинальностью — user_id, request_id, URL с идентификатором. Prometheus хранит отдельную временную серию на каждую уникальную комбинацию лейблов, и Go-клиент держит все эти серии в памяти процесса до рестарта.
Разбираем типичные ошибки при инструментировании Go-сервиса метриками — на github.com/prometheus/client_golang версии 1.x (актуальная линейка на конец 2024 года). Показываю, что именно ломается, как это увидеть и как переписать правильно.
Ошибка 1: высокая кардинальность в лейблах
Самая дорогая ошибка. Выглядит безобидно:
// ПЛОХО: user_id уникален для каждого пользователя
requestsTotal := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Всего HTTP-запросов",
},
[]string{"method", "path", "user_id"},
)
Каждый новый user_id создаёт новую серию. Миллион пользователей — миллион серий, каждая с накладными расходами на строки лейблов и внутренние счётчики. Память растёт линейно и не освобождается: client_golang не удаляет серии сам.
То же самое с path, если в путях есть идентификаторы: /orders/12345, /orders/12346 — это две разные серии. Нужно нормализовать путь до шаблона роута /orders/{id} ещё до записи метрики.
// ХОРОШО: только ограниченный набор значений
requestsTotal := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Всего HTTP-запросов",
},
[]string{"method", "route", "status"},
)
// route берём из роутера как шаблон, а не как фактический URL
requestsTotal.WithLabelValues("GET", "/orders/{id}", "200").Inc()
Правило кардинальности простое: перемножьте число возможных значений всех лейблов одной метрики. Для method (около 5) × route (50 роутов) × status (10 кодов) получается 2500 серий — это нормально. Как только в произведении появляется неограниченный множитель — идентификатор, email, IP — метрика обречена.
Лейбл — это измерение для группировки и фильтрации, а не место для хранения данных запроса. Если значение уникально для запроса или пользователя, ему место в логах или трейсах, но не в метрике.
Как поймать до прода
Открыть /metrics руками и посмотреть глазами уже помогает: если одна метрика занимает сотни строк, лейбл выбран неправильно. В самом Prometheus после сбора это видно через count by (__name__)({__name__=~".+"}) — покажет число серий на метрику.
Ошибка 2: создание метрики внутри обработчика
Метрику нужно объявить один раз при старте и зарегистрировать. Частая ошибка — создавать NewCounter или вызывать MustRegister прямо в хендлере:
// ПЛОХО: паника при втором запросе
func handler(w http.ResponseWriter, r *http.Request) {
c := prometheus.NewCounter(prometheus.CounterOpts{
Name: "my_counter",
})
prometheus.MustRegister(c) // второй вызов = паника
c.Inc()
}
MustRegister паникует при попытке зарегистрировать метрику с уже занятым именем. Второй запрос уронит сервис. Правильно — объявить метрику на уровне пакета или в структуре, инициализировать при старте, а в хендлере только вызывать Inc, Observe, Set.
Ошибка 3: неправильные бакеты гистограммы
Гистограммы латентности часто берут с бакетами по умолчанию (prometheus.DefBuckets: от 0.005 до 10 секунд). Если ваш сервис отвечает за 20–80 мс, все наблюдения падают в один-два бакета, и перцентили из такой гистограммы бессмысленны — histogram_quantile вернёт грубую интерполяцию внутри широкого бакета.
// Бакеты под реальное время ответа сервиса
latency := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Длительность обработки запроса",
Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5},
},
[]string{"route"},
)
Обратная ошибка — слишком много бакетов. Каждый бакет гистограммы — отдельная серия (плюс _sum и _count). Гистограмма с 20 бакетами и лейблом на 50 роутов даёт больше тысячи серий на одну метрику. Держите 8–12 бакетов, подобранных под фактическое распределение.
Гистограмма или Summary
Summary считает перцентили на стороне клиента и их нельзя агрегировать между инстансами: p99 с трёх подов не складывается в общий p99. Гистограмма агрегируется на сервере через histogram_quantile. Для распределённого сервиса почти всегда берут гистограмму.
Ошибка 4: глобальный registry там, где нужен изолированный
Пакетные функции prometheus.MustRegister и promhttp.Handler() используют глобальный DefaultRegisterer. В тестах это приводит к панике «duplicate metrics collector registration» — метрики из предыдущего теста остались в глобальном реестре. Для тестируемого кода передавайте *prometheus.Registry явно:
reg := prometheus.NewRegistry()
reg.MustRegister(requestsTotal, latency)
// свой хендлер поверх изолированного реестра
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
Это же решает проблему с чужими метриками: глобальный реестр по умолчанию экспортирует метрики Go-рантайма и процесса. Если они не нужны, изолированный реестр отдаёт только то, что вы в него положили.
Ошибка 5: счётчики, которые сбрасываются
Counter в Prometheus по определению монотонно растёт, а функции вроде rate() и increase() рассчитаны на сброс до нуля только при рестарте процесса. Если вы вручную обнуляете счётчик или пересоздаёте метрику по расписанию — rate() увидит это как отрицательный скачок и выдаст мусор. Counter не сбрасывают никогда; для «текущего значения, которое может падать» есть Gauge.
Сравнение типов метрик
| Тип | Поведение | Когда использовать |
|---|---|---|
| Counter | Только растёт | Число запросов, ошибок, обработанных задач |
| Gauge | Растёт и падает | Число активных соединений, длина очереди, размер пула |
| Histogram | Бакеты + sum + count | Латентность, размеры тел запросов — с агрегацией по инстансам |
| Summary | Перцентили на клиенте | Латентность одного инстанса, где не нужна агрегация |
Ошибка 6: WithLabelValues в горячем пути
WithLabelValues при каждом вызове ищет серию в мапе по значениям лейблов. На горячем пути с тысячами запросов в секунду это заметная нагрузка. Если набор лейблов известен заранее, получите конкретный счётчик один раз и переиспользуйте:
// Один раз при инициализации маршрута
ordersCounter := requestsTotal.WithLabelValues("GET", "/orders/{id}", "200")
// В горячем пути — просто инкремент, без поиска по мапе
ordersCounter.Inc()
Это микрооптимизация, и в большинстве сервисов разница незаметна. Но если профилировщик показывает время в curryWith или хешировании лейблов — вот откуда оно.
Что мерить и настроить дальше
- Число серий. Метрика
prometheus_tsdb_head_seriesна стороне сервера Prometheus — держите её под контролем и настройте алерт на рост. - Размер
/metrics. Если ответ вырос до мегабайтов, у вас проблема с кардинальностью где-то в коде. - Метрики рантайма Go. Стандартный collector отдаёт
go_goroutines,go_memstats_*— по ним видно утечку горутин и рост кучи ещё до OOM. - relabel_config на сборе. Если убрать высококардинальный лейбл в коде нельзя быстро, его можно отбросить через
metric_relabel_configsна стороне Prometheus как временную меру.
Начните с ревизии всех NewXxxVec в проекте: выпишите лейблы и прикиньте максимальную кардинальность каждого. Один user_id в списке лейблов — и остальное уже неважно.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: prometheus/client_golang — документация и godoc, Prometheus — Metric and label naming best practices
Частые вопросы
У меня client_golang другой версии, приёмы сработают?
Как удалить серию с ошибочным лейблом без рестарта?
Гистограмма или Summary для латентности API?
Приложение падает по OOM — виноваты точно метрики?
Сколько серий на один инстанс считается нормой?
Можно объявлять метрики через глобальный promauto?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.