Kubernetes CPU limits 引发的隐蔽延迟:从 CFS 节流到 Requests 治理
2026/9/8 3:49:01 网站建设 项目流程

那天下午的告警看起来一点都不吓人:某个核心服务在 Kubernetes 里跑得好好的,CPU 平均使用率不到 40%,内存也健康,Pod 从不重启,健康检查也正常。但它的 P99 延迟就是每隔几分钟毫无规律地跳到两秒,然后再落回去。

常规排查全部做过一遍:慢 SQL 没有、下游依赖超时没有、GC 明显异常没有、网络抖动也没有。直到有人翻出一个平时基本没人看的计数器,container_cpu_cfs_throttled_periods_total,才把怀疑对象锁定到 YAML 里的resources.limits.cpu上。

也是在那个阶段,我把社区里那篇标题非常直白的文章认真读了一遍——《For the love of god, stop using CPU limits on Kubernetes》。它记录了一次严重生产事故后的反思,结论乍一听很极端:在大量生产场景里,CPU limits 带来的麻烦,比它解决掉的麻烦更多。

这篇文章后来被引用很多次,也被批评过很多次。但我觉得它最有价值的不是“别用 limits”这个结论,而是它逼着你去想清楚一个核心问题:Kubernetes 的 CPU 限制到底是怎么在内核里实现的。想清楚了这一点,你自然会明白哪些场景该保留 limits,哪些场景去掉反而更安全。

1. 先从一次“CPU 看着没用满,服务却卡了”的现场说起

1.1 一个非常典型的故障现象

先把当时的现场还原一下。

服务一共有 6 个副本,每个副本的配置是:requests.cpu: 250mlimits.cpu: 2。从业务形态看,这是一个典型的突发型服务,平时流量不大,但每天有几次明显的访问高峰,处理请求时会短时间把 CPU 拉高。

从监控面板上看,服务平均 CPU 使用率只有 30% 左右。按常理推断,一个平均只用 0.5 核的服务,给它 2 核的硬上限,怎么都不该有性能问题。

但实际表现完全不是这样。延迟曲线呈现出非常规律的“锯齿状”,每隔一小段时间就出现一次离散的尖峰。最让人困惑的是,CPU 使用率指标一直很低,看起来根本不像资源瓶颈。

这里有一个非常容易踩的认知误区:CPU 使用率低,不代表容器没有在等 CPU。

Kubernetes 的 CPU limits 不是像内存那样“超了直接把你杀掉”,而是用一种更隐蔽的方式执行:当容器在一个极短的时间窗口内用完了配额,内核会把它“掐住”,让它在那段时间里什么都做不了。这个动作在大多数监控体系里几乎是隐形的,但它会直接变成业务延迟。

1.2 真相藏在 cgroup 的节流统计里

我当时用了一个很笨但有效的办法:进到出问题的 Pod 里,直接看 cgroup 的统计文件。

在 cgroup v1 环境里路径是:

cat /sys/fs/cgroup/cpu/cpu.stat

在 cgroup v2 环境里路径变成了:

cat /sys/fs/cgroup/cpu.stat

关键看两个字段:

  • nr_periods:内核一共执行了多少个限流检查周期
  • nr_throttled:其中有多少个周期里容器被强制节流了

在故障现场,这两个数字的比值高得惊人。也就是说,容器每个限流周期里都有接近一半的时间是被内核“摁住”的。当你看到nr_throttled / nr_periods接近 50% 甚至更高时,哪怕kubectl top pod显示 CPU 只有几百毫核,业务延迟也会被拖垮。

这个现象才是题目里那句“stop using CPU limits”真正的出发点:CPU limits 带来的不是一个温柔的速度限制,而是一种非常粗颗粒、对监控不友好、对突发型负载极其不友好的离散节流机制。

2. 为什么 CPU limit 会造成“看不见的卡顿”

2.1 Requests 是预约,Limits 是硬顶

