ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Prometheus在K8s中的资源限制实战:OOM排查与优化

Prometheus在K8s中的资源限制实战:OOM排查与优化

最近在维护模拟项目X的生产K8s集群时,又被Prometheus的资源问题折腾了一轮。事情的起因并不复杂:监控组件连续几天在凌晨出现内存超限重启,Grafana上的指标线齐刷刷断档,业务同事盯着告警面板一脸疑惑。我第一反应是Prometheus自身有bug,但排查到最后发现,真正的诱因是我在配置资源限制时对K8s的CPU和内存语义理解得不够透彻——Prometheus不是业务服务,它的资源消耗模式跟常规应用差别很大,用一套通用配置去套,迟早会翻车。

这篇文章想把我在K8s环境里落地Prometheus时,关于资源限制的方案、估算方法、踩坑记录和经验沉淀完整整理一遍。内容包含Prometheus在K8s里的资源画像分析、requests/limits的核心机制、LimitRange和ResourceQuota的实战配置、一次典型的OOM排查全过程,以及长期运维中的调优和避坑经验。无论你是刚接触K8s监控的新手,还是已经被Prometheus内存问题折磨得想骂人的老兵,这篇都值得收藏。

1. Prometheus在K8s里的资源画像,先把“谁吃资源”搞清楚

1.1 部署形态决定资源分布:Operator全家桶 vs 精简单容器

很多人一开始接触K8s上的Prometheus,习惯直接上Operator一套全家桶。Prometheus Operator会在监控Namespace里拉起一堆组件:Prometheus主服务、Alertmanager、Grafana、kube-state-metrics、node-exporter,以及Prometheus Operator本身和两条用于热更新的Sidecar容器。这样部署的好处很明显,ServiceMonitor、PodMonitor这些CRD能让服务发现和抓取配置自动化,升级维护都省事。坏处是资源占用从一开始就是“全家桶”级别,而且每个组件都有自己独立的requests和limits需求,调配起来如果没全局视角,很容易顾此失彼。

我早期也在某公司干过这件事:为了图省事,直接用Operator的默认配置部署,结果Prometheus主容器requests 100m/100Mi,limits没写,kube-state-metrics的requests小得可怜。集群负载一上来,最先被驱逐的就是这一堆监控Pod。后来改成手动构建精简架构,只保留Prometheus主进程加一个config-reloader Sidecar,配合外部Alertmanager和Grafana,基础监控的资源面窄了很多,排障也简单了。需要说明的是,这不是说Operator不好,而是在选定部署方式时,你必须先回答一个问题:你愿意为“自动化”付出多少资源成本?

两种方式的资源差异其实可以用一张表体现:

部署方式主要组件Prometheus主容器资源需求运维复杂度资源浪费风险
Operator全家桶Prometheus + Alertmanager + Grafana + kube-state-metrics + node-exporter + operator + reloader相对较高,依赖CRD配置中高,组件多,依赖关系复杂较高,默认配置容易堆资源
精简单容器Prometheus主进程 + config-reloader较低,可控性最强较低,配置全在ConfigMap里低,所有资源都花在采集上

我自己现在更多是折中方案:采集、服务发现用Operator的CRD,但告警和可视化拆出去独立部署。这样Prometheus主实例的资源需求是相对稳定的,不至于被全家桶里某个组件的默认值带偏。如果你遇到资源问题时连“到底哪个组件在吃内存”都说不清,第一步先把部署拓扑画出来。

1.2 资源消耗的真正大头:CPU、内存与磁盘的分配逻辑

理解Prometheus资源画像,必须先弄明白它的架构。Prometheus的内存大头主要集中在Head Block——也就是最近约2小时的数据,它保存在内存中,用于处理高频写入和查询。WAL日志也会在内存中缓冲,还有查询窗口、规则评估缓存、索引标签这些开销。换句话说,Prometheus的内存需求不是简单的“metrics数量 × 单metrics大小”,而是跟随时间序列总量、抓取频率、查询负载、规则数量一起波动的。

CPU方面,压力主要集中在几个地方:抓取HTTP请求的解析、TSDB磁盘区块的压缩、PromQL规则评估和高并发查询。一个常见的误解是Prometheus的CPU需求很低,因为看起来就是个数据采集器。但在Query量很大时,CPU会被PromQL引擎按着地板摩擦。你如果给CPU limits设得太抠,会看到容器频繁throttle,表现就是Prometheus进程不退出,但查询响应越来越慢,rule evaluation经常跳过。

