Сколько метрик потянет Prometheus: считаем кардинальность до взрыва памяти
Prometheus обычно падает не потому, что в него добавили ещё одну метрику. Он падает, потому что кто-то добавил в существующую метрику лейбл вроде user_id или request_id, и через пару недель число временных рядов в памяти выросло в сотни раз незаметно для всех, пока процесс не упёрся в лимит RAM и не лёг по OOM. Разберём, что такое кардинальность на самом деле, как её посчитать заранее и в уже работающей системе, и что делать, чтобы она не выросла снова.
Содержание
- Кардинальность, а не число метрик — вот что считает Prometheus
- Как рождается взрыв: типичная ошибка с метками
- Как оценить кардинальность заранее, до продакшена
- Как посчитать кардинальность в уже работающем Prometheus
- Сколько памяти это реально стоит и как прикинуть требования по RAM
- Стратегии снижения кардинальности
Кардинальность, а не число метрик — вот что считает 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, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак оценить кардинальность заранее, до продакшена
Прежде чем метрика попадёт в код, стоит задать себе три вопроса про каждый лейбл:
- Конечно ли множество значений и известна ли его верхняя граница на старте проекта.
method— да (GET, POST, PUT, DELETE, ещё пара).status_code— да, по факту десятки значений.user_id— нет, растёт вместе с базой пользователей. - Растёт ли количество значений со временем без переинициализации. Имя пода в Kubernetes без стабильного идентификатора — растёт при каждом передеплое. Название окружения (
prod,staging) — не растёт. - Действительно ли нужна разбивка по этому лейблу в метриках, или это задача для логов/трейсов. Идентификатор конкретного запроса или пользователя — это данные для трассировки (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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →