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_miss랑 numa_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에서만 할당(딴 노드로 안 흘림)
policy는 bind / 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에 가까우면 됐다.
원격 접근이 줄었나. 워크로드 돌리면서 numastat의 numa_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로 실측해서 의도한 자리에서 도는지 확인하고 넘어가자. 안 보고 넘어가면 나중에 '분명 설정했는데 왜 안 되지' 하고 처음부터 다시 판다.