MAATRIX / Блог / Сколько метрик потянет Prometheus: считаем кардинальность до взрыва памяти

Сколько метрик потянет Prometheus: считаем кардинальность до взрыва памяти

MAATRIX

Prometheus обычно падает не потому, что в него добавили ещё одну метрику. Он падает, потому что кто-то добавил в существующую метрику лейбл вроде user_id или request_id, и через пару недель число временных рядов в памяти выросло в сотни раз незаметно для всех, пока процесс не упёрся в лимит RAM и не лёг по OOM. Разберём, что такое кардинальность на самом деле, как её посчитать заранее и в уже работающей системе, и что делать, чтобы она не выросла снова.

Кардинальность, а не число метрик — вот что считает Prometheus

Метрика в Prometheus — это имя вида http_requests_total. Но в памяти и на диске Prometheus хранит не метрики, а временные ряды (time series) — уникальные комбинации имени метрики и значений всех её лейблов. Одна метрика с двумя лейблами method и status при трёх методах и пяти кодах ответа даёт не одну запись, а до 15 отдельных временных рядов, каждый со своим набором точек во времени.

Кардинальность метрики — это количество таких уникальных комбинаций. Кардинальность всей системы — сумма по всем метрикам. Именно это число, а не количество строк в metrics.yml, определяет, сколько памяти съест Prometheus. Метрика без лейблов (или с 2-3 фиксированными значениями) практически бесплатна. Метрика с лейблом, значения которого растут без ограничения — user_id, session_id, IP-адрес, request_id, — превращается в фабрику новых рядов, которая не останавливается никогда.

Механика простая: каждый новый временной ряд — это не число в хеш-таблице, а отдельная структура в TSDB head (активной, ещё не сброшенной на диск части хранилища): заголовок серии, буфер под последние сэмплы (chunk), запись в индексе по каждому лейблу для обратного поиска. Поэтому рост кардинальности бьёт по памяти сильнее, чем рост частоты скрейпа той же метрики — резидентная память Prometheus в первую очередь пропорциональна числу активных рядов в head, а не числу собранных точек.

Как рождается взрыв: типичная ошибка с метками

Классический сценарий выглядит примерно так. Разработчик инструментирует HTTP-сервис и хочет видеть задержку каждого запроса в разрезе конкретного пользователя или конкретного идентификатора заявки — вроде бы полезно для отладки. Получается:

http_request_duration_seconds{method="POST", path="/api/orders", user_id="48213", status="200"}

Пока пользователей десятки — терпимо. Когда их становятся тысячи и десятки тысяч, каждый новый пользователь создаёт новый временной ряд для каждой уникальной комбинации остальных лейблов. Метрика, которая задумывалась как одна, превращается в десятки и сотни тысяч рядов — и это без учёта того, что таких инструментированных путей в приложении обычно не один.

Та же ошибка встречается в других видах:

  • request_id / trace_id / correlation_id в лейбле метрики — по определению уникален для каждого запроса, кардинальность растёт без ограничения при любой нагрузке;
  • IP-адрес клиента — в публичном сервисе практически неограниченное множество значений;
  • email или произвольная строка из тела запроса, попавшая в лейбл через невнимательную инструментацию middleware;
  • имя пода с суффиксом деплоя (pod="app-7d9f4c8b6-x2kqz") в Kubernetes — при частых передеплоях каждый новый под даёт новый набор рядов для всех своих метрик, даже если сам живёт 5 минут;
  • гистограммы без ограничения бакетов — сама структура гистограммы добавляет лейбл le к каждой серии; 20 бакетов без единого дополнительного лейбла уже дают 20 рядов на один логический показатель, а с user_id умножение работает в обе стороны.

Ловушка в том, что рост кардинальности почти никогда не виден сразу. Метрика появляется в коде, ревью её пропускает (кардинальность не видна по диффу), сервис несколько недель растёт по числу пользователей или подов — и однажды стабильно потреблявший пару гигабайт Prometheus за месяц вырастает на порядок и падает по OOM в самый неподходящий момент.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Как оценить кардинальность заранее, до продакшена

Прежде чем метрика попадёт в код, стоит задать себе три вопроса про каждый лейбл:

  1. Конечно ли множество значений и известна ли его верхняя граница на старте проекта. method — да (GET, POST, PUT, DELETE, ещё пара). status_code — да, по факту десятки значений. user_id — нет, растёт вместе с базой пользователей.
  2. Растёт ли количество значений со временем без переинициализации. Имя пода в Kubernetes без стабильного идентификатора — растёт при каждом передеплое. Название окружения (prod, staging) — не растёт.
  3. Действительно ли нужна разбивка по этому лейблу в метриках, или это задача для логов/трейсов. Идентификатор конкретного запроса или пользователя — это данные для трассировки (Jaeger, Tempo) или лога, а не для агрегированной метрики. В метриках нужны агрегаты — доля ошибок, перцентили задержки, — а не разбивка по каждому уникальному объекту.

