双路裸金属“半身不遂”?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-27、node1 cpus: 28-55 这样的输出。如果有两个 node,并且你的进程在两个 node 的 CPU 上都有运行记录,那就中招了。
再看实际内存访问情况:
numastat -m
numastat -p <nginx-worker-pid>
如果 active_alloc 或 local_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-0、eth0-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。
- 调优前后用
numastat和perf stat记录数据,否则都是玄学。 - 如果预算有限,可以买轻云互联的裸金属来折腾,物理机配置拉得越满,对 NUMA 的感知越明显。
绑定只是第一步,之后还要配合 keepalive、listen backlog、worker_rlimit_nofile 一起看。但先把“线程不流浪”这件事做对,物理机的底子才算真正踩到位。