磁盘资源同样不能忽略。TSDB的持久化机制是先写WAL,再定期压缩成Block。Block压缩和Comapction过程会同时对CPU和磁盘IO造成压力。如果给Prometheus配的PV是机械磁盘,WAL写入延迟一大,内存回收就会滞后,最终也表现为内存增长。现在很多K8s集群把Prometheus丢在低性能存储上,结果资源限制无论怎么调都不稳。

注意:磁盘IO和内存常常是联动关系。WAL落盘慢 -> 内存积压 -> OOMKilled,这个链路在实践里非常常见。所以排查资源问题时,不要只盯着CPU和内存两项,存储性能也要纳入视野。

1.3 估算Prometheus资源需求的实用方法

在配置资源限制前,先做一次粗粒度估算,能省掉后面一大半折腾。我习惯的估算口径是以时间序列总数为基准:

内存估算经验公式:单副本Prometheus所需内存(GiB)约等于时间序列总数(百万级)× 0.8 ~ 1.2,再乘以1.5到2.0的安全系数。这个安全系数覆盖了WAL缓冲、查询窗口、规则评估、标签索引等额外开销。

举例来说,你有一套服务发现目标3000个,每个target平均暴露1500个时间序列。总series数约450万,也就是4.5百万。按上述公式,基础内存需求约4GiB到6GiB。如果你把4Gi写入limits,早晚要炸。另一种更简单的做法是直接看当前采集接口的总series数,在Target列表里把所有up的target的series加总。如果环境里有Thanos或者已经开了TSDB,可以用Prometheus自带的API/api/v1/status/tsdb看HeadStats。

CPU方面,我先给个保守建议:单副本Prometheus处理百万级series时,requests给0.5核到1核,limits给2到4核。如果Query量大,再往上加。CPU和内存的估算不能只取一版,因为你后续如果调大抓取频率、加更多告警规则、延长本地保留时间,资源需求都会线性或超线性往上走。

2. 资源限制配置实战:requests、limits、LimitRange与Quota该怎么配

2.1 CPU和内存限制的底层语义:一个会被限流,一个直接杀掉

这是K8s资源限制里最容易被忽略的技术细节,也是很多OOM事故的根源。requests和limits在K8s调度的模型里分别承担不同职责:

  • CPU requests:调度器依据它选择有足够空闲CPU的节点,同时它也是容器被分配CPU份额的比例依据。
  • CPU limits:通过CFS Quota机制限制容器在单位时间内的CPU使用上限。超限时,K8s不对容器做任何“终止”操作,而是持续节流(throttle)。换句话说,CPU超了不是被打死,而是变慢。
  • 内存requests:同样参与调度。
  • 内存limits:这个不一样,它是硬上限,由Cgroup施加。一旦容器内存使用超过limits,内核会直接触发OOM Killer,把容器里的进程杀掉。这就是你经常看到Exit Code 137的原因。

理解这个语义差异之后,你就能明白为什么Prometheus这种长时间驻留、内存用量跟随工作负载波动的服务,对内存limits的敏感度极高。你把内存limits定得太紧,Pod随时可能被内核“处决”;你把CPU limits定得太紧,它不会死,但会慢到让你怀疑人生,表现为scrape持续超时、rule evaluation延迟、查询结果迟迟出不来。

另外,QoS级别也值得在这里提一嘴。如果你的Pod没有配置resources,QoS是BestEffort;requests小于limits是Burstable;requests等于limits是Guaranteed。节点出现内存压力时,kubelet会按BestEffort、Burstable、Guaranteed的顺序优先驱逐。监控组件如果做成BestEffort,节点内存一紧张,第一个被清理的就是它。所以无论如何,请给监控栈里的Pod都显式声明requests和limits,让它至少有Burstable级别,核心监控甚至建议requests等于limits,达到Guaranteed。

注意:K8s的驱逐(Eviction)和OOMKilled是两回事。驱逐时Pod被终止,事件里Reason是Evicted;OOMKilled则是容器触发了cgroup OOM,Exit Code一般是137。查问题首先看Pod Events和容器状态,别一见到内存报错就无脑加limits。

2.2 我给Prometheus设置资源限制的一组推荐值

实际落地时,我一般会按监控规模把Prometheus分为三类场景来配置。以下是一组基于实测的参考配置,不是绝对标准,但可以作为起点。

小型场景:监控目标少于500个,总series在50万以下,查询量小。配置示例:requests cpu 500m、memory 1Gi,limits cpu 2、memory 3Gi。这个场景下4Gi内存limits已经能给出较充足的安全垫,CPU也能应对常规规则评估和告警。

