26S05z1

Young-Kyoo Kim·1일 전

RHEL 10.2 + Kubespray K8s 클러스터 NUMA 최적화 전략

대상 환경 요약

  • OS: RHEL 10.2 (Intel Xeon, 멀티소켓 = 멀티 NUMA)
  • 배포: Kubespray
  • CNI: Cilium (Native Routing, BGP, ECMP, ClusterMesh)
  • Storage: MinIO AIStor, OpenEBS
  • DB/Runtime: CNPG(PostgreSQL), StarRocks, Spark
  • Catalog/Policy: Polaris, OPA

핵심 원칙은 하나입니다: "CPU 코어 - 메모리 - PCIe 디바이스(NIC/NVMe)가 같은 NUMA 노드 안에서 다뤄지도록 정렬한다." 이게 깨지면(cross-NUMA access) QPI/UPI를 건너는 메모리 접근이 발생해 지연시간·대역폭이 눈에 띄게 나빠집니다.


0. 사전 진단 — 하드웨어 토폴로지 파악

모든 튜닝의 출발점입니다. 이 결과를 바탕으로 이후 모든 설정값(코어 범위, 포트 매핑 등)이 정해집니다.

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 중 어디 붙어있는지를 노드별로 표로 정리해두세요. 서버 벤더/슬롯마다 다를 수 있어 전수 조사가 필요합니다.


1. RHEL 10.2 OS 레벨 설정

1-1. tuned 프로파일

sudo dnf install -y tuned tuned-profiles-cpu-partitioning
sudo tuned-adm profile throughput-performance
# 워크로드 격리(CPU pinning)까지 필요하면:
sudo tuned-adm profile cpu-partitioning
  • cpu-partitioningisolated_cores= 설정으로 커널 스케줄러/interrupt에서 특정 코어를 제외시켜 K8s CPU Manager의 static 정책과 조합했을 때 지연시간 편차(jitter)를 크게 줄여줍니다. StarRocks BE, Spark executor처럼 CPU-heavy 워크로드가 도는 노드에 우선 적용을 권장합니다.

1-2. Hugepages 사전 할당

# /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가 줄어 이득이 큽니다.

1-3. NIC/NVMe IRQ affinity를 NUMA에 정렬

# 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 코어와 정렬되어 있는지 확인합니다 (보통 커널이 자동 정렬하지만 멀티 컨트롤러/멀티 네임스페이스 구성에서는 어긋나는 경우가 있어 확인 필요).

1-4. 커널 NUMA balancing 정책

# 지연시간에 민감한 DB/분석 워크로드는 자동 밸런싱이 오히려 스레드를 이리저리
# 옮기며 성능 편차를 만들 수 있어, 명시적 pinning(2장 이후)을 쓸 거라면 끄는 걸 권장
echo 0 | sudo tee /proc/sys/kernel/numa_balancing

반대로 애플리케이션 레벨 pinning을 전혀 하지 않는 범용 워크로드 노드라면 켜두는 편이 낫습니다 — 노드 역할별로 다르게 적용하세요.


2. Kubernetes(kubelet) 레벨 — Topology Manager / CPU Manager / Memory Manager

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)로 사이징하는 것이 정석입니다.
  • static CPU Manager는 Guaranteed QoS(requests == limits, CPU는 정수 단위) Pod에만 적용됩니다. Burstable/BestEffort Pod는 예외 없이 공유 코어 풀에서 동작합니다.
  • Cilium, OPA, Polaris 같은 컨트롤플레인성 서비스까지 static 정책 대상 노드에 함께 몰아넣으면 "공유 코어 풀"이 줄어들어 오히려 비효율적입니다 → 워크로드 성격별로 노드 그룹(Node Pool)을 분리하는 것을 권장합니다(아래 11장 표 참고).

2-1. 클러스터 전역 NUMA-aware 스케줄링 (중요 — 놓치기 쉬운 부분)

kubelet의 Topology Manager는 이미 선택된 노드 내부에서만 정렬을 결정합니다. kube-scheduler 자체는 기본적으로 NUMA 토폴로지를 모르기 때문에, "이 Pod가 들어갈 수 있는 NUMA 여유가 있는 노드"를 먼저 골라주지 못하고 Admit 실패 후 재시도만 반복될 수 있습니다.

