Proxmox에서 VM 하나 올리면, 그 VM의 vCPU가 호스트 물리 코어 중 어디서 돌지는 커널 스케줄러가 알아서 정해준다. 웬만한 워크로드는 이걸로 충분하다. 코어 놀면 옮겨 붙이고 부하 몰리면 흩어주고, 내가 손댈 이유가 없다.

문제는 지연에 예민한 것 들이다. DB, 고 PPS 네트워크 처리하는 방화벽·NFV VM, 실시간 처리, Ceph OSD 같은 것들. 여기서는 이 '알아서'가 오히려 발목을 잡는다. vCPU가 소켓을 넘어다니면서 캐시가 날아가고, 메모리는 저쪽 NUMA 노드에 있는데 실행은 이쪽에서 돌아서 접근할 때마다 인터커넥트를 한 번씩 더 탄다. CPU 사용률은 멀쩡한데 지연 그래프 꼬리만 툭툭 튀는, 원인 찾기 짜증나는 부류의 문제다.

이 글은 그걸 잡는 세 가지를 정리한다. NUMA 인지 배치, CPU 핀ning, Hugepage. 셋은 따로 켜면 반쪽짜리고, 묶어서 써야 효과가 난다.


1. NUMA부터 알고 가야 한다

요즘 서버는 소켓이 두 개 이상 박힌 게 흔하다. 듀얼 소켓 Xeon Scalable(Skylake/Cascade Lake, R740xd 같은) 장비를 예로 들면, CPU 소켓마다 자기 메모리 컨트롤러랑 메모리 뱅크를 따로 들고 있다. 이 '소켓 + 그 소켓에 직결된 메모리' 한 덩어리가 NUMA 노드 하나다.

여기서 알아야 할 건 딱 하나. 같은 노드 안 메모리(local)는 빠르게 접근하는데, 다른 노드 메모리(remote)를 건드리려면 소켓 사이 인터커넥트(인텔은 UPI, 옛날엔 QPI, AMD는 인피니티 패브릭)를 한 번 건너가야 한다. 이 원격 접근이 로컬보다 지연도 크고 대역폭도 낮다. 별거 아닌 것 같아도 쌓이면 티가 난다.

내 서버가 어떻게 생겼는지부터 보자.

# NUMA 노드별 CPU / 메모리 분포
numactl --hardware

# 코어-소켓-스레드 매핑
lscpu | grep -i numa
lscpu -e

# 토폴로지를 그림으로 (hwloc 패키지)
lstopo --of txt

numactl --hardware 아래쪽 node distances 표를 보면 노드 간 상대 거리(local=10 기준)가 나온다. 로컬이 10, 원격이 21이면 원격 접근이 대충 두 배 비싸다는 뜻이다.

node distances:
node   0   1
  0:  10  21
  1:  21  10

2. 기본 스케줄링이 왜 문제인가

설정 없이 VM을 띄우면 두 가지가 같이 흔들린다.

하나, vCPU가 노드를 넘어다닌다. 커널이 부하 분산한답시고 vCPU 스레드를 node0 코어에서 node1 코어로 옮기는데, 옮겨가는 순간 그 코어에 데워놨던 L1/L2랑 소켓 단위로 공유하던 LLC가 전부 남 얘기가 된다. 캐시 다시 채우는 값을 치러야 한다.

둘, 메모리랑 실행 위치가 어긋난다. 게스트 메모리는 처음 할당될 때 특정 노드에 자리를 잡는데, vCPU가 다른 노드로 넘어가버리면 그때부터 메모리 접근이 죄다 원격이 된다. 매번 UPI를 건너가는 셈이다.

원격 접근이 실제로 얼마나 터지는지는 numastat으로 볼 수 있다.

# 전체 노드의 numa_hit / numa_miss / numa_foreign
numastat

# 특정 프로세스(여기선 qemu)의 노드별 메모리 분포
numastat -p $(pgrep -f 'id 100')

