绑核救火:从一个性能抖动的深夜说起

凌晨两点,业务的 P99 延迟突然从 10ms 飙到 200ms,毫无规律。排查了一小时——没问题,CPU 明明给得很足,可就是隔几秒抖一下。

后来发现,问题出在频繁的 CPU 上下文切换上:业务线程在各个核心之间“飘来飘去”,L3 缓存来回失效,延迟就失控了。解决方案?CPU 绑核。

如果你没经历过这种痛苦,恭喜你。但如果你正在做 DPDK、Envoy、AI 推理,或者跑着某个对 CPU 敏感的老古董应用,那这篇文章你大概率用得上。

读完你将能:

  • 判断你的业务到底值不值得用 static CPU Manager
  • 手把手配好绑核,拿到检验的手段
  • 提前避开我踩过的 3 个大坑

一、先泼盆冷水:你的业务到底需不需要绑核?

说实话,绝大多数 Web 应用、API 网关,甚至中等并发的中间件,根本不需要绑核。默认的 Linux CFS 调度器加上正常的 requests/limits 已经足够好了。绑核意味着 CPU 核心被你“占死”了,其他 Pod 再也用不了这些核,集群利用率会往下掉。

那什么时候值得呢?

  • 对 CPU 限流(throttling) 极度敏感 —— 性能一掉就熔断那种
  • 上下文切换开销反感 —— DPDK、Envoy、Redis 高负载场景
  • 遗留应用写死了启动线程数 = 整机物理核数,不读容器限制
  • 跨 NUMA 访问导致的延迟波动无法接受

还有一个反例千万别碰CPU 超卖环境。绑核是独占,超卖是共享,把这两个放一起就是自找麻烦。

二、绑核原理(一句话讲清楚)

kubelet 默认的 CPU Manager 策略是 none,内核 CFS 调度器做啥它就做啥,不加任何额外的亲和限制。

改成 static 之后,行为就变了:当一个 Pod 同时满足三个条件时,kubelet 会直接从共享 CPU 池里“摘”出整数个核心,永久分配给这个 Pod 独占。这些核一旦分配出去,其他 Pod 再也拿不到,就连调度器也不会把它们当作“可分配资源”。

状态记录在 /var/lib/kubelet/cpu_manager_state 里,kubelet 重启后还能恢复。

三、3 个核心条件 —— 一个都不能少

想真正触发 static 策略给你的 Pod 绑核,下面三条缺一不可

条件

说明

节点开启 static 策略

kubelet 参数 --cpu-manager-policy=static

Pod 必须是 Guaranteed QoS

每个容器的 limitsrequests 都得设置,且 CPU 和 Memory 都要相等

CPU 请求是正整数

cpu: "2" ✅ ,cpu: "2.5"cpu: "2500m"

(顺便提一嘴,enhanced-static 是某些云厂商(比如华为云)的增强版,可以让部分 Burstable Pod 优先用某些核。这个要看内核支持情况,不是原生社区的)。

四、实战:手把手配置(MacOS 上测的,Linux 差不多)

4.1 在节点上开启 static 策略

方法一:直接改 kubelet 配置文件(我推荐这种)

登录目标 Worker 节点,编辑 /var/lib/kubelet/config.yaml

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cpuManagerPolicy: static
reservedSystemCPUs: "0-1"   # 必须留至少一个核给系统进程,不然 kubelet 自己都可能被饿死

然后重启 kubelet:

systemctl restart kubelet

方法二:通过云厂商控制台配置(如果你的集群托管在云上)

登录云控制台,找到节点池 → 配置管理 → 将 cpu-manager-policy 改为 static

⚠️ 这里有个坑:有些云厂商的 DefaultPool 不支持改这个参数,需要建一个自定义节点池才能生效。

4.2 写一个能触发绑核的 Pod YAML

apiVersion: v1
kind: Pod
metadata:
  name: cpu-pinned-pod
spec:
  containers:
  - name: app
    image: nginx
    resources:
      requests:
        cpu: "2"        # 必须是正整数
        memory: "512Mi" # 必须设置,且与 limits 相等
      limits:
        cpu: "2"
        memory: "512Mi"

关键点:

  • 两个容器都需要满足 Guaranteed 条件吗?是的,Pod 里每个容器(包括 init 容器)都要满足
  • 如果 init container 需要绑核,建议它的 requests 跟业务容器保持一致,否则 CPU Manager 可能多预留一份 CPU。

4.3 用 nodeSelector 确保 Pod 调度到已开启 static 的节点

spec:
  nodeSelector:
    cpu-manager-static: "true"   # 前提是你在目标节点打了这个 label

或者直接用节点亲和调度,把 Pod “赶”到正确的节点上去。

五、NUMA 场景下的进阶配置(你可能遇到的大坑)

如果你的节点是 64 核以上的大规格机器,很可能跨了多个 NUMA 节点。绑核后如果 Pod 拿到的核心分布在两个不同的 NUMA 节点上,内存访问速度会大幅下降——这就是“跨 NUMA”问题。

5.1 先确认你的节点有没有多个 NUMA 节点

lscpu | grep "NUMA node"
# 如果看到 node0 和 node1 两行,说明有多个 NUMA 节点

5.2 开启 Topology Manager

