
一个 Namespace 还有 CPU 配额，新的 Pod 却一直 Pending；另一个 Pod 在创建时就被拒绝，API 中连对象都找不到。两种现象都涉及资源，却发生在不同位置：ResourceQuota 检查 Namespace 能否接受这次资源声明，Scheduler 检查某个节点能否容纳这个 Pod。

LimitRange 又在它们之前引入一个容易忽略的变化：客户端没有填写的 request 和 limit，可能已经被 API Server 补上。因此，排查时不能只看提交的 YAML，还要看实际存储的 Pod。本文用 Kubernetes v1.35.1 的准入实现和隔离实验，把默认值、单对象约束、总配额与节点容量串起来。

<!--more-->

## 资源约束发生在三个位置

LimitRange 可以为 Namespace 中的容器设置资源默认值和最小、最大值，也支持部分 Pod 级和 PVC 约束。它处理单个对象的资源要求，不是 Namespace 总预算。

ResourceQuota 对匹配范围内的对象做总量约束，例如 Pod 数量、声明的 CPU 和内存 request/limit、PVC 存储请求和对象数量。不同配额项的统计语义不同：`pods` 计入的是受该配额约束的非终态 Pod，不能把所有 resource count 配额都理解成“正在运行的进程数量”。

Scheduler 面对的是已经通过准入、写入 API 的 Pod。它结合节点可分配资源、已有 Pod 的资源声明和其他调度约束，选择节点。Namespace 的配额还剩 10 CPU，不代表任意一台节点有 10 CPU，也不代表满足 Pod 亲和性要求的节点有这些容量。

```mermaid
sequenceDiagram
    participant C as 客户端
    participant A as API Server
    participant L as LimitRanger
    participant Q as ResourceQuota
    participant E as 存储
    participant S as Scheduler
    C->>A: 创建 Pod
    A->>L: Mutating 阶段补资源默认值
    L-->>A: 补全后的 Pod
    A->>L: Validating 阶段检查单对象约束
    L-->>A: 约束通过
    A->>Q: 检查声明量和 Namespace 配额
    Q-->>A: 配额通过并完成用量更新
    A->>E: 写入 Pod
    A-->>C: 创建成功
    S->>A: 通过 List/Watch 观察未调度 Pod
    S->>S: 检查节点容量和其他约束
    S->>A: 有可行节点时提交 Binding
```

图只保留本题相关阶段，省略其他准入插件。Quota 用量更新与 Pod 写入不是跨多个对象的一次 etcd 事务；不能从图中推出“任何失败都能立即原子撤销所有计量”。调度也不要求客户端收到创建响应后才开始观察，图中的响应和观察不构成这种严格先后保证。

## 默认值怎样进入实际 Pod

