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

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

<!--more-->

## request 和 limit 分别作用在哪里

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

CPU limit 则限制容器可消费的 CPU 时间。本文使用 cgroup v2，限制可以在容器的 `/sys/fs/cgroup/cpu.max` 中观察到。Kubernetes 对 request 和 limit 的职责划分见[资源管理文档](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#requests-and-limits)。

kubelet 把资源规约转换为 CRI 的 Linux 资源配置：request 用来计算 CPU shares，limit 用来计算 quota 与 period；运行时再把这些配置落实到 cgroup。使用 cgroup v2 时，共享权重表现为 `cpu.weight`，带宽上限表现为 `cpu.max`。这条转换路径见 [v1.35.1 的 `calculateLinuxResources`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kuberuntime/kuberuntime_container_linux.go)。

真正暂停超额执行的是 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 秒作为结束条件，同时使用单调时钟记录实际耗时。

实验配置的差异如下：

```yaml
# 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.max` | `10000 100000` | `max 100000` |
| `nr_periods` 增量 | 100 | 0 |
| `nr_throttled` 增量 | 100 | 0 |

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

## `10000 100000` 怎么读

`cpu.max` 的两个值分别是配额和周期，单位为微秒。本次 `10000 100000` 表示每个 100 毫秒周期最多消费 10 毫秒 CPU 时间，长期平均对应 0.1 CPU；`max` 表示当前这一层没有配置 CPU 带宽上限。字段定义见 [Linux cgroup v2 文档](https://docs.kernel.org/admin-guide/cgroup-v2.html#cpu)。

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

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

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

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

[Linux 6.12 带宽控制文档](https://docs.kernel.org/6.12/scheduler/sched-bwc.html)解释了预算按批次分配到各 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 竞争也可能同时存在。

## 现场怎么确认

```sh
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](https://github.com/jimyag/k8sdev/tree/8f9bfbbd44ac061702310f332ee949fcdf0a213d/deep-dive-cpu)。

