某次生产核心监控集群突然全线熔断,Prometheus 节点 Load Average 飙升至 100+,Pod 陷入持续的 OOMKilled 死亡循环。排查确认,业务研发在一项 HTTP 统计指标中错误注入了 trace_id 和 user_id 作为 Label,导致时间序列(Time Series)基数瞬间暴增千万级别。最终通过介入 metric_relabel_configs 强制丢弃高基数 Label,并物理清理内存映射的 Head 块与臃肿的 WAL(Write-Ahead Log)才得以恢复。
结论先放在这里:监控指标(Metrics)绝对不是日志(Logs),把无边界的动态变量作为 Label 写入 Prometheus,是对 TSDB 存储引擎最无知的谋杀。
案发现场:失控的 OOM 与堵死的 IO
排查过程中,告警通道首先报出 Prometheus target 掉线,紧接着是 Kubernetes 节点资源耗尽告警。登录宿主机,dmesg 日志非常直白:
[52143.123456] prometheus invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=-998
[52143.123458] Memory cgroup out of memory: Killed process 10245 (prometheus) total-vm:42949672960kB, anon-rss:34359738368kB, file-rss:0kB, shmem-rss:0kB
32GB 内存的 Pod 被硬生生撑爆。由于 Pod 重启策略为 Always,Prometheus 尝试重新启动,但在恢复 WAL 阶段再次卡死,磁盘 IOPS 被打满,日志停留在:
level=info ts=... caller=head.go:760 component=tsdb msg="Replaying WAL, this may take a while"
level=info ts=... caller=head.go:812 component=tsdb msg="WAL segment loaded" segment=1023 maxSegment=1045
重放速度极慢,且内存水位在重放过程中成级数增长,最终在启动完成前再次 OOM。这属于典型的高基数(High Cardinality)雪崩。
罪魁祸首:TSDB 的倒排索引与高基数之殇
在处理这种无法启动的僵尸实例时,直接查 PromQL 是行不通的。直接把挂载的 PV 临时挂给一个 Debug 容器,掏出 promtool 对 TSDB 数据目录进行离线分析:
promtool tsdb analyze /prometheus/data/
输出结果直接锁定了元凶:
Block ID: ...
...
Label names with highest number of values:
1. trace_id: 12045678
2. user_id: 8543210
...
Metrics with highest number of series:
1. http_requests_total: 12045678
一个原本只有几十个 endpoint 和 method 组合的 http_requests_total 指标,因为加上了 trace_id,硬生生裂变出了 1200 万个时间序列。
为什么加个 Label 能把 32G 内存干爆?这要从 Prometheus TSDB 的底层机制说起:
Prometheus TSDB 的设计前提是 Label 的组合是有限且收敛的。当前正在写入的数据存放在内存中的 Head Block,Head Block 默认保留最多 3 小时的数据。为了实现快速的多维查询,TSDB 维护了倒排索引(Inverted Index)。
每一条唯一的 Label 组合(例如 http_requests_total{method="GET", trace_id="abc"})都会被当作一个全新的 Series。
-
内存放大:在 Head 块中,每个活跃的 Series 都会占用几百字节到几 KB 不等的内存(包括结构体、索引缓存、Chunk 引用等)。一千万个 Series,光是基础的结构体开销就能轻易吃掉十几 GB 内存。
-
WAL 风暴:每次出现一个新的 Series,TSDB 必须在 WAL 中写入一条 Series Record 以保证宕机不丢失。高基数意味着海量的新 Series 不断产生,WAL 写入量呈指数级上升,直接将磁盘 IO 打到饱和。
-
Compaction 瘫痪:当 Head 块数据落盘生成持久化 Block 时,后台的 Compaction 机制需要对成千万的 Series 进行合并和索引重构,这会耗尽 CPU,并导致 Compaction 积压。
业务将 trace_id 塞进 Label,等于把 O(N) 复杂度的存储系统当成了 O(1) 的 Key-Value 库在用。
止血与修复实战
既然抓到了凶手,修复逻辑就是:阻断毒流量输入,清理已中毒的数据。
第一步:通过 relabel 丢弃高基数 Label
在不改动业务代码(或业务还没来得及回滚)的情况下,运维必须在 Prometheus 抓取阶段直接阉割掉这个恶意的 Label。在 prometheus.yml 中修改对应 Job 的配置:
scrape_configs:
- job_name: 'business_app'
# 注意:必须使用 metric_relabel_configs,这作用于抓取后、落盘前的阶段
metric_relabel_configs:
- source_labels: [trace_id]
regex: '.*'
action: labeldrop
- source_labels: [user_id]
regex: '.*'
action: labeldrop
注:如果是客户端直接暴露了几千万行的 /metrics,那应用本身大概率也会因为构建 metrics 字符串而 OOM。此时需要业务立即回滚。
第二步:处理无法启动的 TSDB 此时由于旧的脏数据还卡在 WAL 里,Prometheus 依然起不来。最粗暴有效的方法是放弃最近几小时的 Head 块数据(监控容忍短暂的断点,但不容忍系统不可用)。
进入数据目录,直接清理 WAL 和 chunk_head:
cd /prometheus/data/
# 备份后删除(如果在乎现场的话)
rm -rf wal/*
rm -rf chunks_head/*
清理后拉起 Prometheus,内存占用瞬间回落到正常的几 GB 水平,Load Average 恢复正常,集群起死回生。
排查清单与同类问题速查
- 内存/OOM 快速定位:
-
永远不要猜测,直接用
promtool tsdb analyze分析本地数据块,查看Metrics with highest number of series排名。 -
区分 Relabel 阶段:
relabel_configs:作用于 Target 发现阶段,用于过滤抓取目标(改 IP、改端口、丢弃整个 Endpoint)。-
metric_relabel_configs:作用于抓取后、写入 TSDB 前,用于修改或过滤具体的 Metrics 和 Label(丢弃高基数 Label 必用)。 -
监控自身的监控:
-
必须为 Prometheus 配置
prometheus_tsdb_head_series和prometheus_target_scrapes_exceeded_sample_limit_total的告警。当 Head 序列数突增时,能在 OOM 发生前拦截。 -
高基数需求替代方案:
-
业务确实需要通过 Metrics 关联 TraceID 怎么办?使用 OpenMetrics 标准的 Exemplars。Exemplars 附着在具体的观测值上,不会被纳入倒排索引,不影响基数,完美解决 Metrics 到 Trace 的联动诉求。
-
防御性配置限制:
- 在
scrape_configs中强制加上sample_limit、label_limit和label_value_length_limit。宁可让超过阈值的抓取失败(报错sample limit exceeded),也绝不让垃圾数据撑爆整个集群。