先看一张表。对于刚了解 Kubernetes 的人来说,requestslimits很容易混淆,面试也经常被问;但对一个要长期维护生产集群的人来说,不理解这两个字段背后的机制,迟早会在线上吃亏。

字段作用底层实现一句话理解
requests.cpu调度器依据它决定 Pod 放在哪个节点;节点上所有 Pod 的 requests 之和不能超过节点可分配资源CFS shares(CPU 权重)预约额度
limits.cpu容器真正能使用的 CPU 时间上限CFS quota(CPU 配额)执行上限

requests是调度层的事。它负责回答“这个 Pod 能不能放进这台节点”。调度完成之后,运行时的 CPU 分配也受到 requests 的影响,但它的作用方式更像“权重”,而不是“天花板”。

limits是执行层的事。它负责回答“这个容器最多能用多少 CPU 时间”。它的实现方式不是简单地“超过 90% 就降速”,而是在一个固定时间窗口内累计 CPU 时间,一旦超过配额,就直接把容器线程挂起,直到下一个窗口开始。

理解这个区别,是理解整篇文章的前提。

2.2 CFS 配额:一个 100 毫秒的窗口

Kubernetes 的 CPU limits 背后用的是 Linux CFS(完全公平调度器)里的带宽控制机制。

默认情况下,Kubernetes 设置 CFS 的检查周期是 100 毫秒。如果你给容器设置了limits.cpu: 2,那内核会这样配置:

  • cpu.cfs_period_us固定为100000(100 毫秒,单位微秒)
  • cpu.cfs_quota_us被设置为200000

含义是:每 100 毫秒真实时间里,这个容器累计最多只能使用 200 毫秒的 CPU 时间。

注意这里说的是“累计”。一个 2 核限制的容器,意味着两个 CPU 核心满载跑完整个 100 毫秒窗口,刚好用完配额。一旦超过,容器里超出配额的部分就会被强制冷却,直到下一个 100 毫秒窗口开启。

也就是说,CPU limits 更像是一个“发工资日当天必须花完,花不完就作废”的机制,而不是“账户里可以一直存钱”的机制。一个 CPU 密集的线程即使平时占有率很低,只要它和一个突发线程处在同一个窗口期内,就可能因为配额被提前吃完而一起被卡住。

2.3 为什么平均使用率不高,也可能被限流

很多人最大的困惑是:监控显示平均 CPU 使用率只有 0.5 核,limits 给的是 2 核,为什么会被限流?

问题出在“平均”上。

CFS 配额检查的不是 5 分钟平均,也不是 1 分钟平均,而是看每一个 100 毫秒窗口内累计使用了多少 CPU 时间。

假设容器设置了 2 核 limits。在某个 100 毫秒窗口内,业务突然来了一个突发请求,四个线程同时跑满 50 毫秒:

  • 四个线程在 50 毫秒内消耗了 200 毫秒的 CPU 时间(4 × 50ms)
  • 配额到 200ms 时,容器瞬间被节流
  • 下半段窗口内,即使业务线程已经不再需要 CPU,一些本应在这 50 毫秒内处理完的工作也会被拖到下一个窗口

看这个例子,容器在 200 毫秒整体的平均使用率只有 1 核,远低于 2 核上限,但它已经被限流了整整 50 毫秒。

对于延迟敏感的业务,这 50 毫秒就是一次 P99 尖峰。而监控里看到的kubectl top却非常平静,因为它采样的粒度和 CFS 的检查粒度完全对不上。

2.4 CPU limits 和内存 limits 不是一种“物种”

这里要特别强调一个容易混淆的地方:很多人觉得“CPU 和内存都需要限制资源”,于是顺手把两个字段都写上,以为它们行为类似。实际上两者的执行机制完全不同。

内存 limits 是“硬性终结”。容器超过内存限制,内核会触发 OOM Killer,挑进程杀掉,Pod 大概率会因为探针失败或进程退出而重启。后果很直接,也容易发现。

