Prometheus 内存爆了:把 240 万 series 砍到 51 万的完整排查记录

🔑 关键词:Prometheus,高基数,series cardinality,Prometheus 内存优化,promtool tsdb analyze

📖 摘要:Prometheus 单实例 OOMKilled 之后,我用 /api/v1/status/tsdb 和 promtool tsdb analyze 把 240 万 active series 砍到 51 万的完整过程:三类高基数的具体成因、relabel 配置写法、分层保留的 recording rule,以及我对「换 TSDB 就能解决高基数」这个流行说法的反对意见。

去年 11 月一个周二凌晨两点四十,值班手机响了。Prometheus 主实例 OOMKilled,重启循环。这个实例 32Gi limit,跑了快两年没出过事。

图片

我打开 Grafana 上 Prometheus 监控自己的那个 dash,看 prometheus_tsdb_head_series,240 万。翻两个月前的截图,是 90 万。中间没人加过新业务监控,硬生生涨了 150 万。

当时的现场:WAL replay 一次要 6 分多钟,重启后还没恢复完就开始 OOM,死循环。那天最后是靠临时把 limit 提到 48Gi 抢回来的,这不是办法。

先说结论:90% 的高基数不是 Prometheus 的问题,是指标设计的问题。换 VictoriaMetrics、Mimir、Thanos 只能解决「存不下」,解决不了「存的是垃圾」。

第一步:先看清楚 series 到底长在哪

别急着重启、别急着加内存。Prometheus 自带一个 HTTP 接口就是干这个的,不用装任何额外组件:

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

返回三组数据,我一般先看 seriesCountByMetricName 找罪魁,再看 labelValueCountByLabelName 找是哪个 label 在作妖。

我们那次 top1 是 http_request_duration_seconds_bucket,87 万条 series。注意 _bucket 的 series 数是要乘 bucket 个数的,我们的 histogram 有 12 个 bucket,所以真正的 label 组合只有 7 万出头。这个坑我第一次排查的时候没反应过来,盯着 87 万懵了半天,还以为是哪里配错了。

图片

第二个接口更细,能看到单个 label 值的分布:

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

这个只能看前 N 个,量大了会很慢,别在生产高峰期跑。我们那会儿就是下午三点跑的,Prometheus 直接卡了半分钟没响应,被同事问了一句「你是不是在查 TSDB status」。

如果 Prometheus 已经重启不了了,直接从磁盘上跑离线分析更稳:

promtool tsdb analyze /prometheus

它会输出整个 block 里每个 label 的 cardinality 排行,包括 Highest cardinality labelsHighest cardinality metric names 两节。这个命令对线上零影响(只要你读的是已经落盘的 block,不是 head),我现在的习惯是每季度跑一次,写进例行巡检。

我们踩到的三类高基数

一、没归一化的 URL path

图片

这是最经典的。我们的 http_request_duration_seconds 带了个 path 标签,41 万个不同值。而整个后端 API 加起来不到 200 个 endpoint。

原因是有个前端把资源 ID 直接拼进了 URL:/api/v1/order/detail/8f3a2c91-...。每个订单一个 series,跑一天就是几十万条,而且这些 series 永远只有一次采样,第二天全变成死的,但还占着索引。

修法有两种。治标的是在 scrape 端用 metric_relabel_configs 挡掉:

- job_name: app
  metric_relabel_configs:
    - source_labels: [path]
      regex: '.*/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*'
      action: drop

治本的是改客户端,把 path 换成 route pattern(/api/v1/order/detail/:id)。我们在代码里包了一层 middleware 做这个,推了两个月才全量上线,中间那两个月就靠上面这条 regex 顶着。顺便吐槽一下,让业务团队改指标 label 这件事,比让他们改接口还难,因为指标这东西平时没人看,改起来没成就感。

二、cAdvisor 的 id 标签

kubelet 暴露的 container_* 系列指标带一个 id 标签,值是 /kubepods/burstable/pod<uid>/<container_id> 这么一长串。这个标签基本没人用,但它让每个容器的每条指标都变成独一无二的 series。

- job_name: kubelet
  metric_relabel_configs:
    - source_labels: [id]
      regex: '/kubepods/.*'
      action: drop

图片

我们只做这一条,series 掉了 18 万。注意别直接用 action: labeldropregex: id,有些 job 的 id 是有意义的,得按值匹配,不然你会把业务指标也一起干掉。

顺便说一句,__meta_kubernetes_pod_uid 这类 meta label 默认不会进 series,只有在 relabel_configs 里显式写成 target_label 才会。我见过有人为了追查容器重启,把它保留成 pod_uid,然后 Deployment 一天滚 20 次,series 直接翻 20 倍。这种「为了排查方便」加的标签,代价通常要到半年后内存告警的时候才浮出来。

