1335 字
7 分钟
你给 gVisor Pod 设的 limits 可能根本没生效

容器平台上的 limits 看起来是一道硬边界,但它只对「被放进那个 cgroup 的进程」有效。gVisor 这类用户态内核运行时,会让真正消耗资源的进程落在这个范围之外。我的判断是:隔离运行时的资源上限必须逐项实测,并且由程序在运行时自行确认,而不是写进 YAML 就视为已经生效。

gVisor 与 runc 的资源上限实测对照

对照实测的数字#

在一个沙箱服务的测试集群上,分别用 gVisor 和 runc 跑一组超过各自限额的负载。

运行时限额负载结果
gVisor内存 1Gi逐页写入 1.5GiB全部写成功,返回码 0
runc内存 512Mi分配 700MiB被 OOM Killer 终止,退出码 137
gVisorCPU 1 核四个忙循环宿主侧量到 3.91 核
runcCPU 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 核,内存越过限额的写入会被终止。但这条路径依赖五个条件同时成立:

  1. 节点使用 cgroup v2;
  2. 节点的 systemd 版本不低于 244;
  3. Pod 里每个容器都写了 limits,遗漏一个就整体失效;
  4. runsc 的配置里显式开启 systemd-cgroup;
  5. 节点没有开启 swap。

这五条分布在三个层面:节点操作系统、Pod spec、运行时配置。它们由不同的人在不同的时间点决定,而任何一条不满足时都不会报错。新扩容的节点用了旧镜像、新加的边车容器漏写 limits,任意一种都足以让某些 Pod 静默地回到没有上限的状态。

它不是配置一次就固定下来的状态,而是一个会持续漂移的条件集合。

让程序自己确认,而不是事后监控#

常见的应对是加监控:采集实际用量,超过限额就告警。这个思路的问题在时序——它要等到业务已经运行、已经占用了不该占用的资源,才能发现上限没生效,那时损失已经产生。

我的选择是把确认提前到接收业务之前。节点上的组件在领取一个会话之前先对自己做一次检查:读取当前 cgroup 的限额值,确认它落在预期的层级上,确认写进去的是具体数值而不是 max。自检不通过就拒绝领取,由控制面调度到别处。

代价是多一次启动期检查,以及维护「什么算生效」的判定逻辑;换来的是把一个静默失效变成一次显式拒绝。宁可少一个可用节点,也不要一个看起来可用、实际没有边界的节点。监控回答的是「现在是不是出问题了」,自检回答的是「这台机器现在能不能用」,只有后者能挡在故障前面。

这条结论的适用范围不止资源上限。同一份只读挂载,runc 下写入类操作一律返回 EROFS,调用方立刻知道失败;gVisor 下 chmod 和 chown 返回成功(rc=0),而宿主上的数据并没有改变,调用方拿到正常返回值,会认为权限已经改完并继续往下执行。隔离运行时对外暴露的接口和宿主内核一致,接口背后的语义不一定一致;凡是把安全性或正确性建立在某个内核行为上的地方,换了运行时都需要重新实测。