LimitRanger 同时实现 mutating 和 validating 两种准入接口。[Admit 与 Validate](https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/admission/limitranger/admission.go#L111-L127) 分别调用资源修改和资源校验逻辑。容器默认值由 `defaultContainerResourceRequirements` 从 LimitRange 的 `defaultRequest` 和 `default` 组装，再由 [mergeContainerResources](https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/admission/limitranger/admission.go#L236-L268) 合入容器。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/admission/limitranger/admission.go#L111-L127" >}}

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/admission/limitranger/admission.go#L236-L268" >}}

这个函数按资源逐项判断是否已经存在。只有缺失的 request 或 limit 才会被填入，不是把用户提交的整份 resources 覆盖为默认模板。因此，一个只写了 CPU request 的容器，仍可能得到默认的内存 request 和 CPU、内存 limit。

API 默认处理本身也可能产生 request，例如只声明某资源 limit 而没有声明相应 request，且没有其他准入默认机制提供 request 时，Kubernetes 会把该 limit 作为 request。不能仅凭“我没写 request”推断调度器会按零计算。遇到多种默认机制时，检查最终 Pod，而不是靠记忆推导一个期望值。[官方资源管理文档](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/)说明了这条默认规则。

修改 LimitRange 不会主动遍历已有 Pod 并重写它们。新增默认值影响后续经过对应准入路径的对象；旧 Pod 的原始声明仍需单独检查。一个 Namespace 有多个含默认值的 LimitRange 时，也不应依赖某个稳定的冲突覆盖顺序，宜让默认资源策略有明确来源。

## Quota 根据声明计账，不根据当前利用率计账

假设一个空闲容器请求 `500m` CPU，它即使当前只用了 `5m`，仍按声明的 `500m` 消耗 `requests.cpu` 配额。提高 Namespace 配额也不会增加节点 CPU，Quota 本身不替 Namespace 预留某台节点的物理容量。

包级函数 [CheckRequest](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/admission/plugin/resourcequota/controller.go#L412-L446) 检查传入的适用配额，使用对应资源 evaluator 计算本次变化的用量。更新已有对象时要考虑旧对象，不能简单地把新对象的完整资源再加一次；对请求声明的差量做计量后，才与 hard 比较。调用它的方法名是小写的 `quotaEvaluator.checkRequest`，两者不是同一个符号。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/admission/plugin/resourcequota/controller.go#L412-L446" >}}

在 [用量上限判断](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/admission/plugin/resourcequota/controller.go#L596-L621) 中，新用量来自 `Status.Used + deltaUsage`。如果超过相应 `Status.Hard`，返回带 `requested`、`used` 和 `limited` 的 Forbidden。这个错误发生在目标 Pod 持久化之前，Scheduler 没有机会为一个未创建的 Pod 产生 FailedScheduling 事件。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/admission/plugin/resourcequota/controller.go#L596-L621" >}}

配额有 CPU、内存要求时，缺失相应资源声明还可能因为无法满足计量约束被拒绝。LimitRange 的默认值可以补齐这些声明，但默认值本身也会消耗配额。Namespace 配额并不承诺每个团队一定能调度到相应资源，它主要限制能够被准入的声明总量。

## 并发创建怎样避免各自都看到旧用量

如果两个客户端同时创建 Pod，只做一次只读比较显然不够：它们可能同时看到配额剩余一个 Pod 的空间。

[checkQuotas](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/admission/plugin/resourcequota/controller.go#L281-L320) 不止计算结果，还调用 `UpdateQuotaStatus` 写入新用量。代码把同一 Namespace 的请求组织到 evaluator 的工作队列中；更新失败时重新取得相关配额并在有限重试次数内重新检查。多个 API Server 副本之间仍需要靠 API 对象版本与更新冲突来协调，单进程队列不是全局锁。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/admission/plugin/resourcequota/controller.go#L281-L320" >}}

这种设计让通过准入依赖配额用量更新成功，而不是等所有 Pod 创建完成后才异步统计。后台 ResourceQuota 控制器仍需重新同步实际使用量，处理对象删除、失败请求或其他状态变化。它不提供删除完成后任何客户端都能在同一瞬间看到额度归还的保证。

降低 `spec.hard` 不会让 Quota 控制器驱逐已有 Pod：控制器重算用量并更新 `status`，后续新增用量受新额度约束。这允许出现现有用量高于新 hard、已有业务仍在运行的状态。滚动发布还要给新旧 Pod 的重叠留出配额；`QuotaV1Pod` 按终态和删除宽限判断是否计入 `pods`，Pending 和部分终止中 Pod 仍可能占额度。Deployment 有 surge 创建预算，不代表 Namespace 一定允许创建。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/quota/v1/evaluator/core/pods.go#L489-L508" >}}

配额控制器负责重算并更新用量，不在这里删除超出新配额的工作负载。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/resourcequota/resource_quota_controller.go#L367-L411" >}}

本次实验没有制造并发竞争；并发计量、重试与重算的解释来自该固定版本源码。单个请求实验只检验默认值和拒绝行为，不能用来证明所有并发场景。

## 实验：默认值、单容器上限和 Namespace 总配额

实验通过 SSH 在 `zj` 的专用 kind 集群 `article-api` 执行，kind v0.33.0，客户端和服务端均为 Kubernetes v1.35.1，提交 `8fea90b45245ef5c8ba54e7ae044d3e777c22500`。配置、脚本、证据和复现说明见 [k8sdev/article-api-quota](https://github.com/jimyag/k8sdev/tree/3cfa654905a750b1716de8c32aa42d7573220954/article-api-quota)。先按该目录 README 的固定提交准备步骤创建独立工作目录，再运行实验。

验证脚本读取默认处理后的 resources，并区分创建被拒绝与已经创建但无法调度。

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-api-quota/run.py#L33-L50" >}}

第一组 Namespace 使用以下关键约束；完整 YAML 保存在实验目录，下面是实际配置的摘录：

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-api-quota/policy.yaml#L1-L17" >}}

创建一个没有填写 resources 的 Pod 后，API 实际存储的 resources 是 CPU request `100m`、limit `200m`，内存 request `32Mi`、limit `64Mi`。这是一次服务端对象读取结果，说明默认值已经进入 Pod，后续计量与调度看到的也是这些声明。

随后创建 CPU limit 为 `2` 的容器，实际返回：

```text
maximum cpu usage per Container is 1, but limit is 2
```

这条拒绝来自单容器上限。即使 Namespace CPU 总配额允许，仍不能越过该 LimitRange。

再创建第二个默认 Pod，两个对象都被准入。ResourceQuota 的实际 used 为 `pods=2`、`requests.cpu=200m`、`limits.cpu=400m`、`requests.memory=64Mi`、`limits.memory=128Mi`。第三个 Pod 被拒绝：

```text
exceeded quota: budget, requested: pods=1, used: pods=2, limited: pods=2
```

这里先耗尽的是 Pod 数量，CPU 和内存配额仍有剩余。看见“还有 CPU 配额”不能推断任意创建都应通过。

## 实验：额度足够，仍找不到节点

第二组使用独立 Namespace，避免前一组 LimitRange 的 CPU 上限干扰。配额允许 `requests.cpu=20000`，一个 Pod 请求 `10000` CPU。这是 API 中的资源声明，不会因为提交请求就让宿主机实际消耗 10000 CPU。

创建请求成功，Pod 存在于 API 中，但 `nodeName` 为空，阶段为 Pending，调度条件是：

```text
type: PodScheduled
status: False
reason: Unschedulable
message: 0/1 nodes are available: 1 Insufficient cpu.
```

该 Namespace 的预算足够容纳这条声明，但实验单节点无法容纳它。调度器的节点筛选使用资源请求量，而不是看到实时 CPU 利用率很低就忽略 request。[调度约束文章](/posts/kubernetes-scheduling-preemption/)进一步解释资源与其他硬约束怎样共同决定可行节点。

区分这两类失败，可以直接决定排查入口：创建被拒绝时读 API 返回的 LimitRange 或 Quota 信息；对象已创建却未绑定节点时读 PodScheduled 条件、调度事件和节点可分配资源。Quota 扩容只能解决前一类中的总预算限制，不能代替后一类所需的节点容量或调度约束调整。