Topology Manager 能让 CPU 管理器和内存管理器协同工作,尽量把 Pod 的 CPU 和内存分配在同一个 NUMA 节点上。

在 kubelet 配置里加上:

cpuManagerPolicy: static
topologyManagerPolicy: single-numa-node

single-numa-node 是最严格的策略:如果节点上任何一个 NUMA 节点都无法满足 Pod 的 CPU 需求,这个 Pod 会被直接拒绝,不会调度上来。

其他策略选项见下表:

策略

行为

none

不做任何对齐,默认值

best-effort

尽量对齐到同一个 NUMA,不行也能调度

restricted

单 NUMA 够用时就坚持用单 NUMA,不够则允许跨 NUMA

single-numa-node

必须完全落在同一个 NUMA 节点,否则拒绝

⚠️ 真实案例:我一个同事在单 NUMA 剩余 8 核、请求 10 核的场景下用了 single-numa-node,Pod 怎么也起不来,排查了两小时才发现是策略太严格。

六、怎么验证绑核真的生效了?

方法一(最快):看 /var/lib/kubelet/cpu_manager_state

登录节点,直接 cat 这个文件:

cat /var/lib/kubelet/cpu_manager_state

输出示例:

{
  "policyName": "static",
  "defaultCpuSet": "0-1,4-7",
  "entries": {
    "pod1234-5678-...": {
      "my-container": "2-3"
    }
  }
}

"my-container": "2-3" 说明这个容器被绑在了 CPU 2 和 3 上。

方法二(最可靠):直接看容器的 cgroup cpuset

先拿到容器的完整 ID:

kubectl get pod cpu-pinned-pod -o jsonpath='{.status.containerStatuses[0].containerID}'
# 输出类似:containerd://a1b2c3d4e5f6...

然后到节点上 cat cpuset:

cat /sys/fs/cgroup/cpuset/kubepods/pod{pod-uid}/{container-id}/cpuset.cpus

如果输出是 2-3,恭喜,绑上了。

方法三:在容器内部用 taskset 确认

kubectl exec -it cpu-pinned-pod -- taskset -c -p 1

输出会显示进程 1 的 CPU 亲和性掩码,跟你期望绑定的核一致才算真生效。

七、我踩过的 3 个大坑(希望你别踩)

坑一:改完 kubelet 配置忘了重启,或者没删旧的 cpu_manager_state

改 kubelet 配置文件后必须重启 kubelet 才能生效。而且如果之前跑过 Pod,旧的状态文件 /var/lib/kubelet/cpu_manager_state 里还记着以前的分配,删掉它再重启更稳妥。

建议的操作顺序:

  1. kubectl drain <node> --ignore-daemonsets 先把节点腾空
  2. systemctl stop kubelet
  3. rm -f /var/lib/kubelet/cpu_manager_state
  4. 改配置文件,重启 kubelet
  5. kubectl uncordon <node>

坑二:忘了预留系统 CPU,结果 kubelet 自己卡死

static 策略会把所有未保留的核都当作可分配的。如果你不设置 reservedSystemCPUs,kubelet 可能把系统进程跑的那些核也分配出去,导致系统抖动甚至节点失联。

我的推荐:至少给系统保留 1-2 个核。16 核的节点可以留 0-1,64 核的留 0-3

坑三:init container 的 CPU 请求不匹配,导致 CPU Manager 多预留核

这是社区一个已知的 Bug:如果 init container 申请的 CPU 核数与业务容器不一致,CPU Manager 会多预留一份 CPU,造成资源浪费。

解决方案:让 init container 的 CPU requests/limits 跟业务容器保持一致,或者尽量别让 init container 要求绑核。

八、常见问题快速定位

现象

可能原因

排查命令

Pod 创建后一直 Pending

节点上没有足够的整数 CPU 分配给 Guaranteed QoS Pod

kubectl describe node <node> | grep -A5 "Allocated resources"

Pod 绑核了但 CPU 还在到处跑

QoS 不是 Guaranteed,或者 CPU 请求不是整数

kubectl get pod <pod> -o yaml | grep -E "qosClass|cpu"

绑核后性能反而下降

CPU 跨 NUMA 节点了,但没开 Topology Manager

`lscpu | grep -E "NUMA

节点可用 CPU 显示少了

被 reservedSystemCPUs 预留了

cat /var/lib/kubelet/cpu_manager_state | jq .defaultCpuSet

九、总结一下

  1. 绑核不是银弹——绝大多数业务不需要,只有对 CPU 抖动敏感的场景才值得用。
  2. 三个条件锁死static 策略 + Guaranteed QoS + 正整数 CPU 请求,少一个都不生效。
  3. 大规格节点务必开 Topology Manager,否则跨 NUMA 访问带来的延迟损失可能比不绑核还大。
  4. 先给系统留好核心,别把 kubelet 自己的核都给分配出去了。
  5. 验证绑核用 cgroup,不要只看 YAML 配得对不对。

最后说一句,这东西我也是在生产线上摔过跟头才摸清楚的。你如果也遇到过绑核相关的怪问题,或者有什么我没提到的骚操作,欢迎在评论区分享出来,咱们一起避坑。

如果觉得有用,欢迎分享给团队里正在折腾性能优化的同学。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