AI时代的Kubernetes教程36招-第17招-Requests、Limits、QoS、LimitRange和ResourceQuota怎么控制资源?

Kubernetes 资源治理:从 Requests/Limits 到 LimitRange 与 ResourceQuota

约定挡不住抢占,只有准入层强制的边界才算数

[读者] 平台工程师 / 多租户集群维护者
[痛点] 没写 request/limit 的 Pod 互相抢占,OOMKilled 与 throttling 轮番出现
[现在读] 共享集群从口头约定走向准入强制的临界点
[读完] 能设计 LimitRange + ResourceQuota + QoS 三层边界,并按证据定位资源类故障
[旧方案] 文档约定 + CI lint 检查工作负载 YAML
    |
    v
[新需求] 多租户共享同一批节点,资源必须由平台强制隔离
    |
    v
[冲突] 约定不可执行,缺省 Pod 落进 BestEffort,抢占没有代价
    |
    v
[后果] 在线服务被驱逐、OOMKilled 循环、CPU 抖动,且定位不到具体租户

我是老李,带一个平台组,维护一套内部多租户 Kubernetes 集群。集群上跑三类负载:在线 API、定时批处理、数据团队的离线训练任务。

早期只有一个团队用,工作负载 YAML 里写不写 resources 全靠自觉,谁也没出过事。后来租户涨到十几个,节点池没变,情况立刻不一样了:既没写 request 也没写 limit 的训练 Pod,被调度器当成零成本负载塞进任意节点;等它真正吃满内存,同一节点上的在线 API 就进了 OOM 名单。我们收到的故障单写着服务偶发 502,而真正的原因在另一个团队的 YAML 里。

我们的第一反应是加规则:所有工作负载必须写 resources。规则跑了三个月,显式声明的覆盖率大概七成——剩下的三成不是故意违规,而是 Helm chart 模板、Operator 生成的 Pod、Job 的临时 Pod,它们根本不经过人手里的那份 YAML。

于是问题变成了:当集群从一个人用变成一群人抢,靠文档约束的资源边界还顶得住吗?

是继续补规则、把人变成准入控制器,还是把边界下沉到集群本身,让不合规的 Pod 根本创建不出来?

如果继续靠人,每多一个租户、每多一个自动化组件,边界就多一道漏洞,直到某天在线服务的 SLO 被别人的训练任务吃掉,而你还得先花时间才能知道是谁。

01、故事

现场是一个共享集群,8 个 16C64G 的工作节点,上面压着十几个租户的工作负载。我们的在线服务 api-gateway、order-svc 和数据团队的训练任务 train-job 混在同一个节点池里。api-gateway 的 Deployment 老老实实写了 requests 和 limits;train-job 来自一个内部 Helm chart,模板里 resources: {},chart 作者的理由是「训练任务的内存随数据集变化,写死会 OOM」。

任务是:在不新增节点的前提下,让任何一个租户都无法通过自己的资源占用影响其他租户的可用性。

变化发生在一个季度里。租户从 1 个涨到十几个,引入 CronJob、Operator 和 Helm hook 之后,集群里每天新增的 Pod 有相当一部分不经过任何人的 YAML。当前方案是三条约定:写 request、写 limit、别超卖。

结果是调度器看到的 train-job 是一个零成本负载——它可以被塞进任何节点,而它真实的内存占用会在启动后几十秒内爬到十几 GB。同一节点上的 order-svc 先出现延迟抖动,随后被 OOMKiller 选中重启,再往后直接收到驱逐通知。

我们把《资源申请规范》发到群里,在 CI 里加了一条 lint,覆盖率确实上去了,但只覆盖「人写的那部分 YAML」。于是每次出事之后,我们都要先花时间回答同一个问题:这次是谁。

约定写在纸上
抢占发生在夜里
告警指向结果
根因藏在别人的仓库

02、问题