中型场景:监控目标500到3000个,总series在50万到300万之间,带有一定查询和告警负载。配置示例:requests cpu 1、memory 2Gi,limits cpu 4、memory 8Gi。这里的关键是内存limits至少按估算结果的1.5倍来落,CPU limits尽量宽,因为Prometheus的查询峰值CPU消耗很难提前预测。

大型场景:监控目标3000个以上,总series超过300万,需要多副本分片和长期存储对接。配置示例:requests cpu 2、memory 4Gi,limits cpu 8、memory 16Gi。这个场景必须引入分片机制,并配合Thanos或者其他长期存储方案,才能让单副本内存不被本地retention拖垮。

如果你拿不准该取哪个档位,我建议从偏大而非偏小的方向开始,跑几天再根据实际使用量往下调。对于长期运行的监控系统,宁可资源多占一点,也不要在一开始就把组件打进OOM循环。CPU可以后面减,内存被反复杀掉造成的WAL回放,反而会让重启后的内存和CPU瞬时需求更高,形成恶性循环。

2.3 用LimitRange兜底,用ResourceQuota做租户隔离

在真实K8s集群里,不是每个人都会自觉给Prometheus相关的Pod配置资源声明。尤其当多个团队共用集群时,有人手动部署了一个node-exporter或一个自定义exporter,就忘了写resources。为了兜底,LimitRange是一个非常有效的机制。

参考配置如下:

apiVersion: v1 kind: LimitRange metadata: name: monitoring-lr namespace: monitoring spec: limits: - default: cpu: 500m memory: 1Gi defaultRequest: cpu: 100m memory: 256Mi max: cpu: "4" memory: 16Gi type: Container

这个LimitRange设定了三个核心能力:default为没有配置resources的容器自动填充默认值;defaultRequest为没有配置requests的容器填充默认请求;max限制单容器最大能申请的资源上限。这里有坑要注意:LimitRange只在Namespace创建时对新Pod生效,它不会去修改已经创建好的Pod。而且如果Pod模板里只写了limits没写requests,defaultRequest也会被自动填充,这一点经常被忽略,容易导致你预期的requests和实际生效的requests不一致。

ResourceQuota则是从Namespace维度限制总资源用量,它的价值在于当你把监控系统和其他业务放在同一个Namespace时,控制整体资源不会失控。比如:

apiVersion: v1 kind: ResourceQuota metadata: name: monitoring-quota namespace: monitoring spec: hard: requests.cpu: "8" requests.memory: 16Gi limits.cpu: "16" limits.memory: 32Gi persistentvolumeclaims: "5"

配额一旦设置,Pod创建时K8s会校验该Namespace下所有Pod的requests或limits总和是否超过配额。如果配额值设置得不合理,比如limits.cpu给了4核,但Prometheus需要的CPU limits是6核,就会直接创建失败。我踩过一次坑:给monitoring Namespace配了相对紧的ResourceQuota,结果扩容Prometheus副本时新Pod一直Pending,kubectl describe一看是ResourceQuota不够用,那叫一个尴尬。所以配ResourceQuota之前,先把你所有监控组件的requests和limits全部汇总计算一遍,留出20%到30%的扩缩容余量,再写进配额。

2.4 用PriorityClass保护监控系统,防止被抢占

资源限制和配额只是资源管理的一部分,节点内存压力下的驱逐顺序同样影响监控可用性。如果监控Pod和其他业务Pod在同一个节点,节点内存一紧张,kubelet按QoS和PriorityClass决定驱逐顺序。为了让监控组件有更高的生存概率,可以给它设置一个更高的PriorityClass。

apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: monitoring-high value: 1000000 globalDefault: false description: "High priority for monitoring stack"

然后在Prometheus或者Alertmanager的Pod模板里指定priorityClassName: monitoring-high。这样即使节点内存告急,kubelet也会优先驱逐低优先级的业务Pod来释放资源,而不是直接把监控Pod清掉。不过PriorityClass不是万能的,如果你整台节点的资源已经被打满到无法容纳任何Pod,再高优先级也会被调度器拒绝。真正稳妥的做法是配合节点亲和性和污点容忍:给监控专用节点打污点,让Prometheus单独调度到这些节点上,从物理上隔离业务负载波动的影响。我见过不少团队把Prometheus和核心业务混跑,一旦业务高峰内存吃紧,监控先跪,等业务恢复后又没有监控日志回溯,排障全靠猜,这是非常痛苦的。

3. 一次真实OOM排查:从Exit Code 137到指标基数爆炸

3.1 现场还原:Prometheus深夜反复重启

