問題:CPUに余裕があるのに仮想マシンが遅い
この問題に初めて気づいたのは、リアルタイムデータ処理専用のVMを動かしていたときのことです。ホストのCPU使用率は30%程度なのに、レイテンシーが短いスパイクを繰り返して異常に高くなる現象が続きました。ログには何も残らず、RAMも十分、ディスクI/Oも正常。しばらくして原因がわかりました。ハイパーバイザーのスケジューラーがVMのvCPUを物理CPUコア間で頻繁に移動させており、それがキャッシュミスとNUMAペナルティを引き起こしていたのです。
筆者はProxmox VEで12台のVMとコンテナを管理するホームラボを運用しており、本番環境に投入する前にあらゆる設定を試す場として活用しています。この環境で実感したのは、データベース、ゲームサーバー、リアルタイムの音声・動画処理など、レイテンシーに敏感なワークロードでは、LinuxカーネルにvCPUの実行コアを任せるだけでは不十分だということです。
解決策はCPUピニングとNUMAトポロジーの適切な設定にあります。設定自体は難しくありませんが、本質を誤解すると間違った場所に適用してしまいがちです。
基本概念
CPUピニングとは?
4つのvCPUを持つVMを作成すると、Proxmox(正確にはQEMU + KVM)は対応する4つのスレッドを生成します。デフォルトでは、ホストのLinuxカーネルがこれらのスレッドを空いている任意の物理コアにスケジューリングします。スケジューラーがスレッドを別のコアに移動するたびに、元のコアのCPUキャッシュが無駄になり、新しいコアはデータを最初からロードし直す必要があります。通常のワークロードではこのコストは無視できますが、レイテンシーに敏感なワークロードでは、1秒間に何千回も発生するキャッシュミスが積み重なって深刻な問題になります。
CPUピニング(CPUアフィニティとも呼ばれます)とは、VMの各vCPUを特定の物理CPUコアのサブセットに固定することです。そのvCPUのスレッドは指定されたコアのみで実行され、あちこち移動しなくなります。キャッシュが温まった状態を保ち、レイテンシーが安定します。
NUMAとは何か、なぜ重要なのか?
マルチソケットサーバー、あるいはAMD EPYC/Ryzen Threadripperのような複数のチップレット構成のCPUでは、メモリへのアクセスが均一ではありません。各ソケットには直接接続されたRAMがあり、これがローカルメモリで非常に高速にアクセスできます。一方、ソケット0のコアがソケット1のRAMからデータを読み取る場合、ソケット間リンク(IntelのQPIまたはAMDのInfinity Fabric)を経由する必要があります。これがリモートメモリで、ローカルアクセスの2〜3倍のレイテンシーが発生します。
このアーキテクチャをNUMA(Non-Uniform Memory Access)と呼びます。設定が不整合な場合に問題が生じます。vCPUをノード0のコアにピン留めしても、RAMがノード1から割り当てられていれば、すべてのメモリアクセスでNUMAペナルティが発生します。中途半端な設定は何もしないよりも悪い場合があります。解決したつもりでもボトルネックが残り続けるからです。
正しい設定は両方を整合させることです。vCPUを同じNUMAノードのコアにピン留めし、VMのRAMもそのNUMAノードから割り当てられるようにする必要があります。
詳細な手順
ステップ1:ホストのCPUトポロジーを確認する
ピニングの前に、ホストのNUMAノード数と各コアの所属ノードを把握する必要があります。
# NUMAトポロジーの概要を表示
numactl --hardware
# より詳細な情報はlscpuを使用
lscpu | grep -E 'NUMA|CPU\(s\)|Thread|Core|Socket'
# 各NUMAノードのCPUリストを確認
cat /sys/devices/system/node/node0/cpulist
cat /sys/devices/system/node/node1/cpulist
2ソケット・各ソケット8コア(HTで16スレッド)のサーバーでnumactl --hardwareを実行した場合の出力例:
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
node 0 size: 64432 MB
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
node 1 size: 64432 MB
node distances:
node 0 1
0: 10 21
1: 21 10
21はNUMA distanceで、もう一方のノードのRAMへのアクセスは2.1倍のコストがかかることを意味します。覚えておいてください:VMがコア0〜7を使用する場合、ペナルティを避けるにはRAMをノード0から確保する必要があります。
ステップ2:ProxmoxでCPUピニングを設定する
ProxmoxにはCPUピニングの直接UIがありません(PVE 8.x時点)。VMの設定ファイルを直接編集する必要があります。
# ID 100のVMの設定ファイル
nano /etc/pve/qemu-server/100.conf
以下の設定を追加します(例:4 vCPUのVMをNUMAノード0のコア0〜3にピン留め):
# vCPU数
cores: 4
sockets: 1
# CPUタイプ — hostを指定すると物理CPUの全機能を使用可能
cpu: host
# 各vCPUをピン留め(vcpu0→core0, vcpu1→core1, ...)
affinity0: 0
affinity1: 1
affinity2: 2
affinity3: 3
Proxmox VE 8.1以降では、affinityパラメーターでrange形式を使って簡潔に記述できます:
affinity: 0-3
保存後、VMを再起動して設定を適用します。以下のコマンドで確認できます:
# VM 100のQEMUプロセスのPIDを取得
ps aux | grep 'qemu.*100'
# QEMUスレッドの現在のアフィニティを確認
taskset -cp <PID>
ステップ3:VMのNUMAトポロジーを設定する
CPUのピニングだけでは半分しか解決できません。次のステップは、VMがNUMA環境を認識できるようにNUMAトポロジーを宣言し、QEMU/KVMが正しいノードからRAMを割り当てるよう強制することです。
VMの設定ファイルを再度編集します:
# NUMAエミュレーションを有効化
numa: 1
# VMのNUMAノード0を宣言:
# cpus = このノードに属するvCPU(0-3)
# memory = このノードに割り当てるRAM量(MB)
# hostnodes = RAMを提供する物理NUMAノード
# policy = メモリ割り当てポリシー
numa0: cpus=0-3,memory=8192,hostnodes=0,policy=bind
policy=bindが核心となるパラメーターです。ノード0に負荷がかかっている場合でも、ホストカーネルはノード0のみからRAMを割り当てるよう強制され、他のノードから取得することは禁止されます。
VMのvCPU数が多く、NUMAノード1にまたがりたい場合(例:2ソケットサーバーの8 vCPU VM):
cores: 8
sockets: 2
numa: 1
affinity: 0-3,8-11
numa0: cpus=0-3,memory=8192,hostnodes=0,policy=bind
numa1: cpus=4-7,memory=8192,hostnodes=1,policy=bind
注意:affinity: 0-3,8-11は、vCPU 0〜3が物理コア0〜3(ノード0)を使用し、vCPU 4〜7が物理コア8〜11(ノード1)を使用することを意味します。ステップ1のnumactl --hardwareの出力と照合して正確にマッピングしてください。
ステップ4:ホストスケジューラーからCPUコアを分離する(上級編)
ピン留めをしても、ホストOSが同じコア上で他のプロセスを実行している場合、割り込みが発生します。極限の低レイテンシーが求められる場合は、VMに割り当てるコアを完全に分離することを検討してください:
# /etc/default/grubのGRUB_CMDLINE_LINUXに追加
# ホストのLinuxスケジューラーからコア0-3を分離
isolcpus=0-3 nohz_full=0-3 rcu_nocbs=0-3
# 設定を適用
update-grub
reboot
再起動後、以下で確認します:
cat /sys/devices/system/cpu/isolated
0-3と表示されるはずです。これらのコアはピン留めされたVMにほぼ専用で使われるようになります。
ステップ5:効果を確認する
VM内でシンプルなレイテンシーベンチマークを実行します:
# cyclictestをインストール(通常rt-testsに含まれる)
apt install rt-tests
# 60秒間のレイテンシーテストを実行
cyclictest --mlockall --smp --priority=80 --interval=200 --distance=0 -D 60
ピニング設定の前後で結果を比較してください。正しく設定できていれば、Maxレイテンシーの値が大幅に改善されるはずです。負荷がかかった状態でも、数ミリ秒から数百マイクロ秒まで低下することがあります。
適用前に覚えておくべき注意点
- すべてのVMにピニングを適用しない:ピニングはスケジューラーの柔軟性を低下させます。本当に低レイテンシーが必要なVMにのみ適用し、それ以外のVMはスケジューラーに任せましょう。
- コアの割り当て計算:16コアのホストで2つのVMに8コアをピン留めすれば、ホストOSと他のVMには残り8コアしかありません。オーバーコミットは禁物です。
- ハイパースレッディング:重い計算ワークロードでは、HTを無効にするか物理コアのみ使用する(兄弟スレッドを使わない)ことを検討してください。同一物理コア内でのリソース競合を避けるためです。
- ライブマイグレーションへの影響:CPUピニングが設定されたVMは、移行先ホストのトポロジーが異なる場合にライブマイグレーションができません。マイグレーション前にピニングを解除する必要があります。
まとめ
CPUピニングとNUMAトポロジーは、すべてのVMに必要な設定ではありません。しかし、ワークロードが安定したレイテンシーを本当に必要としている場合、この2つのテクニックを無視することは手元のハードウェアを無駄にすることと同じです。筆者もデータセンター規模の話だと思い込んでいたため、長い間この設定を避けていました。しかし自分のホームラボがその認識を覆してくれました。
numactl --hardwareから始めて、テスト用に1台のVMを選び、段階的にピニングを適用してください。レイテンシーがどう変化するかを観察してみましょう。実際のサーバーと実際のワークロードで得られる数値は、どんな総合ベンチマークよりも説得力があります。

