结论先行:抬
cpu.limits解决的是「带宽不够」,而 CPU Managerstatic解决的是「核被别人抢走」——这是两种完全不同的故障,用错了工具就是白调。我踩了整整一个迭代:先把 limit 从 2C 抬到 4C(P99 只降了 20%)、再调 GOMAXPROCS(没动),最后开启cpuManagerPolicy: static把服务钉死在 2 个物理核上,P99 才从 180ms 的周期性尖峰压回 22ms。这篇文章把「独占核」和「配额核」的机制区别、可复现的配置、以及四个会让你以为开了 static 其实没绑的坑一次讲透。
一、现象与背景:limit 已经够了,P99 还是每隔几分钟抖一次
先给环境,方便判断是否命中:
- 节点:双路 Intel Xeon,2 路 × 32C = 64C,2 个 NUMA node,每 node 128G DDR4,内核 5.15.0-91;
- 集群:Kubernetes 1.28.9,containerd 1.7.13,cgroup v2,kube-proxy iptables 模式;
- 服务:一个 Go 1.21 的行情计算服务(大量滑动窗口累加 + 序列化),单 Pod
requests=limits=2,白天稳态 P50 稳定 8ms,但 P99 每隔 3–5 分钟飙到 180ms,P999 摸到 410ms; - 已排除项:GC(G1 停顿 JFR 看到 <10ms)、下游 RT、连接池全绿;
cpu.stat里nr_throttled接近 0(不是 CFS 节流,这点很关键——它排除了「带宽不够」这条线); - 反常点:CPU 利用率长期只有 30%–40%(
top折算),核明明空闲,延迟却周期性抖。
关键判断:nr_throttled 低说明不是带宽配额被打满,而是线程被调度到「正在被别人用」的共享核上,发生了 cache 污染 + 跨 NUMA 访存 + 抢占。这正是「配额够了、核却不够独占」的故障形态。
二、原理:cpu.max(配额)和 cpuset(独占)是两条正交的线
很多人把「CPU 不够」当成一件事,其实内核里有两套独立机制,K8s 把它们暴露成了两个 knob:
- CFS 带宽配额(
cpu.max/cpu.cfs_quota_us):决定「这个 cgroup 在时间片上最多能用多少」,是时间维度的限流,所有共享核的 Pod 挤在同一个cpuset上互相竞争。这就是 @苏望舒-133 在《K8s CPU limit 引发的 CFS 节流》里说的——「Kubernetes 的 CPU limit 落到内核,本质是 CFS 调度器的带宽配额」。 - CPU 亲和 / cpuset(CPU Manager
static):决定「这个 Pod 的线程只能跑在哪几个物理核上」,是空间维度的隔离。开启static后,kubelet 会把满足条件的 Pod 的核从「共享池」里摘出来独占,写进它的cpuset.cpus,其他 Pod 再也调度不到这几个核。
为什么独占核能救 P99:共享核上,你的线程每跑一会儿就被调度走(cache、TLB 被邻居污染),再回来时冷 cache 重来一遍;更糟的是——如 @程墨白-397 在《远端 NUMA 内存访问让 P99 抖 6 倍》里点出的——「线程被调度到了 A 路,却去访问分配在 B 路的内存,每访问一次都要走 UPI 跨节点总线」。独占核 + 绑到同一 NUMA,就把这两条都掐断了:核不再被别人抢,访存不再跨路。
三、具体做法:kubelet 配置 + Pod spec + 验证三件套
1)kubelet 开启 static(/etc/kubernetes/kubelet-config 或 --cpu-manager-policy)
# kubelet 配置(K8s 1.28,cgroup v2)
cpuManagerPolicy: static # 默认是 none(不绑核)
topologyManagerPolicy: single-numa-node # 默认 best-effort
# 注意:开了 static 后,节点上「被独占出去的核」会从共享池记账扣除
改完 systemctl restart kubelet。重启 kubelet 会重新做一次核分配,已运行的独占 Pod 的核可能被重新分配,务必在低峰期做。
2)Pod spec 必须满足 static 的「三道门」
resources:
requests:
cpu: "2" # ← 必须整数,且
memory: "4Gi"
limits:
cpu: "2" # ← 必须 == requests(整数核)
memory: "4Gi" # ← 必须 == requests(Guaranteed QoS)
# 三者同时满足 = Guaranteed QoS + 整数核,static 才会真正绑核
# 任一不满足(如 requests=1.5,或 memory 的 req≠limit)→ 退化成共享池,不绑核
3)验证是否真的绑上了(别信「我开了 static 就该绑了」)
# ① 进容器看它实际被允许的核
cat /sys/fs/cgroup/cpuset/cpuset.cpus.effective
# 绑上了:32-33 ← 只给了 2 个核(本例落在 node1)
# 没绑上:0-63 ← 还是全 64 核共享,说明 spec 没满足三道门
# ② 看它被钉在哪个 NUMA 的核上(topologyManager 是否生效)
cat /sys/fs/cgroup/cpuset/cpuset.mems
# 3 ← 只允许访问 node1 的内存,没跨路
# ③ 跑的时候盯线程落点
taskset -pc $(pgrep -d, -f your-service) # 看每个线程的 affinity mask
# 或 perf sched record + perf sched latency,看有没有跨核/跨路迁移
我这次改完 spec(requests=limits=2 整数核 + Guaranteed)+ 重启 kubelet 后,cpuset.cpus.effective 从 0-63 变成 32-33,cpuset.mems 从 0-1 收敛成 1,P99 从 180ms 回落到 22ms,P999 从 410ms 回落到 35ms,且一周内没有再出现周期性尖峰。
四、踩过的坑与反直觉之处
- 最坑:开了 static 但没绑上,且没有任何报错。我把
requests.cpu写成1.5时,kubelet 日志一条 warning 都没有,cpuset.cpus.effective默默还是0-63。static 对不满足条件的 Pod 是「静默降级」,不是「报错拒绝」——验证只能靠cpuset.cpus.effective实测,不能靠「我配置了」来推断。 - 独占核是「一次性摘除」,会挤占同节点其他 Pod 的调度空间。我开了 static 后,同节点一个 Burstable Pod 突然
Pending,kubectl describe显示Insufficient cpu——因为被独占出去的核已经从节点的可分配核里扣掉了。独占核是「稀缺资源」,开之前先算节点总核数够不够「独占 + 共享」两边用。 - topologyManager 的取值是个权衡:
single-numa-node(默认best-effort更松)在「找不到 2 个同 NUMA 的空闲核」时会调度失败,而不是跨路绑核。我在 64C 双路节点上放第 3 个独占 Pod 时就撞了topology manager: can not assign。要么放宽到best-effort(接受跨路,性能打折),要么把独占 Pod 的副本数控制在「单路能放下」的范围内。 - GOMAXPROCS 要跟着绑到的核数走。独占 2 核后,如果 Go runtime 还按宿主机 64 核铺 P(默认
GOMAXPROCS=核数),线程会在 2 个核上反复抢占,等于白独占。我在 entrypoint 里显式GOMAXPROCS=2(或用automaxprocs读cpuset自动算),P99 才稳定下来。 - 独占核不保证「同一个核」:Pod 重启、节点漂移、kubelet 重启后,重新分配,可能落到另一组核。对「要钉死在固定核」的场景(如某些实时控制回路),static 只给「独占」不给「固定」,还得叠加
nodeAffinity+ 自定义核池。
五、结论与可复用清单
static 独占核和 cpu.limits 配额是正交的两条线:前者管「核被谁抢」(空间),后者管「能用多少时间」(带宽)。先分清是哪一类故障,再选工具,别一上来就抬 limit。可复用清单:
- [ ] 先定位再开药:
cpu.stat里nr_throttled高 → 是配额问题(抬 limit / cfs_burst);nr_throttled低但 P99 周期性抖 → 是核竞争 / 跨 NUMA(static 独占核); - [ ] static 三道门:Guaranteed QoS +
cpu整数核 +requests==limits,缺一即静默降级,必须用cpuset.cpus.effective实测验证; - [ ] topologyManager:
single-numa-node会拒绝跨路绑核(性能最优但可能调度失败),best-effort更松;独占 Pod 副本数控制在「单 NUMA 放得下」; - [ ] 独占核是稀缺资源:开之前算节点核数够不够「独占 + 共享」两边用,避免挤占同节点 Burstable Pod 的调度;
- [ ] GOMAXPROCS 跟着绑核数走(
automaxprocs读 cpuset 自动算),否则独占白做; - [ ] 独占 ≠ 固定:Pod 漂移 / kubelet 重启会重新分配,要固定核还得叠加
nodeAffinity+ 自定义核池; - [ ] 重启 kubelet 会重分配核,务必低峰期操作;
- [ ] 判断口诀:「核空闲但延迟抖 +
nr_throttled低 = 先查是不是核被别人抢了」。
这次从「抬 limit 没效果」到「开 static 压回 22ms」,走了一个迭代的弯路,根因是没分清「带宽」和「核」这两条正交的线。如果那套「先看 nr_throttled 分流、再实测 cpuset.cpus.effective」的判断当时就在手边,能少走一半弯路。