一次 Prometheus 内存瘦身:把重复的 series 在入库前丢掉
目录
背景#
homelab 这套监控是 kube-prometheus-stack(chart 87.6.0,跑的是 Prometheus 3.13.0),Prometheus 落在一台 12G 内存的笔记本 VM 上,是这台机器上最大的单一内存消耗者。
2026-08-16 起,ContainerMemoryNearLimit 连着两天报 Prometheus:7 天窗口的内存峰值 2715Mi / 3072Mi = 88%。我当时的处理是把 memory limit 从 3Gi 抬到 4Gi,抬完就发现这条路没得走了——宿主机 available 只剩 0.6G 左右,物理内存见底,再抬也没地方抬。08-18 改成砍产生内存的东西本身,砍完当天把 limit 收回了 3Gi。
今天早上(2026-08-19 07:39)从集群里拉的读数:
| 读数 | 08-18 改动前 | 现在 |
|---|---|---|
| active series | 234,532 | 113,262 |
| ingest rate | 7,562 samples/s | 4,063 samples/s |
| cgroup 内存峰值 | 2,002Mi | 758Mi |
| memory limit | 4Gi | 3Gi |
series 那一栏现在比刚改完那会儿(109,566)略高,是这一天里新增采集的自然增长。今天早上现取的 container_memory_working_set_bytes 是 799Mi,占 3Gi 上限的 26%。
借这次把「内存告警该动哪里」整理一遍。我踩的坑基本都是同一种:以为某个配置项管 A,其实它管的是 B。
active series 才是内存大头#
先说结论:Prometheus 的稳态内存由 head 里的 active series 决定,不是由你保留了多少天数据决定。官方文档对存储的描述是「当前接收样本的 block 保存在内存里,且不会完整落盘」(The current block for incoming samples is kept in memory and is not fully persisted)。
同一份文档还写了一句话,是这次最想强调的:
要降低 ingest rate,可以少抓些 series,也可以拉长 scrape interval;但砍 series 通常更有效,因为同一条 series 内部的样本是压缩存储的。
这点在内存上尤其明显:拉长 interval 完全不改变 active series 的条数,head 该占多少还占多少。所以内存告警时最自然的两个反应——「把 retention 从 7d 砍到 3d」「拉长 scrape interval」——其实都救不了内存:前者管磁盘,后者不影响 head 里的活 series。
丢什么:k3s 把同一批指标吐了两遍#
我这次要砍的东西成因挺特殊:k3s 把 apiserver 和 kubelet 跑在同一个进程里,kubelet 的 /metrics 端点会把整个进程的 registry 全吐出来,里面带着全套 apiserver_* / etcd_*。它们和 job="apiserver" 抓到的是同一批指标的第二份副本——标签不同,所以在 TSDB 里是两批独立的 series。
08-18 当时的实测分布:
| 位置 | series | 占比 |
|---|---|---|
job="kubelet" 里的 apiserver_* |
62,616 | 26.7% |
job="kubelet" 里的 etcd_* |
11,858 | 5.1% |
job="apiserver" 侧无人使用的 7 族 histogram |
30,035 | 12.8% |
| 合计可丢 | 104,509 | 44.6% |
近一半的 series 要么是重复的,要么没人看,可以直接在入库前丢掉。
凭什么能丢:三层核查#
这是我觉得这次最值得写下来的部分。指标能不能丢,靠的不是眼熟程度,我这次是分三层核的。
规则层。 把所有 loaded rule 的表达式拉出来逐条搜指标名。结果是引用这两族的规则全部写死了 job="apiserver",没有一条用 job="kubelet"。唯一还在用的是 cluster_quantile:apiserver_request_sli_duration_seconds:histogram_quantile 这两条 recording rule——所以 sli 那族的 apiserver 副本必须留,kubelet 副本可以丢。
看板层。 扫了全部 42 个 grafana_dashboard ConfigMap,没有任何 panel 用 job="kubelet" 查这两族;实际上连显式带 job 的查询都没有,面板消费的是 recording rule 的输出,不碰原始 bucket。
这一层还顺手推翻了我自己之前写在 values 注释里的一句话。我原以为「apiserver 看板依赖 apiserver_request_duration_seconds_bucket」,核下来不是:这个最大单项(28,016 条)无 rule 无 panel 使用,真正被用的是 sli 版本。要不是逐条扫了一遍,我大概率会反过来留错的、丢对的。
抓取层。 前两层算出来的是「应该少多少」,改完还得看实际少了多少。这两个查询直接对照:
topk(10, sum by (job) (scrape_samples_scraped))
topk(10, sum by (job) (scrape_samples_post_metric_relabeling))
今天早上的读数:
| job | 抓到 | 入库 |
|---|---|---|
| kubelet | 99,013 | 18,228 |
| apiserver | 83,775 | 27,322 |
两者的差就是被 drop 掉的量。改动当天这两个 job 的入库量从 92,702 / 57,357 掉到 18,228 / 27,322,每轮少留 104,509 个样本,和前两层按 rule 和 panel 算出来的 44.6% 分毫不差——对得上才算证明生效了。今天早上的抓取量比当时又涨了一些,所以现在实际丢掉的比 104,509 更多。
两个坑#
chart 默认的 metricRelabelings 不是空的。 kubelet 和 kubeApiServer 这两个 key 在 kube-prometheus-stack 里各自自带一条 drop(分别是 csi/storage 分桶和一长串 le 分桶)。YAML 里 list 是整体覆盖,直接写自己的那条会把默认值挤掉——series 不降反升,而且不会有任何报错。所以我的写法是把默认值原样抄进来再往后追加,并注明抄自哪个 chart 版本:
kubelet:
serviceMonitor:
metricRelabelings:
# ↓ chart 87.6.0 默认值,原样保留
- action: drop
sourceLabels: [__name__, le]
regex: "(csi_operations|storage_operation_duration)_seconds_bucket;(0.25|2.5|15|25|120|600)(\\.0)?"
# ↓ 本仓库追加:整族丢掉 kubelet 端点上的 apiserver/etcd 副本
- action: drop
sourceLabels: [__name__]
regex: "(apiserver|etcd)_.*"
升 chart 的时候要回头比对默认值有没有变。默认值变了而你没跟,等于悄悄回退了 chart 自己的优化。
relabel 的正则是两端锚定的。 官方原文是 The regex is anchored on both ends. To un-anchor the regex, use .*<regex>.*。这一点在这次里正好帮了忙:我要丢 apiserver_request_duration_seconds_bucket,却必须留下 apiserver_request_sli_duration_seconds_bucket(那两条 recording rule 还在用)。因为全锚定,前者的正则不会误伤后者。我拿 13 个指标名跑了一遍回归确认,改完 status/tsdb 里 sli 那族仍有 5,040 条,还是我这台上基数最大的指标。
反过来说,如果哪天不小心写成 apiserver_request.*_bucket,就会连 sli 一起吃掉,而 recording rule 只会安静地查不到数据。
顺带记一下另一种写法:黑名单丢不完的时候用白名单。我这套里 clustermesh 的 ServiceMonitor 走的就是 action: keep + 一条 regex,只留 5 个用得上的指标,其余整个端点全不要。目标本身指标很多、你只要几个的时候,keep 比 drop 好写得多。
收 limit:只回到验证过的值#
把 limit 从 4Gi 收回 3Gi 之前,我核对了一个依据:3Gi 这个值在 234,532 series 的更重负载下已经跑过(7d 峰值 2715Mi = 88%,从未 OOM),现在负载严格更小,回到 3Gi 只会比当时更宽裕。这是回到一个已经验证过的值,不是押一个没验过的新值。
这个区别是因为同一天我在另一个服务上按空载采样收 limit,直接把它打成了 OOM 崩循环。同样是「收 limit」,一个有历史读数背书,一个只有当下的瞬时值,风险性质完全不同。
另外别急着看结果:max_over_time[7d] 在一周内仍记着砍 series 之前的峰值,此刻的低读数是假象;head series 也要等 head compaction(约 2h)才会掉下来。我为此挂了一条 7 天期的 Alertmanager 静默,等窗口滚完再看。
手段一览:几类调优各管什么#
上面是主线。顺着把这次碰过和评估过的几类手段归个位。先把采集链路画开:
target_limit"| S["抓取"] S -->|"metric_relabel_configs
sample_limit / label_limit"| H["head block(内存)"] H -->|"compaction"| B["block(磁盘)"] B -->|"retention time / size"| X["删除"] H --> Q["查询与规则评估"] B --> Q
| 手段 | 管什么 | 不管什么 | 一句关键坑 |
|---|---|---|---|
砍采集面(metric_relabel_configs) |
head 内存、磁盘、查询代价 | —— | 见主线 |
sample_limit 一族 |
拦住基数爆炸 | 已入库数据的成本 | 它是熔断不是裁剪:超了整次抓取判失败 |
| 缩短 retention / retentionSize | 磁盘 | head 里的 active series 不变 | 配置项热加载、不重启 pod |
| 规则降频 / 关闭 | CPU、评估期内存峰值 | active series(除非规则本身产出 series) | 规则的时间窗口不能超过 retention,超了静默截断 |
| 容器 memory limit | OOM 门槛、GC 频率 | 产生内存的东西一点没少 | Prometheus 3 会自动按容器 limit 设 GOMEMLIMIT,limit 直接定了 GC 目标:贴太近往往不是 OOM,是查询慢 |
最容易搞混的是 retention 那一行。「内存告警了,那把 retention 从 7d 砍到 3d」是很自然的第一反应,我当初也把它列进了备选,但它换来的是磁盘——head 里的 active series 一条都没少。
小结#
这次真正省下内存的只有一件事:把 k3s 单进程重复暴露出来的那批 series 在入库前丢掉。active series 从 234,532 降到十一万出头,cgroup 内存峰值从 2,002Mi 掉到 758Mi,今天早上现取的 working set 是 799Mi / 3Gi。其余几类手段各有各的位置,但不能替代砍 series 本身。
把这次的动作压成一个顺序:
- 先量再动。
status/tsdb看基数分布,prometheus_tsdb_head_series和 ingest rate 看趋势,prometheus_rule_group_last_duration_seconds对着组 interval 看规则欠不欠账,runtimeinfo看运行时真正生效的值。 - 先砍产生量的东西。 采集面和规则评估是因;limit、retention 是果那一侧的。
- 砍之前先证明可以砍。 规则层、看板层各扫一遍,改完在抓取层用
scrape_samples_scraped对scrape_samples_post_metric_relabeling验证,三个数字对得上才算数。 - 内存和磁盘分开管。 采集面管内存,retention / retention.size 管磁盘,互相不能替代。
- limit 最后动,只回到验证过的值。
有代价。原始 bucket 现在 ad-hoc 查不到了,Grafana Explore 里也没有;面板不受影响是因为走 recording rule 输出,真要临时看原始数据只能把 drop 摘掉重抓。另外几件事得记在本子上:升 chart 时要重新比对两个 key 的默认 metricRelabelings;重新启用那三条 defaultRules 之前要回来复核 apiserver 侧的 drop;还有 k3s 哪天改了 apiserver/kubelet 的进程模型——这次优化的整个前提就是它们同进程。
参考资料#
- Prometheus Storage —— head block 在内存里、磁盘公式、「砍 series 比拉长 interval 更有效」
- Prometheus Configuration ——
metric_relabel_configs、sample_limit一族、storage.tsdb.retention的配置项与 flag 废弃说明、relabel 正则的两端锚定 - Prometheus HTTP API ——
/api/v1/status/tsdb与/api/v1/status/runtimeinfo - Prometheus 3.0 migration guide ——
GOMEMLIMIT/GOMAXPROCS自动设置