CPU limits 是“软性节流”。容器超过 CPU 配额,不会死,不会重启,不会打印明显的错误,只是部分线程被暂时挂起。它既不直接报警,也不触发重启,但它会被翻译成业务上的超时、重试和用户体验劣化。

一个会主动杀死你的问题,反而好排查;一个悄悄让你变慢的问题,才是生产环境最折磨人的。

3. 不设 CPU limits 会不会失控:把 Requests 用好才是关键

3.1 Requests 才是真正的调度保证

很多人不敢去掉 limits,是担心容器会“把节点 CPU 吃干榨净”。

但这里需要理清一个事实:容器能不能“吃满”节点,和 limits 有没有关系,其实要看调度时预留了多少资源。

requests是调度器的硬约束。一个节点上所有 Pod 的requests.cpu之和不能超过节点的可分配 CPU。调度完成之后,当节点刚刚满足所有 Pod 的 requests 时,系统已经保证了每个 Pod 至少能用上它所预约的那部分 CPU。在 CPU 竞争激烈时,内核通过 CFS shares 按权重复分配 CPU,谁的 requests 大,谁在竞争时拿到的份额就高。

换句话说,真正支撑服务稳定性的是 requests,而不是 limits。limits 解决的是“容器不要超过某个值”,requests 解决的是“调度器要保证容器至少拿到这么多”。

另外还有一个很容易被忽略的点:HPA 的 CPU 扩容逻辑,是拿当前 CPU 使用量 / Pod 的 requests来计算利用率的。如果删了 limits 的同时也把 requests 删了,HPA 的 CPU 指标会直接失效。所以我的建议一直是:去掉 limits,但保留 requests,并且让 requests 尽量贴近真实用量。

3.2 “不加 limits”不等于“没有管控”

“不用 CPU limits”并不等于“裸奔”。这里要区分两个层次的控制:

第一层是工作负载层。容器去掉 limits 之后,靠 HPA 在负载升高时扩容,靠合理的 requests 保证调度预留,靠业务层重试和熔断来消化偶发抖动。

第二层是集群策略层。如果你担心某个命名空间里的 Pod 总量失控,可以用ResourceQuota限制该命名空间的 CPU request 总量;担心容器不写 requests 导致调度混乱,可以用LimitRange或准入控制来强制设置 requests。

这两层都和管理“某个容器单个窗口内最多用多少 CPU”不是一回事。它们解决的是更上层的容量治理问题,而不是给每个容器套上一个 400ms 的紧箍咒。

真正的管理边界应该放在“我允许这个命名空间最多申请多少 CPU”,而不是“你这个容器每个 100 毫秒窗口内最多用多少 CPU”。

3.3 去掉 limits 的副作用:QoS 会变

去掉 limits 不是没有代价。最直接的副作用是 Pod 的 QoS 等级会改变。

Kubernetes 给 Pod 划分了三个 QoS 等级:

  • Guaranteed:所有容器都设置了 CPU 和内存的 requests 和 limits,并且每个资源的 requests 等于 limits
  • Burstable:至少有一个容器设置了 requests,但 requests 不等于 limits,或者某些资源没有设置 limits
  • BestEffort:任何容器都没有设置 requests 和 limits

当容器原本是requests.cpu == limits.cpu时,它是一个 Guaranteed Pod。如果你删掉了limits.cpu,它的 QoS 会变成 Burstable。

QoS 变化带来的影响主要体现在节点资源紧张时的驱逐优先级上。在内存压力下,节点会优先驱逐 BestEffort,然后是 Burstable,最后才是 Guaranteed。所以,把本来 Guaranteed 的服务改成 Burstable,理论上会略微增加它在极端情况下的驱逐风险。

实际影响取决于你的节点是否经常处于内存压力状态。如果节点的内存余量一直健康,这种影响几乎感受不到;但如果节点经常接近内存上限,你就需要重新评估。

