如果你维护过一套多租户的Kubernetes集群,大概率见过这种场面:某个业务方随手丢上来一个服务,YAML里没写resources字段,Pod 被调度到节点上后一路吞内存,同一台节点的其他 Pod 被反复驱逐,最后整个 namespace 的接口延迟全线飙红。这种故障几乎和代码质量无关,纯粹是资源失控。Kubernetes 的资源控制管理,说白了就是把“谁该用多少资源”这件事从口头约束变成集群的强制规则,让调度器有依据、kubelet 有阈值、业务方有边界。这篇内容对应我实际维护集群时的整套作业思路,从 Requests/Limits 的原理、ResourceQuota/LimitRange 的配置,到线上故障的排查链路,再到把资源规范推给多个业务团队的经验,适合 SRE、平台工程师和正在被共享集群资源问题折磨的读者。
1. 为什么资源控制不能靠自觉:先从一次雪崩事故说起
1.1 那次事故到底是怎么发生的
当时我们的集群里同时跑着模拟商城的前端、订单、支付等十几个服务,分成 dev、test、prod 三个命名空间。某次大促前的压测,另一个团队直接往 prod 命名空间部署了一个临时的批量任务,YAML 里既没有requests也没有limits,只写了镜像和命令。任务启动后,它内部有个缓存模块在异常场景下会不断追加内存,从几十 MB 一路涨到节点物理内存的百分之八九十。
Kubernetes 本身不会主动阻止这种行为,因为节点的可分配资源虽然由requests决定,但运行时限制完全依赖 Pod 里声明的limits。没声明 limits,就意味着 kubelet 认为这个容器可以无限制使用宿主机内存。于是节点内存压力攀升,kubelet 开始按 QoS 和实际用量驱逐 Pod。驱逐顺序里最先被清掉的是那些同样没写资源限制的 BestEffort 类 Pod,然后是一些 Burstable 类应用。支付服务被驱逐后,依赖它的订单流程大面积超时,告警群里从凌晨两点刷到早上七点。
1.2 不写 resources 意味着什么
很多人把resources字段当成“可选项”,觉得不写也能跑,写了对业务又没好处。实际恰恰相反。从调度角度说,kubelet 在节点上分配 Pod 的依据是节点上剩余的可调度资源,计算方式是“节点总容量减去已调度 Pod 的requests之和”。如果 Pod 不写 requests,它就被视为 0 资源占用,调度器可以拼命把这种 Pod 往同一台节点塞,塞到节点上实际运行的内存、CPU 远超承载能力,系统稳定性只能靠运气。
从运行角度说,不写 limits,容器内存不受 cgroup 限制,CPU 不受 quota 限制。内存一旦失控,先受害的一定是同节点的其他 Pod,因为内存不像 CPU 那样可以靠 CFS 让度时间片来软性限制,它是物理上谁抢到算谁的。所以一个裸奔的 Pod 就像无人值守的仓库,平时没事,出事就是大事。反过来说,只要把资源限制写清楚,即使业务方出现内存泄漏,影响范围也被限制在它自己的容器里,命中的是 OOMKilled 而不是整节点的驱逐风暴。
1.3 资源控制管理到底管的是哪几层
我在实际作业里把 Kubernetes 资源控制拆成四层来理解。第一层是单 Pod 维度,也就是requests和limits,这决定了每个容器能分到多少资源、上限在哪。第二层是命名空间维度,用ResourceQuota硬性限制整个 namespace 内的资源总量,防止单个业务方把集群资源池掏空。第三层是单 Pod 维度的默认值和边界,用LimitRange兜底,强制让那些没写资源声明的 Pod 得到一份保守的配置。第四层是运维策略,包括 QoS 等级分配、监控告警、成本核算和资源画像。
这四层缺一不可。只有第一层,业务方可以随便把 requests 写得很大,或者在不同 namespace 里反复申请资源;只有第二层和第三层,却没有合理的单 Pod 配置,整个集群的资源利用率也会非常难看。后面几节我会按这个分层逐层展开。
2. 资源控制的底层三件套:Requests、Limits 与服务质量等级
2.1 Requests 和 Limits 各自管什么
先说一个最直白的类比:requests是“保底薪资”,是容器保证能拿到的资源量,调度器靠它来决定能不能把 Pod 调度到某台节点上;limits是“工资帽”,容器最多只能用这么多资源,超过就要被限制或干掉。
一个标准的容器配置长这样:
apiVersion: v1 kind: Pod metadata: name: sample-app spec: containers: - name: app image: sample-app:1.0 resources: requests: cpu: 250m memory: 256Mi limits: cpu: "1" memory: 512Mi这里的250m表示 0.25 个 CPU 核心,Kubernetes 的 CPU 单位是毫核,1000m 等于 1 核心。内存里的Mi是二进制兆字节,和M(十进制兆字节)不要混用,差 24 MB,量大之后误差会越来越大。
CPU 的 limits 走的是完全不同的限制路径。容器一旦超过 CPU quota,并不会被杀死,而是被内核的 CFS 带宽控制节流,也就是 CPU Throttling。进程还在跑,但执行时间被强制延后,表现出来就是接口延迟突然抬高、处理能力被压扁。内存的 limits 则完全不是一个逻辑,内存超过 limits 后没有节流一说,容器直接被 OOM Killer 杀掉,Pod 重启,Exit Code 通常为 137。这种差异在实际排错时经常误导人,看到容器疯狂重启先查内存,看到业务延迟抖动但 CPU 使用不高,就要去量一下节流指标。
2.2 服务质量等级决定了你被牺牲的顺序
Kubernetes 会根据 Pod 里容器requests和limits的声明情况,给每个 Pod 打一个 QoS 等级:Guaranteed、Burstable、BestEffort。这个等级直接决定了节点资源紧张时的“牺牲顺序”。
| QoS 等级 | 判定条件 | 节点资源紧张时的处理优先级 |
|---|---|---|
| Guaranteed | Pod 里每个容器都同时声明了 requests 和 limits,且两者完全相等 | 最低,最后被驱逐 |
| Burstable | 至少有一个容器声明了 requests 或 limits,但不满足 Guaranteed 全部条件 | 中等,先于 Guaranteed 被驱逐 |
| BestEffort | 所有容器都没有声明 requests 和 limits | 最高,最先被驱逐 |
Guaranteed 不是说这个 Pod 永远不会被杀,而是它的资源边界最清晰、优先级最高。BestEffort 在节点内存压力大的时候几乎是首批被清理的对象。所以我在服务上线前都会强制确认一个重要原则:核心服务必须 Guaranteed,至少也要是 Burstable,绝对不允许核心业务是 BestEffort。
想快速检查一个 Pod 的 QoS 等级,用一行命令就行:
kubectl get pod -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass2.3 单位、零值和那些容易踩的细节
资源和财务一样,单位错了很容易出事。CPU 的 1 核、0.5 核、100m 之间的换算,人和人之间容易想当然。内存的Mi和M的问题上文讲过,还有一个零值陷阱:cpu: 0或者memory: 0在语义上等于没设置,但写出来很容易让人误以为设置了限制。
另一个容易踩的坑是requests比limits大。Kubernetes 会直接拒绝这种 Pod,报错信息是“resources: Invalid value: … must be less than or equal to memory limit”。这种情况通常出现在业务方手工调整配置时改了其中一个字段忘了另一个。
还有一点:requests 才是调度时真正生效的数字,limits 只在运行时生效。也就是说,就算你把 limits 写得很大,也不会影响这个 Pod 被调度到哪台节点;但如果你把 requests 写大了,即使实际用量很低,节点也会被“预留”掉这部分资源。这就是后面第 5 节要做成本核算的基础。
3. ResourceQuota 与 LimitRange:集群治理的两道硬闸门
3.1 ResourceQuota:先定好这个命名空间的“总盘子”
单靠业务方自觉写 requests/limits 是不够的,他们可能写是写了,但是把 requests 写得极不合理地大。ResourceQuota 就是用来限制命名空间资源总消耗量的硬性闸门。举一个我在 dev 环境常配的示例:
apiVersion: v1 kind: ResourceQuota metadata: name: dev-quota namespace: dev spec: hard: requests.cpu: "16" requests.memory: 32Gi limits.cpu: "32" limits.memory: 64Gi pods: "50" persistentvolumeclaims: "20" configmaps: "100" services: "50"这段 YAML 的意思是 dev 命名空间里所有 Pod 的 requests 之和不能超过 16 核 CPU、32Gi 内存,limits 之和不能超过 32 核、64Gi,Pod 总数不能超过 50 个。应用这个配额后,如果业务方再创建一个新 Pod 会导致某个维度超限,API Server 会直接拒绝创建,返回类似“exceeded quota”的错误。
这里有个很微妙的变化:一旦命名空间里存在 ResourceQuota,API Server 会要求该命名空间下所有新建 Pod 都必须显式设置resources.requests或resources.limits,否则创建会失败。原因很直接——如果允许不设资源的 Pod 存在,配额系统就不知道应该把它的消耗记在哪。第一次遇到这个报错的业务方会一头雾水,但这种“强制”恰恰是我们想要的。
3.2 LimitRange:给所有 Pod 兜底,让裸奔成为不可能
ResourceQuota 管的是总量,LimitRange 管的是单 Pod 的规格区间。它的核心作用是两件事:一是给没有声明资源限制的 Pod 注入默认的 requests/limits,二是限制单个 Pod 能申请的资源范围,防止有人把 requests 调成 64 核。
apiVersion: v1 kind: LimitRange metadata: name: dev-limit-range namespace: dev spec: limits: - type: Container default: cpu: 500m memory: 512Mi defaultRequest: cpu: 100m memory: 128Mi min: cpu: 50m memory: 64Mi max: cpu: "4" memory: 4Gi配置之后,任何在 dev 命名空间创建的 Pod,只要容器没写 resources,就会自动被加上 CPU 500m、内存 512Mi 的 limits 和 CPU 100m、内存 128Mi 的 requests。如果写了的 resources 超过max或低于min,直接创建失败。
不过 LimitRange 有几个边界要注意。第一,它只对 Pod 创建那一刻生效,不会修改已经存在的 Pod。第二,它按 namespace 生效,每个命名空间都要单独配上。第三,它要求defaultRequest不能大于default,这个逻辑很直观——保底量不能比上限还高。
3.3 两道闸门的配合策略与实际边界
我倾向于这么配合:ResourceQuota 定总量,LimitRange 定单 Pod 上下限,再用 defaults 兜底没写资源的人。这两道闸门分属不同的 Admission 控制阶段,顺序上 LimitRange 的默认值注入发生在 ResourceQuota 校验资源之前,所以被注入默认值后的 Pod 也会被正常计入配额。
但两道闸门也有管不到的边界。ResourceQuota 默认只管 CPU、内存这类核心资源,对临时存储(如 emptyDir 的大小)可以限制requests.ephemeral-storage,但对自定义 CRD 的资源消耗并不感知。LimitRange 同样管不了节点级资源碎片问题,比如 Pod 要求 4Gi 内存,而某台节点明明还剩 5Gi,但分散在不同 NUMA 节点上可能调度失败。
多命名空间的实际划分中,我建议不要把所有业务都塞在 prod 一个空间里。哪怕都在生产集群,也应该按产品线再拆 namespace,这样配额、告警、成本核算都更容易归属到具体业务方。
4. 线上真实故障:一个 namespace 反复 CrashLoopBackoff 的完整排查链路
4.1 现象与第一轮排查
某天上午,prod 命名空间的支付子服务突然出现频繁重启,Pod 状态在 CrashLoopBackoff 和 Running 之间来回切换。我先做了两件基础操作:
kubectl get pod -n prod -o wide | grep payment kubectl describe pod payment-xxxxx -n proddescribe 输出里有一段信息是线索:Last State: Terminated,Reason: OOMKilled,Exit Code: 137。这说明容器确实是用到一半被内存 OOM 杀掉的,不是业务代码 panic。接着看kubectl top pod payment-xxxxx -n prod,发现它的内存实际使用量在 700Mi 左右,而 YAML 里配置的 limits 只有 512Mi。差距已经很明显,问题大概率是 resources 配置严重低估了实际需求。
一般到这里,很多人会直接加大 limits,从 512Mi 改到 1Gi 就完事。但我不想这么做,因为在没搞清楚内存为什么被吃满之前加限制,只是把问题往后推。
4.2 被“看不见的水位”坑了:JVM 与内存 limit
进一步排查发现这个支付子服务是用 JVM 开发的,而我注意到容器内存里摆着一个很典型的陷阱:JVM 在部分基础镜像和旧版本参数组合下,并没有正确读取容器 cgroup 的内存限制,而是按宿主机物理内存来计算默认堆大小。
这台节点有 32Gi 物理内存,JVM 默认最大堆本来可能是 8Gi,结果容器 limit 只有 512Mi。JVM 刚启动时虽然实际用量不到 400Mi,但后续业务流量一上来,堆内存和元空间持续扩展,一撞到 512Mi 就被 OOM Kill。进程一旦被杀,Pod 重启,流量再进来又被杀,形成反复 CrashLoopBackoff 的死循环。
这类问题的标准解法不是单纯加容器 limit,而是先让 JVM 感知到容器边界。我当时的处理分了三步:第一,确认镜像内的 JDK 版本支持-XX:+UseContainerSupport和-XX:MaxRAMPercentage这类参数;第二,把 JVM 最大堆参数改为-XX:MaxRAMPercentage=50.0,让堆大小跟随容器 limit 变化;第三,将容器 limits 调整到一个有依据的值,并结合压测数据重新评估。像这种内存型应用,我后来用了一个原则:requests 设为稳定运行时内存的 70% 左右,limits 设为峰值内存的 1.5 倍。前提是有实际监控数据支撑,不能凭感觉拍。
4.3 CPU Throttling:另一类隐蔽的性能抖动
内存问题解决后,支付子服务稳定了几天,但新的反馈又来了:压测时 P99 延迟非常高,CPU 使用率看起来只有 30%,但响应时间就是上不去。我先排除了网络和锁竞争,又把目光放回容器层,这次问题出在 CPU limit 上。
前面说过 CPU 超限不是被杀,而是被节流。当时支付子服务的 CPU limit 设为 500m,但业务峰值时要跑 800m-900m。多出来的这部分并不会立刻表现出来,而是被 CFS 限制成“每 100ms 最多使用 50ms CPU 时间”。进程明明想跑,却被强制排队,延迟自然飙起来。检查方法是通过容器内的 cpu.stat 文件查 throttled_time,或者看监控平台上 CPU 节流时间增量和总运行时间占比:
cat /sys/fs/cgroup/cpu/cpu.stat如果nr_throttled增长很快,throttled_time占比超过几个百分点,基本就能确认 CPU limit 过小。这时候需要结合真实用量把 CPU limit 上调,或者优化代码降低高峰期的 CPU 消耗,而不是盲目认为“CPU 30% 说明资源多得很”。
4.4 排查链路的复盘与工具清单
这次排错完整的链路是:先看 Pod 状态和 describe 里的退出原因,再对比实际用量与 limits,然后深入应用层确认是否感知 cgroup 边界,最后盯节流指标判断 CPU 配额是否合理。整个链路里,最忌讳的一步就是看到 OOMKilled 就无脑加内存 limit,因为很多内存型应用加再多的 limit 也填不满自己的“无底洞”,真正要解决的是应用对资源边界的感知。
实际操作中我会把这些命令存在本地笔记里,遇到类似问题可以快速套用:
kubectl describe node <node-name> kubectl top pods -n <namespace> kubectl get events --field-selector involvedObject.name=<pod-name> -n <namespace> kubectl logs <pod-name> -n <namespace> --previousdescribe node是为了看节点剩余可分配资源,确认是不是所有节点都满了;get events负责找驱逐、拉镜像失败这类被 process 状态掩盖的更早信号;logs --previous用于读取容器上次退出前的日志,判断是 JVM 参数问题还是业务代码问题。
5. 把资源控制变成团队习惯:规范、监控与成本的落地经验
5.1 资源画像:不要拍脑袋,先取样本
想要把 requests/limits 配得准,最忌讳的是靠直觉填数字。我在推行资源规范时先做了一次“资源画像”:选定几个典型服务,在测试环境用压测工具模拟日常流量、峰值流量和异常流量,然后持续采集每个容器的 CPU 使用率、内存工作集、节流时间等指标,至少采集一周。有了这批数据,再按“requests 等于稳定值的 70%-80%,limits 等于峰值值的 1.5 倍左右”来定初始值。
这里的逻辑是:requests 要留出安全余量,但不能高得离谱,否则资源被白白预留;limits 要能在峰值时兜住,也不能大得让 QoS 形同虚设。实际运行两到三周后,再根据监控曲线做第二轮小幅调整。不要一上来就追求一次调准,Kubernetes 的资源声明本来就是动态优化的过程。
5.2 监控与告警:盯住哪些指标不会让你焦头烂额
资源控制不只是“配置写对”就结束了,后续的监控和告警同样决定这套机制能不能持续运转。我梳理出几类必盯的指标,按优先级排序。
| 指标类别 | 说明 | 建议动作 |
|---|---|---|
| 内存工作集 | 容器实际常驻内存,反映真实需求 | 用量接近 limits 时应告警 |
| CPU 节流时间 | 容器被限制了多少 CPU 时间 | 占比高但 CPU 利用率有余量,应调大 limit |
| OOMKilled 事件 | 容器被杀的明确信号 | 立即查看内存配置和应用参数 |
| 驱逐事件 | kubelet 主动驱逐 Pod | 检查节点水位和该 Pod 的 QoS 等级 |
对于内存告警,我会优先看工作集而不是容器 RSS,因为工作集是内核实际认为“不可回收”的部分,更接近会触发 OOM 的判定口径。大多数 Kubernetes 监控平台都会暴露类似container_memory_working_set_bytes这样的指标,直接拿来做容器内存使用率计算就行。
5.3 成本分摊:让业务方为“预留而未用”的资源买单
很多团队对资源控制积极不起来,原因是成本不由自己负担。我采用过一个简单有效的口径:成本按requests计算,而不是按实际用量计算。一台节点成本是固定的,如果业务方申请了 4 核 CPU 的 requests 却只用到 0.5 核,剩下的 3.5 核既不能调度给别的 Pod,又没人买单,只能算进平台成本里。
把成本归属从实际用量改成请求量之后,业务方就自动有了优化 requests 的动机。每个月给出各命名空间的“资源预留账单”,标注预留了多少、实际用了多少、资源利用率是多少。当业务方发现自己的命名空间资源利用率只有 10%,而账单却按 100% 算钱,他们自己就会来找我们调整配置。
5.4 推行时踩过的坑与建议的推进顺序
我在推行这套规范时踩过不少坑,最典型的是三个。第一个坑是“一步到位”。如果某天突然给所有 namespace 都加上强配额,并且 LimitRange 的默认值设得太小,整个业务方会当天下午就来“围攻”平台组。正确的做法是先挑一两个资源浪费最严重的测试 namespace 试点,跑两周,把问题和改法都摸清楚,再逐步覆盖生产环境。
第二个坑是“YAML 模板没做成硬约束”。只把规范写在文档里,业务方上线的 Pod 依然可以完全不遵守。我在团队的流水线里加了一步静态校验:解析部署 YAML,检查每个容器是否包含 resources 字段以及 limits 不超过某个阈值,不过就阻断发布。这一步让规范从“建议”变成了“硬要求”。
第三个坑是“没有配套的重启策略”。Job、CronJob 这类短任务,业务方经常不写 resources,LimitRange 虽然会兜底注入限制,但 Job 本身对驱逐和重试的行为和普通服务差别很大,如果注入的 limits 过小,Job 会因为 OOM 反复失败。Job 类负载需要单独评估,不能用一套模板生搬硬套。
最后再分享一个我自己的体会:资源控制这件事,技术方案只是其中一半,更关键的是让多方理解“资源不是免费的”。ResourceQuota、LimitRange、QoS 这些东西只是把边界画出来,真正让集群长期稳定的,是每一项资源声明背后都有数据支撑、有成本归属、有监控兜底。我每次调整完一堆资源的配置后,都会重新翻一遍线上 Pod 的 QoS 分布和运行曲线,看到绝大多数核心服务都变成了 Guaranteed,心里才算踏实。