Что такое cardinality
В Prometheus каждая уникальная комбинация labels = отдельный time series:
http_requests_total{method="GET", status="200"} 12345http_requests_total{method="GET", status="500"} 67http_requests_total{method="POST", status="200"} 543http_requests_total{method="POST", status="500"} 12Это 4 series. Cardinality этой метрики = 2 (methods) × 2 (statuses) = 4.
Добавили label region с 5 значениями: 4 × 5 = 20 series.
Добавили pod с 100 значениями: 20 × 100 = 2000 series.
Добавили user_id с 1M значениями: 2000 × 1M = 2 billion.
Это multiplicative. Один высоко-кардинальный label убивает.
Стоимость
Prometheus держит каждый active series в RAM:
- head chunk (последние 2 часа), ~2 KB raw + index
- postings index, label → series mapping
- WAL, write-ahead log
Эмпирическое правило: ~3 KB RAM per active series (Prometheus defaults).
| Series count | RAM | Хост |
|---|---|---|
| 100K | 300 MB | small |
| 1M | 3 GB | medium |
| 10M | 30 GB | large + tuning |
| 50M | 150 GB | OOM, нужен Thanos/Mimir/VictoriaMetrics |
| 100M+ | impossible в одном Prom |
При OOM Prometheus рестартует, replay WAL (10-30 min), снова OOM. Цикл, метрик нет.
Топ-источники cardinality
- user_id, customer_id, request_id, trace_id, session_id в metric labels, никогда. Это в логи и traces.
- Динамические URL paths:
/api/users/12345/orders/678каждый ID = новый label value.- Решение: route normalization. Express.js: используй
req.route.path(/api/users/:id/orders/:oid), неreq.url.
- Решение: route normalization. Express.js: используй
- Container/pod IDs:
pod=app-7f8b9c-abc123уникален на каждый rollout. Через retention рассасывается, но накапливается.- Решение: drop
podlabel если не нужен в queries, оставьdeploymentилиapp.
- Решение: drop
- Версия в каждой метрике:
version=1.4.2-build-12345→ каждый release добавляет cardinality.- Решение: версия в одной info-метрике
(
build_info{version="..."}= 1), join при необходимости.
- Решение: версия в одной info-метрике
(
- Histograms с многими buckets: 50 buckets × 5 endpoints ×
10 statuses = 2500 series только на один histogram.
- Решение: меньше buckets, [[metric-types|native histograms]].
- JVM/Go runtime метрики per-class, некоторые exporters
детализируют до class name.
- Решение:
metric_relabeldrop по regex.
- Решение:
- kube-state-metrics labels из аннотаций,
app.kubernetes.io/versionкаждый раз новый.- Решение: kube-state-metrics flag
--metric-labels-allowlist.
- Решение: kube-state-metrics flag
Диагностика, кто виноват
Топ метрик по cardinality
topk(20, count by (__name__)({__name__=~".+"}))Покажет 20 имён метрик с самым большим числом series. Кандидаты на cleanup.
Топ labels внутри метрики
count(my_high_card_metric) by (suspect_label)
Сколько series с каждым значением подозреваемого label. Если значений тысячи, он source проблемы.
Через tsdb API
curl -s 'http://prom:9090/api/v1/status/tsdb' | jq '.data | {headStats: .headStats,
seriesCountByMetricName: (.seriesCountByMetricName[:10]),
labelValueCountByLabelName: (.labelValueCountByLabelName[:10])
}'
Endpoint /api/v1/status/tsdb (Prom 2.14+), встроенная статистика.
Top metrics, top labels, top label values.
promtool tsdb analyze
promtool tsdb analyze /var/lib/prometheus
Анализ блоков на диске. Top label pairs, top metrics, churn rate. Запускать на остановленном Prometheus или на snapshot.
Churn rate, невидимая cardinality
«Active series» = живые сейчас. Но churn, как быстро series appear/disappear, тоже жрёт ресурсы:
- Каждый новый series → write в index
- Старые остаются в WAL, в blocks, retention
Pod restart → new pod= label value → новый series. Высокий churn
pod-id-based labels, медленные queries (range много series).
Prometheus 2.50+ метрика:
prometheus_tsdb_head_series_created_total
Rate >10K/час = высокий churn, копай.
Чинить, стратегии
1. Drop label в коде
Самое чистое. Пример Go:
// ПЛОХО
requestCounter := promauto.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total"}, []string{"method", "endpoint", "user_id"}, // ← user_id убийца)
// ХОРОШО
requestCounter := promauto.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total"}, []string{"method", "endpoint"},)
2. Drop через metric_relabel
Когда не контролируешь exporter:
scrape_configs:
- job_name: app
metric_relabel_configs:
- regex: 'user_id'
action: labeldrop
- source_labels: [__name__]
regex: 'go_gc_pauses_seconds_bucket'
action: drop
Применяется на стороне Prometheus до записи в TSDB.
3. Normalize URL paths
Сервис Express.js:
app.use((req, res, next) => { res.on('finish', () => {const route = req.route?.path || 'unmatched'; // не req.url
requestCounter.labels(req.method, route, res.statusCode).inc();
});
next();
});
4. Aggregation через recording rules
Если нужны и high-card метрики (для ad-hoc), и low-card (для alerting), храни обе:
# Raw остаётся короткое retention
# Recording rules собирают агрегаты с длинным retention
groups:
- name: aggregations
rules:
- record: app:http_requests:rate5m_low_card
expr: sum without(pod, instance, user_id)(rate(http_requests_total[5m]))
5. Move to VictoriaMetrics
VictoriaMetrics держит series в 3-7× меньше RAM, лучше масштабируется.
Drop-in replacement через remote_write. Не решает cardinality
принципиально, но даёт запас.
Cluster VM: 100M+ series без Thanos-комплексности.
Active vs total series
- Active = scraped в последние 5 minutes (head chunk)
- Total в TSDB = active + retention 15d
Active определяет RAM (head). Total, disk.
prometheus_tsdb_head_series # active
prometheus_tsdb_blocks_loaded * <avg block series> # rough total
Limits
Prometheus 2.x не имеет hard cardinality limit (но есть soft). Включай server-side limits:
scrape_configs:
- job_name: app
sample_limit: 50000 # max samples per scrape
label_limit: 30 # max labels per series
label_value_length_limit: 200
target_label: max_targets # limit per relabel
Если scrape exceeds, target помечается как error, метрики не записываются. Защищает от runaway exporter.
Cortex/Mimir/VM имеют per-tenant limits на active series.
Cardinality в OpenTelemetry/Loki/Tempo
Та же проблема:
- OTel metrics, те же лимиты, attributes = labels
- Loki, labels = streams. >10K streams в tenant = деградация.
structured_metadataдля high-card identifiers (loki-grafana-logging). - Tempo, search index по resource attributes. Не клади request_id в resource.
Принцип универсальный: labels, низкая кардинальность, payload свободно.
Real-world cases
- Cloudflare incident 2022: один сервис добавил
customer_idlabel в metric → 5M series за час, Prometheus OOM, мониторинг down 6 часов. - GitLab incident 2018: kube-state-metrics всегда читал annotations, каждый CI job (миллионы) → cardinality blowup.
- Honeycomb internal: история о labelmap копированиях k8s аннотаций.
Pattern: «а давай ещё один label добавим, удобнее» → через 3 месяца не запускается.
Когда что-то пошло не так
- Prom OOM-цикл,
topk(20, count by (__name__)({}))после рестарта (быстрее чем replay WAL). Найди и drop черезmetric_relabel_configsдля cooldown. /targetsпоказывает "sample limit exceeded",sample_limitпревышен. Подними или (лучше) почини exporter.- Slow queries (>30s), много series в селекторе. Добавь больше
selectors, узжай range.
topk(... count by (label)(metric))найдёт жирные labels. - Запросы возвращают subset, Prom при OOM удаляет старые series
из head, query даёт пустоту. Чек
prometheus_tsdb_head_truncations_total. - VictoriaMetrics 503 на push, превышены
-storage.maxLabelsPerTimeseriesили-storage.maxLabelValueLen. Проверь VM logs. - Cardinality OK сразу после рестарта, но через час OOM.
Это churn, series растут со временем. Проверь rate
prometheus_tsdb_head_series_created_total.
Cheat sheet
# Топ-20 метрик по числу series (главная диагностика)
topk(20, count by (__name__)({__name__=~".+"}))# Сколько values у каждого label
count(http_requests_total) by (label_name)
# Churn, series созданы за час
increase(prometheus_tsdb_head_series_created_total[1h])
# Active series count
prometheus_tsdb_head_series
# Memory от series (rough)
prometheus_tsdb_head_series * 3000 # bytes