另外,如果集群开启了 CPU Manager 的static策略,想要让容器绑定独占物理 CPU 核心,就必须是 Guaranteed QoS,也就是必须设置limits.cpu == requests.cpu且为整数。这是“必须保留 CPU limits”的一个典型例外场景。

3.4 什么场景比较适合先去 limits

基于上面的分析,下面几类服务通常比较适合先尝试去掉 CPU limits:

  • 无状态、有多个副本、失败后能快速重建的服务
  • 流量有明显的突发特征,平均使用率远低于 limits 设置值的服务
  • 已经配置了 HPA,并且 requests 数值相对真实的服务
  • 对偶发的几十毫秒延迟不敏感,有超时重试机制的业务
  • 容器启动或冷启动阶段有短暂 CPU 高峰的场景

这类服务去掉 limits 之后,突发流量可以在短时间内使用更多节点 CPU,业务表现会更快,而不会被一个 100 毫秒的窗口卡住。

4. 什么时候真的不能去掉 CPU limits(先看清楚边界)

4.1 多租户集群、重点计费和强隔离场景

如果你的集群是多个团队共享的,或者你需要对每个团队、每个项目做资源计量和计费,那么 limits 往往是强需求。

原因很简单:requests 只解决调度预留,不解决“实际使用上限”。一个团队把 requests 写得很小,但实际业务突发时可能把节点 CPU 全部吃掉,影响同节点的其他团队。如果没有 per-container 的 hard cap,就很难对“按量计费”或“公平使用”做出明确承诺。

在这种场景下,cpu limits 更像是一个租户隔离的边界。它牺牲了一部分业务突发的灵活性,但换来的是更强的可预期性和可审计性。

4.2 服务等级承诺与已知稳态负载

还有一种场景:你的服务对外承诺了稳定的 CPU 能力,或者它的负载形态非常平稳,CPU 使用率长期贴着某个固定值波动。

比如一个常驻的模型推理服务,每个请求的 CPU 消耗基本可预测,流量也没有剧烈波动。这种服务设置requests.cpu == limits.cpu是有意义的:它告诉调度器“我需要独占这么多 CPU”,同时因为负载平稳,几乎不会触发 CFS 节流。

这里要留意,同样的写法放在突发型服务上会出问题,放在稳态服务上却可能很合适。问题从来不在 limits 本身,而在于你对自己的负载形态有没有准确的认知。

4.3 CPU 绑核(static CPU Manager)特例

前面提过,使用 CPU Manager 的static策略,需要 Pod 达到 Guaranteed QoS,也就是 requests 和 limits 都设置且相等。

这类场景里的 limits 不再是“限流”的含义,而是“绑核授权”。它和“给每个容器随手写一个 500m 的帽子”完全不同,因为此时容器已经被固定到专属物理核心上,CFS 节流的发生概率大大降低,业务的 CPU 时延也更可预测。

如果你正在跑低延迟服务,并且已经用上了绑核,那么请保留 limits。这个特例不能被“不用 CPU limits”这种口号覆盖掉。

4.4 如果平台策略强制要求每个 Pod 都必须有 limits

有些公司或 PaaS 平台要求所有工作负载都必须声明 limits,否则无法通过发布审批。这种策略本意是防止资源失控,但它会误伤大量突发型服务。

如果绕不开平台策略,我的建议是:把 limits 当成“安全网”来设置,而不是当成“精确速度限制”来设置。

例如一个服务真实的 CPU 需求是requests.cpu: 250m,你不需要给它设置limits.cpu: 1,而是可以设置limits.cpu: 4甚至更高。这样一来:

  • 可以满足平台必须写 limits 的合规要求
  • 正常情况下不会触发 CFS 节流
  • 万一业务出现死循环或代码 bug 导致 CPU 异常飙升,仍然能兜住