numa_missnuma_foreign이 계속 올라가면, '로컬에 두고 싶었는데 딴 노드에서 처리된' 접근이 많다는 신호다. 이걸 0 근처로 눌러버리는 게 목표다.


3. CPU 핀ning — vCPU를 코어에 박아버리기

제일 먼저 할 건 vCPU 스레드를 정해진 물리 코어에만 묶는 거다. Proxmox 7.3부터는 VM 설정에 affinity 옵션이 들어와서 이걸 네이티브로 해준다. 속은 taskset이랑 같은 CPU affinity를 QEMU 프로세스에 걸어주는 것이다.

# vmid 100의 vCPU를 물리 코어 0~7에만 배치
qm set 100 --affinity 0-7

또는 /etc/pve/qemu-server/100.conf에 직접 명시한다.

affinity: 0-7

0-7은 호스트 논리 CPU 번호다. lscpu -e 찍어서 어떤 논리 CPU가 어느 소켓(노드)에 붙어 있는지 확인하고 골라야 한다. node0에 붙이려다 node1 코어 번호를 적으면 안 하느니만 못하다.

핀ning할 때 같이 챙길 게 두 개 있다.

하이퍼스레딩 형제 코어. 물리 코어 하나가 논리 CPU 두 개(예: 0번, 20번)로 보이면 이 둘은 실행 유닛을 공유한다. 지연에 진짜 민감하면 형제 한쪽을 비우거나, VM 하나에 형제를 짝으로 몰아주는 식으로 간섭을 관리한다.

호스트 프로세스 격리. 코어를 VM에 줬어도 호스트 커널 태스크나 다른 프로세스가 그 코어에 끼어들 수 있다. 진짜 조용한 코어가 필요하면 부팅 파라미터에서 아예 스케줄러 밸런싱에서 빼버린다.

# /etc/kernel/cmdline (systemd-boot) 또는 GRUB cmdline
isolcpus=8-15 nohz_full=8-15 rcu_nocbs=8-15

isolcpus는 일반 스케줄러가 그 코어에 일을 안 얹게 하고, nohz_full이랑 rcu_nocbs는 타이머 틱이랑 RCU 콜백까지 다른 코어로 밀어내서 지터를 줄인다. 대신 이렇게 격리한 코어는 명시적으로 얹은 VM 말고는 못 쓴다. 유연성을 갖다 바치는 대가라는 걸 알고 써야 한다.


4. NUMA 인지 배치 — vCPU랑 메모리를 같은 노드에

핀ning으로 vCPU 위치를 고정했으면, 게스트 메모리도 같은 노드에 묶어야 그림이 완성된다. Proxmox는 numa 옵션으로 게스트한테 NUMA 구조를 노출하고, numaX로 게스트 노드를 호스트 노드에 바인딩한다.

전부 한 노드에 몰아넣는 제일 단순한 경우부터.

numa: 1
numa0: cpus=0-7,memory=16384,hostnodes=0,policy=bind
  • cpus=0-7 — 게스트 관점의 vCPU 인덱스(0~7번 vCPU)
  • memory=16384 — 이 게스트 노드에 붙일 메모리(MB)
  • hostnodes=0 — 호스트 NUMA 노드 0에 바인딩
  • policy=bind — 이 노드 메모리는 무조건 host node0에서만 할당(딴 노드로 안 흘림)

policybind / preferred / interleave 중에 고른다. 지연 최적화가 목적이면 bind다. preferred는 가능하면 그 노드 쓰고 부족하면 딴 노드로 넘어가는 물렁한 정책이라, 애초에 튜닝하는 취지랑 안 맞는다.

VM이 커서 소켓 하나에 안 들어가면 게스트도 두 노드로 쪼개서 각각 대응하는 호스트 노드에 붙인다.