Практическая прикидка кардинальности метрики делается перемножением количества возможных значений по каждому лейблу:

кардинальность метрики ≈ N(method) × N(status) × N(instance) × N(path) × ...

Например: 4 метода × 6 кодов статуса × 20 инстансов × 30 путей = 14 400 рядов на одну метрику. Это не страшно. Но стоит подставить туда user_id с текущими 50 000 пользователей вместо path, и то же произведение улетает в миллионы. Такое перемножение стоит делать на этапе код-ревью для каждой новой метрики с лейблами — это дешевле, чем разбираться с OOM через два месяца.

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

Как посчитать кардинальность в уже работающем Prometheus

Если Prometheus уже развёрнут, кардинальность можно и нужно замерить напрямую, не гадая.

Общее число активных рядов — самая простая метрика для мониторинга самого Prometheus:

prometheus_tsdb_head_series

Это число временных рядов в текущем head-блоке — то есть тех, что реально занимают память прямо сейчас. Полезно повесить на него алерт с порогом, а не смотреть только по факту падения.

Кардинальность по конкретной метрике — сколько рядов создаёт одно имя метрики:

count(count by (__name__, job) ({__name__="http_request_duration_seconds"}))

Или проще, если нужно просто число временных рядов у метрики:

count({__name__="http_request_duration_seconds"})

Топ метрик по кардинальности — какая метрика вносит больше всего рядов в общую сумму:

topk(10, count by (__name__)({__name__=~".+"}))

Этот запрос на живой системе с сотнями тысяч рядов сам по себе тяжёлый — лучше не гонять его слишком часто и не тогда, когда Prometheus и так близок к пределу памяти.

Встроенная страница диагностики — самый быстрый способ увидеть картину без PromQL, доступна по адресу http://<prometheus-host>:9090/tsdb-status (или через API /api/v1/status/tsdb). Показывает количество серий в head, топ метрик по числу серий (seriesCountByMetricName), топ лейблов по числу серий, которые они создают (labelValueCountByLabelName — именно здесь чаще всего обнаруживается проблемный user_id или pod), и топ по объёму, занимаемому значениями лейблов в индексе (memoryInBytesByLabelName). Это первое место, куда стоит смотреть при подозрении на взрыв кардинальности — обычно виновник виден сразу в топе.

Через API напрямую, если нужно забрать данные скриптом:

curl -s http://localhost:9090/api/v1/status/tsdb | jq '.data.seriesCountByMetricName[:10]'

Сколько памяти это реально стоит и как прикинуть требования по RAM

Точную формулу «столько-то байт на один ряд» дать нельзя — она зависит от версии Prometheus, длины имён лейблов, числа сэмплов в head и от того, насколько удачно сжимаются значения. Официальная документация проекта даёт лишь порядок величины — единицы килобайт на активный ряд в head, — и это стоит воспринимать именно как порядок величины, а не точное число для расчёта бюджета: на одном и том же числе рядов резидентная память может отличаться в разы в зависимости от профиля нагрузки (короткоживущие ряды при частом передеплое подов «сжигают» память быстрее, чем стабильный набор долгоживущих).

Практичнее измерить коэффициент на собственной системе: сопоставить текущее значение prometheus_tsdb_head_series с process_resident_memory_bytes того же процесса за тот же период. Разделив прирост RSS на прирост числа рядов при добавлении новой партии метрик, вы получите свой актуальный коэффициент и сможете экстраполировать прирост памяти на будущие релизы через свои данные, а не через число из чужого блог-поста.

Стоит также следить за скоростью появления и исчезновения рядов — churn rate. Метрика prometheus_tsdb_head_series_created_total в связке с prometheus_tsdb_head_series показывает, не «умирают» ли ряды слишком быстро (типичный признак — лейбл с идентификатором пода, который меняется при каждом деплое). Высокий churn не так сильно раздувает пиковое число активных рядов, как растущая без ограничения кардинальность, но раздувает индекс и WAL и точно так же ест память и диск.

И заложить запас: память Prometheus не растёт линейно вплоть до лимита с мгновенной остановкой — на подходе к пределу растёт задержка обработки запросов, GC становится агрессивнее, и система заметно деградирует ещё до фактического OOM. Поэтому при планировании сервера лучше ориентироваться на измеренный коэффициент «память на ряд» плюс запас 30-50% сверху, а не впритык к текущему потреблению — методика такого запаса на реальных цифрах разобрана в статье про подбор объёма RAM с запасом.

Стратегии снижения кардинальности

Когда проблема обнаружена, есть несколько работающих подходов — их обычно комбинируют, а не выбирают один.