旧方案的失效点不是「有人不写 resources」,而是「不写 resources 的 Pod 不会被拒绝」。Kubernetes 允许一个既不声明 request 也不声明 limit 的容器创建成功,它被归类为 BestEffort,然后照常参与调度、照常运行、照常在节点资源紧张时第一个被驱逐。规则能约束人,约束不了模板、Operator 和一次性 Job。

业务影响有三层:在线服务被驱逐导致 SLO 波动;排查时长从分钟级涨到小时级,因为要从结果反推到某个租户的某个工作负载;最隐蔽的一层是大家开始主动超卖——反正「别人也在超」。

技术表现可以先归纳成三类可观测信号:一是 kubectl describe pod 中出现 Last State: Terminated、Reason: OOMKilled、Exit Code: 137;二是容器被 CFS 限流,container_cpu_cfs_throttled_periods_total 相对 container_cpu_cfs_periods_total 的比例持续偏高;三是 kubectl describe node 的 Allocated resources 与实际用量严重脱节——调度器认为节点已经排满,实际 CPU 利用率却很低。

可验证的完成标准有四条:第一,在任何一个受管 Namespace 新建一个不声明 resources 的 Pod,其 spec.containers[].resources 必须非空;第二,任一 Namespace 的 requests.cpu 总和不得超过该 Namespace 的 ResourceQuota 硬限制,超出的创建请求必须被拒绝;第三,受管 Namespace 中 BestEffort Pod 数量为 0;第四,单个租户不能通过大量创建 PVC、ConfigMap、Secret、Service 挤占共享的控制面资源。

现象只是结果
边界才是原因
先问谁被允许
再问谁被抢占

03、原理

先明确两件事:requests 是给调度器和驱逐器看的,limits 是给 cgroup 看的。这两者由不同的组件在不同时刻执行,很多故障就产生在它们的缝隙里。

requests 的作用点在调度:kube-scheduler 为一个 Pod 选节点时,判断依据是节点上所有 Pod 的 request 之和,而不是实际用量。request 写小了,Pod 更容易被调度上去;写大了,节点上能塞的 Pod 就少。同一个数字还决定第二件事——当节点资源紧张时,kubelet 会按「实际用量超出 request 的幅度」排序来驱逐 Pod。

limits 的作用点在执行:由 kubelet 通过容器运行时写进 cgroup。CPU 的 limit 落到 CFS quota,内存的 limit 落到内核内存上限。这两条路径的行为差异是整个话题里最重要的一点。

反直觉判断:CPU 和内存虽然都被叫做资源 limit,但故障形态完全不同。CPU 是可压缩资源,超出 limit 的后果是线程被挂起等待下一个周期,表现是变慢;内存是不可压缩资源,超出 limit 的后果是内核 OOM killer 直接杀掉进程,表现是容器重启。

Pod 的 QoS class 由所有容器共同推导,规则只有三条:Guaranteed 要求 Pod 中每个容器都设置了 CPU 和内存的 request 与 limit,且同名资源的 request 等于 limit;BestEffort 指 Pod 中没有任何容器设置 request 或 limit;Burstable 是其余情况,即不满足 Guaranteed,且至少一个容器设置了 request 或 limit。

QoS 不是给人看的标签,它有两个真实作用:一是节点资源紧张时 kubelet 的驱逐顺序,BestEffort 先于 Burstable 先于 Guaranteed;二是内核 OOM score 的调整依据。所以哪怕你从不看 QoS 字段,它也在每次节点压力时替你做决定。

LimitRange 与 ResourceQuota 都工作在准入阶段,但职责不同。LimitRange 作用于单个 Pod、Container 或 PVC,负责补默认值和设上下限;ResourceQuota 作用于整个 Namespace 的聚合值,负责算总量。一个容易忽略的点是:一旦 Namespace 中存在限制 requests.cpu 或 limits.cpu 的 ResourceQuota,该 Namespace 中创建 Pod 时就必须显式提供对应的 request 或 limit,否则会被拒绝——这正是 LimitRange 必须同时存在的直接原因。

