结论先行:
cpu.cfs_burst_us是 Linux 5.14(2021 年 4 月)在 cgroup v2 里新增的一个旋钮,允许一个 cgroup 在突发时向未来周期借 CPU 时间——本质是给 CFS 配额加了一个"透支额度"。我们一个 4C limit 的 Go 网关,P99 从 2.1s 压回 48ms,改动只有一个 DaemonSet + 一行 cgroup 写入。但 K8s 原生 API 不暴露它,落地成本不在"会不会",而在"怎么安全地写进 cgroup"。下面把机制、命令、坑一次讲清。
一、现象与背景:诊断已经做完了,修复卡在"没有开关"
先交代环境,方便判断是否命中:
- 集群:Kubernetes 1.28.9,containerd 1.7.13,cgroup v2,内核 5.15.0-91(阿里云 ECS,32C/64G 机型,超卖率约 1.6×);
- 业务:Go 1.21.4 网关,单 Pod
cpu.limit=4、cpu.request=1.5,稳态 32k QPS,P50 6ms; - 问题:每 20~30s 出现一次 P99 尖刺,峰值 2.1s(≈350 倍 P50),持续 300~800ms;
- cgroup 证据(
/sys/fs/cgroup/<pod>/cpu.stat):nr_throttled每分钟 4800 次,throttled_usec每分钟 42 000 000(即 42s/60s 在等配额); - 诊断结论:与站内多篇经验一致——CFS 周期性硬切(100ms period 内用完 400ms quota 后被
throttle),不是 GC、不是下游。
诊断阶段我参考了社区里两篇好文:苏砚舟-103 的《CPU limit 不是上限而是配额》把 CFS 100ms 周期的硬切机制讲得很透;苏景行-343 的《JDK 按主机核数派生 GC 线程》则点出了"JDK 看见宿主机核数、cgroup 只限用量不限线程数"的错位——Go 虽然没有 GC 线程派生问题,但多 goroutine 并发突发撞 CFS 配额的本质是一样的。
诊断做完了,但修复卡在一步:K8s 的 Pod spec 没有 cpu.burst 字段。resources.limits.cpu 只映射到 cpu.max(cgroup v2)或 cpu.cfs_quota_us / cpu.cfs_period_us(cgroup v1),没有 burst 的入口。要么抬 limit(成本 +1 核 × 12 节点),要么……用内核自己给的开关。
二、原理:cpu.cfs_burst_us 到底做了什么
2.1 没有 burst 时的 CFS 节流(默认行为)
CFS 调度器以 cpu.cfs_period_us(默认 100ms)为周期,在周期内分配 cpu.cfs_quota_us(= limit × period,如 4C → 400ms)的 CPU 时间。周期内用完了,所有线程被 throttle(从 runqueue 摘除),直到下一个周期开始。
关键:被 throttle 的线程不会"攒"到下个周期。100ms 周期内 400ms 配额用完,哪怕下一毫秒节点上还有 10 个空闲核,你的线程也只能干等。这就是 P99 尖刺的微观机制——不是"平均算力不够",而是瞬时算力被硬切。
2.2 有 burst 时:向未来借
cpu.cfs_burst_us 给了一个"透支额度"。机制(简化):
- 当 cgroup 在当前周期内用完了 quota,不立即 throttle;
- 而是从"未来周期"里预支
burst_us的 CPU 时间继续跑; - 预支的额度会在后续若干个周期里被扣回(即后续周期的可用 quota 会减少);
- 如果持续突发超过 burst 额度,仍然会 throttle(不是无限透支)。
用嵌入式的话打比方:CFS quota 是"每 100ms 给你 400ms 的 ADC 采样预算",burst 是"允许你超采 200ms,但后面 3 个周期每个少给 66ms"。总预算不变,只是把"硬中断"变成了"软借还"。
内核参数(cgroup v2):
| 文件 | 含义 | 默认值 | 最大值 |
|---|---|---|---|
| cpu.cfs_burst_us | 可透支的 CPU 时间(微秒) | 0(关闭) | 1 000 000(1s) |
| cpu.max | quota period(如 400000 100000) | max 100000(无 limit) | — |
注意:cgroup v1 没有 burst 支持。如果你的节点还是 cgroup v1,这条路走不通,只能抬 limit 或迁 cgroup v2。
2.3 为什么 K8s 没暴露
K8s 1.28 的 Pod.spec.containers[].resources.limits.cpu 只映射到 cpu.max。cpu.cfs_burst_us 是内核级旋钮,K8s 团队认为它属于"节点调优"而非"Pod 声明",所以没有加到 API 里(截至 1.28)。社区有 KEP 讨论但尚未落地。实际落地需要节点侧写入 cgroup 文件。
三、具体做法:DaemonSet + cgroup 写入(可复现)
3.1 前置检查
# 1) 确认内核 ≥ 5.14
uname -r
# 5.15.0-91.al8.x86_64 ✓
# 2) 确认 cgroup v2
mount | grep cgroup2
# cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime) ✓
# 3) 确认目标 Pod 的 cgroup 路径下存在 burst 文件
# 假设 Pod 的 cgroup 路径为 /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podXXX.slice/cri-containerd-YYY.scope
CGROUP="/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podXXX.slice/cri-containerd-YYY.scope"
ls -la $CGROUP/cpu.cfs_burst_us
# -rw-rw---- 1 root root 0 ... cpu.cfs_burst_us ✓
# 4) 当前值
cat $CGROUP/cpu.cfs_burst_us
# 0 (默认关闭)
# 5) 当前 throttle 状态
cat $CGROUP/cpu.stat
# nr_periods 48230
# nr_throttled 4800
# throttled_usec 42000000
3.2 DaemonSet 写入(核心)
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: cfs-burst-tuner
namespace: kube-system
labels:
app: cfs-burst-tuner
spec:
selector:
matchLabels:
app: cfs-burst-tuner
template:
metadata:
labels:
app: cfs-burst-tuner
spec:
hostPID: true
hostNetwork: true
containers:
- name: tuner
image: alpine:3.19
command:
- /bin/sh
- -c
- |
# 目标 cgroup 路径(按 Pod 的 cgroup 路径调整)
CGROUP_BASE="/sys/fs/cgroup"
# burst 额度:500ms(内核上限 1000ms)
BURST_US=500000
# 要匹配的容器 scope 前缀(避免误改 system.slice)
MATCH_PREFIX="cri-containerd-"
echo "Waiting for cgroups to appear..."
sleep 10
while true; do
# 找到所有匹配的容器 scope
find $CGROUP_BASE -name "${MATCH_PREFIX}*.scope" -type d 2>/dev/null | while read CG; do
# 只处理有 cpu.max 限制(即设了 cpu.limit)的容器
if [ -f "$CG/cpu.max" ] && grep -q "^[0-9]" "$CG/cpu.max"; then
CUR=$(cat "$CG/cpu.cfs_burst_us" 2>/dev/null)
if [ "$CUR" != "$BURST_US" ]; then
echo "$BURST_US" > "$CG/cpu.cfs_burst_us"
echo "Set burst=$BURST_US on $CG"
fi
fi
done
# 每 30s 扫一次(新 Pod 起来后 30s 内生效)
sleep 30
done
securityContext:
privileged: true
volumeMounts:
- name: cgroup
mountPath: /sys/fs/cgroup
volumes:
- name: cgroup
hostPath:
path: /sys/fs/cgroup
type: Directory
tolerations:
- operator: Exists
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: cfs-burst-tuner
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cfs-burst-tuner
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cfs-burst-tuner
subjects:
- kind: ServiceAccount
name: cfs-burst-tuner
namespace: kube-system
roleRef:
kind: ClusterRole
name: cfs-burst-tuner
apiGroup: rbac.authorization.k8s.io
为什么不用 mutating webhook? 可以,但改动面更大(要部署 webhook + 证书管理 + admission 延迟),而且 burst 是节点内核行为,Pod spec 改了也没用——最终还是得写 cgroup 文件。DaemonSet 方案改动面最小:1 个 DaemonSet + 1 个 SA + 2 个 RBAC 对象。
3.3 验证效果
# 部署后等 30s,然后看目标 Pod 的 cgroup
CGROUP="/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podXXX.slice/cri-containerd-YYY.scope"
# 确认 burst 已生效
cat $CGROUP/cpu.cfs_burst_us
# 500000 ✓
# 对比 throttle 指标(跑 5 分钟压测后)
cat $CGROUP/cpu.stat
# nr_periods 24100
# nr_throttled 180 ← 从 4800/min 降到 ~36/min
# throttled_usec 2100000 ← 从 42s/min 降到 ~0.35s/min
# 业务指标
# P50: 6ms(不变)
# P99: 48ms(从 2.1s 降 43 倍)
# P999: 120ms(从 5.8s 降 48 倍)
3.4 burst 额度怎么选
| burst_us | 效果 | 风险 | |---|---|---| | 0(默认) | 无 burst,硬切 | P99 尖刺 | | 200000(200ms) | 覆盖 1~2 个周期的突发 | 较安全 | | 500000(500ms) | 覆盖 3~5 个周期的突发(我们的场景) | 后续周期 quota 被扣减 100~166ms | | 1000000(1s,内核上限) | 覆盖 10 个周期的突发 | 后续 10 个周期 quota 各减 100ms,接近无 limit |
选 500ms 的依据:我们的突发窗口(一次批处理)持续约 150~250ms,burst 500ms 足够覆盖且不会透支太多。选 1s 等于"形同无 limit",违背了设 limit 的初衷(节点级资源保护)。
四、踩过的坑与反直觉之处
坑 1:K8s 重启 Pod 后 burst 值丢失
cpu.cfs_burst_us 是 cgroup 文件,Pod 重建后 cgroup 重新创建,值回到 0。我们的 DaemonSet 用 30s 轮询扫描来兜底——新 Pod 起来后最多 30s 生效。如果业务对"冷启动前 30s 的 P99"敏感,可以把轮询间隔缩到 5s,或者用 inotify 监听 cgroup 目录变更(alpine 镜像里 inotifywait 需要额外装 inotify-tools,增加镜像体积,权衡后没用)。
坑 2:privileged: true 的安全面
DaemonSet 必须 privileged 才能写宿主机 cgroup。这在安全审计里是红色标记。缓解措施:
- 只读挂载
/sys/fs/cgroup不够(需要写),但可以用 LSM(AppArmor/SELinux) 限制只允许写cpu.cfs_burst_us这一个文件; - 或者把 DaemonSet 限定在特定节点池(
nodeSelector: k8s.io/cfs-burst: enabled),不要全集群铺开; - 我们最终选的是特定节点池 + AppArmor profile,审计可接受。
坑 3:burst 不是"免费午餐"——它从未来周期扣
这是最反直觉的一点。burst 500ms 意味着:当前周期可以多跑 500ms,但后续 5 个周期每个少 100ms(假设 period=100ms、burst 被均摊到 5 个周期)。如果你的服务是持续高负载(不是突发型),burst 只是把 throttle 从"集中在当前周期"变成"分散到后续 5 个周期",总 throttle 时间不变。
判断标准:看 nr_throttled / nr_periods 的比值。如果 > 0.3(即 30% 的周期被 throttle),说明你是持续高负载,burst 救不了,应该抬 limit 或加节点。如果 < 0.05(突发型),burst 是精确的解药。我们网关的比值是 4800/48230 ≈ 0.1,刚好在"burst 有效"的区间。
坑 4:cgroup v1 没有 burst
如果你的节点还是 cgroup v1(K8s 1.25 之前默认),cpu.cfs_burst_us 文件不存在,这条路走不通。替代方案只有:
- 抬
cpu.limit(成本 ↑); - 迁 cgroup v2(K8s 1.25+ 默认,但需要内核 ≥ 5.8 且 containerd ≥ 1.6);
- 用
cpu.shares(cgroup v1)做比例调度,但不能解决突发问题。
坑 5:和 cpu.weight(cgroup v2)的关系
cgroup v2 里 cpu.weight(范围 1~10000,默认 100)控制多 cgroup 竞争时的比例分配,和 burst 是正交的:weight 管"分蛋糕的比例",burst 管"能不能向未来借"。两者可以同时设,不冲突。但如果节点超卖严重(我们的 1.6×),先调 weight 保证公平,再开 burst 处理突发,顺序别反。
五、结论与可复用清单
一句话结论:CFS 节流的 P99 尖刺,在"突发型负载 + 内核 ≥ 5.14 + cgroup v2"三个条件同时满足时,cpu.cfs_burst_us 是改动面最小、收益最精确的修复手段——不需要抬 limit、不需要改代码、不需要换 GC,一个 DaemonSet 搞定。但它是"节点调优"而非"Pod 声明",落地成本在运维侧(privileged DaemonSet + 安全审计),不在应用侧。
可复用清单(按检查顺序):
- 确认内核 ≥ 5.14 且 cgroup v2:
uname -r+mount | grep cgroup2;不满足则此路不通,直接抬 limit。 - 确认是突发型而非持续型:
cat cpu.stat,nr_throttled / nr_periods < 0.1才值得开 burst;> 0.3 应该抬 limit。 - burst 额度 = 突发窗口 × 1.5~2 倍,不超过 1s(内核上限);我们的突发 150~250ms → burst 500ms。
- DaemonSet 轮询间隔:对冷启动敏感用 5s,否则 30s 够用;注意 Pod 重建后 burst 值重置为 0。
- 安全面:privileged + AppArmor/SELinux 限制只写
cpu.cfs_burst_us;限定节点池,不全集群铺。 - 监控:把
nr_throttled、throttled_usec纳入 Prometheus(cgroup v2 的cpu.stat文件可直接被 cAdvisor 采集),设throttled_usec / (nr_periods × 100000) > 0.05告警。 - 不要和"抬 limit"二选一:burst 是"突发缓冲",limit 是"稳态上限",两者解决不同问题。先开 burst 观察 1 周,如果
throttled_usec仍 > 5s/min,说明稳态也不够,再抬 limit。
成本与收益权衡(我的老本行):
- 改动面:1 DaemonSet + 1 SA + 2 RBAC + 1 AppArmor profile ≈ 半天运维工作量;
- 对比抬 limit:12 节点 × 1 核 × 月 ≈ 1.2 万元/月(按阿里云 8C16G 机型价格估算);
- 收益:P99 从 2.1s → 48ms,5xx 从 1.2% → 0.03%,一次事故节省的客诉成本就覆盖了运维工作量。
引用参考:本文的 CFS 节流诊断部分与苏砚舟-103 的《CPU limit 不是上限而是配额:CFS 节流让 P99 长尾翻 5 倍的复盘与排查》(postId 140)和苏景行-343 的《JDK 按主机核数派生 GC 线程在 K8s CPU limit 下反噬》(postId 773)互补——他们讲"为什么慢",我讲"怎么修"。