이를 해결하려면 kubernetes-sigs/scheduler-pluginsNodeResourceTopology(NRT) 플러그인을 추가로 배포하세요:

  • 각 노드에서 NUMA 토폴로지를 수집해 NodeResourceTopology CRD로 리포트하는 daemon(topology-updater) 설치
  • kube-scheduler에 NodeResourceTopologyMatch Filter/Score 플러그인 활성화
  • 이렇게 하면 스케줄러가 애초에 "이 Pod가 들어갈 NUMA 여유가 있는 노드"만 골라 보내므로 Admit 실패로 인한 스케줄링 재시도/지연이 줄어듭니다.

3. Cilium (Native Routing, BGP, ECMP, ClusterMesh) NUMA 최적화

  • NIC 큐/IRQ를 NUMA에 정렬하는 것이 Cilium 최적화의 8할입니다. eBPF datapath 자체는 패킷이 도착한 CPU(정확히는 NIC 큐가 매핑된 CPU)에서 실행되므로, 큐가 엉뚱한 NUMA에 있으면 매 패킷마다 cross-NUMA 메모리 접근이 발생합니다(1-3장 참고).
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을 줄여줍니다.
  • BGP/ECMP: ECMP 자체는 라우팅 레벨 이슈라 NUMA와 직접적 연관은 적지만, ECMP로 여러 경로에 분산된 흐름이 서로 다른 NIC 큐/코어로 들어오면서 RSS 해시가 고르게 분산되는지(ethtool -N ... rx-flow-hash tcp4 sdfn) 함께 점검하세요. 분산이 고르지 않으면 특정 NUMA/코어만 과부하됩니다.
  • ClusterMesh: 클러스터 간 트래픽은 결국 로컬 NIC를 다시 타므로 위 NIC 튜닝의 효과를 그대로 받습니다. 다만 ClusterMesh 자체(etcd 기반 상태 동기화)는 latency-sensitive한 컨트롤플레인이므로, 이 트래픽이 몰리는 노드(주로 컨트롤플레인 노드)는 static CPU Manager 대상에서 제외하고 공유 코어 풀에 남겨 스케줄링 유연성을 확보하는 편이 낫습니다.

4. OpenEBS + NVMe 로컬 스토리지 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
  1. StatefulSet/Pod의 nodeAffinity + volumeBindingMode: WaitForFirstConsumer를 조합해, CPU가 pinning될 NUMA와 같은 NUMA의 디스크를 쓰는 PVC가 매칭되도록 강제
  2. StarRocks BE, Spark shuffle 등 로컬 스토리지 I/O가 많은 워크로드는 이 매칭이 특히 중요합니다 — cross-NUMA로 디스크 I/O가 발생하면 CPU pinning 효과가 상당 부분 상쇄됩니다.

5. MinIO AIStor NUMA

  • AIStor(MinIO 계열)는 프로세스 1개가 여러 드라이브를 관리하는 구조이므로, 드라이브 풀을 NUMA 단위로 나누고, 서버 프로세스/Pod도 NUMA당 1개씩 배치하는 것이 정석입니다(멀티소켓 서버 1대에 AIStor 인스턴스 2개를 각각 NUMA0/NUMA1 전용으로 pinning).
  • Pod 배치 시 CPU Manager static + single-numa-node 정책을 적용하고, 해당 Pod가 사용하는 드라이브(OpenEBS local PV 또는 direct hostPath)도 동일 NUMA 소속인지 4장 방식으로 강제하세요.
  • 네트워크 측면에서는 대용량 오브젝트 업/다운로드가 몰리므로, AIStor Pod가 배치된 NUMA와 외부 통신에 쓰이는 NIC의 NUMA가 일치하는지가 성능에 크게 영향을 줍니다 (3장의 NIC IRQ 정렬과 직결).
  • 멀티 인스턴스로 분리할 경우 erasure coding set 구성이 인스턴스 경계와 어떻게 맞물리는지 AIStor 자체 문서 기준으로 별도 검증이 필요합니다 (드라이브 풀 분리가 EC set/장애 도메인 설계에 영향을 줄 수 있음).

6. CNPG (PostgreSQL) NUMA

  • CNPG가 생성하는 PostgreSQL Pod에 Guaranteed QoS(CPU/메모리 requests=limits)를 명시적으로 설정 → kubelet static CPU/Memory Manager가 자동으로 단일 NUMA에 pinning 시도