这里有一个特别容易踩的坑:很多团队为了“规范”,喜欢给所有服务统一添加一个LimitRange,实现“没写 limits 就自动补一个默认值”。结果是,你从 Deployment 里删掉了limits.cpu,新 Pod 创建时又会被 LimitRange 静默补回去。改配置前一定要先检查命名空间里是否存在 LimitRange。

注意:清理 CPU limits 之前,先确认当前命名空间的 LimitRange 不会把旧的默认值重新注入到新 Pod 里,否则你改了半天,线上行为一点没变。

5. 想稳妥地移除 CPU limits,按这个顺序做

5.1 第一步:先盘点哪些工作负载在被打节流

不要凭感觉删配置。先把所有工作负载的节流情况量化出来。

在 Prometheus 里,可以用 cAdvisor 暴露的容器指标计算节流比例:

sum by (namespace, pod) ( rate(container_cpu_cfs_throttled_periods_total{container!="POD"}[5m]) ) / sum by (namespace, pod) ( rate(container_cpu_cfs_periods_total{container!="POD"}[5m]) )

这个查询的结果表示“在过去 5 分钟里,每个 Pod 被节流的周期占比”。

通常可以按这个经验值判断:

  • 小于 1%:低风险,可以继续观察
  • 1% 到 5%:已经值得关注,尤其是 P99 有抖动时
  • 大于 5%:说明负载已经频繁撞墙,需要认真评估

同时,盘点现有配置:

kubectl get deploy -n <namespace> -o custom-columns=NAME:.metadata.name,CPU_REQUEST:.spec.template.spec.containers[*].resources.requests.cpu,CPU_LIMIT:.spec.template.spec.containers[*].resources.limits.cpu

注意这个命令在多容器 Deployment 下显示会比较混乱,但它足够用来做第一轮排查,找出“requests 和 limits 差距过大但使用率又不高”的嫌疑对象。

5.2 第二步:把服务分成三档

不要一上来就批量删。先把服务分成三档,按优先级推进。

分类特征处理建议
可以先试水无状态、多副本、有 HPA、突发型负载、偶发抖动可接受优先移除 CPU limits
观察后再决定核心 API,但有多副本和重试机制;延迟有一定容忍度先用监控观察节流比例,再做决定
保守不动单实例、强一致性、对外 SLA 严格、无监控覆盖、延迟极度敏感保留 limits,或走绑核专用路线

这里最重要的判断标准是两个问题:如果它突然占满整台节点,会不会影响别人?如果它自己被卡一下,业务能不能重试?

两个都不满足的,优先级最高。

5.3 第三步:小步摘除,别一把梭

对于单容器的 Deployment,可以从 YAML 里直接删掉limits.cpu

resources: requests: cpu: 500m memory: 512Mi # limits.cpu 已删除 limits: memory: 1Gi

也可以用 JSON Patch 的方式:

kubectl patch deployment <name> -n <namespace> \ --type='json' \ -p='[{"op":"remove","path":"/spec/template/spec/containers/0/resources/limits/cpu"}]'

但这里要特别提醒:

  • 如果 Deployment 里有多个容器,containers/0这种下标需要对应到具体容器,不要弄错
  • 如果容器同时设置了 memory limits,请保留 memory limits,不要顺手一起删。内存和 CPU 的机制完全不同,CPU 可以节流后恢复,内存如果失控可能会引发节点级别的连锁反应
  • 如果删完后发现 QoS 从 Guaranteed 变成 Burstable 导致了不可接受的影响,快速回滚的方式就是把这个字段加回去
  • 修改后观察 1 到 2 周,重点看延迟、节流比例、节点 CPU 饱和度和 HPA 扩容行为

建议:每次只改一个服务,并且选择流量低谷时发布。不要同一个下午改完整个集群,否则一旦出问题,你无法定位是哪个改动引起的。

5.4 第四步:把告警从“容器级别”搬到“节点级别和业务级别”

