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。这带来两个后果:
- 节点内存紧张时,被杀的由内核决定,判据是"谁占得多",而不是"谁越界了"、"谁的责任"。没有
limits的容器在争抢中没有任何保护。 - 因为没有任何人被限制,节点被用到了极限才触发 OOM——触发得很晚,而越晚触发,争抢越激烈。
所以这个现场的根因不是"某个服务泄漏",而是整台机器上没有一条资源边界。修改方案也不是查泄漏,而是先把边界画出来(并给可能泄漏的容器足够的内存余量)。
一、两个数字,两个使用者
requests |
limits |
|
|---|---|---|
| 回答的问题 | 要预留多少才能把这个 Pod 放上来 | 这个容器最多能用多少 |
| 谁使用它 | kube-scheduler(以及 kubelet 做准入时的资源核算) | cgroup(内核级别的运行时强制) |
| 什么时候起作用 | 调度那一刻(一次性决策) | 容器整个生命周期(持续生效) |
| 超了会怎样 | 不会"超"——它在调度时就被当成了已占用 | CPU:被限速;内存:被 OOM 杀 |
| 不写它会怎样 | 按 0 计算 → 节点会超卖到失控 | 没有上限 → 单容器可以吃掉整台节点 |
| 能不能改 | 能,但改小 requests 不会立刻腾出资源(要等 Pod 重建) | 能,改完对运行中的容器生效 |
一行话记住区别:
requests是"我占个位子",limits是"我的天花板"。位子在调度时用掉,天花板在运行时才生效。
这解释了几件日常现象:
- 改了
requests之后 Pod 没重启:因为requests是调度期概念,已经调度的 Pod 不会因为账本变化被赶走。 limits调小了不用重建 Pod:它是运行时 cgroup 参数,直接改就生效(当然这也意味着它可能触发一次 OOM)。- 节点上显示"已分配"超过了实际用量:因为那是
requests之和,是一个承诺,不是真实消耗。
二、超卖:一份真实清单的账
把本课示例清单的账算出来(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。
为什么允许这样做?因为**"承诺"和"可能"不是一回事**:
- 调度时按
requests记账,保证每个 Pod 都能拿到它承诺的量(这也是为什么 requests 之和不能超过节点可分配量); - 但实际用量通常远低于各自的
limits,而且峰值在时间上错开。
于是超卖是一种受控的赌注:赌"不会所有容器同时打满"。这个赌注的赔率就是你写的两个数字:
- 超卖比越高,节点利用率越高,但同时峰值的概率越大;
- 超卖比越低(越接近 1),越安全,越浪费。
"超卖比多少才合适"没有通用答案——它取决于你的负载是否同相(比如同一时刻都在处理同一批数据,那就是同相,超卖很危险)。要看的是用量的时间分布,这是拿指标才能回答的问题。
三、内存与 CPU 的超限后果完全不同
这是本章最关键的一条,也是把第 01 章的失败分支接上:
| CPU | 内存 | |
|---|---|---|
| 资源性质 | 可压缩:不够用就慢一点 | 不可压缩:不够用就是真的没有 |
| 超限的处置 | 限速:额度用完被推迟调度 | OOM 杀进程 |
| 症状 | 变慢:延迟上升、超时增多 | 突然消失:进程没了、日志空白 |
| 排查起点 | 延迟分布、限流指标 | OOMKilled、内核日志(在节点侧) |
这两行症状的差别决定了排查方向完全不同,而它们常常被混在一起:
- "CPU 限制太小导致服务被杀"是不成立的——CPU 永远不会导致被杀。看到被杀就去看内存和 limits。
- "内存限制太小会被限速"也是不成立的——内存不够的结局是杀,不是慢。
(这与《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 | 最先被驱逐 |
对应到实际决策:
- 关键服务(数据库、网关) → 配成 Guaranteed(requests = limits),用余量换确定性;
- 普通服务 → Burstable,
requests反映稳态用量、limits留峰值余量; - 批处理 / 可重跑的任务 → 接近 BestEffort 也可以接受,因为被驱逐重跑的成本低。
值得说明的是:BestEffort 不是"没有保障"这么简单,它的实际含义是"在争抢中排最后"。一个没有 requests 的容器在节点被填满时会被优先杀掉,而它的"份额"是 0——所以它连"我该被分到多少"这件事都没有主张过。
六、初始值怎么定:从可观测量倒推
和第 03 章的探针一样,具体数值不该照抄。倒推的路径是:
| 写什么 | 依据 | 定错的症状 |
|---|---|---|
requests.cpu |
稳态用量的高分位(例如 p95),不是峰值 | 定得太高 → 调度器以为节点满了,大量 Pending 其实节点很闲 |
requests.memory |
稳态用量的高分位 | 同上 |
limits.cpu |
可以贴近"峰值能接受被限速的水平" | 定得太低 → 高峰期全体变慢 |
limits.memory |
峰值 + 余量(余量是对付泄漏与突发的) | 定得贴着峰值 → 一次突发就被杀 |
两条能立刻用的判断:
requests反映的是"我平时要多少",不是"我最坏要多少"。 用峰值当 requests 会让调度器浪费大量真实容量——节点看起来满了,实际 CPU 利用率很低。- 内存的
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 肯定装得下"这个推论,方向对但依据不完整。
动手
- 用
kubectl kustomize构建清单,算出每个对象的单副本 requests/limits 与全清单合计; - 算出你的超卖比,并回答"我的负载是否同相"——如果所有副本同时处理相同类型的工作,同相的可能性很高;
- 用一个真实的用量观测(不是猜)去核对
requests是否接近稳态高分位; - 判断标准:能回答"如果我的容器内存用量突然翻倍,会发生什么"——答案必须包含"会不会被杀"和"节点账本是否会变";
- 完成标志:写出你每个关键服务的 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,而节点实际利用率很低——调度失真 |
自测
requests和limits分别被哪个组件使用?为什么"limits合计不超节点容量就行"是错的?- CPU 超限与内存超限的处置方式与症状分别是什么?为什么这个差别决定了排查方向?
- 什么叫超卖比?本课示例清单的 CPU 超卖比是多少?超卖比高会带来什么具体风险?
- QoS 三个等级是怎么由写法决定的?为什么关键服务应该配成 Guaranteed?
- 为什么"
requests填峰值更安全"这个直觉在调度层面是反的? - 为什么内存的
limits必须比观测到的峰值更高,而 CPU 的limits可以贴近实际?
现在能解释什么
requests供调度器用(承诺),limits供 cgroup 用(天花板)——两者在不同的阶段起作用。limits不进调度账本,所以超卖是常态;超卖比多少合适取决于用量是否同相。- CPU 与内存超限的后果性质不同:限速(可恢复、变慢)vs 杀进程(丢请求、消失)。这决定了余量策略:内存必须留余量。
- 没写
limits不是"不会触发限制",而是"争抢时没有保护、且节点会被用到极限才触发"。 - QoS 等级由写法决定,本质是驱逐顺序;
requests填得虚高会让自动扩缩失灵。
下一章进入第三个判断点:滚动更新在滚什么,maxUnavailable: 0 到底保证什么。