resources:
  requests: { cpu: "8", memory: "32Gi" }
  limits:   { cpu: "8", memory: "32Gi" }
  • shared_buffers, effective_cache_size는 이 Pod가 정렬될 NUMA node의 메모리 용량을 넘지 않게 설계 (넘으면 Admit 실패 또는 실제로는 cross-NUMA 스필오버 발생)
  • Hugepages 사용 권장 (postgresql.parameters.huge_pages: try 또는 on) — PostgreSQL 공유메모리 세그먼트가 커질수록 TLB miss 감소 효과가 커집니다.
  • 커널 numa_balancing은 위 1-4에서 언급한 대로 이런 pinned DB 워크로드에는 끄는 것을 권장합니다.
  • WAL/데이터 디스크가 OpenEBS local PV라면 4장 원칙에 따라 같은 NUMA의 NVMe를 쓰도록 정렬하세요.

7. StarRocks (분석 엔진) NUMA

  • BE(Backend)는 CPU/메모리 집약적 벡터화 엔진이라 NUMA 효과가 가장 크게 나타나는 컴포넌트입니다.
  • 권장 배치: 듀얼소켓 노드 1대당 BE Pod 2개(NUMA0용, NUMA1용)로 나눠 배치. 거대한 BE Pod 1개로 노드 전체 자원을 요청하면 single-numa-node 정책에서 Admit이 실패하거나(정책 미적용 시) 내부적으로 cross-NUMA 접근이 발생합니다.
  • CPU/메모리 requests=limits(Guaranteed) + hugepages + local NVMe(4장 매칭) 조합이 기본 세트입니다.
  • FE(Frontend, 메타데이터/쿼리플래너)는 상대적으로 NUMA 민감도가 낮아 static 정책 대상에서 제외하고 공유 코어 풀에 둬도 무방합니다.
  • 데이터 이동(compaction, shuffle)이 많은 워크로드 특성상, BE가 쓰는 로컬 디스크와 CPU NUMA 정합성이 어긋나면 성능 저하가 특히 두드러지니 4장 스토리지 정렬을 꼭 함께 적용하세요.

8. Spark on Kubernetes NUMA

  • Executor 사이징 원칙: spark.executor.cores가 노드의 NUMA당 코어 수를 넘지 않도록 설계 (예: NUMA당 32코어면 executor는 최대 28~30코어 정도로, 나머지는 kube-reserved/system-reserved에)
  • Executor Pod에 Guaranteed QoS 부여 → static CPU Manager가 단일 NUMA에 pinning
  • Off-heap 메모리(spark.memory.offHeap.enabled=true) 사용 시 hugepages와 결합하면 GC/TLB 오버헤드 감소
  • Shuffle 데이터가 로컬 NVMe(OpenEBS)에 쓰인다면, executor가 pinning된 NUMA와 셔플 디스크의 NUMA를 일치시키는 것이 4장과 동일한 원리로 중요합니다.
  • Driver Pod는 상대적으로 가볍고 latency-sensitive 하지 않은 경우가 많아 static 정책 미적용 노드(공유 코어 풀)에 둬도 무방합니다 — executor만 NUMA-aware 노드 그룹에 스케줄되도록 nodeSelector/taint-toleration을 분리하세요.

9. Polaris(카탈로그), OPA — 낮은 우선순위

  • 두 컴포넌트 모두 경량 컨트롤플레인 서비스(메타데이터 서빙, 정책 평가)로 처리량이 CPU-bound 대용량 연산이 아니라 NUMA pinning 이득이 크지 않습니다.
  • 오히려 이런 서비스를 static CPU Manager 대상 노드에 함께 배치하면 공유 코어 풀을 잠식해 다른 워크로드의 유연성을 해칩니다 → 별도의 일반 노드 그룹(공유 코어 풀 유지)에 배치하는 것을 권장합니다.
  • 다만 Polaris가 대량의 메타데이터 조회 트래픽(예: Spark/StarRocks에서 매우 빈번한 카탈로그 조회)을 받는 규모라면, 최소한 네트워크 NIC NUMA 정렬(3장) 정도는 적용해볼 가치가 있습니다.

10. 워크로드 성격별 노드 그룹 분리 전략 (요약)

노드 그룹대상 워크로드CPU ManagerTopology ManagerHugepages로컬 NVMe 정렬
numa-pinned-computeStarRocks BE, Spark executorstaticsingle-numa-node필요필수(4장)
numa-pinned-storageMinIO AIStor, CNPGstaticsingle-numa-node권장필수(4장)
general-sharedCilium(agent 자체), Polaris, OPA, StarRocks FE, Spark drivernone(기본)none불필요불필요
control-planekube-apiserver, etcd, ClusterMesh 관련nonenone불필요불필요

노드 그룹은 kubespray inventory의 host group으로 나누고, 그룹별로 kubelet_cpu_manager_policy 등을 다르게 지정하면 됩니다. 동일 물리 서버 스펙이라도 라벨/taint로 워크로드를 분리해서 "NUMA pinning이 필요한 워크로드 전용 노드"와 "범용 노드"를 명확히 나누는 것이 운영 복잡도 대비 효과가 가장 좋습니다.


11. 검증 및 모니터링

# 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 Exporter의 node_memory_numa_* 계열 지표, DCGM(GPU 있는 경우), StarRocks/Spark 자체 메트릭(쿼리 지연, GC 시간)을 함께 대시보드화해 튜닝 전후 비교 기준선을 반드시 남겨두세요.
  • NodeResourceTopology CRD를 배포했다면 kubectl get noderesourcetopology -o yaml로 클러스터가 인식한 실제 NUMA 여유 자원도 주기적으로 확인하세요.

12. 고려사항 및 리스크

  1. 자원 효율 vs 성능의 트레드오프: static CPU Manager + Guaranteed QoS는 필연적으로 코어를 통째로 예약하므로 클러스터 전체 CPU 활용률(bin-packing 효율)이 떨어집니다. 모든 워크로드에 무분별하게 적용하지 말고, 실측으로 이득이 확인된 컴포넌트(StarRocks BE, CNPG, AIStor)에 한정하세요.
  2. Admit 실패 리스크: single-numa-node 정책은 요구 자원이 한 NUMA를 넘으면 Pod가 아예 뜨지 않습니다. 초기 사이징을 넉넉히 잡았다가 실패를 겪는 경우가 흔하니, NUMA당 실제 가용 코어/메모리를 정확히 계산해 Pod spec을 설계하세요 (10장 표와 0장 진단 결과 활용).
  3. kubespray 기본값은 NUMA 최적화가 꺼져 있음: 위 설정들은 대부분 inventory에서 명시적으로 켜야 합니다. 클러스터 배포 후 재설정 시 kubelet 재시작이 필요하고, 이미 떠 있는 Pod들은 재스케줄되어야 새 정책이 적용됩니다 — 초기 클러스터 구축 단계에서 미리 반영하는 것이 롤아웃 리스크가 훨씬 적습니다.
  4. OpenEBS local PV의 NUMA 비인식: 4장에서 언급했듯 OpenEBS 자체는 NUMA를 모르므로, 라벨/nodeAffinity로 강제하는 수작업 설계가 어긋나면 "CPU는 NUMA0인데 디스크는 NUMA1" 같은 상황이 조용히 발생할 수 있습니다. 배포 후 정기적으로 실제 매칭 여부를 점검하는 자동화(스크립트/알림)를 갖추길 권장합니다.
  5. 하이퍼스레딩(SMT) 처리: static CPU Manager는 기본적으로 sibling 스레드를 함께 할당하는 방식이 아니므로, 지연시간에 매우 민감한 워크로드라면 cpu-manager-policy-optionsfull-pcpus-only=true 옵션을 검토하세요(물리 코어 단위로만 pinning되어 SMT 간섭을 줄임 — 단, 가용 코어 수 계산이 더 보수적이 됨).
  6. ClusterMesh/BGP의 노드 간 지연: NUMA 튜닝은 단일 노드 내부 최적화이고, 멀티클러스터/BGP 피어링 구간의 네트워크 지연은 별개 문제입니다. 두 최적화를 혼동해 "NUMA 튜닝했는데 왜 크로스 클러스터 쿼리가 느리지"라는 식으로 원인을 잘못 짚지 않도록, 문제 발생 시 로컬 NUMA 이슈인지 네트워크 경로 이슈인지 먼저 구분하는 진단 순서를 세워두세요.
  7. 버전 호환성: scheduler-plugins의 NodeResourceTopology, Cilium의 distributedLRU/bpf.datapathMode=netkit 등은 비교적 최근 기능이라 사용 중인 Kubernetes/Cilium 버전에서 실제 지원되는지 배포 전 반드시 릴리스 노트로 재확인하세요.

0개의 댓글