4. Label и их опасности
Кардинальность и утечки памяти
«Сложность — это единственная вещь, которая всегда растет сама по себе, если только с ней сознательно не бороться».
— Брайан Керниган
В предыдущей статье мы научились экспортировать базовые метрики из Go-сервиса. Однако плоский счетчик http_requests_total, возвращающий единственное агрегированное число, мало чем поможет инженеру в три часа ночи: невозможно понять, какой эндпоинт деградирует, какой метод дает сбои и какой код ошибки возвращается клиентам.
Всю мощь и выразительность современным системам мониторинга дают Labels (Лейблы, или метки). Лейблы превращают одномерный скаляр в многомерное пространство измерений (Dimensions). Но за эту гибкость приходится платить. Бездумное обращение с лейблами — это причина номер один аварий систем мониторинга и внезапных OOM-падений бэкендов в продакшене.
Лейблы: Мощь и опасность
Лейблы позволяют разбить одну метрику на множество измерений (Dimensions). Например, метрика http_requests_total без лейблов покажет общее количество запросов. Но добавив лейбл status, мы можем узнать, сколько было успешных ответов (200), а сколько ошибок сервера (500).
Мы можем мгновенно фильтровать и группировать потоки данных по методам, HTTP-статусам, срезам инфраструктуры:
http_requests_total{method="POST", status="500"}sum by (status) (rate(http_requests_total[5m]))
Однако каждый добавленный лейбл — это не просто дополнительное поле в JSON. Это порождение нового физического объекта как в памяти вашего приложения, так и в базе данных Prometheus.
Модель данных: Time Series
В Prometheus нет понятия "объект" или "экземпляр". Есть только Time Series (Временной ряд).
Временной ряд однозначно идентифицируется комбинацией имени метрики и всех её лейблов.
$$ \text{Time Series} = \text{Metric Name} + \text{Label Set} $$
Если у вас есть метрика http_requests_total с лейблами method="GET" и status="200", это один временной ряд.
Если пришел запрос с status="404", Prometheus создаст другой временной ряд: http_requests_total{method="GET", status="404"}.
Каждый уникальный кортеж значений лейблов порождает независимый поток данных, который требует своего буфера в оперативной памяти, своей записи в инвертированном индексе и отдельного сжатого чанка на диске.
Under the Hood: Как это работает в Go
В библиотеке client_golang метрики с лейблами реализованы через структуры *Vec (CounterVec, GaugeVec, HistogramVec).
Когда вы вызываете метод .WithLabelValues("value"), происходит следующее:
- Хеширование: Клиент вычисляет хеш (обычно быстрый 64-битный FNV-1a) от переданных значений лейблов.
- Поиск в карте: Он ищет этот хеш во внутренней карте (
map[uint64]metric)) подRWMutex.RLock(). - Создание или получение:
- Если метрика с таким набором лейблов уже есть — возвращается ссылка на неё.
- Если нет — захватывается
RWMutex.Lock(), создается новый объект (новый счетчик) и помещается в карту.
flowchart TD
Call["WithLabelValues('GET', '200')"] --> Hash["1. Hash FNV-1a('GET', '200')"]
Hash --> Lookup{"2. metricMap.RLock()<br>Хеш найден?"}
Lookup -->|"Да"| Return["3. Возврат существующего *Counter"]
Lookup -->|"Нет"| Lock["4. metricMap.Lock()"]
Lock --> Alloc["5. new(Counter) + аллокация в Heap"]
Alloc --> Insert["6. metricMap[hash] = counter"]
Insert --> Return
style Call fill:#312e81,stroke:#818cf8,color:#fff
style Return fill:#065f46,stroke:#10b981,color:#fff
style Alloc fill:#78350f,stroke:#f59e0b,color:#fff
Главный враг: Unbounded Cardinality (Неограниченная кардинальность)
Самая частая ошибка, убивающая мониторинг — использование неограниченных значений в лейблах.
Кардинальность (Cardinality) — это количество уникальных комбинаций лейблов. Если число возможных значений конечно и мало (десятки или сотни), система работает безупречно. Если число значений потенциально бесконечно — наступает катастрофа.
Пример ошибки: URL Paths
Представьте, что вы пишете метрику для HTTP-хендлера:
// ОПАСНЫЙ КОД
httpRequests := promauto.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
}, []string{"path"})
func handler(w http.ResponseWriter, r *http.Request) {
// r.URL.Path может быть любым: /users/1, /users/2, /users/99999
httpRequests.WithLabelValues(r.URL.Path).Inc()
}
Если ваш сервис обрабатывает запросы вида /users/{id}, то для каждого пользователя будет создан новый временной ряд.
- 1 пользователь = 1 ряд.
- 1 миллион пользователей = 1 миллион рядов.
- Результат: Prometheus падает с OOM, а ваше Go-приложение тратит гигабайты памяти на хранение карты метрик.
Решение: Нормализация путей
Вам нужно преобразовать динамический путь в статический шаблон.
// ПРАВИЛЬНЫЙ ПОДХОД
// Используем роутер (например, Chi или Gin), который умеет возвращать шаблон пути
func promMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
// Получаем паттерн вместо реального URL
// Например, вместо /users/123 -> /users/{id}
route := getRoutePattern(r)
httpRequests.WithLabelValues(route).Inc()
})
}
Библиотеки вроде gorilla/mux, chi или gin позволяют получить "шаблон" маршрута (r.URL.Path -> /users/:id), что превращает бесконечное множество URL в один дискретный лейбл. В стандартной библиотеке Go 1.22+ обновленный net/http.Request также предоставляет метод r.Pattern, возвращающий зарегистрированный шаблон маршрута (например, /users/{id}).
Cardinality Explosion: Влияние на TSDB
Проблема масштабируется не только в памяти вашего приложения, но и в памяти Prometheus сервера.
flowchart TD
A["App: /metrics"] -->|"100k unique paths"| B["Prometheus Scrape"]
B --> C["TSDB Head Block"]
subgraph C ["Memory Consumption"]
D["Series 1: /users/1"]
E["Series 2: /users/2"]
F["..."]
G["Series N: /users/100000"]
end
C -->|"Too many chunks"| H["OOM Killer"]
style H fill:#7f1d1d,stroke:#f87171,color:#f8fafc
Каждый временной ряд в Prometheus требует выделения памяти под "Head Chunk" (буфер для записи новых точек данных). Чем больше рядов, тем больше оперативной памяти потребляет сервер мониторинга.
Кроме того, Prometheus строит инвертированный индекс (Inverted Index) по всем лейблам. При миллионах уникальных строк размер индекса раздувается до десятков гигабайт, время сжатия (Compaction) возрастает экспоненциально, а CPU сервера мониторинга начинает захлебываться при выполнении любых PromQL-запросов.
Best Practices: Безопасная работа с лейблами
-
Ограниченное множество (Bounded Set): Используйте лейблы только тогда, когда вы можете заранее перечислить все возможные значения (или хотя бы оценить их порядок).
status_code(200, 404, 500) — OK.method(GET, POST) — OK.region(us-east, eu-west) — OK.user_id— ЗАПРЕЩЕНО.
-
Избегайте IP-адресов: В Kubernetes поды пересоздаются, IP-адреса меняются. Используйте лейблы K8s (Deployment name, Service name), а не IP.
-
Два способа "убить" прод:
- Memory Leak в App: Высокая кардинальность в Go-приложении (бесконечная карта).
- Crash в Prometheus: Высокая кардинальность в TSDB (бесконечные чанки).
Также в веб-интерфейсе Prometheus доступна встроенная системная страница Status -> TSDB Status, которая мгновенно показывает топ-10 метрик по количеству серий, самые часто встречающиеся имена лейблов и максимальную длину postings-списков без необходимости писать сложные PromQL-запросы.
Итог
Лейблы — это мощный инструмент для срезов данных, но они требуют дисциплины.
- Никогда не помещайте уникальные данные (ID, Email, IP) в метрики. Это работа для логов или трейсов.
- Нормализуйте динамические параметры URL в именованные маршруты (
:id). - Мониторьте кардинальность своих метрик, чтобы предотвратить падение инфраструктуры мониторинга.
В следующей статье мы отойдем от технических аспектов сбора данных и поговорим о том, как эти данные превращаются в бизнес-ценность через SLA, SLO и SLI: 5. SLA, SLO, SLI.