容器平台上的
limits看起来是一道硬边界,但它只对「被放进那个 cgroup 的进程」有效。gVisor 这类用户态内核运行时,会让真正消耗资源的进程落在这个范围之外。我的判断是:隔离运行时的资源上限必须逐项实测,并且由程序在运行时自行确认,而不是写进 YAML 就视为已经生效。
对照实测的数字
在一个沙箱服务的测试集群上,分别用 gVisor 和 runc 跑一组超过各自限额的负载。
| 运行时 | 限额 | 负载 | 结果 |
|---|---|---|---|
| gVisor | 内存 1Gi | 逐页写入 1.5GiB | 全部写成功,返回码 0 |
| runc | 内存 512Mi | 分配 700MiB | 被 OOM Killer 终止,退出码 137 |
| gVisor | CPU 1 核 | 四个忙循环 | 宿主侧量到 3.91 核 |
| runc | CPU 0.5 核 | 同样负载 | 稳定在 0.50 核 |
runc 两行是预期行为:越过上限,内核立刻介入。gVisor 两行说明上限没有约束力——标称 1 核的 Pod 占用了节点近四核,内存越过限额同样没有被拦下。节点上还有其他负载时,这就不只是单个 Pod 超额使用,而是直接挤占同节点其他 Pod 的资源。
整个过程没有任何报错。Pod 状态正常,限额字段确实写在 spec 里,kubectl describe 看不出异常。
消耗资源的进程不在被限额的那个 cgroup 里
cgroup 把一组进程圈定为一个计账单位,规定这组进程能用多少资源。kubelet 创建容器时把容器进程放进去,内核按这个单位计账,Kubernetes 的 limits 落到底层就是它。
gVisor 的结构和普通容器不同。用户进程并不直接运行在宿主内核上,而是运行在名为 sentry 的用户态内核之上,由它接管系统调用;真正承载用户代码的是一组 stub 进程。关键在于,sentry 和 stub 由运行时自身创建,在旧版 cgroup 下它们被归到 containerd 自己的目录下,不属于 Pod 的那个计账单位。
于是出现割裂:限额写在 Pod 的 cgroup 上,消耗内存和 CPU 的进程却在它之外。内核按计账单位统计,这个单位里几乎没有用量,自然永远不会触发节流和 OOM。
这也解释了为什么没有报错——从内核的角度看,并没有发生越界。
让上限生效要五个条件同时成立
把节点换成 cgroup v2 并启用 systemd 驱动之后,同一个 gVisor Pod 的上限确实生效:限 0.5 核时实测只用到 0.500 核,内存越过限额的写入会被终止。但这条路径依赖五个条件同时成立:
- 节点使用 cgroup v2;
- 节点的 systemd 版本不低于 244;
- Pod 里每个容器都写了 limits,遗漏一个就整体失效;
- runsc 的配置里显式开启 systemd-cgroup;
- 节点没有开启 swap。
这五条分布在三个层面:节点操作系统、Pod spec、运行时配置。它们由不同的人在不同的时间点决定,而任何一条不满足时都不会报错。新扩容的节点用了旧镜像、新加的边车容器漏写 limits,任意一种都足以让某些 Pod 静默地回到没有上限的状态。
它不是配置一次就固定下来的状态,而是一个会持续漂移的条件集合。
让程序自己确认,而不是事后监控
常见的应对是加监控:采集实际用量,超过限额就告警。这个思路的问题在时序——它要等到业务已经运行、已经占用了不该占用的资源,才能发现上限没生效,那时损失已经产生。
我的选择是把确认提前到接收业务之前。节点上的组件在领取一个会话之前先对自己做一次检查:读取当前 cgroup 的限额值,确认它落在预期的层级上,确认写进去的是具体数值而不是 max。自检不通过就拒绝领取,由控制面调度到别处。
代价是多一次启动期检查,以及维护「什么算生效」的判定逻辑;换来的是把一个静默失效变成一次显式拒绝。宁可少一个可用节点,也不要一个看起来可用、实际没有边界的节点。监控回答的是「现在是不是出问题了」,自检回答的是「这台机器现在能不能用」,只有后者能挡在故障前面。
这条结论的适用范围不止资源上限。同一份只读挂载,runc 下写入类操作一律返回 EROFS,调用方立刻知道失败;gVisor 下 chmod 和 chown 返回成功(rc=0),而宿主上的数据并没有改变,调用方拿到正常返回值,会认为权限已经改完并继续往下执行。隔离运行时对外暴露的接口和宿主内核一致,接口背后的语义不一定一致;凡是把安全性或正确性建立在某个内核行为上的地方,换了运行时都需要重新实测。