Убрать лейбл с высокой кардинальностью из инструментации приложения. Самый надёжный способ — не собирать user_id или request_id в метрике вообще. Для разбора конкретного запроса или пользователя нужны логи и трейсы, а не метрики: exemplars в Prometheus позволяют привязать к точке гистограммы ссылку на конкретный trace_id, не делая trace_id лейблом самой метрики.

Отбросить лейбл на стороне scrape-конфига, если менять код приложения быстро нельзя:

scrape_configs:
  - job_name: 'app'
    metric_relabel_configs:
      - action: labeldrop
        regex: 'user_id|request_id'

metric_relabel_configs применяется уже после сбора, поэтому лишний трафик со сборщика до Prometheus всё равно идёт, но в TSDB эти ряды не попадают.

Отбросить всю метрику целиком, если она вообще не нужна в мониторинге:

    metric_relabel_configs:
      - source_labels: [__name__]
        regex: 'go_gc_duration_seconds.*'
        action: drop

Это особенно полезно для метрик рантайма (Go, JVM), которые экспортёры добавляют «по умолчанию» и которые редко смотрят руками, но которые вносят заметный вклад в кардинальность на десятках инстансов.

Ограничить число бакетов у гистограмм и не плодить их бездумно — гистограмма с 30 бакетами и парой дополнительных лейблов легко становится самой дорогой метрикой в системе. Часто достаточно 8-12 логарифмически распределённых бакетов вместо стандартного набора «на всякий случай».

Агрегировать до записи через recording rules, если нужна именно суммарная картина, а не разбивка по каждому инстансу:

groups:
  - name: aggregations
    rules:
      - record: job:http_request_duration_seconds:p99
        expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))

Recording rule не снижает кардинальность исходной метрики, но снижает нагрузку на дашборды и алерты, которые иначе гоняли бы тяжёлые запросы по сырым рядам каждый раз.

Использовать встроенные предохранители Prometheus как последний рубеж — не вместо чистой инструментации, а как страховку от будущей ошибки:

scrape_configs:
  - job_name: 'app'
    sample_limit: 10000
    label_limit: 30
    label_value_length_limit: 200

sample_limit обрывает скрейп таргета, если тот вернул больше указанного числа сэмплов за раз — жёстко, но предсказуемо: лучше потерять один скрейп с алертом в логе, чем не глядя утопить в памяти весь Prometheus.

Вынести высококардинальные, но нужные метрики в отдельный инстанс с коротким retention (часы или дни), а агрегированные метрики держать в основном Prometheus с длинным retention. Разделение «горячих» подробных данных и «холодных» агрегатов работает лучше, чем единый процесс с одинаковыми настройками хранения для всего.

Перейти на хранилище, спроектированное под высокую кардинальность, если задача принципиально требует много уникальных рядов (метрики на клиента в multi-tenant SaaS). VictoriaMetrics использует другую модель индексации и по конструкции отодвигает предел кардинальности дальше, чем классический Prometheus TSDB — подробнее о переходе в статье VictoriaMetrics вместо Prometheus. Это не универсальное решение — миграция сама стоит времени, поэтому сначала имеет смысл снизить кардинальность у источника, а смену хранилища рассматривать, когда снижать уже нечего.

Для базовой установки связки Prometheus и Grafana пригодится отдельный разбор настройки Prometheus и Grafana — там разобрана типовая конфигурация scrape-джобов, на которую можно накладывать описанные выше предохранители.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Сколько временных рядов Prometheus может держать на сервере с 8 ГБ RAM?

Однозначного числа нет — зависит от длины имён лейблов, churn rate и версии. Правильный путь — измерить собственный коэффициент «память на ряд» на своей системе (см. раздел про оценку памяти) и заложить запас 30-50% сверху текущего потребления.

Можно ли просто увеличить scrape_interval, чтобы снизить нагрузку на память?

Это снизит нагрузку на CPU и объём хранимых сэмплов на диске, но почти не повлияет на число активных рядов в head — а именно оно определяет основной расход памяти. Это лечит другую проблему, не кардинальность.

Что делать, если высокая кардинальность нужна объективно — например, метрики по каждому клиенту в SaaS?

Разделить данные: в основном Prometheus держать агрегаты (по тарифу, региону, типу клиента), а детальные метрики по клиентам — в отдельном инстансе с коротким retention или в хранилище под высокую кардинальность, куда данные попадают через remote_write.

Как понять, что кардинальность уже стала проблемой, а не просто выросла вместе с проектом?

Смотреть не на абсолютное число рядов, а на скорость его роста относительно роста бизнес-метрик (числа пользователей, серверов, подов). Если ряды растут быстрее, чем реальные сущности в системе — почти всегда виноват лейбл с уникальным идентификатором.

Гистограммы или summary — что безопаснее по кардинальности?

У summary нет лейбла le, поэтому она даёт меньше рядов при том же числе дополнительных лейблов. Но перцентили summary считаются на клиенте и не агрегируются корректно между инстансами — выбор это компромисс между кардинальностью и корректностью агрегации.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →