핵심 원칙은 하나입니다: "CPU 코어 - 메모리 - PCIe 디바이스(NIC/NVMe)가 같은 NUMA 노드 안에서 다뤄지도록 정렬한다." 이게 깨지면(cross-NUMA access) QPI/UPI를 건너는 메모리 접근이 발생해 지연시간·대역폭이 눈에 띄게 나빠집니다.
모든 튜닝의 출발점입니다. 이 결과를 바탕으로 이후 모든 설정값(코어 범위, 포트 매핑 등)이 정해집니다.
lscpu # 소켓/코어/스레드, NUMA node별 CPU 범위
numactl --hardware # NUMA node별 메모리 용량, node간 거리(distance)
lstopo --of txt # (hwloc-gui 패키지) CPU-PCIe-NIC-NVMe 물리 연결 트리
lspci -vvv -t # PCIe 슬롯의 NUMA 소속 확인
for d in /sys/class/net/*; do echo $d: $(cat /sys/class/net/$d/device/numa_node 2>/dev/null); done
for d in /sys/class/nvme/*; do echo $d: $(cat /sys/class/nvme/$d/device/numa_node 2>/dev/null); done
이 단계에서 NIC(ConnectX-6/E810류)와 NVMe가 NUMA node 0/1 중 어디 붙어있는지를 노드별로 표로 정리해두세요. 서버 벤더/슬롯마다 다를 수 있어 전수 조사가 필요합니다.
sudo dnf install -y tuned tuned-profiles-cpu-partitioning
sudo tuned-adm profile throughput-performance
# 워크로드 격리(CPU pinning)까지 필요하면:
sudo tuned-adm profile cpu-partitioning
cpu-partitioning은 isolated_cores= 설정으로 커널 스케줄러/interrupt에서 특정 코어를 제외시켜 K8s CPU Manager의 static 정책과 조합했을 때 지연시간 편차(jitter)를 크게 줄여줍니다. StarRocks BE, Spark executor처럼 CPU-heavy 워크로드가 도는 노드에 우선 적용을 권장합니다.# /etc/sysctl.d/99-hugepages.conf
vm.nr_hugepages = <NUMA당 필요량 합산>
NUMA-aware하게 노드별로 나누고 싶다면 부팅 파라미터로:
hugepagesz=1G hugepages=<N> default_hugepagesz=1G
CNPG(PostgreSQL)의 shared_buffers, StarRocks의 벡터화 엔진, Spark의 off-heap 메모리가 hugepage 사용 시 TLB miss가 줄어 이득이 큽니다.
# irqbalance는 기본적으로 cross-NUMA 분산도 허용하므로, 트래픽이 많은 NIC는
# irqbalance 대상에서 제외하고 수동 pinning을 권장
systemctl status irqbalance
# NIC가 물려있는 NUMA node의 CPU 목록만 사용해 큐/IRQ를 정렬 (예시)
ethtool -l <iface> # 큐 개수 확인
ethtool -L <iface> combined <N> # 해당 NUMA의 코어 수에 맞춤
ethtool -N <iface> rx-flow-hash tcp4 sdfn
# 각 큐의 IRQ를 해당 NUMA CPU에 개별 pinning (smp_affinity_list)
NVMe도 동일한 원리로, /sys/block/nvme*/mq/*/cpu_list가 해당 디스크의 NUMA 코어와 정렬되어 있는지 확인합니다 (보통 커널이 자동 정렬하지만 멀티 컨트롤러/멀티 네임스페이스 구성에서는 어긋나는 경우가 있어 확인 필요).
# 지연시간에 민감한 DB/분석 워크로드는 자동 밸런싱이 오히려 스레드를 이리저리
# 옮기며 성능 편차를 만들 수 있어, 명시적 pinning(2장 이후)을 쓸 거라면 끄는 걸 권장
echo 0 | sudo tee /proc/sys/kernel/numa_balancing
반대로 애플리케이션 레벨 pinning을 전혀 하지 않는 범용 워크로드 노드라면 켜두는 편이 낫습니다 — 노드 역할별로 다르게 적용하세요.
Kubespray inventory(group_vars/k8s_cluster/k8s-cluster.yml 등)에 다음을 노드 그룹별로 다르게 지정할 수 있습니다.
# StarRocks BE, Spark executor, CNPG처럼 NUMA pinning이 이득인 노드 그룹
kubelet_cpu_manager_policy: static
kubelet_topology_manager_policy: single-numa-node # 가장 엄격, 리소스가 한 NUMA node에 다 들어가야 Admit
kubelet_topology_manager_scope: pod # 컨테이너 단위가 아니라 pod 전체 기준 정렬
kubelet_memory_manager_policy: Static
kubelet_reserved_cpus: "0,1,32,33" # NUMA별 system-reserved 코어를 균등 배분 (예시)
kubelet_config_extra_args:
reservedMemory:
- numaNode: 0
limits:
memory: 4Gi
- numaNode: 1
limits:
memory: 4Gi
주의할 점
single-numa-node 정책은 Pod가 요청한 CPU/메모리(+hugepages)가 하나의 NUMA node 용량을 넘으면 Admit 자체가 실패합니다. 따라서 StarRocks BE 등은 "노드 전체 자원을 다 쓰는 거대한 Pod 1개"가 아니라, NUMA node 1개당 Pod 1개씩(듀얼 소켓이면 노드당 2개 Pod)로 사이징하는 것이 정석입니다.kubelet의 Topology Manager는 이미 선택된 노드 내부에서만 정렬을 결정합니다. kube-scheduler 자체는 기본적으로 NUMA 토폴로지를 모르기 때문에, "이 Pod가 들어갈 수 있는 NUMA 여유가 있는 노드"를 먼저 골라주지 못하고 Admit 실패 후 재시도만 반복될 수 있습니다.
이를 해결하려면 kubernetes-sigs/scheduler-plugins의 NodeResourceTopology(NRT) 플러그인을 추가로 배포하세요:
NodeResourceTopology CRD로 리포트하는 daemon(topology-updater) 설치NodeResourceTopologyMatch Filter/Score 플러그인 활성화helm upgrade cilium cilium/cilium --namespace kube-system \
--set routingMode=native \
--set bpf.masquerade=true \
--set kubeProxyReplacement=true \
--set bandwidthManager.enabled=true \
--set bpf.distributedLRU.enabled=true
bandwidthManager(BBR 등)와 distributedLRU(per-CPU 분산 LRU맵)는 멀티코어/멀티NUMA 환경에서 conntrack/정책 맵 접근의 lock contention을 줄여줍니다.ethtool -N ... rx-flow-hash tcp4 sdfn) 함께 점검하세요. 분산이 고르지 않으면 특정 NUMA/코어만 과부하됩니다.Kubernetes의 device plugin 기반 Topology Manager는 GPU 등 device-plugin이 NUMA 힌트를 리포트하는 리소스에만 작동합니다. OpenEBS의 local PV(hostpath, LVM, ZFS 등)는 기본적으로 NUMA를 인식하지 못합니다 — 여기가 수작업이 가장 많이 필요한 지점입니다.
전략
1. 노드별 NVMe 디스크를 0-1단계에서 파악한 NUMA 소속대로 그룹핑하고, 각 그룹을 별도의 OpenEBS StorageClass/PoolConfig로 분리 (예: openebs-lvm-numa0, openebs-lvm-numa1)
2. 노드에 커스텀 라벨 부여:
kubectl label node <node> numa0-storage=true
kubectl label node <node> numa1-storage=true
nodeAffinity + volumeBindingMode: WaitForFirstConsumer를 조합해, CPU가 pinning될 NUMA와 같은 NUMA의 디스크를 쓰는 PVC가 매칭되도록 강제single-numa-node 정책을 적용하고, 해당 Pod가 사용하는 드라이브(OpenEBS local PV 또는 direct hostPath)도 동일 NUMA 소속인지 4장 방식으로 강제하세요.resources:
requests: { cpu: "8", memory: "32Gi" }
limits: { cpu: "8", memory: "32Gi" }
shared_buffers, effective_cache_size는 이 Pod가 정렬될 NUMA node의 메모리 용량을 넘지 않게 설계 (넘으면 Admit 실패 또는 실제로는 cross-NUMA 스필오버 발생)postgresql.parameters.huge_pages: try 또는 on) — PostgreSQL 공유메모리 세그먼트가 커질수록 TLB miss 감소 효과가 커집니다.numa_balancing은 위 1-4에서 언급한 대로 이런 pinned DB 워크로드에는 끄는 것을 권장합니다.single-numa-node 정책에서 Admit이 실패하거나(정책 미적용 시) 내부적으로 cross-NUMA 접근이 발생합니다.spark.executor.cores가 노드의 NUMA당 코어 수를 넘지 않도록 설계 (예: NUMA당 32코어면 executor는 최대 28~30코어 정도로, 나머지는 kube-reserved/system-reserved에)spark.memory.offHeap.enabled=true) 사용 시 hugepages와 결합하면 GC/TLB 오버헤드 감소nodeSelector/taint-toleration을 분리하세요.| 노드 그룹 | 대상 워크로드 | CPU Manager | Topology Manager | Hugepages | 로컬 NVMe 정렬 |
|---|---|---|---|---|---|
| numa-pinned-compute | StarRocks BE, Spark executor | static | single-numa-node | 필요 | 필수(4장) |
| numa-pinned-storage | MinIO AIStor, CNPG | static | single-numa-node | 권장 | 필수(4장) |
| general-shared | Cilium(agent 자체), Polaris, OPA, StarRocks FE, Spark driver | none(기본) | none | 불필요 | 불필요 |
| control-plane | kube-apiserver, etcd, ClusterMesh 관련 | none | none | 불필요 | 불필요 |
노드 그룹은 kubespray inventory의 host group으로 나누고, 그룹별로 kubelet_cpu_manager_policy 등을 다르게 지정하면 됩니다. 동일 물리 서버 스펙이라도 라벨/taint로 워크로드를 분리해서 "NUMA pinning이 필요한 워크로드 전용 노드"와 "범용 노드"를 명확히 나누는 것이 운영 복잡도 대비 효과가 가장 좋습니다.
# Pod에 실제로 할당된 cpuset이 기대한 NUMA와 일치하는지 확인
crictl inspect <container-id> | grep -A5 cpuset
cat /sys/fs/cgroup/kubepods.slice/.../cpuset.cpus
# NUMA 노드 간 메모리 접근/미스 통계
numastat -p <pid>
numastat -m
# cross-NUMA 메모리 접근이 실제로 성능에 영향을 주는지 (perf 설치 필요)
perf stat -e node-loads,node-load-misses -p <pid>
# NIC IRQ가 기대한 CPU/NUMA에 정렬되어 있는지
cat /proc/interrupts | grep <iface>
node_memory_numa_* 계열 지표, DCGM(GPU 있는 경우), StarRocks/Spark 자체 메트릭(쿼리 지연, GC 시간)을 함께 대시보드화해 튜닝 전후 비교 기준선을 반드시 남겨두세요.NodeResourceTopology CRD를 배포했다면 kubectl get noderesourcetopology -o yaml로 클러스터가 인식한 실제 NUMA 여유 자원도 주기적으로 확인하세요.single-numa-node 정책은 요구 자원이 한 NUMA를 넘으면 Pod가 아예 뜨지 않습니다. 초기 사이징을 넉넉히 잡았다가 실패를 겪는 경우가 흔하니, NUMA당 실제 가용 코어/메모리를 정확히 계산해 Pod spec을 설계하세요 (10장 표와 0장 진단 결과 활용).cpu-manager-policy-options의 full-pcpus-only=true 옵션을 검토하세요(물리 코어 단위로만 pinning되어 SMT 간섭을 줄임 — 단, 가용 코어 수 계산이 더 보수적이 됨).scheduler-plugins의 NodeResourceTopology, Cilium의 distributedLRU/bpf.datapathMode=netkit 등은 비교적 최근 기능이라 사용 중인 Kubernetes/Cilium 버전에서 실제 지원되는지 배포 전 반드시 릴리스 노트로 재확인하세요.