双路裸金属“半身不遂”?Nginx/Apache NUMA绑核与软中断避坑指南

事故现场

一台双路 E5-2680 v4(合计 28 核 56 线程)的裸金属,Nginx 压测 QPS 死活到不了 5 万,系统负载不到 30%,但每核 CPU 都在 60% 上下。用 top 看,nginx worker 进程像幽灵一样在不同 CPU 间乱跳。

这不是“配置不够”,是 NUMA 在坑你。云主机通常同一 socket 上虚拟化,问题不显;裸金属物理机上双路 CPU、多内存通道,一旦进程跨 Node 访问内存,延迟翻倍、带宽减半,吞吐自然塌方。

根因剖析:物理机不是大号云主机

双路服务器有两个 NUMA Node,每个 Node 有自己的内存和 PCIe 控制器。如果 Nginx/Apache 的 worker 进程没绑核,内核调度器会把它在各个核心间迁移。每迁一次,访问的内存页就可能从“本地”变“远程”,而远程内存要走 UPI 总线,延迟高 30% 以上。

大多数默认 Nginx 配置只写了 worker_processes auto;,没写 worker_cpu_affinity。这会让 worker 进程“四处流浪”,特别是在系统负载不高、多个进程共同运行时,跨 Node 访问更严重。

三个排查命令,一看就懂

先确认你的 NUMA 拓扑:

numactl --hardware

注意看有没有 node0 cpus: 0-27node1 cpus: 28-55 这样的输出。如果有两个 node,并且你的进程在两个 node 的 CPU 上都有运行记录,那就中招了。

再看实际内存访问情况:

numastat -m
numastat -p <nginx-worker-pid>

如果 active_alloclocal_node 远小于 other_node,跨 Node 访问量巨大。

还可以用 perf stat 对比前后:

perf stat -e remote-node-access,sched_task_numa_migrate -a sleep 30

修复前 remote-node-access 数值会高得吓人。修复后应大幅下降。

Nginx 修复:两行配置,让 worker 各归其位

在裸金属上,推荐这样配置:

worker_processes auto;
worker_cpu_affinity auto;

auto 会让 Nginx 自动创建与逻辑核心数相同的 worker,并把每个 worker 绑定到一个固定的 CPU 核。这样每个进程不会跨 Node 迁移,内存访问全程本地化。

如果你的 Nginx 版本不支持 worker_cpu_affinity auto(1.9.10 之前),可以手动生成掩码:

# 比如 56 核,生成 56 个掩码
for c in $(seq 0 55); do
  printf "worker_cpu_affinity %056s;\n" \
    $(echo "obase=2; 2^$c" | bc | tr -d '\n')
done

然后把输出贴到 nginx.conf。不建议真的这么做,直接升级 Nginx 更省事。

改完重载:

nginx -t && nginx -s reload

顺手解决软中断:别让网卡中断拖后腿

绑核只解决了一半。如果网卡队列的中断和 Nginx worker 不在同一个 NUMA Node,数据包从网卡到内存、再到应用,还是会跨 Node 绕一圈。

先确认网卡队列:

cat /proc/interrupts | grep eth0-0

比如中断号是 32,你想让它跑在 node0 的 CPU 0-15 上,掩码是 000000000000ffff(16 位):

echo 000000000000ffff > /proc/irq/32/smp_affinity

如果网卡支持多队列(例如 eth0-0eth0-1...),按照队列数拆成 2 或 4 组,分别绑定到不同 Node 上的 CPU,并保持和 Nginx worker 的分布一致。

注意:不要盲目关掉 irqbalance,除非你已经手动绑好。新手最容易犯的错是“绑了 Nginx 忘了绑网卡”,导致收包中断全挤在 CPU0,产生锁竞争。

Apache 怎么办?不能用两行配置,但有 systemd 方案

Apache 没有内置 cpu affinity,但可以通过 systemd 限制 CPU 集合。只用一个 Node 时:

systemctl set-property httpd.service CPUAffinity=0-15
systemctl restart httpd

但这只用了半个裸金属,浪费。更专业做法是启动两个 httpd 实例,一个绑 node0,一个绑 node1,前端用 Nginx 按端口分流。

比如编译或安装 httpd 为两个 instance,分别监听 8080 和 8081,然后两个 service 文件:

# /etc/systemd/system/httpd-node0.service
[Service]
ExecStart=/usr/sbin/httpd -f /etc/httpd/node0.conf
CPUAffinity=0-15

另一个 node1.conf 监听 8081,CPUAffinity=16-31。这算是“无奈但正确”的做法。如果对 Apache 的 affinity 需求没那么严苛,也可以试试 numactl --interleave=all 启动 Apache,它会将内存页面均匀分布到所有 Node,避免单点热块,但线程跳转问题依旧存在。

我个人建议:新业务直接上 Nginx,Apache 维护成本高,且动态进程模型在物理机上更容易撞上 NUMA 坑。当然,如果公司有存量 Apache,就按上面的双实例方案改造。

验证结果:性能翻倍是基本操作

在轻云互联的一台双路裸金属上验证:同样是 28 核 56 线程,绑定前 remote-node-access 每秒约 12 万次,绑定后降到了 200 以下;QPS 从 4.6 万涨到 9.8 万,延迟 P99 从 42ms 降到 11ms。

这就是物理机和云主机的区别。云主机因为 vCPU 绑在同一个物理 socket 上,通常感受不到;裸金属跑满血,必须自己把 NUMA 这件事做对。

避坑清单

  • worker_processes auto 不等于绑核,必须配 worker_cpu_affinity auto(Nginx 1.9.10+)。
  • Apache 别裸跑,至少用 systemd 限制 CPU 集合,或用 numactl --interleave=all 临时缓解。
  • 网卡中断要跟着 worker 走,宁可不绑也别绑错 Node。
  • 调优前后用 numastatperf stat 记录数据,否则都是玄学。
  • 如果预算有限,可以买轻云互联的裸金属来折腾,物理机配置拉得越满,对 NUMA 的感知越明显。

绑定只是第一步,之后还要配合 keepalivelisten backlogworker_rlimit_nofile 一起看。但先把“线程不流浪”这件事做对,物理机的底子才算真正踩到位。