反直觉判断:requests 不是「预留资源」。节点上并不存在一块被锁起来的内存,它只是一组被调度器和驱逐器读取的数字。写 request 不会占用资源,只会改变调度决策和驱逐排序,所以把 request 调大并不能保护任何东西,它只是让调度器更保守。

声明不改内核
数字只做排序
压缩者会变慢
不可压缩者会死

04、架构

[输入] 租户提交的 Pod / PVC / 工作负载对象
    |
    v
[准入] LimitRange 注入默认值与上下限 → ResourceQuota 校验 Namespace 聚合值
    |
    v
[状态] Pod.spec.resources + QoS class + Namespace 已用量
    |
    v
[处理] Scheduler 按 requests 选节点 → kubelet 写 cgroup 上限
    |
    v
[输出] Running Pod / 拒绝创建 / OOMKilled / CPU throttling / 驱逐

这套架构的边界很清楚:它管的是「Namespace 内的资源声明」,不是运行时隔离。它不限制 Pod 之间的网络访问,不限制磁盘 IO,也不保证带宽。GPU 和临时存储需要单独声明和单独观测。

收益是把「人是否遵守规范」变成了「集群是否允许创建」。准入层是 Pod 进入集群的唯一入口,不管请求来自 Helm、Operator、CronJob 还是 kubectl run,都必须经过它,所以覆盖率天然是 100%,不需要统计也不需要巡检。

代价是多了一层强依赖:LimitRange 或 ResourceQuota 配错,会直接阻断该 Namespace 的所有创建操作,包括你自己用来排障的那个 Pod。所以这套配置本身应当比应用配置更保守,变更前也应当先在测试 Namespace 验证。

适用条件是「命名空间即租户边界」。如果多个租户共享同一个 Namespace,ResourceQuota 就退化成团队内部的自我约束,隔离能力随之消失。

准入挡在门前
调度只看数字
内核执行上限
观测还原真相

05、实战一次

环境:一个可用的 Kubernetes 集群(kind、minikube 或托管集群均可),kubectl 已配置。具体版本需按当前官方文档核验。

依赖:无第三方组件,只用内置的 LimitRanger 与 ResourceQuota 两个准入控制器。这两个插件需要在 API Server 的 --enable-admission-plugins 中启用,多数发行版默认启用,需按当前官方文档核验。

目录结构,对应项目里的 deploy/base/resources/:

deploy/base/resources/
├── kustomization.yaml
├── limitrange.yaml
└── resourcequota.yaml

limitrange.yaml,负责给没声明 resources 的容器补默认值,同时设上下限和比例上限:

apiVersion: v1
kind: LimitRange
metadata:
  name: tenant-defaults
  namespace: tenant-a
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: "100m"
        memory: "128Mi"
      default:
        cpu: "500m"
        memory: "512Mi"
      min:
        cpu: "10m"
        memory: "32Mi"
      max:
        cpu: "4"
        memory: "8Gi"
      maxLimitRequestRatio:
        cpu: "10"
        memory: "4"
    - type: PersistentVolumeClaim
      min:
        storage: "1Gi"
      max:
        storage: "20Gi"

resourcequota.yaml,负责给整个 Namespace 的总量与对象数量封顶:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-a-quota
  namespace: tenant-a
spec:
  hard:
    requests.cpu: "8"
    requests.memory: "16Gi"
    limits.cpu: "16"
    limits.memory: "32Gi"
    requests.storage: "40Gi"
    persistentvolumeclaims: "10"
    pods: "50"
    services: "10"
    configmaps: "20"
    secrets: "20"
    count/deployments.apps: "10"
    count/jobs.batch: "20"

kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - limitrange.yaml
  - resourcequota.yaml

再准备一个故意不声明 resources 的 Deployment,用来验证默认值是否被注入:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-api
  namespace: tenant-a
spec:
  replicas: 1
  selector:
    matchLabels:
      app: demo-api
  template:
    metadata:
      labels:
        app: demo-api
    spec:
      containers:
        - name: demo-api
          image: registry.k8s.io/pause:3.9

