jimyag's Blog

Kubernetes CPU 资源管理:request、limit 与 CPU 限流的性能影响

节点 CPU 看起来很空,容器里的计算任务却明显变慢。把副本数加大之前,需要先回答一个问题:任务是在等物理 CPU,还是已经用完了这个容器在当前周期内允许使用的 CPU 时间?

在 zj 上的 kind 实验中,同一段消耗约 1 CPU 秒的计算,设置 100m CPU limit 后耗时约 10 秒;不设 limit 时约 1 秒。两者的 CPU request 都是 100m,区别只有是否配置执行配额。

request 和 limit 分别作用在哪里

CPU request 首先参与调度:scheduler 判断节点剩余的可分配请求量是否足以接纳 Pod。它不是给进程绑定一个物理核,也不意味着进程永远只能使用这个数量的 CPU。在 Linux 的共享 CPU 调度中,request 还会影响竞争时的相对权重。

CPU limit 则限制容器可消费的 CPU 时间。本文使用 cgroup v2,限制可以在容器的 /sys/fs/cgroup/cpu.max 中观察到。Kubernetes 对 request 和 limit 的职责划分见资源管理文档。

kubelet 把资源规约转换为 CRI 的 Linux 资源配置:request 用来计算 CPU shares,limit 用来计算 quota 与 period;运行时再把这些配置落实到 cgroup。使用 cgroup v2 时,共享权重表现为 cpu.weight,带宽上限表现为 cpu.max。这条转换路径见 v1.35.1 的 calculateLinuxResources。

真正暂停超额执行的是 Linux 内核。一个 cgroup 耗尽当前周期的 CPU 配额后,其中可运行的任务也需要等待配额补充;宿主机此刻即使有空闲核,也不会因此自动解除该 cgroup 的限制。下面读取 cpu.max 和 cpu.stat,就是为了把 Kubernetes 配置与实际执行约束对应起来。

用同一份计算比较两种配置

环境为 Linux 主机 zj、kind v0.33.0、Kubernetes v1.35.1。两个 Pod 使用同一个 Python 镜像,启动后保持空闲,再通过 kubectl exec 执行测量。为了避免以固定循环次数代替真实 CPU 工作量,循环以 time.process_time() 增加 1 秒作为结束条件,同时使用单调时钟记录实际耗时。

实验配置的差异如下:

1
2
3
4
5
6
# limited 容器:请求量与执行上限都是 0.1 CPU。
resources:
  requests:
    cpu: 100m # 参与调度和竞争权重。
  limits:
    cpu: 100m # 限制可消费的 CPU 时间。

另一组仅保留 requests.cpu: 100m,不配置 limits.cpu。没有改变镜像、测量程序或计算目标。

测量前后还读取了 cpu.stat,计算计数器增量,避免把容器启动阶段的历史 throttling 算到本次任务里。实际结果如下:

观察项limit=100m不设 CPU limit
进程 CPU 时间1.000 秒1.000 秒
实际耗时10.000 秒1.001 秒
cpu.max10000 100000max 100000
nr_periods 增量1000
nr_throttled 增量1000

两组进程消耗的 CPU 时间相同,实际耗时却相差约 9 秒;受限组还记录了 100 次 throttling。这组对照把额外等待与配额限制对应起来。

10000 100000 怎么读

cpu.max 的两个值分别是配额和周期,单位为微秒。本次 10000 100000 表示每个 100 毫秒周期最多消费 10 毫秒 CPU 时间,长期平均对应 0.1 CPU;max 表示当前这一层没有配置 CPU 带宽上限。字段定义见 Linux cgroup v2 文档。

这 10 毫秒是一个 cgroup 的总 CPU 时间预算,不是为每个线程各发 10 毫秒。多个线程并行执行时可能更快耗尽预算,然后一起等待后续周期。线程数增加,不一定让受相同配额限制的任务更快。

不设当前容器的 limit,也不表示拥有无限算力。它仍受宿主机容量、其他进程竞争,以及可能存在的上层 cgroup 限制。kind 的节点本身也是宿主机上的容器,所以这里的结果不能替代真实业务节点的容量测试。

配额为什么不是一颗速度更慢的 CPU

进程获得 CPU 时仍按实际处理器速度运行,预算用完后要等待后续可用预算。因此即使长期平均值相同,“短时间跑完后等待”和“均匀地慢速运行”对请求延迟也不同。

Linux 6.12 带宽控制文档解释了预算按批次分配到各 CPU 运行队列的实现:批量转移降低了多个 CPU 争用全局计账状态的开销,代价是计账粒度与开销之间需要取舍。未用完的本地份额还可能带来短时偏差,不能把每个采样窗口都当作精确的 10 毫秒开关。本次实验用单个计算任务验证 100m 配额,没有额外测量多线程延迟。

本次无 limit 对照约 1 秒完成,说明当时有足够可用 CPU,不能理解为无 limit 就保证独占一个核。

为什么 CPU 图表可能看起来不高

首先要看分母。容器使用 0.1 CPU,对一个有 12 个逻辑 CPU 的宿主机来说,占整机容量不到 1%;但对 limit=100m 的容器来说,已经用满了配置上限。两个图表并不矛盾。

其次要看时间窗口。一个任务先计算、再等待下游,或只在短时间内突发,较长窗口内的平均 CPU 可以很低。平均值无法显示每个配额周期是否发生等待。

因此,排查时应该把请求耗时、容器 CPU 使用量、配置的 limit 和 cpu.stat 的增量放在相同时间段内比较。只看到 nr_throttled 增加,还不能证明某次请求延迟完全由 throttling 导致;I/O、锁竞争、下游等待和 CPU 竞争也可能同时存在。

现场怎么确认

1
2
3
kubectl --context kind-deep-dive -n dd-cpu get pod limited -o yaml
kubectl --context kind-deep-dive -n dd-cpu exec limited -- cat /sys/fs/cgroup/cpu.max
kubectl --context kind-deep-dive -n dd-cpu exec limited -- cat /sys/fs/cgroup/cpu.stat

先确认 limit 是否由工作负载直接配置,或由准入阶段补入。再确认应用的工作单位和并发度,使用同一负载比较配额变化前后的完成时间。如果放宽 limit 后 throttling 消失,但延迟仍然很高,就要继续找真正的等待位置。

是否保留 CPU limit,需要结合隔离要求和工作负载决定。这次实验支持“配额会拉长任务耗时”,不支持“所有服务都应删除 CPU limit”。多租户场景下,上限本身可能就是需要的隔离策略。

实验代码与使用说明见 k8sdev/deep-dive-cpu。

#Kubernetes #Kind #Troubleshooting