去掉 limits 之后,原来那种“容器 CPU 被掐住”的告警自然就没了,但这不代表你可以关掉告警系统。你只是把控制手段从“容器内部硬限制”切换成了“外部观察和自动化响应”。

至少需要补齐这几类监控:

节点 CPU 饱和度告警

100 - ( avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) by (instance) * 100 )

当节点 CPU 长时间高水位时,即使没有 limits,也会因为节点整体繁忙导致所有 Pod 一起抖。这时需要的是扩容节点或疏散工作负载。

容器节流告警(针对仍然保留 limits 的少量工作负载):

sum by (namespace, pod) ( rate(container_cpu_cfs_throttled_periods_total{container!="POD"}[5m]) ) / sum by (namespace, pod) ( rate(container_cpu_cfs_periods_total{container!="POD"}[5m]) ) > 0.1

业务延迟告警。这是最本质的指标。P99 延迟一旦上抬,告警就应该被触发,不管 CPU 指标是否正常。

6. 删掉一份 limits 容易,难的是把 Requests 预算当作长期工程来经营

6.1 Requests 不准确,去不去 limits 都会出事

去掉 limits 之后,真正决定集群稳定性的变成了 requests。如果 requests 只是随便填的数字,问题不会消失,只会换一个形状出现。

场景一:requests 填得过大。比如实际只用 200m,requests 写了 2 核。节点会因为“看起来满了”而拒绝新 Pod,集群调度冗余被白白浪费。最后你想扩容都扩不进去。

场景二:requests 填得过小。实际峰值会用到 4 核,requests 只写了 100m。调度器会把很多这样的小请求塞进同一台节点,平时看起来利用率很低,一旦流量高峰同时到来,节点 CPU 直接被打爆,所有 Pod 一起遭殃。

所以,去掉 limits 不是把责任推给内核,而是把责任交给了 Requests 预算的准确性。

6.2 把 Requests 变成团队可维护的预算

长期来看,比较可行的做法是把 Requests 当作“代码审查的一部分”。

推荐一个简单的四步流程:

  1. 测量:对每一个工作负载,记录一周内 CPU 使用率的中位数、P95 和 P99
  2. 设定:requests 取中位数或略高于中位数的值,保留一定的缓冲;不要用峰值去做 requests
  3. 验证:发布后观察 HPA 行为,确认扩容频率是否合理
  4. 周期复核:每季度重新审视一次 requests,业务形态变化后及时调整

VPA(Vertical Pod Autoscaler)的 recommendation 模式也可以作为辅助工具,它可以基于历史指标给出 requests 建议值。但建议把它作为参考,而不是直接自动应用,因为 VPA 的推荐结果有时会受短时峰值影响,导致 requests 被过度放大。

6.3 回到最初那个标题:它真正想说的不是“禁止”,而是“别盲从”

那句 “For the love of god, stop using CPU limits on Kubernetes” 之所以能引起共鸣,是因为太多团队把“每个容器必须写清 requests 和 limits”当成一条不可质疑的默认规则,却从没问过:限制到底是怎么执行的?对我的负载形态是否合适?

它真正想批评的不是 limits 这个功能,而是不假思索的默认策略:不管服务形态如何,一律给 limits 填一个看起来合理的数值,完了再也不看节流指标。

所以我的最终判断是:

  • 突发型、有冗余、有 HPA 的服务:去掉 CPU limits,保留准确的 requests,更合理
  • 稳态、低延迟、绑核或多租户强隔离的服务:保留 CPU limits,但要知道它在里内核里做了什么
  • 无论哪一种,都要有监控、告警、回滚手段,而不是改完配置就完事

下次再有人争论“Kubernetes 到底该不该设置 CPU limits”,不用急着站队。先把每个服务的 CPU 时间曲线、CFS 节流比例、QoS 等级和 HPA 行为拉出来看一眼,答案往往自然就出来了。

技术上的很多争论,到最后拼的其实不是立场,而是你对底层机制的理解深度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询