背景#

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 不是空的。 kubeletkubeApiServer 这两个 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 静默,等窗口滚完再看。

手段一览:几类调优各管什么#

上面是主线。顺着把这次碰过和评估过的几类手段归个位。先把采集链路画开:

flowchart LR T["target 的 /metrics"] -->|"relabel_configs
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 本身。

把这次的动作压成一个顺序:

  1. 先量再动。 status/tsdb 看基数分布,prometheus_tsdb_head_series 和 ingest rate 看趋势,prometheus_rule_group_last_duration_seconds 对着组 interval 看规则欠不欠账,runtimeinfo 看运行时真正生效的值。
  2. 先砍产生量的东西。 采集面和规则评估是因;limit、retention 是果那一侧的。
  3. 砍之前先证明可以砍。 规则层、看板层各扫一遍,改完在抓取层用 scrape_samples_scrapedscrape_samples_post_metric_relabeling 验证,三个数字对得上才算数。
  4. 内存和磁盘分开管。 采集面管内存,retention / retention.size 管磁盘,互相不能替代。
  5. limit 最后动,只回到验证过的值。

有代价。原始 bucket 现在 ad-hoc 查不到了,Grafana Explore 里也没有;面板不受影响是因为走 recording rule 输出,真要临时看原始数据只能把 drop 摘掉重抓。另外几件事得记在本子上:升 chart 时要重新比对两个 key 的默认 metricRelabelings;重新启用那三条 defaultRules 之前要回来复核 apiserver 侧的 drop;还有 k3s 哪天改了 apiserver/kubelet 的进程模型——这次优化的整个前提就是它们同进程。

参考资料#

相关文章#