凌晨两点,线上某服务连着三次重启,kubectl get events里OOMKilled红通通地躺着。第一反应很直接:把 memory 的 limit 从 512Mi 加到 1024Mi,重新发布,然后坐等。结果第二天凌晨,同样的时间点,Evicted又来了。这类场景我见过太多次,问题不在“limit 不够大”,而在大家把 Pod 资源模型想成了一行数字,但 K8S 里这套资源机制从调度器一路贯穿到内核的 cgroup 和 OOM Killer,每个字段管的是不同层的事。这篇内容我就把 Pod 资源这条链路从头到尾捋一遍,把 requests、limits、QoS、驱逐排序这些概念串起来讲,适合正在被 Pod 无故被杀困扰、或者想把资源配到心里有数的人。
1. 从Pod被杀现场说起:资源这条隐形链路到底连着哪些内核机制
1.1 三种“死亡方式”的差异:OOMKilled、Evicted 与启动即失败
很多人看到 Pod 状态不对,第一反应都是“加内存”。但 Pod 死法不一样,背后对应的机制完全不同,不先分清再动手,基本白改。
OOMKilled是容器内进程触发了内核 OOM Killer。最常见的原因有两个:一是容器 cgroup 内存上限设得太低,进程申请内存时被内核直接干掉;二是整台节点的物理内存被耗尽,内核开始全局扫杀。两者的共同点是,动手杀进程的不是 Kubernetes,而是操作系统内核。
Evicted则完全是另一回事,它是 kubelet 主动驱逐。kubelet 每隔一段时间检查节点上的驱逐信号,比如memory.available太低、nodefs.available太低、imagefs.available太低,一旦越过阈值,它会挑选一批 Pod 杀掉或标记为失败,让节点恢复稳定。Event 里会写The node was low on resource: memory或者disk。
启动即失败和上面两种也不一样。比如CreateContainerConfigError、CreateContainerError,通常是镜像拉取、存储卷挂载、环境变量引用出了问题,跟资源配额没有直接关系,但很多人排查时会把它们混在一起,绕半天弯子。
所以排查的第一步永远是先看 Event 和 Pod 的 Last State,而不是凭“可能内存不够”就先把 limit 拉高。
1.2 从YAML三行文字到内核动作:一条完整的资源链路
一个最普通的 Deployment 片段长这样:
resources: requests: cpu: 200m memory: 256Mi limits: cpu: 500m memory: 512Mi这三行写下去之后,会依次走过这么一条链路:
- API Server 持久化:资源信息跟着 Pod 定义进入 Etcd,这只是个“声明”,没有任何人执行它。
- 调度器记账:Scheduler 在 Filter 阶段会看每个节点的
Allocatable,然后算出“所有已调度 Pod 的 requests 总和”,加上新 Pod 的 requests,不能超过节点剩余可分配量。注意,它只看 requests,完全不看 limits。 - kubelet 监听与创建:Pod 被绑定到某节点后,kubelet 从 API Server Watch 到变化,调用容器运行时创建容器。
- 运行时设置 cgroup:容器运行时(比如 containerd)在 cgroup 目录里创建对应的控制组,并把 limits 数值写进内核文件。CPU 对应
cpu.max或cpu.cfs_quota_us,内存对应memory.max或memory.limit_in_bytes。 - 内核执行限制:进程运行后,一旦 CPU 使用超过配额,CFS 带宽控制会强制“节流”;内存超过上限,内核就会回收页面,回收不动就 OOM。
- kubelet 回收状态:容器被杀或驱逐后,kubelet 更新 Pod 状态,Controller 决定是否重启或重新调度。
关键结论是:requests 是调度器用来签“工位合同”的,limits 是内核用来执行“上限刑罚”的,两者根本不在同一层生效。这也是为什么很多人把 limit 调得再大,调度问题依然解决不了。
1.3 为什么“调大 limit”经常解决不了问题
结合上面的链路,调大 limit 只在一种情况下有效:容器确实是死在自己 cgroup 内存上限太低导致的 OOM。但如果问题出在节点物理内存被耗尽,或者根本就是磁盘水位触发了驱逐,那你改 limit 等于改了个寂寞。
还有个更容易忽略的场景:内存工作集逼近 limit 时,内核会频繁回收 page cache,容器响应变慢,但还没死。你以为“还没到 limit 所以没问题”,其实性能已经劣化到阈值边缘了。
比较靠谱的排查顺序是:先kubectl describe pod xxx看 Last State 的 Exit Code 和 Message,再翻节点监控确认当时的内存水位、磁盘水位和 cgroup 实际用量,最后才决定是不是要改 resource 定义。这个顺序看着简单,但真实环境里很多人是直接跳到最后一步的。
2. requests和limits:一个是工位合同,一个是天花板限高
2.1 request:调度器签工位合同的依据
request 本质上是在告诉调度器:“我这个 Pod 至少要占用这么多资源,你把我放到节点上后,这块资源别人就不能用了。” 一个节点的可分配资源被所有 Pod 的 requests 总和瓜分,不管这些 Pod 实际用没用完。
想象一个办公室:你给这个项目组划了 10 个工位,即使项目组只来了 3 个人,剩下的 7 个工位也在合同期内锁着,别的团队搬不进来。Pod 的 request 就是这张工位合同。K8S 调度器按“工位数”?不,按 requests 总和来算剩余容量。
所以在资源紧张的集群里,把 request 调大,会导致调度明显变困难;调小,调度容易,但节点超卖风险上升。很多人只关心 limit 够不够,却不知道真正卡调度的是 request。
2.2 limit:kubelet设置运行上限的依据
limit 是运行时上限。kubelet 会要求容器运行时把这个值写进 cgroup,内核据此限制进程使用。这跟调度器没有一毛钱关系,调度器在过滤节点时根本不看 limit。
这意味着你完全可以把 request 设成 100m,limit 设成 8 核。调度器只会按 100m 帮你找位置,但实际运行时,这个容器可以冲高到 8 核。这种配置是合法的,也常用于“允许突发但保底调度”的场景,但代价是节点可能被突发流量打穿。
反过来,你也不能把 limit 设成 100m 但 request 设成 2 核,因为调度器会按 2 核记账,而内核会把你限制在 100m,等于白白浪费调度配额。
2.3 CPU可压缩与内存不可压缩:两种limit的本质差异
CPU 和内存的 limit 行为有着本质区别:
| 维度 | CPU | 内存 |
|---|---|---|
| 资源性质 | 可压缩 | 不可压缩 |
| 超过 limit 时 | CPU 节流(throttle),进程不停止 | 触发内核 OOM,进程可能被杀死 |
| 对同节点影响 | 节流后让出 CPU,影响相对可控 | 内存耗尽可能拖垮整台节点 |
| 关键监控指标 | container_cpu_cfs_throttled_periods | container_memory_working_set_bytes |
拿生活场景类比:CPU limit 像高速公路的限速牌,超速不是扣车,而是被慢悠悠地压在限速上走;内存 limit 像一张信用卡的额度,你刷爆了不是被警告,而是直接被停卡。
这条差异决定了配置策略上的大方向:CPU 可以靠 introspection 和扩容扛过去,内存问题则必须留足缓冲。CPU limit 设小了,你最多看到容器变慢、耗时上升、被 throttled,但它不会死;内存 limit 设小了,那就是一场事故。所以要给内存留的余量,通常比 CPU 高一个量级。
3. 调度器只读request:看清“看得见的账”和“看不见的墙”
3.1 节点到底有多少可分:Allocatable、预留与驱逐阈值
很多人的资源账是这么算的:节点 4 核 16G,那我放 4 个 1 核 4G 的 Pod 总没问题吧?实际一调度就 Pending,报错Insufficient cpu或Insufficient memory。
原因是节点并不是所有 Capacity 都能分给 Pod。kubelet 在计算节点可分配资源时,会扣掉两块:
- 系统预留:通过
--system-reserved和--kube-reserved指定,给操作系统守护进程、kubelet、容器运行时预留的份额。 - 驱逐阈值:比如
memory.available低于 100Mi 时触发驱逐,这部分空间也不能算作可分配。
Allocatable = Capacity - 系统预留 - 驱逐阈值。
实际生产环境里,很多人没配置--system-reserved。这意味着 kubelet、containerd、sshd 这些进程用的内存没有从任何 Pod 的 request 里扣除,它们会和业务 Pod 抢物理内存。结果节点明明“还有空闲”,却提前进入 MemoryPressure,业务 Pod 莫名被驱逐。这就是“看不见的墙”。
3.2 明明节点很闲却调度不上去:碎片化的本质
另一种常见 Pending 是:节点 CPU 实际使用率才 20%,但新 Pod 就是放不上去。这不是 Bug,而是因为调度器记账用的是 requests 总和,不是实时用量。
举个直观例子:某节点 4 核,上面跑着 4 个 Pod,每个 requests 都是 900m,实际用量都只有 100m。此时节点剩余 request 是 400m,实际空闲却接近 3.6 核。你想放一个 requests 为 500m 的新 Pod,调度器一看剩余 400m,直接拒绝。
这就像奶茶店收银抽屉里全是零钱,你要给一张 100 元整钞,店员就是找不开。每个 Pod 的 request 都是预占用的“整钞”,哪怕它实际消费很小,别人也借不了这块。
要缓解碎片化,我能给的思路有四条:
- 把长期稳定服务的 request 调到贴近真实用量,而不是拍脑袋写大;
- 合理使用节点池,让规格差异不要过大;
- 用 PriorityClass 加抢占,高优先级 Pod 实在插不进去时,可以挤掉低优先级 Pod;
- 接受一定程度的超卖,但必须配合监控和驱逐兜底。
3.3 亲和性、污点与拓扑分布:资源账之外的隐藏成本
还有一类 Pending,连Insufficient cpu都不是,而是调度器在别的维度上卡死。比如:
- 节点上有污点,而 Pod 没有对应容忍;
- Pod 带了
nodeAffinity,把可选节点锁死在一小撮机器上; - 开了
topologySpreadConstraints,要求 Pod 必须打散到不同可用区或节点,哪怕某个区资源明明更空。
这些约束和资源是两套独立系统,但常常叠加在一起。排障的时候如果只盯着资源,很容易陷进去。我一般先看kubectl describe pod里的 Events,里面会明确写“0/N nodes are available”以及具体原因是Insufficient cpu、node(s) didn't match node selector还是node(s) had untolerated taint。原因不一样,解法完全不同。
4. 内核如何执行limit:从CPU周期到OOM Killer的完整链路
4.1 CFS带宽控制:100ms周期里的时间片游戏
CPU 的 limit 不是“每秒只能用 0.5 秒”这么简单。内核的 CFS 带宽控制把时间切成一段段的周期(默认 100ms),再给每个容器分配一个配额。比如 limit 为 500m,意味着在每个 100ms 周期里,容器最多能占用 50ms 的 CPU 时间。
如果容器在周期内把配额用完,内核会把它“节流”到下一个周期。这相当于一个闸机:你不是被禁止前进,而是排队到下一轮再走。对延迟敏感的服务来说,节流直接表现为 P99 毛刺和超时,但 CPU 使用率看起来又没满。
判断是否被节流,监控指标要盯container_cpu_cfs_throttled_periods和container_cpu_cfs_throttled_periods / container_cpu_cfs_periods。如果节流比例长期高于 10%,要么调高 CPU limit,要么减少容器内的线程竞争。
一个容易踩的坑是,Java 或 Go 这类运行时内部有多线程,limit=2 只是一个周期最多 200ms CPU 时间,但它可以在 2 个核上同时跑满 100ms。千万别把进程内的线程数理解成“受 limit 保护”,limit 只限制总量,不限制并发形态。
4.2 内存cgroup上限与OOM评分
内存的 limit 写在 cgroup 的memory.max里。内核会持续监控这个 cgroup 的内存使用,当接近上限时,先尝试回收 page cache 等可回收页面;回收完还不够,就触发 OOM Killer。
OOM Killer 不是随机杀,它按照每个进程的oom_score排序,分高的先杀。这个分数大致由进程的物理内存占用和 oom_score_adj 共同决定。容器里的 pid 1 如果吃内存最狠,通常它自己就是第一个牺牲品。
对 Java 应用来说,这里有个典型悲剧:老版本 JVM 不感知容器 cgroup limit,默认堆大小按物理机内存来算,结果堆一扩,立刻撞 limit,进程反复被杀。JDK 8u191 之后默认开启UseContainerSupport,但如果你还在用老镜像,得显式加-XX:MaxRAMPercentage=75.0这类参数。
排查内存问题,不要只看container_memory_usage_bytes,更准的是container_memory_working_set_bytes。它排除了可以被内核回收的 page cache,反映的是“真正脏着的、赖在内存里不走的部分”。工作集贴着 limit 走,才是崩溃的前兆。
4.3 从网上抄limit值为什么容易翻车
我在社区里见过太多“通用模板”,什么“Java 服务 memory limit 设 2G 准没错”“Go 服务 requests 给 500m 就行”。这些数值对别人可能合理,但换一个业务就完全不是一回事。
举个实际例子:同样是 Nginx 网关,静态文件缓存命中率高的工作集可能只有 100Mi,但开启了 Lua 脚本和复杂 access log 后,工作集能冲到 500Mi。你照着网上模板写 256Mi,容器三天两头 OOM;写 4G,又白白占着节点内存,调度器按 request 记账,后面全是资源浪费。
正确姿势是结合第 6 节说的基线采集法,拿自己业务的数据来定。别人的模板只能作为初始参考,不能作为长期配置。
5. QoS三兄弟与节点驱逐次序:危机关头的生存排序
5.1 三档QoS怎么判定:只看request和limit的设定关系
K8S 会根据 Pod 里每个容器的 requests 和 limits 设定,盖一个 QoS 章,共三档:Guaranteed、Burstable、BestEffort。判定规则如下:
| QoS 等级 | 判定条件 | 风险倾向 |
|---|---|---|
| Guaranteed | 每个容器都设置了 requests 和 limits,且同一资源的二者相等 | 被驱逐优先级最低 |
| Burstable | 至少一个容器设置了 requests 或 limits,但不满足 Guaranteed 条件 | 常见主形态,超用风险中等 |
| BestEffort | 所有容器都没有设置任何 requests 和 limits | 被驱逐优先级最高 |
注意,这个判定是“每个容器”维度的。如果 Pod 里有一个 Java 容器配了 Guaranteed,另一个 sidecar 只写了 requests 没写 limits,整个 Pod 就会被降级成 Burstable。所以想拿 Guaranteed 的稳定性,所有容器都得严格配齐。
QoS 最重要的意义是:当节点资源告急,轮到 kubelet 决定“谁先走”的时候,它是分档排序的核心依据之一。但它只是排序规则,并不保证 Guaranteed 就一定不死。
5.2 驱逐流程:先来后到,但又不完全是
kubelet 的驱逐不是简单的 FIFO。实际排序逻辑大致是:
- 按 PriorityClass 排序,优先级低的先被驱逐;
- 优先级相同时,按 QoS 排序:BestEffort 先于 Burstable,Guaranteed 最后;
- 仍然相同时,按 Pod 内存使用量相对于 request 的超用比例排序,超用比例大的先走。
这个排序在 Eviction API 的代码里有明确实现,不是玄学。对你的启示是:
- 核心在线服务,Priority 和 QoS 都要拉满,做成 Guaranteed;
- 离线任务、Batch Job,可以安心用 Burstable 或 BestEffort,反正它优先级低,牺牲起来不心疼;
- 只是 QoS 高但 Priority 低,在驱逐场景下依然可能先被杀。
一个常被忽略的细节是:Pod 被驱逐后状态是Failed,Event 里写Evicted。Pod 对象还在,只是容器没了,配套的 PVC 可能还占着。做清理的时候别忘了kubectl delete pod把尸骨收走。
5.3 Guaranteed不是免死金牌
很多人以为 QoS 设成 Guaranteed、Priority 设高,Pod 就安全了。这是认知盲区。
kubelet 驱逐确实会尽量不碰 Guaranteed,但别忘了还有一个独立于 kubelet 的“暴力部门”——内核 OOM Killer。如果你的容器内存工作集超过了 cgroup limit,内核会直接杀进程,这个时候 QoS 章一点用都没有。QoS 管的是 kubelet 的驱逐顺序,OOM Killer 管的是内核的内存战备线,两层机制完全平行。
另外还有个场景:整台节点陷入 MemoryPressure,kubelet 的硬驱逐阈值已经触发了,但如果根因是节点上的系统进程吃内存,就算你把所有业务 Pod 都调成 Guaranteed,该被殃及还是会被殃及。所以配好节点级--system-reserved、--kube-reserved,比在 Pod 层面死磕 QoS 更治本。
6. 从原理到策略:一次靠谱的资源清单长什么样
6.1 基线采集三步法:不看监控改配置等于闭眼开车
讲了这么多原理,最后落到实操。我自己的习惯是,给任何一个服务写 resource 之前,先做三天到一周的“基线采集”。
第一步,部署监控,用 Prometheus 抓container_cpu_usage_seconds_total和container_memory_working_set_bytes。注意是 working set,不是 raw usage。
第二步,统计 P95 / P99。requests 建议取 P95 附近再乘 1.1~1.2 的保险系数。为什么不用 P99?因为 P99 往往被毛刺抬高,翻倍配置会让 request 虚高,白白浪费节点容量。如果一个服务的内存工作集长期稳定在 200Mi,突然某天 2 分钟冲到 400Mi,那可能是 GC 或缓存填充,把 limit 留在 512Mi 更合适,而不是把 request 改成 400Mi。
第三步,定 limits。CPU limit 一般是 request 的 1.5~2 倍,内存 limit 是 request 的 1.5~2.5 倍,具体看业务对延迟敏感度和缓存的突刺程度。拿不准的时候,宁可先放宽 limit,也不要放窄——内存 limit 设小了是会出人命的,CPU throttled 只是变慢而已。
6.2 结合HPA/VPA的容量规划:requests不再是拍脑袋
如果你的服务挂了 HPA,那 requests 的准确性就更加关键,因为 HPA 的扩缩容计算用的是CPU 当前使用量 / request,不是 limit。
这句话翻译一下:如果 requests 设得过大,HPA 永远算不出高利用率,结果就是副本数涨不起来,流量全压在少数几个实例上;如果 requests 设得过小,HPA 又会被轻微的 CPU 毛刺刺激得疯狂扩容,Pod 数暴增但每个 Pod 都在节流。
VPA 能自动推荐 requests,但默认只给建议,不会改你 YAML。即便你开了 Auto 模式,它调整 requests 时也需要重新创建 Pod,对在线服务多少有影响。我的建议是:把 VPA 当“测量仪器”跑一段时间,看它推荐的值跟你手工算的 P95 是否一致,再决定要不要采纳,别一上来就全自动。
6.3 一套可直接套用的初始配置示例
最后给一份模板,适合中等规模的 web 服务,你可以按自己采集到的基线调整:
apiVersion: apps/v1 kind: Deployment metadata: name: demo-web spec: replicas: 3 strategy: rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: spec: containers: - name: app image: demo:1.0.2 resources: requests: cpu: 200m memory: 256Mi limits: cpu: 500m memory: 512Mi这份配置的设计逻辑是:CPU requests 200m 保证调度器给它一个稳定的物理位置;CPU limits 500m 给突发留了 2.5 倍空间,避免节流严重;内存 requests 256Mi 贴近多数 web 服务的空闲工作集;内存 limits 512Mi 就是为了给 GC 和高峰留缓冲。
同时别忽略滚动更新的资源叠加:maxSurge: 1意味着发布瞬间集群里会有 4 个副本同时跑,峰值容量大概是4 × 512Mi = 2Gi。如果你按“3 副本刚好打满节点”去规划容量,发布时就会因为资源不够而卡住滚动更新。这是 Deployment 交付时最常见的隐性故障,很多人没把 Surge 算进资源账。
我在实际配置中的体会是:资源参数没有“一劳永逸”,业务流量和镜像版本一变,工作集就会跟着漂。最有效的做法是每个月回头看一眼监控,问自己三个问题:节流比例涨了吗?工作集离 limit 多远?HPA 扩容频率正常吗?如果这三项里有两项不对劲,就该重新走一遍基线采集,调整 resource 了。这才是让资源模型长期健康运转的方式,而不是每次出事都靠临时加 limit 续命。