numa: 1
sockets: 2
cores: 8
numa0: cpus=0-7,memory=16384,hostnodes=0,policy=bind
numa1: cpus=8-15,memory=16384,hostnodes=1,policy=bind

이러면 게스트 OS도 자기가 NUMA 머신인 걸 알고, 게스트 안 스케줄러가 한 번 더 로컬리티를 챙겨준다. 여기서 놓치면 안 되는 게, 3번 affinity(호스트 코어)랑 이 numaX의 hostnodes가 물리적으로 같은 노드를 가리켜야 한다는 거다. numa0을 host node0에 묶었는데 affinity로 준 코어가 node1이면, 실행은 node1에서 하고 메모리는 node0에서 끌어오는 제일 멍청한 조합이 나온다.


5. Hugepage — TLB 압박 덜기

위치는 잡혔고, 마지막은 페이지 크기다.

CPU가 가상주소를 물리주소로 바꿀 때 TLB(Translation Lookaside Buffer)라는 작은 캐시를 쓴다. 기본 페이지가 4KB니까, 메모리 많이 쓰는 VM은 페이지 수가 어마어마해지고 TLB가 금방 꽉 찬다. TLB 미스 나면 페이지 테이블을 걸어서(page table walk) 주소를 찾아야 하는데, 이것도 지연의 원인이다.

Hugepage는 페이지 한 장을 2MB나 1GB로 키운다. 같은 메모리를 훨씬 적은 페이지로 덮으니 TLB 엔트리가 확 줄고, 테이블 워크도 얕아진다.

호스트에 hugepage 예약

2MB 페이지는 런타임에 sysfs로 잡을 수 있다.

# node0에 2MB 페이지 8192장(=16GB) 예약
echo 8192 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages

1GB 페이지는 부팅 이후에 연속된 1GB 물리 영역 잡기가 힘들어서(단편화 때문에) 부팅 파라미터로 예약하는 게 마음 편하다.

# cmdline
default_hugepagesz=1G hugepagesz=1G hugepages=32

부팅하고 노드별로 잘 잡혔는지 확인:

grep -i huge /proc/meminfo
cat /sys/devices/system/node/node0/hugepages/hugepages-1048576kB/nr_hugepages

NUMA랑 엮을 때 이거 하나는 꼭 기억하자 — hugepage는 노드별로 따로 예약된다. node0에 묶은 VM인데 hugepage를 node1에만 잡아놨으면 VM이 아예 안 뜬다. 노드별 nr_hugepages를 맞춰줘야 한다.

VM에 적용

hugepages: 1024

1024면 1GB 페이지, 2면 2MB 페이지다. 이 옵션 켜면 QEMU가 게스트 RAM을 hugepage로 깐다. 대신 이때부터 그 VM은 메모리를 부팅 시점에 통째로 선점(pre-allocation)해버려서 오버커밋은 못 쓴다. 어차피 지연 민감 VM에 오버커밋 걸 일은 없으니 상관없다.

THP는 끄자

리눅스엔 THP(Transparent Hugepage)라고, 알아서 hugepage로 합쳐주는 기능이 있다. 편하긴 한데 khugepaged가 백그라운드에서 페이지 합치는 순간 짧게 지연이 튈 수 있다. 지연이 생명인 워크로드면 명시적 정적 hugepage 쓰고 THP는 꺼두는 게 예측 가능성이 좋다.

echo never > /sys/kernel/mm/transparent_hugepage/enabled

6. 다 합치면

듀얼 소켓 서버에서 지연 민감 VM(vmid 100)한테 8 vCPU / 16GB 주고 전부 node0에 몰아넣는 완성형이다.

/etc/pve/qemu-server/100.conf:

cores: 8
sockets: 1
memory: 16384
affinity: 0-7
numa: 1
numa0: cpus=0-7,memory=16384,hostnodes=0,policy=bind
hugepages: 1024

호스트 사전 준비:

