从接触Kubernetes到能在生产环境里睡个安稳觉,中间隔着的不是一堆概念,而是对“资源限制”这套机制真正的理解。我见过太多团队,刚开始用k8s时把requests和limits当成两个可填可不填的字段,结果服务一上线就频繁重启,节点内存告警,Pod像多米诺骨牌一样被驱逐,运维同学半夜爬起来看监控,最后发现根因全在容器资源限制上。
这篇文章不绕弯子,直接围绕k8s容器的资源限制展开,把requests、limits、QoS等级、LimitRange、ResourceQuota、OOMKilled、CPU Throttled这些关键词从原理讲到实操,再从实操讲到排障。刚入门的朋友能从中建立一套完整的资源管理思维,已经在用k8s但经常被资源问题困扰的朋友,也能从里面找到排查方向和优化思路。读完之后,你至少能回答清楚三个问题:limits设多少算合理?为什么CPU超限是降速而内存超限是杀进程?Pod被驱逐时到底谁先走?
1. 资源限制的核心概念:requests与limits
1.1 requests和limits的字面含义与调度逻辑
很多第一次接触Kubernetes的同学,会觉得requests是“最小值”、limits是“最大值”,但实际上这两个字段在调度器和运行时眼里是完全不同的东西。
requests是给调度器看的。Pod创建时,调度器会扫描所有节点的可分配资源(Allocatable),然后把集群里所有Pod的requests值累加起来,和节点规格做对比。只有剩余资源满足requests要求的节点,才会被纳入候选列表。也就是说,requests是Pod“入住”节点的门票,它决定Pod能不能被调度到某台机器上。至于Pod真正用到多少,调度器不关心。
limits是给kubelet和容器运行时看的。Pod落到节点上之后,kubelet会把limits换算成cgroup参数写进内核,由Linux内核强制执行。可以把它理解成一道真正的“天花板”,CPU超了会被限流,内存超了会被直接杀掉。
我用一个类比帮你理清思路:requests相当于你去酒店订房时告诉前台“我占一个房间”,前台按这个数据计算酒店还剩多少空房,决定能不能接你这单;limits相当于你住进去之后给自己房间设置的最高酒水消费额度。前台记账只看占房数不看消费额度,而服务员实际操作时会盯住消费额度。
明白了这层区别,你就能理解生产环境中一个非常常见的坑:只设置limits、不设置requests。这样做在调度器眼中等于Pod不占资源,它会把Pod调度到任何“满负荷”的节点上,因为Pod的requests是0。结果就是节点上的资源被严重超卖,随后Pod运行起来了,资源又从limits里真实扣除,节点直接被打垮。所以我才反复强调,requests和limits一定要一起配,这不是规范洁癖,是保命手段。
1.2 QoS等级:资源限制决定Pod的“生死优先级”
QoS全称Quality of Service,中文叫服务质量等级,它由requests和limits的组合方式自动推导出来,不是手动指定的字段。这个等级在节点资源紧张时决定驱逐顺序,非常关键。
Kubernetes将Pod分成三种QoS等级:
| QoS等级 | 触发条件 | 节点资源紧张时的命运 |
|---|---|---|
| Guaranteed | Pod内所有容器都设置了requests和limits,且requests等于limits | 最优先保护,节点内存压力极高时才有可能被杀 |
| Burstable | 至少一个容器设置了requests或limits,但不满足Guaranteed的严格条件 | 中等优先级,内存压力过大时被驱逐 |
| BestEffort | 所有容器都没有设置requests和limits | 最先被驱逐,毫无保护 |
我用一个具体的配置来说明。如果一个Pod里的容器全部写成requests和limits相同的值,比如CPU都是500m、内存都是256Mi,那它就是Guaranteed。如果requests写250m、limits写500m,那就是Burstable。如果什么都不写,就是BestEffort。
为什么驱逐顺序要这样设计?因为Guaranteed Pod是系统里“承诺不超卖”的部分,调度器严格按照它的requests去预留资源,驱逐它会导致整个资源账目信用崩塌。BestEffort Pod不承诺任何资源,节点压力大时它们最容易被牺牲,毕竟它们一开始就没有“预订”资源。
必须强调:QoS等级不是用kubectl给Pod加一个字段,而是由你写出的requests和limits组合自动推导的结果。所以你想保护核心业务,就把核心业务的容器配置成Guaranteed,也就是说让requests等于limits。但要注意,这样做的代价是资源利用率下降,因为limits一旦等于requests,你就没有超卖空间了。具体如何取舍,我放到后面的配额规划里细说。
2. 实操:从零为Pod设置一份合格的限制清单
2.1 声明式配置示例与字段解析
先给一个完整的Pod配置,这是最常见的入门姿势,生产环境里的Deployment、StatefulSet里同样是这样写的,只是外面多套了一层工作负载模板。
apiVersion: v1 kind: Pod metadata: name: resource-demo namespace: demo spec: containers: - name: app image: demo-app:1.0.0 resources: requests: cpu: 250m memory: 128Mi limits: cpu: 500m memory: 256Mi这里面有几个细节值得展开:
第一,CPU的单位是“核”的千分之一。1表示一个完整的CPU核心,250m表示0.25个核。Kubernetes内部用整数和毫核进行计算,所以CPU 250m会被记成250毫核。为什么这么设计?因为集群里节点可能有很多核,如果把核当作最小单位,资源碎片太严重。比如一个4核节点,一个小Pod要0.3核,如果用整数单位就只能给它1核,白白浪费0.7核的空间。
第二,内存单位用的是Mi、Gi而不是MB、GB。Mi是Mebibyte,1Mi等于1024×1024字节,而1MB等于1000×1000字节,两者差了约4.8%。如果你习惯用MB的概念,很容易把128MB的请求误写成128Mi,实际分配量比预期多了差不多5MB,单看不大,但乘以几百个Pod就非常可观。
第三,limits不能小于requests,否则kubelet会直接拒绝创建Pod,报错信息通常是“resources.limits.cpu should be greater than or equal to requests”。这是API Server的校验逻辑,不需要额外插件。配置审查时优先检查哪些Pod违反了这条规则,基本能发现一批低级错误。
2.2 如何选择合理的数值:别再拍脑袋
设置资源限制最让人头疼的其实是数值。我曾经见过开发同学给一个静态页面服务设置limits为4核8G,结果集群被撑爆;也见过给压测服务设置250m CPU,导致请求延迟突刺的。说实话,资源数值没有一劳永逸的正确答案,但有相对可靠的推导过程。
我的习惯是三步走:
第一步,先不设limits,或者设一个很宽松的limits,在开发环境部署服务,通过Prometheus或kubectl top pod连续观测3到5天,收集两类关键指标:CPU Usage和Memory Working Set。Working Set是内核认为该容器正在使用的内存量,比RSS更接近真实需求。
第二步,对采集到的数据做分位数统计。requests的值建议取P95,也就是服务95%时间消耗的资源量,核心业务可以取P99。limits的值建议取P99到P99.5,再额外加上20%到30%的缓冲。这样设置的逻辑是:requests用来保证日常运行的资源占比,limits用来容纳峰值和突发流量。
第三步,上线后再观察一周。重点看三个指标:kubectl top pod的实际峰值是否逼近limits、container_cpu_cfs_throttled_periods是否快速上升、容器是否有OOMKilled记录。如果实际峰值离limits只有10%的距离,说明limits给窄了;如果实际用量长期低于requests,说明requests给多了,浪费了调度空间。
有一个经验值可以参考:对常规无状态Web服务,requests给平均使用量的1.5倍,limits给峰值流量的1.3倍左右,之后根据压测结果微调。Java服务要额外考虑JVM堆内存和元空间的开销,Python和Node服务则要注意多进程/线程模型下的内存驻留,不能只看单个请求的内存消耗。总之不要抄网上某个固定数字,你的业务和别人的业务永远不一样。
2.3 状态化服务和批处理任务的特殊考虑
Deployment负责的无状态服务相对简单,requests和limits可以直接按上述思路设置。但StatefulSet部署的有状态中间件,比如数据库、消息队列,要谨慎处理limits。这类服务通常对延迟敏感,CPU throttling对它们的影响远大于普通Web应用,而且它们的容量规划高度依赖负载模型,limits设得过于激进会导致请求毛刺和延迟飙升。
批处理任务则相反,它们往往没有长期驻留的Pod,跑完就退出,经常被人忽视。但批处理任务恰恰是节点资源风暴的重灾区:如果一个数据清洗任务不做limits限制,它可能吃光节点上所有CPU,把同节点其他在线服务拖垮。处理方式是在Job模板里设置一个合理的CPU limits,比如1到2核,内存则按数据量评估,宁可设宽一点也不能让它轻松触碰limits。
另外提醒一点:设置的值超过节点规格时,Pod会一直Pending。用kubectl describe pod能看到类似0/4 nodes are available的调度错误,Causes下面写着Insufficient cpu、Insufficient memory。很多人第一步排查居然怀疑网络问题,其实看一眼这个events就能定位。
3. 原理深挖:CPU和内存的限制定价为什么不对称
3.1 CPU限制:可压缩资源,超限只会被限流
Linux内核通过cgroup子系统来限制CPU,k8s的CPU limits最终会落到cgroup的cpu.max(cgroup v2)或cpu.cfs_quota_us/cpu.cfs_period_us(cgroup v1)这两个参数上。简单说,内核以100000微秒为一个调度周期,limits 500m就意味着容器在每个周期里最多使用50000微秒的CPU时间。
如果你对这组数字感到陌生,可以这样理解:一个核心的CPU时间被切成了很多很小的调度周期,容器在这个周期内用完配额后,内核会把它的线程暂时挂起,等下一个周期再放行。这个挂起的过程叫CPU Throttling,表现为容器的CPU使用率曲线到达一个平台后不再上升,业务延迟开始显著增加。
CPU是“可压缩资源”,因为它可以被暂停、被降速,但不会导致进程死亡。CPU超限后服务可能会变慢,但不会崩溃。这是CPU和内存最大的区别,也是很多人压测时最困惑的地方:明明CPU Time已经烧到上限了,Pod却没死,只是响应时间一路飙升。这不是故障,是内核在按你写的limits执行纪律。
requests对应的是cgroup的cpu.shares(v1)或cpu.weight(v2),它有点像一个权重系数:节点CPU空闲时,Pod可以用远超requests的资源,需要多少用多少;只有发生CPU争抢时,内核才按权重比例分配CPU。换句话说,requests不是Pod真正使用的资源上限,它是资源紧张时Pod能占据的优先份额。
3.2 内存限制:不可压缩资源,超限就会被杀
内存是完全不同的故事。内核没办法像暂停CPU那样“暂停”一个正在写内存的进程,内存页一旦分配出去就无法压缩回收,除非把数据写回磁盘,然后进行reclaim。所以当容器内存使用量撞上cgroup的memory.max或memory.limit_in_bytes时,内核唯一能做的就是触发OOM Killer,杀掉容器内最消耗内存的进程。
被OOM Killer选中并执行的进程会收到内核发来的SIGKILL信号。在k8s视角里,这个容器的退出码通常为137,Pod的容器状态会变成OOMKilled,然后根据restartPolicy决定是否重启。所以你会看到Pod反复出现CrashLoopBackOff,其实日志里什么都没写,因为进程是被内核强杀的,根本没有机会吐异常栈。
这也是为什么我一直强调:内存的limits是硬边界,而CPU的limits只是软约束。内存超限没有优雅降级这一说,直接就是进程消失。你可以在CPU上做超卖,因为CPU最多让服务慢一点;但在内存上做超卖,等于在雷区跳舞,节点内存一旦不足,驱逐风暴马上就来。
3.3 容器里看到的内存为什么“不对”:/proc 陷阱
排查内存问题时,很多人会进到容器里执行free -m或者cat /proc/meminfo,然后发现显示的内存竟然是物理机的内存总量,再一看自己的Pod明明限制了256Mi,可free显示有几十GB,立刻怀疑是不是资源限制没生效。
其实不是限制没生效,而是/proc/meminfo所在的procfs没有做namespace隔离。容器里的/proc直接共享宿主机内核信息,所以看到的是物理机的全局内存视图。想知道容器真实限制和用量,有三个可靠途径:
- kubectl top pod,展示Pod维度的实际内存使用量;
- 进入容器执行cat /sys/fs/cgroup/memory.max和memory.current(cgroup v2)查看限制和当前用量;
- 在Pod里挂载一个sidecar采集cadvisor暴露的container_memory_working_set_bytes指标。
很多内存排查走进死胡同,都是因为看错了数据源。用对数据源,再结合业务压测,90%的内存类问题都能快速定位。
4. 集群层面的资源管控:LimitRange与ResourceQuota
4.1 LimitRange:给命名空间设定默认值和边界
单个Pod的配置可以人为保证,但多人协作的集群就没那么幸运了。一个开发同学忘了给Deployment写limits,这个Pod就会用BestEffort等级跑在生产节点上,一旦节点压力上来,第一个被牺牲的就是它。为了防住这种“人为疏漏”,需要在命名空间级别设置一道兜底防线,这就是LimitRange的作用。
来看一个示例:
apiVersion: v1 kind: LimitRange metadata: name: default-limit namespace: demo spec: limits: - default: cpu: 500m memory: 256Mi defaultRequest: cpu: 250m memory: 128Mi max: cpu: "2" memory: 2Gi min: cpu: 50m memory: 64Mi type: Container这段配置干了三件事:
默认值注入:凡是demo命名空间下新创建的Pod,如果容器没有设置resources字段,LimitRange控制器会自动把default的值作为limits、defaultRequest的值作为requests写进去。这样即使开发同学完全不写资源,最终创建的Pod也会有默认限制,不会再以BestEffort等级裸奔。
上限校验:max字段限制单个容器的limits不能超过2核和2Gi内存。有人误把limits写成8核后,API Server会拒绝创建Pod,并提示exceeded max constraint。这个设定能拦住一些极端的配置错误。
最低值校验:min字段只校验requests,不校验limits。你的Pod如果连50m CPU都不满足,创建时也会被拒。这个字段更多用于约束测试环境,防止有人把资源压得太低导致服务频繁重启。
LimitRange的生效时机是在Pod创建的时候,对已存在的Pod不会回溯修改。所以你在配置完LimitRange后,需要用滚动更新让现有工作负载重建一次,才能真正生效。
4.2 ResourceQuota:限制命名空间的资源总量
LimitRange管的是单个Pod的边界,ResourceQuota管的则是整个命名空间的资源总量。它特别适合用在多团队共享的集群里,防止某一个团队创建几百个Pod,把整个集群的资源都吞掉。
示例配置如下:
apiVersion: v1 kind: ResourceQuota metadata: name: demo-quota namespace: demo spec: hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi persistentvolumeclaims: "5" pods: "20"解释一下几个关键字段:
requests.cpu和requests.memory是命名空间内所有Pod的requests之和上限。比如上面配置中,这个命名空间里的所有Pod requests CPU加起来不能超过4核。limits.cpu和limits.memory是所有Pod limits之和的上限。pods字段限制Pod总数,防止创建大量零资源Pod;persistentvolumeclaims限制PVC数量,防止滥建存储卷。
当Pod创建触发了ResourceQuota时,API Server会返回类似exceeded quota的报错。常见表现是:Deployment创建副本时,部分Pod一直被卡在Pending,kubectl describe能看到配额不足的events。这个时候就得在做资源规划时拿计算器认真算一遍当前命名空间已用配额和剩余配额。
ResourceQuota和LimitRange建议配合使用:先用LimitRange给每个Pod兜底默认值,再用ResourceQuota控制总量。两者一个管“单个规格”,一个管“总量水位”,层级分明,缺一不可。
4.3 集群配额规划:从账目到监控的闭环
配额设多少,取决于环境定位。开发环境可以宽松:requests和limits的总量按“把所有节点可用资源加总”的80%设置,因为开发环境的特征是需求碎片化,总量控制的意义大于精确分配。
生产环境要更仔细:我一般建议“requests总量不超过节点allocatable的60%,limits总量不超过allocatable的150%”。为什么留这40%的余量?因为每个节点还要跑kubelet、容器运行时、日志采集、监控Agent等系统组件,这些进程占用真实资源;还有一些突发峰值,比如节点里最大的Pod重新调度时,新Pod的requests和旧Pod的requests会短暂叠加,超过allocatable就会导致调度失败或节点过载。
limits总量超过allocatable是允许的,因为limits不参与调度决策。它在配额层是“纸上承诺”,代表最坏情况下命名空间可能拉高到多少资源。只要requests总量被管住,超卖风险就可控。如果你把limits也卡得非常紧,很多业务会因为limits不够而创建失败,本质上是把超卖能力直接废掉了,资源利用率会很低。
最后补一句:配额不是配完就完事。你需要为每个命名空间建立资源水位监控,重点看quota使用率和节点实际使用率两条曲线。当quota使用率长期超过80%时,就该启动配额扩容评审;当节点实际使用率长期低于requests分配率时,就该考虑缩容或迁移业务。资源限制的核心目的不是“限制”本身,而是让资源账本变得可预测。
5. 常见问题与排查技巧实录
5.1 Pod反复重启:先查是不是OOMKilled
现象是Pod一直在重启,kubectl get pods看到CrashLoopBackOff,但kubectl logs只能看到少量业务日志或者什么都没输出。这个时候不要浪费时间反复看日志,先执行kubectl describe pod,查看容器状态里的Last State字段。如果显示:
Last State: Terminated Reason: OOMKilled Exit Code: 137那就基本确定了:内存超限,容器进程被内核OOM Killer强杀。Exit Code 137是128+9,其中9对应SIGKILL,是Linux下被OOM强杀的标志性退出码。
解决方向有三个:第一,判断是业务内存泄漏还是正常增长;第二,查看监控里memory working set的曲线,判断峰值是否逼近limits;第三,适度调整limits或者优化代码/依赖资源占用。如果pod有很多副本,还可以用kubectl top pod --sort-by=memory按内存从大到小排序,快速找出内存异常Pod。
这里要区分一个概念:Pod显示OOMKilled并不代表容器内存真的突破了limits,还有一种可能是节点本身内存不足,kubelet根据QoS等级触发驱逐,容器被强杀后也显示OOMKilled。想进一步区分,可以查节点层面的内核事件、/var/log/syslog或message日志,看看OOM Killer的触发点是cgroup级别的memory cgroup,还是整个节点的全局内存。
5.2 CPU使用率不高但延迟飙升:大概率被Throttled
另一种非常隐蔽的问题:kubectl top pod显示CPU使用率只有30%,但接口延迟从10ms涨到300ms,集群负载也很低。很多人会去查代码、查数据库,结果方向跑偏。
正确排查姿势是看CPU Throttling指标。如果你用的是Prometheus,直接查container_cpu_cfs_throttled_periods_total和container_cpu_cfs_periods_total,看throttled周期占比。如果占比超过20%,说明容器经常处于CPU限流状态。kubectl top pod显示的是平均使用率,它掩盖了瞬时高峰,而limits限制的恰恰是瞬时突发。比如一个服务平均只用200m CPU,但每秒有几百个请求集中到来,瞬时需要用600m,如果limits设置500m,内核会在每个周期里把进程挂起一段时间,延迟自然就起飞了。
对这类服务,解决方案不是无限加高limits,而是找到真实的CPU并发需求。可以适当把limits抬高到平均值的2倍左右,同时调整应用的并发模型,比如控制线程池和事件循环的并发数,让CPU请求曲线更平滑。还有一个实用技巧:给这类服务设置合理的HPA,让副本数随着QPS增长,而不是让单Pod硬扛。
5.3 Pod被驱逐:优先优化requests而不是limits
还有一种情况让人摸不着头脑:Pod没有超限,也没有OOMKilled,但状态变成Failed,原因是Evicted。这是节点级别的内存压力驱逐。
kubelet会周期性地检查节点内存分配情况。一旦节点物理内存接近耗尽,它会根据QoS等级和实际使用量驱逐Pod,释放内存。驱逐规则大致是:BestEffort先走,然后是Burstable中实际使用量超出requests的Pod,最后才是Guaranteed。如果你有一个Pod明明切成Guaranteed还被驱逐,多半是节点内存真的到了极限,而且还有其他更重要的系统进程在占用资源。
这里的经典误区是:遇到驱逐事件,第一反应是调大limits。实际上kubelet在做驱逐决策时,依据的是Pod的requests和实际使用量,而不是limits。limits只决定内核OOM的触发阈值,不参与驱逐评分。所以正确的优化方向是:要么调低该Pod的requests,要么减少节点上的Pod数量。如果调低了requests,Pod会降级为Burstable,驱逐顺序靠前,这在核心业务上是不可接受的。更稳妥的方案是提升requests,让Pod变成Guaranteed,并确保同节点上存在合理的资源余量,当然前提是集群有足够资源,否则调高requests只会导致调度不上去。
5.4 常见问题速查表
| 现象 | 大概率原因 | 排查命令 | 修复思路 |
|---|---|---|---|
| Pod CrashLoopBackOff | 内存超限OOM | kubectl describe pod | 调内存limits,查内存泄漏 |
| Exit Code 137 | 被内核OOM强杀 | kubectl get pod -o yaml | 检查memory limits与working set |
| 延迟上升但CPU使用率低 | CPU Throttling | container_cpu_cfs_throttled_periods_total | 调整limits或并发模型 |
| Pod调度Pending | requests超节点剩余 | kubectl describe pod显示Insufficient cpu/memory | 调低requests、扩容节点 |
| 节点内存压力,Pod被Evicted | requests总量超节点能力 | kubectl get events | 降低节点负载,提高Pod QoS |
| 命名空间创建Pod被拒 | ResourceQuota超限 | kubectl describe resourcequota | 扩配额或清理无用资源 |
| Pod没配资源也能创建 | LimitRange未配置 | kubectl get limitrange | 配置LimitRange兜底默认值 |
6. 避坑清单与配置自查建议
这篇文章最后,整理一份我实际工作中沉淀下来的避坑清单。不追求面面俱到,每一条都是踩过坑之后总结出来的。
第一,生产环境每个工作负载都必须同时配置requests和limits。只配limits不配requests会让调度器“看不见”这个Pod,最终导致节点严重超卖。只配requests不配limits虽然不会导致调度失误,但内存一旦超限不会被cgroup拦截,Has a 风险。
第二,内存的limits必须设置,而且要给足余量。内存超限直接杀进程,这一点比CPU要严酷得多。余量建议是监控峰值的1.2到1.5倍,宁可多给也别让业务频繁被杀。压力测试时的内存峰值往往比日常高出很多,所以在压测环境调出来的值,上生产前还要额外加缓冲。
第三,CPU的requests决定了调度和驱逐,CPU的limits决定是否被限流。如果你希望服务在CPU争抢发生时更稳,requests可以适当调高;如果你希望单Pod不至于拖垮整个节点,limits必须设置。两者用途不同,不要混为一谈。
第四,资源限制不是配置完之后就一劳永逸。业务变化、流量模型变化都会让原来的数值失效。我的习惯是每半个月到一个月看一次资源使用趋势,每个月根据监控数据微调一次配额。资源调优本质上是压测、监控、再调优的循环,不是一次性工作。
第五,别迷信网上某个固定数值的配置模板。每个业务的内存分配模式和CPU曲线都不同,数据库和静态页面服务的资源画像完全是两码事。把别人的模板直接拿过来用,大概率环境一换就出事。
第六,给开发团队准备一个标准的资源模板。在模板里把requests和limits字段预填好,并配套一段简单的压测说明,要求开发同学在提测前附上压测报告里的资源峰值曲线。这件事能有效降低“上线后资源异常”类事故的发生频率。
作为收尾,我只分享个人最真实的感受:在k8s集群里,资源限制看起来只是YAML里几行配置,但它决定的是整个集群的稳定边界。写得不好,节点会被打挂、服务会被杀、事故会连续爆发;写得好,集群就像一台账目清晰的大机器,每一份资源都花在明处,遇到流量高峰也能从容应对。所以,宁可多花一个晚上压测和调参,也值得在生产环境里换回长久的安稳。