打开任何一个线上K8s集群的Node节点,你都会发现一个残酷的事实:真正把集群搞垮的,往往不是大流量,不是网络抖动,而是某几个容器毫无节制的资源占用。我就亲眼见过一个没写资源限制的内部工具服务,在内存泄漏的第三天把整台Node的可用内存吃光,Kubelet反复OOM,最后同一节点上的核心业务全被拖下水。那次事故之后,我才真正意识到,容器资源限制看起来是每个Pod YAML里几个不起眼的字段,实际却是企业生产集群稳定性的第一道闸门。
这篇文章就以我们内部某业务集群的实战经验为背景,说清楚K8s容器资源限制的完整逻辑:requests和limits的底层语义、QoS等级怎么影响驱逐顺序、CPU限制的节流陷阱、内存限制和OOM的运维套路,以及LimitRange和ResourceQuota在企业多团队共用集群时怎么落地。不管你是刚开始学K8s的运维,还是已经接触过一些生产环境问题,这篇都应该能帮你在配置资源限制时少踩几个坑。
1. 资源限制不写清楚,生产环境的第一个坑就是"互相踩踏"
很多开发者在本地用Docker启动容器,内存随便写或者干脆不写,跑起来没任何问题。但K8s是共享集群,一个Node上可能同时跑着二三十个Pod,大家的宿主机资源是共用的。这时候"不限制"的后果就不是你一个容器的事,而是整个节点的事。
1.1 单容器失控:从"内存缓慢上涨"到"节点不可用"的完整链路
我们在某个模拟项目X里遇到过这样一个案例。一个后台异步任务服务,代码里有个列表全量加载的逻辑,每次处理一批数据就多占几百MB内存,数据量涨到一定程度后内存开始缓慢爬升。由于部署时YAML里完全没有设置resources字段,这个容器可以一直把Node的内存吃到快满。
Kubelet每10秒会做一次节点状态检查,当节点可用内存低于eviction-hard阈值(默认是100Mi),它就开始按QoS顺序驱逐Pod。因为那个泄漏容器不仅是Pod本身,它所在节点的Page Cache、系统进程也都需要内存,结果就是整台Node进入MemoryPressure状态。随后节点上其他正常的Pod开始出现响应变慢、连接被拒,因为所有Pod都在争抢所剩无几的内存。
整个过程分了三步:
- 第1步,容器内存持续上涨,节点可用内存下降。
- 第2步,触发节点压力阈值,Kubelet开始驱逐Pod,最先被驱逐的是BestEffort类型和无辜的普通Pod,造成大量重启。
- 第3步,节点直接-not-ready,调度器把新Pod调度到其他节点,又加剧了其他节点压力。
这个案例里最让人难受的不是被驱逐那个容器本身,而是它所在节点的"邻居们"。这串连锁反应里只要在最开始给那个容器加上一个合理的内存limit,后面的事情全部都可以避免。可以说,不写资源限制的容器,本质上是把整台Node变成了一颗定时炸弹。
1.2 为什么说"开发环境能跑"和"生产环境能扛"完全是两回事
开发环境里,一台机器上的容器少,流量低,即使不限制资源,也很少有人注意到问题。但生产环境集群通常有几十台Node、几百个Pod,任何一个Pod的内存泄漏、突发CPU占用,经过共享调度放大之后都可能影响整个集群。
还有一个常见误区是:"我加了limit,资源就一定能被限制住。"
这句话在内存上是基本成立的,因为内存是不可压缩资源,超了就会被OOM Kill;但在CPU上并不完全成立,CPU是可压缩资源,容器的CPU使用率即使超过limit,也只会被throttle(节流),而不是被杀掉。但有意思的是,很多Java应用在CPU被throttle后表现得比被杀掉还惨——请求超时、GC停顿加剧、接口响应时间从几十毫秒涨到几秒钟。
所以,资源限制这件事不只是写几个字段,它直接决定了容器在资源紧张时的行为模式,这也是本文后面要展开的核心。
2. requests和limits的底层语义:调度时说清楚你要什么,运行时才知道怎么管你
K8s资源限制由两个核心字段组成:requests和limits。很多教程只会告诉你"requests是预留、limits是上限",但在企业实战里,这两个字段背后的调度逻辑和运行逻辑完全不同,理解错误比不写还要危险。
2.1 requests:给调度器的"承诺声明",不是真的预占资源
requests翻译成"请求"其实不太准确,更准确的说法是"声明"。它表示:这个容器运行所需的最少资源量。调度器在把Pod放到某个Node上时,会检查这个Node的"已分配requests总额 + 当前Pod的requests"是否超过Node总容量。如果超过了,这个Node就不会被选中。
有个关键点:requests并不等于物理上给你预留了这么多资源。比如你设置cpu为500m,调度器会认为这个容器占用半个核,但它实际运行时,如果整台Node很空闲,它可以跑满整个核甚至更多。因为CPU是共享调度模型,只要有空闲配额,容器就能用。内存同理,requests 512Mi不代表物理上划了512Mi给这个容器独占,而是调度器“记账”用了512Mi。
这就有一个非常实际的后果:如果你把requests值设置得很大(比如CPU设2核,但业务每天CPU峰值才200m),那么一台Node实际可以跑的Pod数量会被严重高估,导致大量资源被“纸面占用”,实际利用率很低。反过来,如果你把requests设得很小(比如内存设64Mi,实际需要2Gi),调度器可能在一个Node上堆上十几个Pod,等业务高峰期内存一涨,立刻触发节点OOM。
我们内部定过一个经验值:在压测出峰值之前,requests可以保守一点;但一旦确定业务规律,requests应当尽量贴近真实基线,而不是拍脑袋。这个后面第7章会专门讲怎么用监控数据来定。
2.2 limits:容器运行时的"强制边界",超了就按超了处理
limits表示运行时的上限。CPU的limit是CPU周期的配额,超出后被throttle;内存的limit是硬性边界,超出后触发OOM Killer把这个容器杀掉。K8s不会去“规劝”容器,只会强制执行。
这里有两个最常见的误解,我必须单独拆开讲:
误解一:limits一定大于等于requests。其实K8s允许requests和limits不相等,甚至允许只设置limits不设置requests。如果你只设置limits,requests会被默认成和limits相等。在QoS分类里,requests等于limits的Pod才属于最高等级Guaranteed,这一点很多人不知道。
误解二:limits设置得越大越好。里面有一条隐藏风险:limits会同时用于调度之外的一些计算(比如节点压力驱逐打分),如果你给所有Pod都设置一个非常宽松的limits(比如内存8Gi,实际只用512Mi),那么在节点内存总量有限的条件下,调度器虽然能塞下更多Pod,但一旦整体内存使用率上升,这个“宽松”的limit反而让Pod先被驱逐——因为Kubelet驱逐Pod时不仅看QoS,还会看它的limits怎么分类。
我觉得大家都应该把requests和limits的关系理解为:requests决定调度器“能不能让你坐进来”,limits决定运行时间“最多能占多少资源”。两者的职责边界完全不同,不能用一句话概括掉。
2.3 CPU和内存的单位细节:一个字节大小的换算就能让你排错一整天
先看CPU单位。K8s里CPU的计量单位是核(core),可以用整数(1、2),也可以用毫核(100m、250m)。1000m = 1核。这个比较简单。
内存单位则有几个容易混的地方。K8s里默认使用二进制单位:Ki、Mi、Gi,它们的关系是:
| 单位 | 字节数 | 常见场景 |
|---|---|---|
| 1Mi | 1048576字节 | 配置Java堆或业务容器内存limit |
| 1Gi | 1073741824字节 | 配置数据库类Pod的整体内存 |
| 1MB | 1000000字节 | 部分监控系统导出时的显示单位 |
| 1GB | 1000000字节 | 云厂商控制台的展示口径 |
很多监控面板默认显示MB/GB(十进制),而你在YAML里写的是Mi/Gi(二进制),如果你是按照监控上的GB去修改YAML,会发现总是差一点点。一个4Gi的容器,在监控面板上显示可能是4.29GB。这个不是bug,是单位差异。排错的时候先看清楚监控面板用的是哪个口径,不然很容易把“看起来容量不足”误判成Pod的问题。
YAML写法的参考示例:
apiVersion: v1 kind: Pod metadata: name: resource-demo spec: containers: - name: app image: registry.example.com/app:latest resources: requests: cpu: 500m memory: 512Mi limits: cpu: "2" memory: 2Gi这段配置表达的意思是:调度时这个容器按0.5核、512Mi内存去占名额;运行时最多可以用2核、2Gi内存;而因为requests和limits不相等,这个Pod的QoS等级是Burstable,在节点压力下属于第二优先被驱逐的梯队,不是最高保障级。
3. QoS服务质量等级:资源限制怎么写,决定了节点挤兑时先杀谁
K8s把所有Pod按资源限制方式分成了三个QoS等级:Guaranteed、Burstable、BestEffort。它们之间的区别绝不是概念题,而是节点内存不足、需要驱逐Pod时“谁先走”的问题。
3.1 三个等级的分类判断方法,别背概念,直接看写法
判断一个Pod属于哪个等级,不需要看别的,就看resources字段的组合方式:
| QoS等级 | 触发条件 | 典型效果 |
|---|---|---|
| Guaranteed | 每个容器都设置了requests和limits,且requests == limits | 最不易被驱逐,进程通常能跑到系统内存耗尽 |
| Burstable | 设置了部分资源请求/限制,或requests小于limits | 在节点有压力时,比Guaranteed先被驱逐 |
| BestEffort | 完全没有设置resources字段 | 节点一有压力最先被清走 |
一个很容易踩的坑是:你以为设置了一个很大的limits就是高保障,其实如果requests没跟着设置一样大,它只是Burstable。反过来,有些团队严格要求所有Pod都是Guaranteed,虽然牺牲了一部分超卖能力,但换来的防护等级是最高的。
3.2 节点驱逐顺序的底层逻辑:不是按名字,也不是按优先级,而是按QoS和实际使用量
当节点进入MemoryPressure状态时,Kubelet会按Ordered Pod Selection逻辑选择要驱逐的Pod。具体顺序很重要:
- 首先驱逐已经超过limits的Pod(内存超过limit会被视为“非法使用”)。
- 其次按QoS等级:BestEffort最先被驱逐,其次是Burstable,最后是Guaranteed。
- 在同一QoS等级内,比较Pod的内存使用量相对于requests/limits的比例,比例高的先驱逐。
- 再往下还有Pod的优先级(PriorityClass)、存活时间等辅助排序。
这个逻辑的实际含义是:如果你的Pod是Burstable,并且实际内存已经跑到limit的90%以上,那么在节点内存吃紧时,它几乎是第一批被选中的“牺牲品”。而一个Guaranteed的Pod,即使内存使用率接近limit,也会排在后面,因为Kubelet默认认为它“已经预留了资源”。
所以,对生产环境的核心业务,我的建议非常明确:如果不打算精确调优,宁可把requests和limits写成一样,也不要留出一个看似灵活的“弹性空间”。稳定远比那点弹性更重要。
3.3 QoS等级对“超卖”的影响:为什么有些集群CPU使用率只有30%,Pod还是调度不上去
超卖(overcommit)是K8s集群提升资源利用率的常见手段。比如一个Node有8核CPU,你可以调度上去各自requests只有200m的20多个Pod,总requests超过8核也没关系,因为实际运行未必用得到那么多。
但超卖是有代价的:如果那些Pod的limits明显高于requests,一旦所有Pod同时进入峰值,整台Node的CPU就竞争激烈,内存也容易爆。此时QoS等级和驱逐顺序会异常频繁地启动,业务表现就是“定期抽风”。
我见过一个内部中间件集群,所有Pod都写了requests=100m,limits=4核,表面上资源利用率很高,但一到每月底的数据任务高峰,就有几个Pod被驱逐重启。根本原因就是超卖比例过高,QoS又都是Burstable,底层调度和驱逐逻辑在节点压力下做了大量“止损”。后来把核心任务的requests和limits调成一致,问题立刻缓解。
所以我的一个排障习惯是:看到Pod频繁被驱逐,先别急着查代码,去看整个节点的requests总量和limits总量,算一下超卖比。如果超卖比超过某个阈值(例如CPU超卖比超过3倍),那就不是业务代码的问题,而是资源规格设计的问题。
4. CPU limits的真正陷阱:节流(throttle)比OOM更隐蔽,也更难查
之前提到CPU是可压缩资源,因此CPU超过limit不会被杀掉,而是被Cgroup的CPU带宽控制机制节流。这个机制在很多Java、Node.js服务上会引发一系列诡异的性能问题。
4.1 CPU throttling的工作原理:不是“限速”,是“周期性冻结”
需要理解CPU limit的底层实现。Linux CFS(完全公平调度器)配合Cgroup的cpu.max参数,把CPU时间划分成周期(period)和配额(quota)。默认period是100000微秒(100ms),quota就是limit对应的微秒数。假设设置cpu limit为500m(半核),那么quota就是50000微秒。
这意味着:每个100ms的周期内,这个容器最多只能跑50ms的CPU时间。一旦用完,那么本周期剩余的时间都会被彻底冻结。如果应用线程在周期初把配额跑完了,周期末的几十毫秒里线程完全得不到调度,表现为瞬间的“停顿间隙”。
对高并发服务来说,这种毫秒级的停顿非常致命。因为线程被冻结时,没有在处理请求,也没在持锁;但下游可能已经在等响应超时了。应用层看到的表现可能是:
- 请求P99延迟突然跳到几百毫秒甚至秒级。
- GC日志里出现不正常的停顿,但实际内存并没有问题。
- 偶发“No reachable JVM”或“deadline exceeded”错误。
我建议大家在排查这类现象时,直接查看容器CPU throttle的时间。命令如下:
cat /sys/fs/cgroup/cpu.stat重点关注nr_throttled和throttled_time两项。如果throttled_time很大,说明CPU limit已经把服务压住了。这一步比在应用层瞎猜GC参数、连接池配置要有效得多。
4.2 为什么一个实例跑得好好的,一上K8s就变慢?很多锅其实在CPU limit
有个真实案例:某Java服务在本机跑,压测TPS能到2000,部署到K8s后同一份压测脚本只能跑到1200,而且CPU使用率看着只有40%。开发团队把JVM参数、日志配置、网络都查了一遍,最后发现是CPU limit设成了1核。
表面上CPU使用率40%,但这40%是按宿主机多核总量算出来的。在Cgroup限制单核quota的情况下,这个服务每100ms最多用100ms的CPU时间,一旦线程在某个周期内CPU消费超过配额,剩余十几毫秒的周期全部冻结。因为JIT编译、GC线程、业务线程都在抢那点配额,GC次数变多,STW时间变长,TPS自然就下来了。
这里有个非常反直觉的结论:有时候把CPU limit从1核提升到2核,TPS反而翻倍还不止,因为节流减少了,线程调度更顺畅了。反过来,在探查清楚业务真实需求量之前,不要拍脑袋把CPU limit压得很低。
针对这些,我的建议是:
- 对延迟敏感型服务,CPU limit最好设置为压测时峰值的1.2到1.5倍,而不是“平均值的两倍”。
- 如果业务真的需要稳定延迟,可以考虑用静态CPU管理器策略(Static CPU Manager)让Guaranteed Pod独占物理核,避免和邻居Pod争抢L3 Cache和超线程资源。
- 如果只是跑离线任务、批处理,CPU limit可以设置得很紧,节流对它们影响不大。
4.3 CPU requests和limits调优实验路径
我们在内部做CPU配置时,通常会先做一个保守基线,再用压测逐步逼近。我习惯的最小实验路径是:
- 先用默认配置(不设limit)采集7天生产数据,看P95 CPU使用率。
- 把requests设为P50值,limits设为P95值。
- 观察1周,如果
throttled_time每天增长超过几十秒,就说明limits太紧,往上升。 - 调整后再观察1周,直到
throttled_time基本不增长,同时节点整体CPU超卖比保持在安全范围。
这个循环至少要跑两轮。不要指望一次性把参数调到完美,资源限制本身就是动态演进的。
5. 内存限制与OOM:杀容器是K8s最粗暴但有效的"止损工具"
内存这个东西,一旦超了就没有挽回余地。CPU超了可以节流,内存超了只能杀进程。这也是为什么内存限制配置错了,代价通常是直接中断业务。
5.1 K8s杀Pod的完整信号链:从limit超限到Kubelet标记为OOMKilled
当一个容器内存使用超过limits时,Kernel的OOM Killer会介入,杀掉容器主进程。Kubelet随后会把容器状态标记为OOMKilled,并把退出码设为137(SIGKILL)。
如果你用kubectl describe pod看到了类似下面的状态原因,基本就是被OOM Kill了:
Status: Failed Reason: OOMKilled Exit Code: 137这里有一个很关键的细节:容器被杀时,应用可能连堆栈都来不及打。如果你想排查Java或Go服务的精确内存使用位置,只能靠日志、监控曲线、代码审查来缩小范围。所以我的建议是:如果某个Pod频繁OOMKilled,第一步不是调大limits,而是先看监控里内存曲线是“阶梯式上涨”还是“持续上升”。前者多半是缓存类数据没有释放,后者多半是业务增长导致的内存需求变大。
5.2 内存limit和JVM堆之间的关系:经典的企业实战坑
在企业项目里,JVM应用的内存限制最让人头疼,因为JVM会主动占用内存,而且默认的最大堆可能远大于容器limit。
举个例子:容器limits设为512Mi,JVM启动参数里没设置-Xmx,那么JVM在64位系统上默认按物理内存的1/4来分配堆大小。如果宿主机是64Gi内存,JVM可能直接把最大堆设为16Gi,远超容器limit。结果就是:容器一启动,JVM不断申请堆内存,还没跑业务就OOMKilled了。
解决办法有两个方向:
- 方向一:在JVM参数中显式设置-Xmx(如
-Xmx384m),让JVM堆上限和容器limit匹配。 - 方向二:在容器环境下使用类似’UseContainerSupport’的参数(JDK 8u191+默认开启),让JVM感知容器限制并自动调整。
但方向二有个细节:JDK能感知的是cgroup的内存限制,并且默认会预留一部分给堆外内存、线程栈、元空间等。所以我自己设置的黄金比例是:
容器内存limit的60%到70%给堆,其余留给线程栈、Metaspace、堆外内存、JIT代码缓存。
举个例子:如果容器limit是2Gi,那么-Xmx最好是1228m(1.2Gi)到1433m(1.4Gi)之间。如果你把-Xmx设置得太接近limit,比如1.8Gi,看似利用率高,实际上非常容易触发OOM——因为JVM的堆外部分完全不受-Xmx控制。
5.3 观察内存真实使用情况:RSS、working-set、page cache别混着看
排查内存问题时,很多人会去看容器RSS,但Kubelet判断是否OOM不是用RSS,而是用memory.working_set。working_set的计算公式是:
working_set = 活跃匿名内存 + 活跃page cache + 非活跃page cache的一部分(剔除可回收部分)。
所以你可能看到容器的RSS并不高,但working_set已经接近limit。典型场景是应用读取了大量文件,page cache被算进了working_set,最终触发了OOM。此时即使你把limits调大,可能也只是暂时缓解,并没有解决读文件导致的page cache占用问题。
在实际运维里,我一般用三个指标一起看:
container_memory_usage_bytes:Cgroup统计的总内存使用。container_memory_working_set_bytes:Kubelet驱逐和OOM判断用的核心指标。container_memory_cache:page cache部分。
如果发现cache占比很大,那说明业务逻辑里可能有大量文件读写或网络缓冲。要优化业务本身,而不是一味加内存。
6. LimitRange与ResourceQuota:把“个人自觉”变成“组织强制”
前几章讲的都是单个Pod或容器怎么配置资源限制。但在企业级K8s集群里,光靠各业务团队自觉写resources字段是不够的。一定有人忘记写,一定有人写一个离谱的limit。这时候就需要在集群层面做两道“护栏”:LimitRange和ResourceQuota。
6.1 LimitRange:为命名空间里的Pod设置“默认值”和“边界值”
LimitRange作用于命名空间级别,主要干两件事:
- 为没有设置resources的Pod自动注入默认的requests和limits。
- 限制单个Pod/容器能够申请的最大最小资源值。
一个典型配置如下:
apiVersion: v1 kind: LimitRange metadata: name: dev-limits namespace: my-dev spec: limits: - max: cpu: "4" memory: 4Gi min: cpu: 100m memory: 64Mi default: cpu: 500m memory: 512Mi defaultRequest: cpu: 200m memory: 256Mi type: Container这里需要说明几个字段:
default:如果容器没写limits,自动填上这里的值。defaultRequest:如果容器没写requests,自动填上这里的值。max和min:单个容器能申请的资源上下限,超出会被直接拒绝。
有了LimitRange之后,即使哪个团队从网上复制了一段没有resources的YAML,apply的时候也会自动带上默认值,不会出现“裸奔”容器。同时,如果某个团队试图把一个容器的limit设为32核,控制平面会直接拒绝创建,因为已经超出了LimitRange上限。
这个机制的价值不是“限制开发”,而是“避免低级错误”。我们内部就靠这个把“忘了写资源限制”的概率降到了接近零。
6.2 ResourceQuota:命名空间总体“可分配资源”的硬约束
ResourceQuota限制的是命名空间下所有Pod的资源总和,防止一个团队把整个集群的资源都占光。
apiVersion: v1 kind: ResourceQuota metadata: name: dev-quota namespace: my-dev spec: hard: requests.cpu: "16" requests.memory: 32Gi limits.cpu: "32" limits.memory: 64Gi在这个命名空间里,所有Pod的requests.cpu总和不能超过16核,limits.cpu总和不能超过32核,内存同理。一旦超出,后续的Pod创建请求会被拒绝,报错信息类似:
Error creating: pods "app-xxx" is forbidden: exceeded quota: dev-quota, requested: requests.cpu=500m, used: requests.cpu=16, limited: requests.cpu=16添加ResourceQuota之后,每个团队在创建大规格Pod时都会先算一下自己命名空间的剩余配额。这种做法把“资源使用”变成了团队内部的规划工作,而不只是运维的监控工作。
我自己的经验里,ResourceQuota和LimitRange通常是搭配着用的:
- LimitRange控制“单个Pod不能太离谱”。
- ResourceQuota控制“整个团队不能占太多”。
- 两者配合后,即便有人误操作,控制面也能提前拦截。
6.3 多团队共用集群时,Quota怎么划分才不容易闹矛盾
企业里的多团队共用集群,配额划分没有万能公式,但有几个原则非常实用:
原则一:先按业务优先级分层。核心交易链路、中间件、日志消费这类服务,要给到稳定且有冗余的配额;非核心的试验性项目,配额可以给得小一点,并允许被核心业务挤压。
原则二:requests和limits的Quota要分开设置。只设limits配额不设requests配额,团队可能把所有Pod的requests都写得很低,最后造成调度的“纸面余量”很充足,但实际运行又会集中爆发;只设requests配额不设limits配额,团队可能把limits写得很高,节点超卖风险也随之升高。
原则三:配额调整要有“止血通道”。如果一个团队的配额用完了,线上又急需扩容,这时候不要让人去手动改ResourceQuota的YAML,那样流程太慢。我建议在CI/CD平台里留一个配额变更接口,让团队负责人自行提交调整申请,审计链路完整。
7. 压测与调优:把资源限制从“拍脑袋”变成“有据可循”
最后这部分我放一些可执行的经验。资源限制写多少,不能靠感觉,要靠数据和压测。写得太紧,业务性能受损;写得太松,集群稳定性又没法保障。下面是我在项目里走通的一个“三步法”。
7.1 第一步:先跑不带limits的“压力摸底”
很多团队一上来就照着网上模板把limits写死,结果上线前压测就暴露问题。我的做法相反:在测试环境或者生产的小流量节点上,先不要给容器设置limits,只设置一个保守的requests,然后加压测试。
这一步的目的,是看业务真实需要多少资源。认真采集以下数据:
- CPU使用率曲线:重点看是否持续接近100%。
- 内存使用曲线:重点看是否存在持续递增趋势。
- OOM次数:如果不带limits都OOM,说明代码本身内存管理有问题,先回去修代码。
- 业务延迟:记录P50、P99、P99.9的延迟值,和资源使用曲线对齐。
只有拿到了这些数据,你才有资格去设置limits。
7.2 第二步:设置“基线规格”,同时保留调整预留量
压测完之后,我会按以下公式设置初始资源规格:
| 指标 | 建议基线 | 备注 |
|---|---|---|
| requests.cpu | 压测峰值CPU的50% | 保证调度时的基本“名额” |
| limits.cpu | 压测峰值CPU的120%-150% | 给JIT编译、GC等临时峰值留余量 |
| requests.memory | 压测峰值内存的80% | 不能低于这个值,否则调度不健康 |
| limits.memory | 压测峰值内存的150%-200% | 给堆外内存和page cache留余量 |
比如某个服务压测峰值CPU是800m、内存是600Mi,那我初始配置建议是:requests.cpu=400m,limits.cpu=1200m,requests.memory=500Mi,limits.memory=1Gi。
注意这个配置只是初始值。真正的目标是通过监控和线上反馈不断收敛,而不是一蹴而就。
7.3 第三步:上线后监控“资源痛苦度”,不是只看资源利用率
上线后,要看的指标不只是CPU和内存的平均使用率。我特别推荐两个“痛苦度”指标:
- CPU throttle时间:前面讲过,如果持续增长,说明limit太紧。
- 驱逐和重启次数:如果Pod频繁被驱逐,说明requests/limits配置与节点资源总量不匹配。
查看throttle时间的命令之前给过,这里再给一个监控PromQL的例子:
increase(container_cpu_cfs_throttled_periods_total[5m])这个指标代表5分钟内CFS节流周期数的增量。如果这个值每5分钟新增几百上千,说明CPU limit已经让这个容器持续挨饿了。正常情况下,核心服务的这个指标增量应该接近于0。
内存方面可以看:
container_memory_working_set_bytes配合limit一起看,算出“使用率基线”。如果working_set长期稳定在limit的80%以下,说明限制设得比较健康;如果长期顶着90%以上运行,就要小心了,稍微一点流量上涨就会触发OOM。
7.4 一个调优实例:从“频繁OOM”到“稳定运行”的完整过程
最后分享一个我们调整某订单处理服务的真实案例。这个服务最初配置是requests.memory=512Mi,limits.memory=1Gi,每天凌晨因为数据汇总任务,内存会急速上涨,几乎每天都要OOMKilled一次。
排查时没有直接调大limits,而是先看监控。发现它的内存基线是400Mi左右,但每天凌晨会有一批批处理任务,内存峰值能冲到1.2Gi。也就是说1Gi的limit确实是紧了一点,但requests只有512Mi意味着调度器可能把它和一堆高内存Pod塞在同一Node上,节点内存压力一起来,驱逐顺序又不占优势。
我们把requests.memory调整为800Mi,limits.memory调整为2Gi,同时在批处理代码上做了一个批量大小限制。之后连续观察了两周:节点内存压力事件少了很多,Pod也没有再出现过OOMKilled。这个案例的亮点不是“调大limit”,而是先确认了峰值规律,再同时调整了requests和业务逻辑,才算真正解决问题。
8. 我处理资源限制问题的经验清单
说了这么多,最后把我在实际运维中的一些判断标准和个人习惯列一下。不保证适用所有团队,但至少能帮你在遇到同类问题时少走弯路。
第一习惯:每次做资源限制变更,先问自己“这个值是从哪个压测或监控数据推出来的”。如果答不上来,就不要改。拍脑袋的值带来的问题通常比不写还多。
第二经验:核心业务入集群之前,强制要求质检脚本检查YAML里有没有resources字段。我们内部在CI流水线里加了一步校验,凡是production命名空间的Deployment没有设置完整requests和limits,直接拦截发布。这个措施实施后,线上“裸奔容器”的数量降为零。
第三心得:不要过度依赖K8s自动驱逐来兜底。驱逐机制是集群自愈的最后一道防线,但它会给业务带来明显抖动。你应该把“驱逐次数”当作一个重要报警指标,而不是把它当作正常现象。
第四提醒:CPU和内存的调优节奏完全不一样。内存要盯峰值和OOM,CPU要盯节流和延迟。它们在问题表象和排错路径上没有太多交集,别混在一起处理。
关于容器资源限制,我能分享的核心就这些。它看起来是K8s里最基础的配置,但几乎所有生产事故的根因都能追溯到资源规划不合理或配置遗漏上。希望你在下次部署新服务时,能先花十分钟,认真算一算这个服务到底需要多少CPU和内存。这十分钟,比事后熬一个通宵处理生产故障要划算得多。