# 1) node0에 1G hugepage 16장 예약 (cmdline에 넣고 재부팅한 상태 가정)
#    default_hugepagesz=1G hugepagesz=1G hugepages=16
#    isolcpus=0-7 nohz_full=0-7 rcu_nocbs=0-7  ← node0 코어 격리

# 2) THP off
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 3) node0 hugepage 확인
cat /sys/devices/system/node/node0/hugepages/hugepages-1048576kB/nr_hugepages

전제는 lscpu -e에서 논리 CPU 0~7이 실제로 node0 소속이라는 거다. 장비마다 번호가 다르니 꼭 자기 서버에서 확인하고 숫자 바꿔 쓴다.


7. 검증 — 먹혔는지 확인

설정만 하고 끝내면 안 된다. 진짜 그 위치에서 도는지, 메모리가 그 노드에 몰렸는지 눈으로 봐야 한다.

vCPU 스레드가 지정한 코어에서 도나.

# qemu 프로세스의 스레드별 현재 실행 CPU(PSR)
ps -To 'pid,tid,psr,comm' $(pgrep -f 'id 100')

PSR 열이 배치한 범위(0~7) 안에 들어와 있어야 한다.

메모리가 묶은 노드에 몰렸나.

numastat -p $(pgrep -f 'id 100')

거의 전부 Node 0에 찍히고 Node 1이 0에 가까우면 됐다.

원격 접근이 줄었나. 워크로드 돌리면서 numastatnuma_foreign / numa_miss 증가폭이 튜닝 전보다 확 낮아졌는지 비교한다.


주의할 점

  • 번호 착각 affinity의 호스트 CPU 번호랑 numaX의 hostnodes가 물리적으로 안 맞으면 튜닝이 거꾸로 논다. 실행은 A노드, 메모리는 B노드가 최악이다.
  • 노드별 hugepage 빼먹기. 묶은 노드에 hugepage 없으면 VM이 부팅도 안 된다. 에러 메시지가 불친절해서 한참 헤맨다.
  • 라이브 마이그레이션 제약. hostnodes 바인딩에 hugepage까지 쓰면 마이그레이션이 까다로워진다. 대상 노드가 같은 hugepage 예약이랑 NUMA 구조를 갖추고 있어야 하고, 아니면 실패하거나 튜닝이 무의미해진다. 핀ning한 VM은 사실상 특정 호스트에 묶인다고 보면 된다.
  • 핀ning 과욕. 호스트 코어를 죄다 VM에 나눠주고 격리까지 걸어버리면, 정작 호스트 자신이랑(Ceph 같이 돌리면 OSD 데몬이) 쓸 코어가 모자란다. 격리 코어랑 공용 코어 경계를 미리 그어두는 게 낫다.
  • 아무 VM에나 적용. 이건 유연성 반납하고 지연 특성을 사는 거래다. 웹 서버나 일반 앱 VM엔 그냥 기본 스케줄러가 낫다. 지연이 진짜 문제인 VM에만 골라서 건다.

정리

  • NUMA 인지 배치: vCPU랑 메모리를 같은 노드에 묶어서 원격 접근을 없앤다.
  • CPU 핀ning: vCPU가 노드 못 넘어다니게 코어에 고정한다 (+ isolcpus로 격리).
  • Hugepage: TLB 압박 줄여서 주소 변환 지연을 낮춘다 (노드별 예약, THP off).

셋을 따로 켜면 효과가 반이다. 같은 노드 코어에 vCPU 박고, 그 노드 메모리에 게스트 RAM 묶고, 그 메모리를 hugepage로 까는 한 세트가 돼야 지연 꼬리가 잡힌다. 설정했으면 꼭 numastat이랑 ps -o psr로 실측해서 의도한 자리에서 도는지 확인하고 넘어가자. 안 보고 넘어가면 나중에 '분명 설정했는데 왜 안 되지' 하고 처음부터 다시 판다.