Метрики в Go: типичные ошибки при работе с Prometheus
Метрика с UUID в лейбле раздувает память exporter'а до гигабайтов, а забытый reset ломает подсчёт запросов. Разбираем ошибки, из-за которых мониторинг на Go врёт или падает.

Инцидент банальный: под нагрузкой у сервиса начала расти память, при этом код обработчиков не менялся. Профилировщик показал, что растёт не бизнес-логика, а внутренний реестр метрик prometheus/client_golang. Причина — лейбл с идентификатором запроса. Каждый уникальный запрос создавал новую временную серию, и они не удалялись. За сутки набежало несколько сотен тысяч серий и полтора гигабайта в куче.
Это одна из пяти-шести ошибок, которые повторяются почти в каждом проекте на Go с метриками. Разберём их на примере официального клиента github.com/prometheus/client_golang (версии v1.19.x). Речь именно о том, что ломается в проде, а не о том, как поставить первый счётчик.
Высокая кардинальность в лейблах
Главный источник проблем. Prometheus хранит отдельную временную серию на каждую уникальную комбинацию имени метрики и значений лейблов. Пока лейблы — это method, status, handler, серий десятки. Как только в лейбл попадает то, что меняется от запроса к запросу, число серий уходит в бесконечность.
Что нельзя класть в лейбл:
- идентификаторы пользователей, заказов, сессий, request ID;
- полный URL с query-параметрами (
/order/12345вместо/order/:id); - email, IP-адреса, номера телефонов;
- тексты ошибок целиком — их сотни вариантов;
- timestamp или что-либо, зависящее от времени.
Лейбл — это категория, а не значение. Если поле может принять больше нескольких десятков разных значений за жизнь процесса, ему не место в лейбле.
Проблема в том, что раздувание не видно на дев-стенде: там пять пользователей и три URL. Оно проявляется только под реальным трафиком. К этому моменту серии уже съели память и exporter, и самого Prometheus, а запросы к нему тормозят.
Как поймать заранее
Периодически смотрите количество серий: в самом Prometheus запрос count({__name__=~".+"}) покажет общее число, а topk(10, count by (__name__)({__name__=~".+"})) — какие метрики раздуты сильнее всего. Если у одной метрики десятки тысяч серий, ищите в её лейблах динамическое значение.
Создание метрики внутри обработчика
Второй по частоте промах — вызывать NewCounter или получать метрику через NewCounterVec(...).WithLabelValues(...) с новыми объектами на каждый запрос. Метрика должна быть объявлена один раз при старте, а в обработчике только инкрементироваться.
package metrics
import "github.com/prometheus/client_golang/prometheus"
// Объявляем один раз на уровне пакета. registeredOnce следит,
// чтобы MustRegister не вызвался дважды и не паникнёт.
var HTTPRequests = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Всего HTTP-запросов по методу и статусу",
},
[]string{"method", "status"},
)
func init() {
prometheus.MustRegister(HTTPRequests)
}В обработчике — только инкремент по уже существующему вектору:
// method и status — из фиксированного набора значений, кардинальность мала
metrics.HTTPRequests.WithLabelValues(r.Method, statusText).Inc()Если создавать CounterVec заново в каждом запросе, во-первых, повторный MustRegister с тем же именем вызовет панику; во-вторых, при использовании Register вместо MustRegister вы будете молча терять данные — вернётся ошибка AlreadyRegisteredError, а метрика не обновится.
Гистограммы с плохими бакетами
Гистограмма латентности полезна ровно настолько, насколько удачно выбраны границы бакетов. Дефолтные prometheus.DefBuckets заточены под секунды: .005, .01, .025, ..., 10. Если ваш сервис отвечает за 2–20 миллисекунд, все значения попадут в первый бакет, и перцентили посчитать будет невозможно — histogram_quantile вернёт мусор.
Бакеты подбирают под реальное распределение времени ответа:
// Для сервиса, где типичный ответ 1-200 мс
latency := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Длительность обработки HTTP-запроса",
Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1},
},
[]string{"handler"},
)Помните и об обратной стороне: каждый бакет — это отдельная серия. Гистограмма с 15 бакетами и лейблом handler на 20 значений даёт 300+ серий только под латентность. Умножьте на кардинальность остальных лейблов — и вернётесь к первой ошибке.
Счётчик вместо Gauge и наоборот
Counter — только растёт и сбрасывается в ноль при рестарте процесса; для него в запросах применяют rate(). Gauge — произвольно меняется в обе стороны, его читают как есть. Путаница ломает графики:
| Что измеряете | Тип | Почему |
|---|---|---|
| Число обработанных запросов | Counter | Монотонно растёт, интересна скорость через rate() |
| Текущее число активных соединений | Gauge | Скачет вверх-вниз |
| Размер очереди задач | Gauge | Растёт и падает |
| Байт отправлено всего | Counter | Только накапливается |
Если применить rate() к Gauge — получите бессмыслицу. Если построить график Counter без rate() — увидите монотонно растущую прямую вместо нагрузки, которая при каждом рестарте будет обнуляться уступом.
Забытый Observe и утечка на defer
Для измерения времени удобен таймер, но легко ошибиться с моментом остановки. Правильный шаблон — NewTimer с ObserveDuration через defer:
func handleOrder(w http.ResponseWriter, r *http.Request) {
// Таймер фиксирует старт здесь и запишет длительность при выходе
timer := prometheus.NewTimer(latency.WithLabelValues("order"))
defer timer.ObserveDuration()
// ... обработка ...
}Частая ошибка — вызывать WithLabelValues с динамическим значением прямо здесь, возвращаясь к проблеме кардинальности. Лейбл handler должен быть константой маршрута, а не значением из запроса.
Метрики без reset там, где он нужен
Если вы используете CounterVec с лейблами, которые всё же имеют ограниченный, но меняющийся во времени набор (например, версии клиентов), старые серии остаются в памяти навсегда. Клиент обновился — а серия со старой версией продолжает торчать. Для таких случаев есть DeleteLabelValues и Reset, но применять их надо осознанно: Reset стирает всё разом, и вы потеряете накопленные значения между скрейпами Prometheus. Лучший вариант — не допускать таких лейблов вовсе.
Что проверить в своём проекте
- Пройдитесь по всем
WithLabelValues: нет ли там значений из тела или пути запроса без нормализации. - Убедитесь, что все метрики объявлены на уровне пакета и регистрируются один раз.
- Сверьте бакеты гистограмм с реальным p50/p99 из логов — при необходимости замерьте на нагрузочном тесте.
- Запустите
count({__name__=~".+"})на проде и посмотрите динамику числа серий за неделю: растёт линейно со временем — где-то течёт кардинальность. - Проверьте, что Counter и Gauge не перепутаны, а на графиках Counter стоит
rate().
После этого настройте алерт на рост числа серий: если prometheus_tsdb_head_series растёт без остановки, проблема с кардинальностью вернётся, и лучше узнать об этом до OOM.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: prometheus/client_golang — официальный репозиторий и документация, Prometheus: Metric and label naming best practices
Частые вопросы
У меня старая версия client_golang, эти примеры подойдут?
А если серии уже раздулись — как почистить, не теряя данные?
Сколько серий на один процесс считается нормой?
Можно ли класть в лейбл имя эндпоинта, если их у меня сотни?
Почему rate() к Gauge даёт мусор, а не ошибку?
Нужно ли вручную вызывать Reset для CounterVec?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.