三、没人看的 recording rule

prometheus_tsdb_head_series 的 label 分布时,我发现有 12 万条 series 来自一个叫 api_latency_p99_5m_by_tenant_route_region 的 recording rule。这个 rule 是三年前做多租户 SLA 报表时加的,报表下架了,rule 还在,每 15 秒算一次,算出来的东西没人查。

我们那次一口气删了 9 条僵尸 recording rule。光看总数是看不出这类问题的,得去 prometheus_tsdb_head_series 的 label 里按 __name__ 分组,或者直接翻 rules 文件跟 Grafana 面板对一遍——面板里引用不到的 rule,八成可以删。

说实话,这一步最费时间,纯人工,没有好工具。有的话请告诉我。

几种处理手段,我按性价比排个序

图片

  1. 客户端改指标设计。根治,但是要跨团队推,周期以月计。
  2. metric_relabel_configs 丢标签或丢 series。当天见效,缺点是配置分散在各个 job 里,半年后自己都看不懂谁加的、为什么加。
  3. recording rule 预聚合 + drop 原始指标。适合那种「只关心汇总值、不需要下钻」的场景,比如容量规划用的资源指标。
  4. 换 TSDB(VictoriaMetrics / Mimir / Thanos)。这个我要多说两句。

很多团队的第一反应是换存储。我们当时也算过账:240 万 series,30 天保留,本地盘 1.4TB。换到 Mimir + 对象存储,存储成本大概从每月 400 多块(如果是云盘)降到几十块,看着很香。

但这里有个陷阱——Mimir、VM 这些之所以能扛住高基数,是因为它们把索引做得更省、把老的 series 更快地踢进冷存储。它们不改变「每条 series 都是一次写入、一次索引、一次查询扫描」这个本质。你把 240 万条垃圾 series 灌进 Mimir,查询该慢还是慢,rate() 该扫多少还是扫多少,只是账单好看了一点,还多了一堆组件要维护。

我的判断是:series 数在 200 万以下,先把指标设计收拾干净,比换存储划算得多;真到了 500 万以上或者要多集群联邦,再考虑换。这个数字不是拍脑袋,是我们单机 Prometheus(8C32G,SSD)的实测拐点,过了 200 万之后查询 p99 从 1.2s 涨到 4s 以上,涨得很快。

代价:我丢过一次东西,记到现在

砍指标这事,最怕的是砍掉自己以为不重要的东西。

我们当时把一个内部服务的 gRPC method 级指标给 drop 了。理由很充分:2000 多个 method,大部分 QPS 是 0,纯占坑。三个月后出了个 P2,只影响 BatchSubmitV2 这一个冷门 method,QPS 平时 0.02。我们花了 40 分钟从日志里拼出结论。如果那个 method 的指标还在,rate() 一看就出来了,可能 5 分钟。

所以现在我的做法是分层,不做全量 drop。高频的保留原始 label,低频的折叠成 other

图片

groups:
  - name: cardinality-tiering
    interval: 30s
    rules:
      - record: grpc:handled:top_methods
        expr: |
          sum by (service, method, code) (grpc_server_handled_total)
          and on (service, method)
          topk(50, sum by (service, method) (rate(grpc_server_handled_total[5m])))
      - record: grpc:handled:other
        expr: |
          sum by (service, code) (grpc_server_handled_total)
          - sum by (service, code) (grpc:handled:top_methods)

上面这段我改了三版才跑对,and on (...) 那块特别容易踩 label 对不齐的坑,你们要是照抄,先拿一个小 job 试试,看输出行数对不对再说。别直接上生产,这个我吃过亏。

最后的结果

项目 优化前 优化后
active series 2,410,000 510,000
head block 内存 18.3 GB 4.1 GB
30 天本地磁盘 1.4 TB 380 GB
WAL replay 时间 6 分 10 秒 52 秒
scrape p99 8.2s 1.9s

内存 limit 从 48Gi 降回了 16Gi,稳定跑了 5 个月。

回头看,最有价值的不是那张表,是意识到指标也是一种要还的技术债。加一个 label 的人当天就能看到图,付账的是半年后凌晨被叫醒的人。

我现在给团队定的规矩就三条:任何 _bucket 类型的指标,label 组合数不超过 5000;任何带 iduiduuidtrace 字样的 label,必须在 relabel 里处理掉;recording rule 每季度对一次 Grafana 面板,没人查的删。

第三条执行得最差,我承认。

🏷️ 标签: