KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · 资源预算:`requests` 与 `limits` 是两件事 — keel 龙骨

这一章回答:为什么每个容器要写两个资源数字?它们分别被谁使用?"超卖比 4.17 倍"意味着什么?

这一章回答:为什么每个容器要写两个资源数字?它们分别被谁使用?"超卖比 4.17 倍"意味着什么?

这是 K8s 里第二处"同一个概念被拆成两个字段"的地方(第一处是第 03 章的探针)。和第 03 章一样,拆开之后两个字段的用途完全不同:一个参与调度决策,一个参与运行时强制。把它们当成"下限和上限"会带来一连串误判。

现场

一个节点上的服务开始不稳定。运维看到的现象是:

kubectl get pod → 某个 Pod 状态 RESTARTS 12(次),LAST REASON OOMKilled

被杀的不是占内存最多的那个服务,而是本来就用得比较多的那个。团队的解读是"这个服务有内存泄漏",于是花了两天去查它的代码——没查到。

真正的原因是所有容器都没有写 limits。这带来两个后果:

  1. 节点内存紧张时,被杀的由内核决定,判据是"谁占得多",而不是"谁越界了"、"谁的责任"。没有 limits 的容器在争抢中没有任何保护。
  2. 因为没有任何人被限制,节点被用到了极限才触发 OOM——触发得很晚,而越晚触发,争抢越激烈。

所以这个现场的根因不是"某个服务泄漏",而是整台机器上没有一条资源边界。修改方案也不是查泄漏,而是先把边界画出来(并给可能泄漏的容器足够的内存余量)。

一、两个数字,两个使用者

requests limits
回答的问题 要预留多少才能把这个 Pod 放上来 这个容器最多能用多少
谁使用它 kube-scheduler(以及 kubelet 做准入时的资源核算) cgroup(内核级别的运行时强制)
什么时候起作用 调度那一刻(一次性决策) 容器整个生命周期(持续生效)
超了会怎样 不会"超"——它在调度时就被当成了已占用 CPU:被限速;内存:被 OOM 杀
不写它会怎样 按 0 计算 → 节点会超卖到失控 没有上限 → 单容器可以吃掉整台节点
能不能改 能,但改小 requests 不会立刻腾出资源(要等 Pod 重建) 能,改完对运行中的容器生效

一行话记住区别:

requests 是"我占个位子",limits 是"我的天花板"。位子在调度时用掉,天花板在运行时才生效。

这解释了几件日常现象:

二、超卖:一份真实清单的账

把本课示例清单的账算出来(kubectl kustomize base 的构建产物,用静态检查脚本统计):

  对象                        副本        单副本 requests          单副本 limits
  --------------------------------------------------------------------
  orders-api                 2     250m / 256.00Mi     1.00 / 512.00Mi
  orders-worker              1     500m / 512.00Mi       2.00 / 1.00Gi
  orders-migrate             1     200m / 256.00Mi     1.00 / 512.00Mi
  --------------------------------------------------------------------
  合计 requests : 1.20 CPU / 1.25Gi
  合计 limits   : 5.00 CPU / 2.50Gi
  超卖比(limits/requests): CPU 4.17x 内存 2.00x

limits 的合计是 requests 合计的 4.17 倍(CPU)/ 2.00 倍(内存)。 这就是"超卖":一台只有 1.2 个 CPU 可分配余量的节点,上面跑着的容器总共被允许用到 5 个 CPU。

为什么允许这样做?因为**"承诺"和"可能"不是一回事**:

于是超卖是一种受控的赌注:赌"不会所有容器同时打满"。这个赌注的赔率就是你写的两个数字:

"超卖比多少才合适"没有通用答案——它取决于你的负载是否同相(比如同一时刻都在处理同一批数据,那就是同相,超卖很危险)。要看的是用量的时间分布,这是拿指标才能回答的问题。

三、内存与 CPU 的超限后果完全不同

这是本章最关键的一条,也是把第 01 章的失败分支接上:

CPU 内存
资源性质 可压缩:不够用就慢一点 不可压缩:不够用就是真的没有
超限的处置 限速:额度用完被推迟调度 OOM 杀进程
症状 变慢:延迟上升、超时增多 突然消失:进程没了、日志空白
排查起点 延迟分布、限流指标 OOMKilled、内核日志(在节点侧)

这两行症状的差别决定了排查方向完全不同,而它们常常被混在一起:

(这与《Docker 应用课》第 07 章同一张表,因为这层语义是从容器继承下来的:K8s 的 limits 最终就是 cgroup 参数。)

由此得到一条实操判据:内存的 limits 要留余量,CPU 的 limits 可以贴近实际。

原因:CPU 定紧的代价是"高峰期慢一点"(可恢复、可观测);内存定紧的代价是"被杀一次"(丢请求、重启、冷缓存,还可能连锁)。代价的性质不同,所以余量策略也该不同。

四、一次完整运行:从调度到超限

把三件事连起来:调度按 requests 找位置 → 绑定后节点的"已分配"账本加上这份 → 运行时 cgroup 按 limits 设天花板 → 超限走两条不同的路。

flowchart TD
  P["① Pod 待调度<br/>requests: 500m / 512Mi"] --> S{"② 有节点的「可分配量 - 已分配 requests」<br/>装得下这份 requests 吗?"}
  S -->|"没有任何节点装得下"| PEND["Pending<br/>无限期等待,不会自己好"]
  S -->|"有"| N["③ 绑定到该节点<br/>节点账本:已分配 requests += 这份"]
  N --> RUN["④ kubelet 起容器<br/>cgroup 按 limits 设天花板"]
  RUN --> W{"⑤ 容器实际用量超过 limits?"}
  W -->|"没超"| OK["正常运行"]
  W -->|"CPU 超了"| TH["限速:被推迟调度<br/>症状=变慢、延迟上升"]
  W -->|"内存超了"| OOM["cgroup OOM 处置:杀进程<br/>症状=突然消失、日志空白"]
  S -.->|"注意:这里的账本只用 requests<br/>limits 完全不参与调度计算"| S

图上那条折回自身的虚线是本章想钉住的一点:limits 不进调度器的账本。 所以"我给每个容器写了 4 核的 limits,应该有足够的余量"这个推论是错的——调度器只看 requests,它可能把很多个"limits 4 核、requests 100m"的容器塞到同一台机器上。

五、QoS 等级:写法决定了被杀的先后

requests 与 limits 怎么组合,会让 Pod 落到三个不同的 QoS(服务质量)等级。等级影响的是节点资源紧张时,谁先被驱逐:

写法 QoS 等级 资源紧张时的处境
每个容器都写了 requests 与 limits,且两者相等 Guaranteed 最后被驱逐(有明确保证)
写了,但两者不相等(或只写了其中一个) Burstable 中间;越贴近 requests 越安全
都不写 BestEffort 最先被驱逐

对应到实际决策:

值得说明的是:BestEffort 不是"没有保障"这么简单,它的实际含义是"在争抢中排最后"。一个没有 requests 的容器在节点被填满时会被优先杀掉,而它的"份额"是 0——所以它连"我该被分到多少"这件事都没有主张过。

六、初始值怎么定:从可观测量倒推

和第 03 章的探针一样,具体数值不该照抄。倒推的路径是:

写什么 依据 定错的症状
requests.cpu 稳态用量的高分位(例如 p95),不是峰值 定得太高 → 调度器以为节点满了,大量 Pending 其实节点很闲
requests.memory 稳态用量的高分位 同上
limits.cpu 可以贴近"峰值能接受被限速的水平" 定得太低 → 高峰期全体变慢
limits.memory 峰值 + 余量(余量是对付泄漏与突发的) 定得贴着峰值 → 一次突发就被杀

两条能立刻用的判断:

  1. requests 反映的是"我平时要多少",不是"我最坏要多少"。 用峰值当 requests 会让调度器浪费大量真实容量——节点看起来满了,实际 CPU 利用率很低。
  2. 内存的 limits 一定要比"你观测到的峰值"高。 因为你观测到的峰值一定小于未来会出现的峰值(观测窗口有限),而内存越界的代价是丢请求。

七、误判澄清

读者常见的理解 核对后的事实 为什么会被误导
"requests 是下限、limits 是上限" 更准确:requests 是调度时的承诺,limits 是运行时的天花板。两者的使用者不同 "下限/上限"听起来对称,但它们作用于不同阶段
"limits 加起来不超过节点容量就行" 调度器完全不看 limits,只看 requests。limits 合计可以远超节点容量 直觉上的"资源守恒"在这里不成立
"没写 limits 就不会被杀" 没写 limits 只是"没有保护"——争抢时按用量被杀,且节点会被用到极限才触发 把"没有限制"理解成"不会触发限制"
"CPU limits 太小会被 OOM 杀" CPU 是可压缩资源,超限只限速;被杀只可能是内存 两个数字都是"资源",症状被合并记忆了
"改了 requests 能让正在跑的 Pod 变小" 改 requests 不影响已调度的 Pod(要重建才套用新账本) 以为它像 limits 一样实时生效
"QoS 只是分类标签" 它是驱逐顺序:BestEffort 最先被赶走,Guaranteed 最后 名字像文档字段
"requests 填峰值最安全" 会让调度失真:节点显示满、实际很闲,大量 Pod 无谓 Pending "多要一点更安全"的直觉在调度层面是反的

生产边界

教学替身 真实替换点 要注意什么
手工算清单的 requests 合计 集群侧的容量规划:节点可分配量 vs 各命名空间 requests 配额(ResourceQuota) 要比较的是可分配量,不是节点规格(见下)
一台节点 多节点 + 自动扩缩(Cluster Autoscaler / HPA) 扩缩的触发依据是利用率,而利用率的分母是 requests——requests 填得虚高会让扩缩彻底失灵
静态检查算超卖比 监控里的实际用量分布 + 争抢指标(CPU throttling、OOM 计数) 超卖比只是"潜势",真实风险要看用量是否同相
手写 QoS 等级 按服务等级目标(SLO)分类配置 关键路径上的服务才值得用 Guaranteed 换成本
单容器排障 节点侧的内核日志(OOM 记录在节点上,不在容器里) 容器里看不到宿主的内核记录(《Docker 应用课》第 07 章)

一个必须澄清的量:清单的 requests 合计要和**节点的"可分配量"**比,不是和"节点规格"比。kubelet 会先从总容量里扣掉一部分系统预留,剩下才是可分配量——而扣除比例取决于配置,不是一个固定百分比。所以"我这台是 4 核 16G,1.2 核 1.25G 肯定装得下"这个推论,方向对但依据不完整。

动手

  1. 用 kubectl kustomize 构建清单,算出每个对象的单副本 requests/limits 与全清单合计;
  2. 算出你的超卖比,并回答"我的负载是否同相"——如果所有副本同时处理相同类型的工作,同相的可能性很高;
  3. 用一个真实的用量观测(不是猜)去核对 requests 是否接近稳态高分位;
  4. 判断标准:能回答"如果我的容器内存用量突然翻倍,会发生什么"——答案必须包含"会不会被杀"和"节点账本是否会变";
  5. 完成标志:写出你每个关键服务的 QoS 等级,并解释为什么它应该是这一档。

故障注入

注入方式 观察什么 说明的现象
把 requests 调到一个节点装不下的值 Pod 状态 Pending(第 02 跳失败分支),且不会自己好
把 limits.memory 压到低于实测峰值 Pod 状态与 OOMKilled 字段 被 cgroup 杀,日志空白;LAST REASON: OOMKilled
把 limits.cpu 压得很低(内存不动) 请求延迟分布 只变慢,不会被杀——与内存的症状完全不同
删掉所有 limits 后把节点压到内存紧张 被杀的 Pod 是哪一个 按用量被选中,与"谁越界"无关(现场那个 bug)
把某个 Pod 的 requests/limits 设成相等 该 Pod 的 QoS 等级 变成 Guaranteed,在驱逐顺序里排最后
把 requests 全部填成峰值 可调度副本数 大量 Pending,而节点实际利用率很低——调度失真

自测

  1. requests 和 limits 分别被哪个组件使用?为什么"limits 合计不超节点容量就行"是错的?
  2. CPU 超限与内存超限的处置方式与症状分别是什么?为什么这个差别决定了排查方向?
  3. 什么叫超卖比?本课示例清单的 CPU 超卖比是多少?超卖比高会带来什么具体风险?
  4. QoS 三个等级是怎么由写法决定的?为什么关键服务应该配成 Guaranteed?
  5. 为什么"requests 填峰值更安全"这个直觉在调度层面是反的?
  6. 为什么内存的 limits 必须比观测到的峰值更高,而 CPU 的 limits 可以贴近实际?

现在能解释什么

下一章进入第三个判断点:滚动更新在滚什么,maxUnavailable: 0 到底保证什么。

进入 keel 阅读