如何在香港服务器的 CentOS 中优化 KVM 的 vCPU Pinning,提高虚拟机稳定性?

夜里 2 点半,香港葵涌机房的监控面板上有台支付风控 VM 的延迟曲线忽高忽低,跨境流量高峰还没退去,业务侧在钉钉里不断 @我。经验告诉我:这种“偶发抖动”十有八九是 CPU 调度迁移+NUMA 远程内存访问 搞的鬼。那一夜,我把整柜机器的 CPU 拿起“梳子”重新梳了一遍,从 BIOS 到内核,再到 libvirt 逐层 pin 定位,第二天早上 6 点,抖动消失,P95 延迟收敛 37%——这篇文章就按我那晚的操作轨迹来写,尽可能把坑点和细节都摆上台面。
一、环境说明与目标
目标:通过合理的 vCPU pinning(绑定物理核)、NUMA 对齐、线程与中断亲和,降低 VM 延迟抖动与上下文切换,提升负载稳定性。
现场硬件与软件(示例)
| 组件 | 型号/版本 | 备注 |
|---|---|---|
| 机型 | 1U 双路服务器(Dell R650 同档级) | 香港机房,双电源 |
| CPU | 2 × Intel Xeon Silver 4214R(12C/24T) | 共 24 物理核 / 48 线程,HT 开启 |
| 内存 | 256 GB(8 × 32 GB,NUMA 均衡) | 两路平均分布 |
| 磁盘 | 2 × NVMe(OS/日志),2 × SATA SSD(数据) | 虚拟化主机与 VM 数据分区 |
| 网卡 | Intel X710 10GbE 双口(驱动 i40e) | 上联 ToR 低延迟交换机 |
| 操作系统 | CentOS 7.9 | 生产稳定优先 |
| 内核 | 5.4.x(ELRepo kernel-lt) | 为了更好的网卡/存储栈与调度器 |
| 虚拟化 | qemu-kvm-ev 2.12 / libvirt 4.x | Virt SIG/ovirt 源(版本随仓库略有差异) |
为什么不是内核 3.10?
X710、NVMe、CFS/调度的成熟度与 5.x 内核差距明显。香港机房网络 jitter 敏感,升级到 ELRepo 的 LTS 5.4 带来更稳的驱动与调度表现。
二、基线观测:别着急 pin,先量一遍
命令集合(先拿“体温”再下药):
# 拿硬件与拓扑
lscpu -e
numactl --hardware
hwloc-ls --whole-system --no-io # 如未安装可跳过
# 线程迁移与中断分布
pidstat -wt 1 10
grep . /proc/irq/*/smp_affinity_list | head
cat /proc/interrupts | egrep "eth|ens|enp|x710|nvme"
# 延迟与抖动(宿主与 VM 内分别测)
cyclictest -p95 -m -D 30s
stress-ng --cpu 8 --timeout 60s
netperf -H <peer_ip> -t TCP_RR -l 30
常见“异常体征”:
- pidstat -wt 看到 qemu-system-x86_64 的线程(vCPU、iothread、emulator)在不同 PSR 来回跳。
- /proc/interrupts 显示 X710 的中断跨多个 NUMA 节点乱跑。
- cyclictest 的 max latency 偶发尖峰(> 1~2ms,甚至十几毫秒)。
- VM 内 mpstat -P ALL 1 出现间歇性的 run queue 抬头。
我的基线(示例)
| 指标 | 调优前 |
|---|---|
| VM P95 RPC 延迟 | 7.9 ms |
cyclictest max |
2.3 ms |
| vCPU 平均迁移/30s | 180+ 次 |
| 远程 NUMA 内存占比 | ~22% |
| ksoftirqd 占用峰值 | 80%+(偶发) |
三、原理与策略:pin 的对象不止是 vCPU
要 pin 的线程族:
- vCPU 线程(每个 vCPU 一个)
- emulator 线程(QEMU 主线程)
- iothread(磁盘/网络 IO 线程,配置后出现)
- vhost-net kthread(内核线程,处理 virtio-net,加速通道)
配套要 pin 的还有:
- IRQ(网卡/存储中断)亲和到对应 NUMA 节点
- 内存(numatune)尽量在本地 NUMA
- HugePages(减少缺页与 TLB 抖动)
宿主要留“家务核”:ksoftirqd、kswapd、ssh、监控代理等需要独立小核区,别和 VM 抢。
四、规划 CPU 编排:先画一张“拓扑草图”
典型双路 + HT 的 lscpu 片段(示例):
CPU(s): 48
Thread(s) per core: 2
Core(s) per socket: 12
Socket(s): 2
NUMA node0 CPU(s): 0-11,24-35
NUMA node1 CPU(s): 12-23,36-47
解读:011 为 socket0 的物理核,2435 为其超线程;1223 为 socket1 物理核,3647 为其超线程。
经验:同一物理核的兄弟线程(超线程)编号差 24(=每路的线程数)。
我的分区策略(示例):
- 宿主家务核:CPU 0-1, 24-25(同一物理核的俩线程)
- VM-A(支付风控):绑在 NUMA0:选 CPU 4-7, 28-31(4 物理核 ×2 线程 = 8 vCPU)
- VM-B(风控特征计算):也在 NUMA0:CPU 8-11, 32-35
- VM-C(日志/队列):放 NUMA1:CPU 12-15, 36-39
- …(按业务重要性与资源需求继续分)
为什么 HT 也要算进去?
对延迟敏感场景,优先给 vCPU 绑定到“不同物理核的第一个线程”;如果需要更多 vCPU,再考虑用到该物理核的超线程位。但不要让两个高负载 vCPU 挤在同一物理核的两个超线程上。
五、系统级准备:从 BIOS 到内核
BIOS(供应商 UI 略有不同)
- Power/Performance:Performance(固定高性能 P-State)
- C-States:只保留 C0/C1,关闭深度 C-State(C6/C7)
- Hyper-Threading:开启(可控使用)
- NUMA:开启
- VT-x/VT-d:开启
- 关自动风扇曲线省电策略(避免温度抖动触发降频)
内核与 Tuned 配置(CentOS 7)
# 升级到 ELRepo LTS 内核(谨慎:生产变更走变更流程)
yum install -y https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm
yum --enablerepo=elrepo-kernel install -y kernel-lt kernel-lt-tools
# 配置默认内核并重启
grub2-set-default 0
reboot
GRUB 内核参数(/etc/default/grub 的 GRUB_CMDLINE_LINUX 追加):
intel_pstate=disable processor.max_cstate=1 idle=poll
isolcpus=4-23,28-47 nohz_full=4-23,28-47 rcu_nocbs=4-23,28-47
- isolcpus/nohz_full/rcu_nocbs:把预留给 VM 的核“隔离”出一般调度与 RCU 回调,减少干扰。
- idle=poll 与 max_cstate=1:牺牲功耗换取一致性。高热环境注意温控。
- intel_pstate=disable:配合 cpufreq performance 固定频率,避免频率抖动。
# 生成并应用 grub
grub2-mkconfig -o /boot/grub2/grub.cfg
# 性能调优 profile
yum install -y tuned kernel-tools
tuned-adm profile latency-performance
cpupower frequency-set -g performance
# HugePages(1G 大页示例,先小规模验证)
echo "vm.nr_hugepages=64" >> /etc/sysctl.d/99-hugepages.conf
sysctl --system
mkdir -p /dev/hugepages
mount -t hugetlbfs nodev /dev/hugepages
IRQBalance 与中断亲和
关闭 irqbalance(或限制其不要动隔离核):
# 禁止自动搬运中断,避免与我们手工亲和策略冲突
systemctl stop irqbalance
systemctl disable irqbalance
将 X710 中断绑到 宿主家务核(例如 0-1,24-25),为 vhost/VM 留出干净的核区:
# 查找网卡中断号
grep -i "ixgbe\|i40e\|x710\|ens\|enp" /proc/interrupts
# 写 smp_affinity_list(示例把中断固定在 0-1)
echo 0-1 > /proc/irq/<irq_id>/smp_affinity_list
如果使用 DPDK/OVS-DPDK 路线,亲和策略另算;本文以内核 vhost-net 为例。
六、libvirt / QEMU:把线程和内存都“拴”在对的位置
关键点:
- cpu mode="host-passthrough":让 VM 获得接近裸机的指令集特性。
- cputune:vcpupin 绑定每个 vCPU;emulatorpin 绑定 QEMU 主线程;iothreadpin 绑定 IO 线程。
- numatune:内存绑定到本地 NUMA;mode="strict" 防止越界。
- iothreads:为磁盘/网络单独线程提高并发可控性。
- 网卡:virtio + vhost=on + mq(多队列)并与队列数匹配 vCPU。
VM 示例(节选 XML):
<domain type='kvm'>
<name>vm-pay-risk</name>
<vcpu placement='static'>8</vcpu>
<cpu mode='host-passthrough'>
<topology sockets='1' cores='4' threads='2'/>
</cpu>
<iothreads>2</iothreads>
<cputune>
<!-- vCPU 0..7 绑到 NUMA0 的 4 个物理核及其超线程:4,28 / 5,29 / 6,30 / 7,31 -->
<vcpupin vcpu='0' cpuset='4'/>
<vcpupin vcpu='1' cpuset='28'/>
<vcpupin vcpu='2' cpuset='5'/>
<vcpupin vcpu='3' cpuset='29'/>
<vcpupin vcpu='4' cpuset='6'/>
<vcpupin vcpu='5' cpuset='30'/>
<vcpupin vcpu='6' cpuset='7'/>
<vcpupin vcpu='7' cpuset='31'/>
<!-- QEMU 主线程放在宿主家务核,避免抢 VM 的核 -->
<emulatorpin cpuset='0-1'/>
<!-- IO 线程分别固定 -->
<iothreadpin iothread='1' cpuset='2'/>
<iothreadpin iothread='2' cpuset='3'/>
</cputune>
<numatune>
<memory mode='strict' nodeset='0'/>
<memnode cellid='0' mode='strict' nodeset='0'/>
</numatune>
<memoryBacking>
<hugepages>
<page size='1048576' unit='KiB'/>
</hugepages>
</memoryBacking>
<devices>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2' io='threads' iothread='1'/>
<source file='/vmstore/pay-risk.qcow2'/>
<target dev='vda' bus='virtio'/>
</disk>
<interface type='bridge'>
<source bridge='br0'/>
<model type='virtio'/>
<driver name='vhost' queues='4'/> <!-- 与 vCPU 数匹配或取其倍数 -->
</interface>
</devices>
</domain>
版本差异提醒:较新的 libvirt 支持 <interface><tuning><vhost><threads> 为 vhost 线程单独亲和;旧版可在 VM 启动后用 ps -eLo pid,psr,comm | grep vhost- 找到 kthread,再用 taskset -pc 绑定(持久化需自写 udev/systemd 单元)。
临时 pin 与在线校验:
# 在线修改某 vCPU pin(重启不保留)
virsh vcpupin vm-pay-risk 0 4
virsh emulatorpin vm-pay-risk 0-1
virsh iothreadpin vm-pay-risk 1 2
# 查看线程与亲和
virsh vcpupin vm-pay-risk
virsh emulatorpin vm-pay-risk
virsh iothreadinfo vm-pay-risk
七、网络与存储协同:把数据路径也对齐
virtio-net 多队列:driver queues=<n> 与 vCPU 或 NIC RSS 队列数一致;ethtool -l 查看物理队列,ethtool -L 调整。
RPS/XPS:宿主 /sys/class/net/ethX/queues/rx-*/rps_cpus 与 /tx-*/xps_cpus 配置到 宿主家务核 或对应 VM 的 NUMA 核。
NVMe/SSD 中断:同样绑到本地 NUMA 的宿主家务核,避免跨节点。
八、验证与对比:用数据说话
调优后指标(同上业务窗口测 30 分钟)
| 指标 | 调优前 | 调优后 |
|---|---|---|
| VM P95 RPC 延迟 | 7.9 ms | 4.9 ms |
cyclictest max |
2.3 ms | 0.72 ms |
| vCPU 平均迁移/30s | 180+ 次 | ≤ 5 次(主要为维护/抢占) |
| 远程 NUMA 内存占比 | ~22% | < 3% |
| ksoftirqd 占用峰值 | 80%+(偶发) | < 25% |
在线观察点:
htop(打开 CPU 编号)看 vCPU 是否稳定停在规划的核上。
numastat -p <qemu-pid> 检查内存是否稳在对应 node。
/proc/interrupts 中断是否集中在规划核。
业务曲线是否去掉“锯齿”。
九、踩坑记录与快速解法
只 pin 了 vCPU,忘了 emulator/iothread
现象:偶发长尾仍在。
解决:<emulatorpin>、<iothreadpin> 都要配;磁盘 io='threads' 并分配 iothreads。
irqbalance 把中断“搬”走
现象:中断亲和失效,一会儿在 node0 一会儿在 node1。
解决:停掉 irqbalance,或 IRQBALANCE_BANNED_CPUS 限制(CentOS 7 版本不同配置项名不同,保守做法是停服务 + 手工亲和)。
nohz_full/isolcpus 配过猛,宿主家务核不够
现象:ksoftirqd 飙升,管理操作掉队。
解决:至少留 2~4 个逻辑核 给宿主家务;磁盘/网络中断也集中到这几颗核。
NUMA 与 HugePages 不匹配
现象:numastat -p 里 remote 分配升高。
解决:numatune mode='strict' + 预留足够同 NUMA 的大页;不够就降低 VM 内存或扩大大页。
Docker/容器与 libvirt cgroup 冲突
现象:/sys/fs/cgroup/cpuset 下容器/VM 互相“踩界”。
解决:给容器单独划分 cpuset,避免进入 VM 的隔离核区。
开启 HT 但把两个重负载 vCPU 绑到同一物理核
现象:吞吐看似够、抖动仍大。
解决:对延迟敏感 VM,优先使用不同物理核的“第一个线程”;超线程只作补位。
live migration 需求与静态 pin 冲突
现象:迁移后目标宿主没有同样核位。
解决:在主机群层面制定统一 CPU 编号规划与“核位契约”,或迁移后自动化重 pin。
十、可复用的小工具
1)一键生成 vcpupin 片段(bash)
#!/usr/bin/env bash
# gen_vcpupin.sh: 传入起始物理核列表,为每个物理核生成两条(含超线程)
# 用法:./gen_vcpupin.sh 4 5 6 7
set -e
vcpu=0
for pcore in "$@"; do
ht=$((pcore+24)) # 按示例拓扑,超线程=物理核+24
echo "<vcpupin vcpu='$vcpu' cpuset='$pcore'/>"; vcpu=$((vcpu+1))
echo "<vcpupin vcpu='$vcpu' cpuset='$ht'/>"; vcpu=$((vcpu+1))
done
2)绑定 vhost kthread(旧 libvirt 环境)
#!/usr/bin/env bash
# pin_vhost.sh vmname cpulist
VM=$1; CPUS=$2
PID=$(pgrep -f "qemu-system.*-name $VM" | head -1)
ps -eLo pid,psr,comm | awk -v p=$PID '$0 ~ p && $3 ~ /^vhost-/{print $1}' | \
while read -r T; do
taskset -pc $CPUS $T
done
十一、回滚与变更窗口建议
GRUB 内核参数、HugePages、nohz_full/isolcpus 等属于系统级变更,建议业务低谷、分批次套用,有控制的灰度。
libvirt XML 可先 virsh define 到一台影子 VM 验证,再推广到核心业务。
保留可回滚的内核条目;grub2-reboot 1 指定下次仅一次性回到旧内核。
关键指标纳入看板:vCPU 迁移率、NUMA 远程比例、网络/存储中断分布、P95/P99 延迟。
十二、结尾:黎明前的那杯咖啡
当我在机房门口捧着便利店的热美式,看着业务群里“抖动消失”的反馈,脑子里浮现的是一张张 CPU 编号表。Pinning 看似“粗暴”,但它让复杂系统的 不确定性 变得可控:vCPU 不再漫游,内存不再远行,中断不再四处惊扰。
如果你的 VM 也在香港这个对网络时延极为敏感的环境中承载关键流量,别指望默认调度器能替你做完所有权衡——把核位安排清楚,比什么都重要。
附:操作清单(Checklist)
- BIOS:Performance、C-state≤C1、HT On、NUMA On
- 内核:ELRepo 5.4、isolcpus/nohz_full/rcu_nocbs、performance governor
- HugePages:预留并与 NUMA 对齐
- IRQ:X710/NVMe 中断亲和到宿主家务核
- libvirt:host-passthrough、vcpupin、emulatorpin、iothreadpin、numatune strict
- virtio-net:vhost=on、多队列与 vCPU/RSS 匹配
- 验证:numastat -p、/proc/interrupts、pidstat -wt、业务 P95/P99 曲线
只要思路和手法跑通,你的 VM 延迟曲线也能像那天清晨的海风一样,稳。