在 Kubernetes 集群里排查性能问题,有一个非常反直觉的现象:某个服务的 CPU 使用率明明只有 500 毫核,配额给了 1 核,按理说余量充足,但接口延迟就是居高不下。这时候很多人的第一反应是去看 GC、看 SQL、看 Redis,折腾一圈之后才发现,真正的瓶颈藏在limits里。Kubernetes 社区有一篇文章的标题把这件事说得很直白:For the love of god, stop using CPU limits on Kubernetes。这个标题看起来像气话,但背后是一个讨论了很多年、依然被大量团队踩中的真实问题:CPU limits 不是普通的“资源上限提醒”,它会触发内核级的 CPU 节流,而节流对延迟敏感型服务的影响相当隐蔽。这篇文章会讲清楚 requests 和 limits 的本质区别,用一个最小实验演示 limits 为什么会拖慢服务,再给出生产环境里到底该不该设 limits 的判断方法。读完你会明白,正确的做法不是无脑去掉 limits,而是理解资源模型之后,把它放回它该在的位置。
1. 为什么社区一直劝你“停止使用 CPU limits”
先看一个高频场景。团队在 Kubernetes 上部署了一套 Java 网关,为了保证稳定性,给每个 Pod 设置了resources.limits.cpu: "1",同时requests.cpu: "500m"。在机器负载不高的时候,一切正常;一旦流量上来,CPU 总使用率还不到节点的一半,服务却开始出现大量超时。查看监控,Pod 的 CPU 使用率曲线带着明显的锯齿,每隔一段时间就被“压平”,而 GC 时间、线程池指标都很正常。
这不是业务代码的锅,而是 limits 触发了 CFS 配额限制。Kubernetes 把 limits 下发到底层容器运行时,最终会写到 Linux cgroup 的 CPU 配额上。cgroup 会严格限制容器在每个调度周期内最多使用的 CPU 时间,超过部分不是排队,而是直接冻结线程,等技术术语叫 throttle。对 Web 服务、API 网关这类对延迟敏感的进程来说,线程被冻结几十毫秒,反映到用户端可能就是一次 P99 超时。更麻烦的是,这种节流不会杀掉进程,不会报错,日志里看不出异常,让排查方向完全跑偏。
所以社区里喊出的所谓“停止使用 CPU limits”,针对的不是“资源管理”本身,而是“把 limits 当成万能保险丝”这种默认习惯。CPU 本来就是可压缩资源,超出 limits 不会导致进程被杀,只会让线程在配额耗尽时被暂停。如果请求量本身有长期峰值,节流会成为一个持续性的性能瓶颈;如果只是偶尔超过配额,节流则是间歇性的不确定性来源。对延迟敏感的业务,这种不确定性比 OOM 更难受,因为它无法通过重启恢复,也很难通过日志定位。
那是不是所有场景都应该去掉 limits?也不是。后面会讲到,批处理、离线任务、测试命名空间仍然需要 limits 来防止负载失控。真正的核心是搞清楚 requests、limits、QoS、cgroup 节流之间的关系,而不是把某个配置项一刀切地禁止。
2. 先把基础概念对齐:requests、limits、QoS
很多 Kubernetes 面试题都会问 requests 和 limits 的区别,但真实项目里能讲清楚的人并不多。如果只是背定义,很容易产生一个误解:requests 是给调度器看的,limits 是给运行时看的,两者互不影响。这个理解不算错,但漏掉了最重要的部分——它们在节点上落地时,走的是完全不同的机制。
2.1 requests 与 limits 分别管什么
requests 是容器对资源的“需求声明”。调度器为 Pod 选节点时,会把所有容器的 requests 加起来,判断节点剩余可分配资源是否够用。它还会转换成 cgroup 的 CPU 权重,也就是 CPU shares。当节点 CPU 充足时,这个权重几乎不会产生可感知的影响;当多个 Pod 竞争 CPU 时,内核按权重分配时间片。所以 requests 决定了“你需要多少”,以及“CPU 紧张时你能分到多少”。
limits 是容器对资源的“上限声明”。调度器不会把 limits 当作调度依据(Node 上的可分配资源不包含 limits),但它会转换成 cgroup 的 CPU 配额。Linux CFS 调度器按周期检查配额:假设一个周期是 100ms,limits 设为 1 核,那么容器在这个周期内最多只能使用 100ms 的 CPU 时间。无论容器里有多少个线程、多少个进程,共享的都是这个配额。一旦用完,剩余时间要么等待下一个周期,要么被强制暂停。
用表格看会更清楚:
| 维度 | requests | limits |
|---|---|---|
| 主要作用 | 调度器选节点的依据 | cgroup 对 CPU 使用的硬性上限 |
| 内核机制 | CPU shares 权重 | CFS quota 配额 |
| CPU 充足时 | 几乎无影响 | 会持续限制,超限即节流 |
| CPU 紧张时 | 按权重参与竞争 | 超限线程被冻结 |
| 是否导致进程被杀 | 否 | 否,但会导致 latency 飙升 |
2.2 QoS 分级:Guaranteed、Burstable、BestEffort
根据 requests 和 limits 的组合方式,Pod 会被划分到三个 QoS 等级。这个等级直接影响节点资源不足时 Pod 的驱逐优先级。
| QoS 等级 | 设置方式 | 特点 |
|---|---|---|
| Guaranteed | requests 等于 limits,且内存、CPU 都设置 | 资源保障最高,默认最不容易被驱逐 |
| Burstable | 至少有一个容器设置了 requests,但 requests 不等于 limits | 最常见,如果 limits 大于 requests,就存在超用可能 |
| BestEffort | 没有设置任何 requests 和 limits | 系统尽力而为,资源紧张时最先被驱逐 |
很多团队把 Pod 都配成 Guaranteed,理由是“这样最稳定”。但如果 CPU limits 设置得过低,Guaranteed 的代价就是每个周期都可能发生节流。QoS 保障的是“不被邻居抢走资源”,它并不能缓解“你自己给自己设了上限”。
2.3 CPU 是可压缩资源,不等于可以随便超用
Kubernetes 把资源分成可压缩和不可压缩两类。内存是不可压缩资源,超过 limits 的后果是 OOM Kill,进程直接死掉,重启后恢复,问题定位起来反而直接。CPU 是可压缩资源,超过 limits 时进程不会被杀,而是被内核节流。这种“看起来还活着,实际时不时被暂停”的状态,在监控图表上往往表现为使用率被削平、延迟升高,非常具有欺骗性。
还有一个常见误区是认为“单个容器只用 500m,limits 给 1000m,肯定够”。CFS 配额是全体进程共享的。如果一个 Java 应用触发了 Full GC,或某个线程池突发创建了大量线程,瞬时 CPU 需求超过配额,所有线程都会被一起暂停。也就是说,limits 并不只限制“超出的那部分”,它会在配额耗尽的瞬间冻结整个容器的执行流,包括那些本来只用了很小 CPU 的线程。
3. CPU limits 的副作用为什么这么隐蔽
如果你只是写了一个每秒钟请求量很低的内部服务,CPU limits 带来的影响可能很难察觉。但一旦服务对延迟敏感,或者行为存在周期性峰值,问题就会暴露出来。理解副作用,需要从 Linux CFS 配额原理说起。
3.1 CFS 配额与节流的原理
在 cgroup v2 环境下,容器对应的 cgroup 目录里有一个cpu.max文件,格式是$MAX $PERIOD。比如100000 100000表示每个 100ms 周期最多运行 100ms,也就是 1 核。如果改成50000 100000,表示每个周期最多 50ms,对应 0.5 核。
当 cgroup 内所有进程在一个周期内累计运行时间达到$MAX后,剩下未用完的时间片会被回收,进程进入 throttle 状态,直到下一个周期开始。这个机制本意是公平分配 CPU,但它没有感知业务优先级。对一个实时性要求较高的 Web 服务来说,线程在请求处理中途被冻结,等待重新调度,这就是延迟抖动的主要来源。
一个容易忽略的细节是:CFS quotas are accounted at the cgroup level, not per-thread level。也就是说,多线程应用的线程之间共享配额。即便你的应用把线程数开到 200,在配额耗尽时 200 个线程会被同时暂停,而不是每个线程单独分配一份 CPU 时间。
3.2 节流对延迟敏感服务的影响
延迟敏感型服务的典型特征是短请求、高并发、对 tail latency 敏感。这类服务平时 CPU 使用率低,但偶尔会有短促的峰值。如果 limits 设置得恰好低于峰值需求,就会在流量抖动时触发节流,制造大量 P99 超时。团队通常会先怀疑代码性能、依赖组件问题,花很长时间排查后才发现是资源配额造成的。
更麻烦的是,节流与 CPU 使用率不是同步的。假设 limits 是 2 核,应用实际平均使用 1 核,但其中有一个 50ms 的突发使用 3 核,配额就会超支。监控看到的平均使用率不高,看不出问题;只有在更细粒度的 CPU 时间维度上,才能发现节流计数在增长。
3.3 对 Java 这类多线程运行时的额外影响
Java 的垃圾回收器、线程池、异步框架都依赖 CPU 时间。发生节流时,如果 GC 线程和业务线程被一起暂停,可能导致一系列连锁反应:GC 暂停时间变长、线程池任务堆积、健康检查响应变慢。Kubernetes 的存活探针一旦因为这种抖动连续失败,Pod 还会被反复重启,造成恶性循环。
这也是很多 Java 服务在物理机上运行正常,迁移到 Kubernetes 后反而“变慢”的原因之一。物理机没有 cgroup 配额约束,容器环境里配置不当的 limits 成了新的隐藏瓶颈。
3.4 limits 与资源配额、利润率的关系
还有一种情况,团队给所有 Pod 都设置了 limits,并不是因为业务需要,而是因为 Kubernetes 的ResourceQuota要求资源必须有上限。比如命名空间配置了 quotas,而配额按 limits 计算。在这个前提下,去掉 CPU limits 会导致 Pod 创建失败。
这种配置需要调整思路:ResourceQuota 是管理命名空间整体资源的手段,可以限制所有 Pod 的 requests 总大小;而 limits 是单个容器的资源上限。两者不是一回事。下面会专门讲生产环境怎么通过配额、LimitRange 来替代“给每个容器都写 limits”的做法。
4. 什么场景该用、什么场景不该用 CPU limits
社区争论“stop using CPU limits”多年,最终的正确结论绝不是“永远不设置”,而是“不要默认设置”。判断标准应该围绕业务特征展开:这个服务对延迟是否敏感?是否存在资源失控风险?是否能接受 Pod 的平均性能被压平?
| 场景 | 建议 | 原因 |
|---|---|---|
| 延迟敏感型在线服务(API、网关、RPC) | 优先不设 CPU limits,只设置精确 requests | 避免节流造成的尾部延迟抖动 |
| 离线批处理、数据流水线 | 可以设置 limits,甚至设得较小 | 任务对绝对完成时间不敏感,要防止占满节点 |
| 测试/开发命名空间 | 设置 limits 兜底 | 测试代码可能有 bug,需要隔离风险 |
| 共享集群的多租户环境 | 必须设置 limits 或配额 | 防止单个应用拖垮整个节点 |
| 无法快速扩容的在线服务 | 先设置 limits 并持续监控,后续评估调整 | 极端情况下保护节点稳定性优先 |
这不是一个简单的“设或不设”的选择,而是一个“看得懂代价”之后的决定。设置 limits,等于接受节流风险;不设置 limits,等于相信应用的资源使用是可控的。生产环境里更稳妥的做法,是用监控和自动扩缩容来管理在线服务的 CPU 使用,而不是把希望寄托在一个静态的 limits 上。
5. 环境准备与验证思路
下面做一个最小实验,目标是让节流现象“肉眼可见”。你只需要一个能运行的 Kubernetes 集群,minikube、kind 或者任何测试集群都可以。这里不限定具体版本和发行版,因为实验依赖的是 Linux 内核和容器运行时通用的 cgroup 行为。
需要准备的工具:
- 一个可用的 Kubernetes 集群,且有权限创建 Deployment。
- 本机安装
kubectl,并已配置好 kubeconfig。 - 一个方便执行压测命令的容器镜像。推荐包含
yes和bash的基础镜像,比如 busybox 或你正在用的业务镜像。 - 如果想查看 cgroup 文件,需要能进入容器执行命令;如果容器内看不到 cgroup 路径,可以改用节点视角,但需要相应权限。
实验分为三个步骤:
- 创建一个设置了 CPU limits 的 Deployment,并压测触发节流。
- 在容器内或节点 cgroup 里观察配额文件和节流周期计数。
- 创建一个只设置 requests、不设置 limits 的 Deployment,对比同样压测下的表现。
这个设计能让每个现象都有对应的配置和观测方法,而不是停留在“理论上会节流”的层面。
6. 最小实验:观察 CPU 节流现象
6.1 创建带 CPU limits 的 Deployment
先创建一个带 limits 的 Deployment,requests 设为 500m,limits 设为 1 核。文件内容如下:
# 文件路径:deploy-with-limits.yaml apiVersion: apps/v1 kind: Deployment metadata: name: demo-with-limits labels: app: demo-with-limits spec: replicas: 1 selector: matchLabels: app: demo-with-limits template: metadata: labels: app: demo-with-limits spec: containers: - name: demo image: busybox:1.36 command: ["sleep", "3600"] resources: requests: cpu: "500m" memory: "128Mi" limits: cpu: "1" memory: "256Mi"这个配置的含义是:调度器按 500m 判断节点资源是否足够;运行时 cgroup 配额按 1 核限制。创建后确认 Pod 正常运行:
kubectl apply -f deploy-with-limits.yaml kubectl get pods -l app=demo-with-limits -o wide6.2 压测触发节流
进入容器,启动多个yes > /dev/null进程来制造 CPU 负载。yes命令会不断输出字符,输出重定向到/dev/null后,单进程可以打满一个逻辑核。
kubectl exec -it deploy/demo-with-limits -- sh # 在容器内执行 yes > /dev/null & yes > /dev/null & yes > /dev/null &启动三个进程后,容器的需求大约为 3 核,而 limits 只有 1 核。此时如果查看容器的 CPU 使用率,会看到它被限制在大约 1 核附近,而不是真正的 3 核。这就是节流发生的直观证据。
6.3 观察 cgroup 配额和节流周期
在容器内查看 cgroup 文件,能够得到配额信息:
# cgroup v2 路径 cat /sys/fs/cgroup/cpu.max # 预期输出类似:100000 100000 (表示 1 核配额,周期 100ms) # 如果 limits 为 500m,则输出类似:50000 100000同一路径下还有cpu.stat,可以看到节流相关的统计信息:
cat /sys/fs/cgroup/cpu.stat输出中nr_throttled表示容器被节流的次数,throttled_usec表示累计被节流的时间。压测开始后,多观察几次,这两个值会不断增长。如果配置了 Prometheus,也可以通过指标查询节流情况,对应的指标名是:
rate(container_cpu_cfs_throttled_periods_total{namespace="default", pod=~"demo-with-limits.*"}[1m])这个指标统计的是容器在每个周期里实际发生节流的周期数,是判断 limits 是否成为瓶颈最直接的依据。
6.4 对比去掉 limits 后的表现
创建一个只设置 requests、不设置 limits 的 Deployment:
# 文件路径:deploy-requests-only.yaml apiVersion: apps/v1 kind: Deployment metadata: name: demo-requests-only labels: app: demo-requests-only spec: replicas: 1 selector: matchLabels: app: demo-requests-only template: metadata: labels: app: demo-requests-only spec: containers: - name: demo image: busybox:1.36 command: ["sleep", "3600"] resources: requests: cpu: "500m" memory: "128Mi" limits: memory: "256Mi"同样进入容器,执行三个yes > /dev/null &。此时没有 CPU 配额,容器可以充分使用节点上的空闲 CPU。如果你观察cpu.max,会看到类似max 100000的输出,表示没有上限;cpu.stat里也不再出现持续的节流增长。
这个对比实验并不复杂,但它把“limits 会改变容器的 CPU 行为”这件事从理论变成了可观测的事实。你的业务镜像可能比 busybox 复杂得多,但底层机制是完全一样的。
7. 生产环境里如何正确管理 CPU 资源
实验做完之后,很多人的第一反应是把所有 Deployment 的 CPU limits 都删掉。这个动作本身没有错,但没有配套措施就直接删,等于把一个可能出问题的机制换成了另一个可能出问题的机制。生产环境需要的是系统性调整,而不是一次性改动。
7.1 先定好 requests,用监控校准
requests 是整篇文章里真正需要认真对待的值。它不应该照抄 limits,也不应该随便填一个小数,而应该来自过去一段时间实际运行数据的统计。如果使用 Kubernetes HPA,可以根据业务指标自动调整副本数;requests 偏大,会导致资源浪费和调度失败;requests 偏小,节点过载时 CPU 争用会加剧。
推荐的做法是:选取业务高峰期的一周数据,统计 CPU 使用率的中位数或 P95,再乘以安全余量。不要直接把当前使用率的上限当作 requests,这样在性能优化、流量突增时没有缓冲空间。
7.2 用 ResourceQuota 和 LimitRange 兜底
如果担心“不设置 limits 之后,某个没写好代码的应用会打爆节点”,可以不在 Pod 级别加 limits,而在命名空间级别配置 ResourceQuota:
# 文件路径:namespace-quota.yaml apiVersion: v1 kind: ResourceQuota metadata: name: dev-quota namespace: dev spec: hard: requests.cpu: "20" requests.memory: 64Gi这个配额限制的是命名空间内所有 Pod 的 requests 总量。它不会限制单个容器的 CPU 峰值,但能控制整体资源申请规模。再配合 LimitRange,可以约束每个容器的默认 requests:
# 文件路径:namespace-limitrange.yaml apiVersion: v1 kind: LimitRange metadata: name: default-requests namespace: dev spec: limits: - max: memory: 4Gi defaultRequest: cpu: 100m memory: 256Mi type: Container这种组合的好处是:把“防止资源失控”的职责交给平台层,把“性能与延迟”的职责留给业务层。平台层保证不会有太多请求挤进命名空间,业务层则可以更灵活地管理单容器资源。
7.3 真正需要保留 CPU limits 的少数情况
延迟敏感型在线服务不设 CPU limits,不代表所有工作负载都要这么做。批处理任务和数据流水线通常有固定预算,设置 limits 可以把资源消耗限制在可控范围内,避免任务之间相互干扰。测试环境更是如此,测试代码可能存在死循环或内存泄漏,limits 是安全兜底。
在共享集群中,如果多个团队共用一个节点池,而节点规格固定、无法快速扩容,CPU limits 仍然有它的价值。此时更像是一个运维取舍:接受部分延迟波动,换取整体稳定性。这种场景下,limits 应该写得有依据,比如根据压测结果确定合理的峰值,而不是随手填一个"2000m"。
7.4 批量调整的逐步升级策略
生产环境批量去掉 limits 时,不要一次性修改几百个 Deployment。更稳妥的是按服务重要性分组,先选一个非核心、且有完善监控的服务做试点。观察对象包括:CPU 使用率、P99 延迟、错误率、Pod 重启次数、节点 CPU 使用率。
如果试点运行一周后,服务延迟没有劣化,节点资源没有失控,再逐步扩大到其他服务。每次变更都用标准发布流程走:代码评审、灰度发布、监控验证、回滚预案。如果某个服务在去掉 limits 后出现资源占用过高的问题,第一反应不是立刻加回 limits,而是分析它的资源使用曲线,调整 requests 或 HPA 指标。
8. 常见问题与排查思路
社区和实际项目中,围绕 CPU limits 的问题高度集中。下面整理几个最常见的场景,帮助快速定位。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| CPU 使用率不高,P99 延迟高 | limits 触发 CFS 节流 | 查看container_cpu_cfs_throttled_periods_total是否增长 | 调整或移除 CPU limits,优先保证延迟 |
Pod 内cat /sys/fs/cgroup/cpu.max看到大量配额 | cgroup v2 配额设置与 limits 一致 | 计算 limits 对应周期值,与 YAML 对照 | 按需调整 limits,或改用 requests-only |
| 去掉 limits 后单个 Pod 占满节点 CPU | 应用本身存在资源失控风险 | 检查应用线程数、死循环、流量突增 | 配置 HPA、调整 requests,必要时在命名空间加配额 |
| Pod 反复重启,但内存正常 | 存活探针因节流连续失败 | 查看事件和探针日志,确认节流时间窗口 | 增加探针超时,同时评估 limits 是否合理 |
| 想给 Pod 设置 limits 但创建失败 | ResourceQuota 限制 | 查看命名空间 quota 和 LimitRange | 调整配额策略,不要让业务配置被平台限制反向绑架 |
| 节点 CPU 跑满,但容器实际影响不明显 | 多个容器竞争,shares 权重在起作用 | 查看节点指标和各 Pod CPU 时间 | 调整 requests,保证核心服务权重更高 |
这里特别说明一下“去掉 limits 会不会导致节点被打满”。即使所有在线服务都去掉 CPU limits,节点上仍然存在 CPU shares 竞争机制。当 CPU 资源不足时,内核按各容器 requests 对应的权重分配时间片。这个机制比静态 limits 更贴近真实资源需求,但前提是每个容器的 requests 设置得准确。如果 requests 全部默认填 500m,而实际需要的只有 50m,权重分配就会失真。
9. 总结与后续学习方向
这篇文章讨论的核心对象是 Kubernetes 中经常被误用的 CPU limits。它真正想传递的判断是:limits 在 Kubernetes 里不是一个无脑的保护措施,而是一个会改变容器运行时行为的硬性约束,尤其是在你对延迟敏感、对性能稳定性有要求的服务里,由 limits 引发的 CPU 节流会带来比资源超用更隐蔽的负面影响。正确的管理思路是先设置准确的 requests,用监控校准,再按业务场景决定是否保留 limits;在线核心服务优先考虑去掉 CPU limits,用 HPA 和命名空间配额代替硬编码上限;批处理、测试环境、共享集群则仍然保留 limits 做安全兜底。
如果这篇文章让你对 requests、limits、QoS、cgroup quota 之间的关系有了新的理解,下一步可以继续研究几个更深入的方向:cgroup v2 的cpu.max与带宽控制参数、Kubernetes 节点级的 CPU Manager 策略对 QoS 的影响、HPA 与 VPA 在 CPU 资源管理中的配合。把这些内容吃透,遇到 CPU 相关的性能问题时,你就会有比“改配置”更完整的判断能力。建议先拿一个非核心服务做实验,把第三节和第六节里的观测方法跑一遍,让节流数据说话,再决定你的集群里到底该怎么处理 CPU limits。