启动:

kubectl create namespace tenant-a
kubectl apply -k deploy/base/resources/
kubectl -n tenant-a apply -f demo-api.yaml

验证一,检查默认值是否注入:

kubectl -n tenant-a get pod -l app=demo-api -o jsonpath='{.items[0].spec.containers[0].resources}'

未在当前环境实测,以下为预期结果。示例输出:

{"limits":{"cpu":"500m","memory":"512Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}

验证二,检查配额账本:

kubectl -n tenant-a describe quota tenant-a-quota

示例输出:

Name:                     tenant-a-quota
Namespace:                tenant-a
Resource                  Used    Hard
--------                  ----    ----
configmaps                0       20
count/deployments.apps    1       10
limits.cpu                500m    16
limits.memory             512Mi   32Gi
pods                      1       50
requests.cpu              100m    8
requests.memory           128Mi   16Gi
secrets                   0       20
services                  0       10

验证三,检查超额请求是否被拒绝:

kubectl -n tenant-a run big --image=registry.k8s.io/pause:3.9 \
  --overrides='{"spec":{"containers":[{"name":"big","image":"registry.k8s.io/pause:3.9","resources":{"requests":{"cpu":"32"}}}]}}'

示例输出:

Error from server (Forbidden): pods "big" is forbidden: exceeded quota: tenant-a-quota, requested: requests.cpu=32, used: requests.cpu=100m, limited: requests.cpu=8

到这里 V1 是跑通的:默认值被注入,总量被封顶,超额被拒绝。但它留下两个必须解决的问题。第一,全局只有一个默认值,512Mi 对内存密集型的训练任务是致命的,500m 对启动期需要多个核心的 JVM 也是致命的,而这两个问题都会在 V1 的保护下安静地发生。第二,边界只存在于已经被 apply 的 Namespace 里,新增租户漏了一步就等于没有边界,而漏掉的那一步没有任何地方会报错。

先让边界生效
再让边界精准
默认值会撒谎
覆盖率先于正确

06、排查

第一条诊断链,OOMKilled。

现象:train-job 的 Pod 反复重启,kubectl -n tenant-a get pod 显示 RESTARTS 持续增长,最终落到 CrashLoopBackOff。

怀疑:内存 limit 太小。

检查:

kubectl -n tenant-a describe pod train-job-0
kubectl -n tenant-a top pod train-job-0

证据,示例输出:

    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137

同时观察容器内存指标,working set 曲线在启动阶段一路爬升并贴到 limit,然后垂直掉回接近零,再重新爬升,形成锯齿。

根因:LimitRange 的 default.memory: 512Mi 被注入到这个没有声明 resources 的训练 Pod 上。训练任务在加载数据集阶段的实际内存需求远超它,而且这是启动峰值,不是稳态泄漏。

修复:不应该靠调大全局默认值来解决,而应该给这类负载一个显式的资源声明。V2 里通过按负载类别分档加显式声明来解决。

错误尝试: 第一反应是把 LimitRange 的 default 和 defaultRequest 一起往上调,比如内存调到 8Gi。为什么错:LimitRange 的默认值只在 Pod 创建的那一刻由准入链注入,改 LimitRange 不会影响已经存在的 Pod,所以训练任务该 OOM 还是 OOM;更严重的是,defaultRequest 调大之后,所有未声明 request 的工作负载都被记上 8Gi 的调度成本,调度器会认为节点已满,新的 Pod 开始成片 Pending。这是把一个「个别负载内存不够」的问题,换成了「整个集群调度容量失真」的问题,方向完全错了。

第二条诊断链,CPU throttling。

现象:api-gateway 上线新版本后 P99 明显抖动,但 kubectl top pod 显示的 CPU 用量看起来只有一半。

怀疑:节点 CPU 争抢,或者网络抖动。

检查第一步,看这个 Pod 实际的 resources 声明:

kubectl -n tenant-a get pod -l app=api-gateway -o jsonpath='{.items[0].spec.containers[0].resources}'

发现新版本的 Deployment 模板里 resources 被改回了 {},因此它拿到了 LimitRange 注入的 500m。

检查第二步,看限流指标:

rate(container_cpu_cfs_throttled_periods_total{namespace="tenant-a",container="api-gateway"}[5m])
/
rate(container_cpu_cfs_periods_total{namespace="tenant-a",container="api-gateway"}[5m])

证据:这个比值在延迟抖动的时间窗内明显抬升,而同一时间节点 CPU 利用率并不高。

根因:CPU limit 太小,稳态用量贴着 500m 走,每个 CFS 周期都在被限流。top 之所以显示用量只有一半,是因为被限流的线程根本没有机会执行,它统计的是实际执行时间,而不是需求。

修复:显式声明匹配负载特征的 requests 与 limits,而不是继续用默认值兜着。

错误尝试: 先加了两个副本,理由是「单副本扛不住就横向扩」。为什么错:每个副本都带着同一个 500m 的 limit,加副本只是把被限流的线程分散到更多容器里,单副本的额度一点没变;同时因为 request 没改,调度器仍然按 100m 计算,节点上会被塞进更多 Pod,限流面反而扩大。横向扩缩解决的是需求总量问题,不是单容器额度问题。

先看 Terminated
再看 throttled
一个会致死
一个只会变慢

07、优化

回到第 06 章的证据,根因可以归纳成三条。根因 A:所有负载共用一个默认值,这个默认值对内存密集型和 CPU 突发型负载都不成立。根因 B:默认值让「没声明」看起来像「已声明」,问题从缺失配置变成了错误配置,更难发现。根因 C:没有东西阻止极端超卖的声明,也没有东西阻止 BestEffort Pod 再次出现。

修改 1,把 deploy/base/resources/ 拆成「基线 + Profile」:

deploy/base/resources/
├── kustomization.yaml
├── limitrange-baseline.yaml
├── resourcequota-tenant.yaml
├── resourcequota-objects.yaml
└── profiles/
    ├── online.yaml
    └── batch.yaml

修改 2,LimitRange 只做兜底,把默认值压到保守的小值,用 maxLimitRequestRatio 挡住极端超卖:

apiVersion: v1
kind: LimitRange
metadata:
  name: tenant-baseline
  namespace: tenant-a
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: "50m"
        memory: "64Mi"
      default:
        cpu: "200m"
        memory: "256Mi"
      maxLimitRequestRatio:
        cpu: "8"
        memory: "4"

原因:兜底值的职责是「让 Pod 能起来,并且不会吃掉大块配额」,而不是「猜中这个负载需要多少」。猜错时的代价必须可控——小默认值猜错,后果是限流或 OOM 一个容器;大默认值猜错,后果是全球范围的调度容量失真。这就是根因 A 和 B 的直接修法。

修改 3,加一条 scope 为 BestEffort 的 ResourceQuota,把 BestEffort 从准入层直接禁掉:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-no-besteffort
  namespace: tenant-a
spec:
  hard:
    pods: "0"
  scopes:
    - BestEffort

原因:这条 quota 不依赖 LimitRange 是否被正确 apply,也不依赖任何 webhook 是否在线,它是一条独立的、声明式的准入红线。多租户环境里,任何依靠「某个组件没被删掉」才能生效的规则,都应该有一层独立兜底,这正是根因 C 的修法。

新行为:未声明 resources 的 Pod 仍然能创建,但拿到保守的小额度;任何试图创建 BestEffort Pod 的请求被拒绝;CPU 的 limit/request 比例超过 8、内存超过 4 的声明被拒绝;PVC 容量、ConfigMap、Secret、Service、Job 的数量也进入配额;online 与 batch 两类负载在应用模板里显式声明各自的 resources,不再依赖默认值。

验证方式,未在当前环境实测,以下为预期结果:

kubectl -n tenant-a apply -k deploy/base/resources/
kubectl -n tenant-a describe quota

预期:出现 tenant-no-besteffort 条目,pods 硬限制为 0,当前 Used 为 0。

再验证 BestEffort 是否真的被拒,示例输出:

Error from server (Forbidden): error when creating "besteffort.yaml": pods "besteffort" is forbidden: exceeded quota: tenant-no-besteffort, requested: pods=1, used: pods=0, limited: pods=0

默认只做兜底
红线独立存在
比例挡住超卖
分档承担精度

08、演进

[同一输入] 租户提交未声明 resources 的 Pod
    |
    +--[V1] 注入全局默认值 100m/500m,并用 Namespace 总量配额封顶
    |       代价:默认值要么太小(OOMKilled / throttling),要么太大(调度容量失真)
    |
    +--[V2] 保守兜底 + 比例上限 + BestEffort 红线 + 对象数量配额 + 应用显式声明
    |       代价:新增工作负载必须先选 profile,交付路径变长
    |
[Trade-off]
得到准入层的强制边界、可解释的故障定位路径、按租户可核算的配额账本;
失去随手部署的便利,Namespace 创建变成一个带前置依赖的操作;
适用边界是「一个租户一个 Namespace」。

从正确性看,V1 的默认值可能完全不符合负载,V2 把正确性交回给应用声明,默认值只保证可运行。从稳定性看,V1 在默认值被调大时会让调度容量失真,V2 的保守兜底把失真范围压到单个容器。

维度 V1 V2
正确性 默认值可能完全不符合负载 默认值只兜底,正确性由应用声明保证
稳定性 默认值过大时调度容量失真 兜底值保守,失真范围可控
复杂度 3 个文件 6 个文件,交付流程多一步
成本 无额外组件 需维护 profile 目录并纳入交付规范
适用范围 单租户或弱共享 多租户共享集群
遗留问题 无法区分负载类型 profile 与真实需求的漂移需要定期校准

遗留问题要讲清楚:兜底值仍然是猜的;maxLimitRequestRatio 只能挡住声明层面的超卖,挡不住 request 写得很小、实际用量很大的负载——这类负载仍然会在节点压力时优先被驱逐。这是设计意图,不是缺陷,因为它把「超卖者先承担风险」变成了默认规则。

旧版靠一个值
新版靠一条链
兜底要足够小
声明要足够准

09、洞见

9.1 requests 决定的是排序,不是预留

第 02 章里我们看到「Allocated resources 已经满了,实际 CPU 利用率却很低」。这正是 request 的本质:它是调度器和驱逐器读取的一组数字,节点上没有任何物理资源被锁住。把 request 调大不会保护你的服务,只会让调度器更保守;把 request 调小不会节约成本,只会让这个 Pod 在节点压力时排到驱逐队列的前面。

所以「request 该写多少」的正确问法不是「我需要多少」,而是「我宁愿被驱逐,还是宁愿被预留」。

9.2 CPU 和内存的限流是两种故障,不是同一种

反直觉判断:给容器设 CPU limit 不会让它更快,只会让它在上限处被 CFS 限流;给容器设内存 limit 不会让它变慢,只会让它被内核直接杀掉。

第 06 章两条排查链的区别就来自这里。OOMKilled 有明确的事件和退出码 137,是硬故障;CPU throttling 没有任何事件,只有一个累积计数器,是软故障。软故障更难查,因为 kubectl top 会告诉你「CPU 用量不高」——它统计的是实际执行时间,被限流的线程根本没进入统计。

由此得到一个可操作的结论:内存的 request 与 limit 应该尽量接近,避免容器在启动峰值后被反复杀死;CPU 的 request 与 limit 之间则可以留出突发空间,因为超出的部分只会让线程等一会儿,不会有更坏的后果。

9.3 LimitRange 的默认值只在创建瞬间存在

反直觉判断:修改 LimitRange 不会改变任何一个已经存在的 Pod。它的执行点是准入链,而不是控制器循环。

这意味着两件事。第一,用 kubectl edit limitrange 去「修复」线上问题一定是无效的,必须重建 Pod 才会生效。第二,任何一次 LimitRange 变更,真实效果会在接下来的几天里逐步显现,因为只有新建的 Pod 才受影响。这是一个缓慢生效、难以回滚的变更,应当当作一次需要通告的发布来做,而不是一次救火。

9.4 LimitRange 和 ResourceQuota 必须成对出现

第 03 章提到:一旦 Namespace 里有 ResourceQuota 限制 requests.cpu,没有声明 request 的 Pod 会被直接拒绝。如果这时只有 ResourceQuota 没有 LimitRange,结果是所有没写 resources 的工作负载全部创建失败——问题从抢占变成了停摆。

反过来,只有 LimitRange 没有 ResourceQuota,默认值会生效,但总量没有上限,一个租户仍然可以通过大量 Pod 把节点池吃满。

所以两者是一对:LimitRange 保证每个 Pod 有声明,ResourceQuota 保证声明总量有上限。缺任何一个,这套机制都会以另一种方式失败。

9.5 多租户隔离的执行点在准入层,不在监控层

我们最初的方案是规范加 lint 加告警,本质上是把执行点放在监控层:先让它发生,再发现它。第 01 章里 CI lint 只能覆盖人写的 YAML,覆盖率永远到不了 100%,因为 Pod 的创建路径不止一条。

准入层的价值在于它是唯一入口。无论 Pod 来自 Helm、Operator、CronJob 还是 kubectl run,都必须经过它。把边界放在唯一入口上,覆盖率就是 100%,不需要统计,也不需要巡检。

排序不是预留
硬故障有事件
默认值只活一瞬
隔离要靠入口

10、系统落地

原来有什么:deploy/base/resources/ 只有零散的手写 YAML,没有命名规范;集群层面靠一份《资源申请规范》文档和一条 CI lint;观测只有节点级 CPU 与内存面板,没有容器级的 OOMKilled 和 throttling 视图。

本篇新增什么:deploy/base/resources/ 变成正式的基线目录,包含 LimitRange 兜底、ResourceQuota 总量、ResourceQuota 对象数量,以及一条独立的 BestEffort 红线;同时形成了一套「未声明 resources 也能安全落地」的准入链路。

现在能做什么:新建一个租户 Namespace 时,apply 一次基线即可获得完整边界;任何工作负载创建时,要么显式声明 resources,要么拿到保守兜底,不会出现 BestEffort;出现资源类故障时,可以按「是硬故障还是软故障」直接分流到两条排查路径,一条看 Terminated 与退出码,一条看 CFS 限流比例。

还缺什么:临时存储与磁盘 IO 仍然没有纳入边界,需要单独声明和单独观测;profile 与真实需求之间的漂移还没有自动校准机制;配额接近上限时的告警还没有接上;LimitRange 对 PVC 只支持容量上下限,不能设默认值,这一点容易被误以为和容器一样。

下一步演进:把 profile 的选择前置到应用模板里,让 Deployment 模板默认带一套 online profile;为 ResourceQuota 增加接近上限的告警;把容器级的 OOMKilled 与 throttling 指标接入统一面板,让「谁在被限流」这个问题不需要再人工回答。

基线一次落地
租户各自成界
硬故障走快路
软故障看面板

11、小结

Q1 → 约定能不能替代准入?不能。Pod 创建路径不止一条,只有准入层是唯一入口
Q2 → 为什么设了 limit 还是会慢?CPU 是可压缩资源,超限表现为 throttling,不表现为重启
Q3 → 兜底默认值能不能当配置用?不能。它只负责让 Pod 起来,正确性必须靠应用显式声明
状态 → deploy/base/resources/ 基线可 apply,未声明 resources 的 Pod 不再落进 BestEffort

边界进准入
默认只兜底
故障分两类
根因可归位

12、作业

12.1 理解题:为什么一个配置了 requests.cpu 的 ResourceQuota 的 Namespace,还需要同时有 LimitRange?

参考答案:ResourceQuota 只做校验,不做补全。如果 Namespace 里的 ResourceQuota 限制了 requests.cpu,任何没有声明 CPU request 的 Pod 都会在准入阶段被拒绝,结果是未声明 resources 的工作负载直接创建失败。LimitRange 负责在准入阶段给这些容器注入默认的 request 和 limit,让它们能通过 ResourceQuota 的校验。两者一个负责总量有上限,一个负责每个 Pod 有声明,缺一个都会以另一种方式出问题。

12.2 实战题:写一份 LimitRange,要求任何未声明 resources 的容器拿到 cpu 50m / memory 64Mi 的 request 和 cpu 200m / memory 256Mi 的 limit,并且 CPU 的 limit/request 比例不得超过 8,内存不得超过 4。

参考答案:

apiVersion: v1
kind: LimitRange
metadata:
  name: tenant-baseline
  namespace: tenant-a
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: "50m"
        memory: "64Mi"
      default:
        cpu: "200m"
        memory: "256Mi"
      maxLimitRequestRatio:
        cpu: "8"
        memory: "4"

验证方法:先 apply,再创建一个不声明 resources 的 Pod,用 kubectl -n tenant-a get pod <name> -o jsonpath='{.spec.containers[0].resources}' 检查注入结果;再提交一个 request 写 1m、limit 写 4 的 Pod,确认被 maxLimitRequestRatio 拒绝。

12.3 排障题:某个 Pod 的 P99 延迟上升,kubectl top pod 显示 CPU 用量已经贴近 limits.cpu,下一步应该查什么,可能的根因是什么?

参考答案:先查限流,而不是先加资源。查询 rate(container_cpu_cfs_throttled_periods_total[5m]) / rate(container_cpu_cfs_periods_total[5m]),如果比值明显大于 0,说明容器正在被 CFS 限流。top 显示用量贴顶,是因为被限流的线程没有机会执行,统计的是实际执行时间而不是需求。根因通常是 CPU limit 小于稳态需求。修复方向是显式声明更匹配的 requests 与 limits,而不是增加副本数——增加副本不会改变单容器的额度。

12.4 架构判断题:为了「保护」在线服务,把它的 requests.cpu 从 500m 调到 4,limits.cpu 保持不变。判断这个改动是否合理。

参考答案:不合理,至少理由不成立。request 不是预留资源,节点上不存在被锁住的 CPU;调大 request 的唯一效果是让调度器认为这个 Pod 成本更高,从而减少同一节点上的其他 Pod,同时让这个 Pod 在节点压力时更晚被驱逐。它不会让容器获得更多的 CPU 时间。如果目的是减少限流,应该看 limit;如果目的是减少被驱逐的概率,调大 request 是有效的,代价是集群装箱率下降。这是一个目标与手段必须对齐的判断,不能笼统地说「调大 request 更安全」。

先问要什么
再问改哪个
目标与手段
必须对齐

13、思考

回到本篇的核心冲突:约定与准入。在线服务的可用性很少是被某一次误操作吃掉的,而是被一批不合规的工作负载慢慢吃掉的。这类问题的特征是,单个违规看起来无害,合规成本看起来很高,所以团队会持续选择「先上线再说」,直到路径依赖形成——所有人都默认别人会守规矩。

把边界下沉到准入层的意义不在于更严格,而在于它是唯一一个不依赖任何人记性的执行点。它把「资源够不够」这个模糊问题,变成了一组可验证的、在创建时就会给出明确失败的声明。

同时也要接受它的代价:准入层一旦出错,错的是整个 Namespace;兜底默认值一旦设得太大,错的是整个调度器。所以准入层的配置本身需要比应用配置更保守,也需要比应用配置更早被 review。

约定止于纸
准入止于门
边界要更小
判断要更早

posted @ 2026-10-12 10:15  李福春  阅读(2)  评论(0)    收藏  举报