容器云资源 CPU 限额(CPU Throttling)排查:K8s Request/Limit 黄金配比

上个月中旬,我们团队在预发环境组织双 11 容量首轮压测。压力刚推到设计峰值的 60%,网关和订单服务的 P99 延迟就从 18ms 坐火箭般蹿到了 720ms。奇怪的是,查看云厂商控制台,宿主机节点的 CPU 使用率才刚刚达到 38%,容器内部的平均 CPU 也稳稳停在 45% 左右,内存更是毫无压力。没有慢 SQL,没有锁竞争,但服务就是卡得像假死一样。
运维第一反应是网络抖动,但调出内核调度监控后,真相立刻浮出水面:容器的 CPU Throttling(CPU 限流节流)比率高达 64%。容器并不是算力不够,而是在每个调度周期里都被操作系统内核强行“急刹车”冻结了。
这是很多小厂在“降本增效”收紧 K8s 资源配置时最容易踩的巨坑。
为什么 CPU 没满却会被内核限流?
在 Kubernetes 中,当我们给 Pod 设置了 resources.limits.cpu,底层的 Docker 或 containerd 会通过 Linux Cgroups 的 CFS(Completely Fair Scheduler,完全公平调度器)来强行限制算力。
CFS 调度有两个核心参数:
cpu.cfs_period_us:调度周期,K8s 默认固定为 100,000 微秒(即 100ms);cpu.cfs_quota_us:当前容器在该调度周期内允许消耗的 CPU 时间总和。
假设我们给一个 Go 服务配置了 limits.cpu: 1(即 1 核),那么对应的 quota 就是 100ms。
如果这是一个单线程应用,它在 100ms 内一直跑满,正好用完配额。
但 Go 语言天生是高并发、多线程(Goroutine 映射到系统线程 M)的运行模型。如果一台物理宿主机有 32 核,Go 运行时默认探测到 32 核,在并发请求进来时,32 个操作系统线程同时启动工作:
$$32 \text{ 个线程} \times 3.125\text{ms} = 100\text{ms 配额}$$
这意味着,仅仅过了 3.125 毫秒,该容器在当前 100ms 周期内的配额就已经被彻底耗尽!
在剩下的 96.875 毫秒里,内核将强制挂起该容器内的所有线程,禁止它们调度执行,直到下一个 100ms 周期开始。对于上游调用方来说,这个请求就在这几十毫秒里被活活“冷冻”了。如果多轮迭代都遇到冷冻,延迟直接突破几百毫秒,最终引发超时熔断。
如何精准捕获 CPU Throttling?
不要看云厂商大盘上的 CPU 平均利用率,平均值会抹平所有的微观毛刺。要直接排查 Linux Cgroups 原生数据和 Prometheus 指标。
1. 进入容器现场查看 Cgroup 统计
进入怀疑卡顿的 Pod 容器执行:
# 对于 Cgroup v1:
cat /sys/fs/cgroup/cpu/cpu.stat
# 输出示例:
# nr_periods 15820 # 经历了多少个周期
# nr_throttled 8721 # 其中有多少个周期被限流(超过 50% 极其危险!)
# throttled_time 982182741914 # 累计被冷冻纳秒数
# 对于 Cgroup v2:
cat /sys/fs/cgroup/cpu.stat
# 查看 throttled_usec 即可
2. 配置 Prometheus 监控指标告警
在 Grafana 中添加 CPU Throttling 比例曲线,计算公式如下:
sum(rate(container_cpu_cfs_throttled_periods_total{container!="",namespace="production"}[5m]))
/
sum(rate(container_cpu_cfs_periods_total{container!="",namespace="production"}[5m])) * 100
经验法则:当该比率持续超过 15% 时,服务的尾部延迟(P95 / P99)必然受到明显波及;超过 30% 时,主流程一定会产生连锁超时报错。
小厂 Request 与 Limit 的黄金配比方案
小厂机器资源紧张,不可能像大厂那样给每个 Pod 奢侈地配置独占核心(Guaranteed QoS,即 Request == Limit)。为了既省钱又杜绝延迟毛刺,我们经过多轮大促验证,总结出以下资源配比黄金法则:
法则一:核心在线交易服务(Burstable 宽松型,比率 1:3 或无 Limit)
对于网关、订单、支付等延迟敏感型 Go 服务:
- CPU Request:按日常真实平均使用量配置(如日常跑 0.8 核,配置
request: 1000m)。 - CPU Limit:放宽至 Request 的 3 倍,甚至在 Node 具备完善保护的前提下不设 Limit。
- 内存 Memory:必须保持
request == limit。内存是不可压缩资源,一旦超额会被 OOM Killer 直接杀进程,绝不能超卖。
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: production
spec:
replicas: 6
template:
spec:
containers:
- name: order-app
image: internal-registry/order-service:v1.27
resources:
requests:
cpu: "1000m" # 1 核保底
memory: "2Gi"
limits:
cpu: "3000m" # 允许短时间脉冲到 3 核,避免被 CFS 扼杀
memory: "2Gi" # 内存严格相等
法则二:离线任务与边缘服务(严格限额型,比率 1:1.5)
对于报表计算、日志拉取、AI 异步转写等非实时交互服务,配置相对紧凑的 Limit,防止由于代码 Bug 陷入死循环把宿主机算力拖垮:
resources:
requests:
cpu: "500m"
limits:
cpu: "800m"
memory: "1Gi"
Go 服务终极调优:引入 automaxprocs
除了调整 YAML,Go 语言服务本身必须做一项关键改造:显式适配容器配额。
Go 默认在运行时通过 runtime.NumCPU() 获取的是物理宿主机的核心数(比如 32 核或 64 核)。即便给 Pod 限制了 1 核 Limit,Go 运行时依然会创建 32 个 P(Processor)和调度线程,引发前面提到的 3ms 内耗光配额。
在 main.go 中引入 go.uber.org/automaxprocs,让 Go 运行时自动识别容器的 CFS Quota 并动态调整 GOMAXPROCS:
package main
import (
"log"
"net/http"
_ "go.uber.org/automaxprocs" // 自动根据容器 Cgroup 配额修正 GOMAXPROCS
)
func main() {
log.Println("Starting high performance server with correct CFS GOMAXPROCS")
// 启动 HTTP 服务
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
_ = http.ListenAndServe(":8080", nil)
}
收益对比与账单成果
在将全站微服务的 CPU Limit 从原本严苛的 1:1 调整为 1:3 黄金配比,并全员引入 automaxprocs 修正后,我们重新执行了同等强度的双 11 峰值压测:
| 关键指标 | 优化前(Limit 严格设为 1 核) | 优化后(Request: 1 核, Limit: 3 核) | 改善幅度 |
|---|---|---|---|
| CFS CPU Throttling 比率 | 64.2% | 0.8% | -98.7% |
| 网关接口 P99 响应延迟 | 720 ms | 22 ms | -96.9% |
| 超时错误(Context Deadline) | 3,420 次 / 分钟 | 0 次 | 彻底消除 |
| 宿主机总节点成本 | 需额外采购 8 台云主机抗压 | 零新增节点,原有集群承载量提升 40% | 节约预算数万元 |
省钱的真谛不是在参数上抠门,而是理解底层调度器的运转逻辑。消除毫无意义的内核节流,才能把小厂每一核 CPU 的算力真正压榨到极限。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)