模拟项目X有多个K8s节点,监控Namespace里跑着一个Prometheus双副本的StatefulSet,每个副本承载了大部分ServiceMonitor的抓取任务。某天晚上开始,Prometheus Pod出现规律性的重启:凌晨3点左右,Pod状态变成CrashLoopBackOff,容器重启,几分钟后恢复,然后在下一个凌晨时段再次崩溃。更麻烦的是,Prometheus每次重启后,需要回放WAL,这段时间内不响应查询,Grafana里的面板就会有大片空白。因为凌晨的业务指标波动最大,崩溃时段恰好又覆盖住了问题高发期,导致关键告警和趋势分析完全失真。

我最初以为是业务指标量突增导致的偶发现象,但连续观察了两天,崩溃时间点几乎固定,这就说明不是随机抖动,而是资源被系统性吃满。查看Pod描述时,Last State显示Exit Code为137,Reason是OOMKilled。此刻基本可以确定,是内存limits设置和实际需求脱节。

3.2 排查步骤:先看Exit Code,再看TSDB Analysis

遇到OOMKilled,不要急着调大limits。先按顺序做下面几件事:

第一步,确认容器崩溃原因。执行kubectl describe pod prometheus-0 -n monitoring,查看Containers状态里的Last State。Exit Code 137表示进程被SIGKILL杀掉,绝大多数情况是OOM。接着看上一轮容器的日志,kubectl logs prometheus-0 -n monitoring --previous,经常能看到类似fatal error: runtime: out of memory或者Error: OOMKilled的记录。

第二步,看实时资源使用。kubectl top pod prometheus-0 -n monitoring,这一步能给你当前内存使用量的快照。如果发现使用量稳定在某个值附近,对照limits比例,基本能判断安全余量够不够。

第三步,也是最关键的一步,计算真实的时间序列基数。Prometheus自身提供了TSDB状态接口,访问/api/v1/status/tsdb,能看到Head区块的series数量、内存占用等关键指标。或者直接用promtool命令分析数据目录:

promtool tsdb analyze --data-path /prometheus-data

这个命令会输出按metrics名称、label组合排序的Top列表,非常直观。分析后我发现,有个业务控制器的metrics基数大得离谱:本来预计单个ServiceMonitor的target会暴露几千个序列,结果实际暴露了上百万个序列。原因是业务代码在metrics label里带上了大量动态值,比如用户ID、请求路径、端口号,导致Prometheus为这些label的每个唯一组合都创建了独立时间序列。

这就是“指标基数爆炸”的典型场景。Prometheus的抓取频率固定,每次抓取时都会为新的label组合建立序列,时间序列数量指数增长。而我的内存limits是按“target数量”来估算的,完全没有预判到单个target内部会有这种爆发式增长,所以一旦业务侧某类高基数label持续出现,内存瞬间被打穿。

3.3 定位“杀手指标”并用relabel裁剪

拿到promtool tsdb analyze的输出后,我在序列数量Top列表里找到了那个罪魁祸首指标,它的序列数占了整个Prometheus总序列数的一半以上,甚至比所有系统级指标加起来还多。确定了高基数指标的具体名字和label结构之后,思路就很清晰了:要么让业务方修复指标定义,要么在采集端用relabel规则把高基数label或整个指标丢弃。

如果业务代码不方便立刻改,优先在采集端做裁剪。在对应的ServiceMonitor或PodMonitor里配置metric_relabel_configs。举一个典型的示例,把高基数的user_id label干掉的写法:

metricRelabelings: - action: labeldrop regex: "user_id|request_id|session_id" - action: drop sourceLabels: [__name__] regex: "app_http_latency_seconds_bucket.*"

执行labeldrop之后,原本为每个user_id创建的时间序列就会被合并到同一标签集下,序列数量可以骤降几个数量级。这里要注意,labeldrop动作只影响采集入库的序列,Prometheus依然会抓取原始metrics。也就是说,抓取CPU方面的消耗并没有完全降下来,但内存和存储压力会大幅改善。如果想彻底降低抓取开销,就得对整个指标执行drop。

这种在大规模集群里经常会遇到的高基数问题,落地的核心是一句话:永远不要让任何唯一性强的动态标签进入指标库。实际的业务metrics设计原则是,label应该用于描述维度,而不是用于存储数据。用户ID、IP、请求号这些应该作为日志字段,而不是指标label。

3.4 调整资源限制后的最终效果

在完成了高基数指标裁剪后,我又重新评估了Prometheus的资源需求。此时总series数已经回落到几十万,明显低于最初的几百万,内存基础需求也随之下降。我按估算结果重新设置了Prometheus主容器的资源限制:

resources: requests: cpu: 1 memory: 2Gi limits: cpu: 4 memory: 6Gi

同时把本地的retention从默认时间调整为6小时,配合外部的Thanos receive做长期数据归档。这一步让本地WAL和Head block压力大幅降低,即使遇到瞬时查询高峰,内存也更不容易突破limits。优化后连续运行了三个多星期,Prometheus没有再出现过一次OOMKilled,Grafana的数据曲线保持完整。

这次排查给我留下的最深刻经验是:OOM不是简单加内存就能解决的。如果你不查清序列基数,不砍掉高基数指标,加再多的内存也只是一步步延迟崩溃时间。调资源限制之前,先看数据基数,再谈配置大小,这个顺序一定不能反。

4. 长期运维经验与避坑清单

4.1 降低Prometheus长期资源消耗的6个细节

Prometheus长期运行后,资源增长几乎是必然趋势。定期做以下优化,能有效控制资源需求:

  1. 调整采集间隔。默认30秒的scrapeInterval对很多高基数target来说过于激进。把那些耗时且数据价值不高的target改成60秒,能明显降低CPU和网络消耗。
  2. 裁剪不必要标签。抓取端的relabel配置里,尽量只保留查询和告警真正需要的label,把可留可不留的标签全丢掉,内存和存储都会轻松不少。
  3. 控制规则评估频率。Prometheus的规则评估默认每分钟一次,如果规则数量多且复杂,评估本身会占用不少CPU。把低频告警规则的评估间隔改成5分钟,效果明显。
  4. 使用Recording Rule缓存高频查询。对于Grafana面板里反复出现的查询,设置recording rule提前把结果算好并存储为新的时间序列,查询时直接读结果,不仅CPU消耗低,查询响应也快很多。
  5. 本地保留时间要克制。除非你有大量磁盘和内存,否则不要随意拉长retention。将本地retention控制在7天以内,长期数据交给Thanos或者对象存储,这是K8s环境下比较推荐的做法。
  6. 副本分散调度。Prometheus双副本如果调度到同一节点,节点故障会导致两副本同时不可用。加上PodAntiAffinity,让两个副本尽量落在不同节点,同时为监控节点打污点,正常业务的Pod不会跑上去抢占资源。

4.2 查询内存优化:限制并发与采样数

Prometheus的资源限制配置里,还有一个很多人容易忽视的隐藏内存消耗大头:查询路径。Grafana一个面板里如果有很多大时间范围的图表,每个图都会并发执行PromQL查询。Prometheus默认的--query.max-concurrency是20,意味着可以同时跑20个重型查询。在内存limits一定的情况下,20个重型查询同时执行,内存很容易瞬间翻倍。

建议对查询参数做显式收敛。在Prometheus启动参数里调整:

--query.max-concurrency=6 --query.timeout=120s --query.max-samples=50000000

max-concurrency调小之后,并发查询会被排队,单个查询的内存占用受到限制,整体内存峰值会下降不少,但也意味着高峰时期查询响应时间会变长。这个需要在“内存安全”和“查询体验”之间找到平衡。如果最终用户是数据分析团队,建议单独部署一个读取副本或者对接Thanos Query来承载高并发查询,不让主Prometheus承受全部压力。

4.3 我踩过的坑:常见误区速查表

常见问题可能原因处理思路
Pod频繁OOMKilled,Exit Code 137内存limits过小或指标基数暴涨先用promtool分析series数,再调大limits
Prometheus查询越来越慢,但进程不崩溃CPU limits过低导致throttlekubectl describe看throttling统计,适当提高CPU limits
新增监控Pod一直PendingResourceQuota不足,或LimitRange拒绝查看Namespace的ResourceQuota使用情况,调整配额
Prometheus重启后恢复时间特别长WAL过大,回放耗时缩短retention,配合长期存储,或清理多余指标
监控Pod在节点内存压力下被优先驱逐QoS等级太低,未设置PriorityClass项目模板配置requests,设置Guaranteed QoS和监控专用PriorityClass
GitHub仓库模板/公共文档默认值太感人默认配置面向小规模环境参考本文估算公式,按实际series数调整

这份速查表里的每一项,基本都能对应到实操中至少一次真实的故障经历。K8s里Prometheus资源限制从来不是“配置完就完事”的事情,它要求你持续观察、持续调整。资源画像会随着业务指标的变化而变化,所以你既要会配初始值,也要会快速定位和优化。

最后再分享一个我在操作中印象很深的小技巧:每当要调整Prometheus的资源限制时,我会先花10分钟看一遍promtool tsdb analyze的输出,统计一下总series数和Top10高基数指标,然后再决定limits怎么设。这个流程看起来朴素,但比凭感觉或者照抄文档配置靠谱得多。如果你也在被K8s里的Prometheus资源问题折磨,不妨从这一步开始。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进