토스증권이 GPU-aware를 넘어 GPU-native 클러스터를 구축한 방법
안녕하세요. 토스증권 Data Infra 팀 Data Engineer 장재영입니다.
지난 상반기 동안 ML Platform 팀의 김진웅 님과 함께 Kubernetes 기반 GPU 클러스터를 구축했는데요. 오늘은 그 과정에서 겪은 문제와 해결 방법을 공유하려고 합니다.
토스증권의 서비스와 데이터 플랫폼은 쿠버네티스를 기반으로 운영됩니다. 수백 명의 개발자가 이 환경에서 다양한 대고객 서비스를 개발하고 운영합니다. AI 서비스도 같은 기반을 활용할 수 있도록, GPU 워크로드를 기존 쿠버네티스 클러스터 안에서 실행할 수 있게 해야 했습니다.
그래서 저희의 첫 과제는 쿠버네티스가 GPU를 자원으로 인식하고 할당하며, 컨테이너에서 사용할 수 있도록 연결하는 것이었습니다. 이 글에서는 GPU를 인식하고 워크로드에 할당할 수 있는 상태를 GPU-aware라고 부르겠습니다.
하지만 GPU를 사용할 수 있게 된 뒤에도 운영에서 풀어야 할 문제는 남았습니다. 노드가 Ready여도 개별 GPU에는 오류가 있을 수 있었습니다. 점검이나 설정 변경을 마친 노드를 언제 다시 서비스에 투입해도 되는지 판단할 기준도 필요했습니다. 서비스마다 필요한 자원의 크기가 다른데 GPU를 한 장씩 제공하는 방식은 비효율적이었고요.
여러 팀이 GPU를 함께 쓰려면 플랫폼에서 이 문제들을 다뤄야 했습니다. GPU의 정상 여부를 판단하고, 검증된 노드만 워크로드를 받게 하며, 서비스에 필요한 단위로 자원을 나누고 배치할 수 있어야 했습니다. 저희는 이러한 운영 조건까지 갖춘 상태를 GPU-native라고 부르고 있습니다. 토스증권은 멀티테넌트 GPU 클러스터를 운영하며 이 조건들을 하나씩 갖춰 왔고, 필요한 경우 업스트림에도 기여했습니다.[1]

흥미로운 점은 쿠버네티스 자체에서도 관련 변화가 이어지고 있다는 것입니다. DRA(Dynamic Resource Allocation)[2]는 단순히 ‘GPU 한 개’를 요청하는 데서 나아가, 필요한 조건을 가진 장치를 요청하고 할당받을 수 있도록 합니다. 저희가 지난 2년 동안 별도 구성 요소와 운영 로직으로 보완해 온 문제 중 일부가 쿠버네티스 자체에서도 다뤄지고 있는 셈입니다.
이 글에서는 먼저 GPU-aware한 쿠버네티스를 구성하는 기본 요소를 살펴보겠습니다. 이어 실제 운영에서 마주한 여러 문제와, 이를 해결하며 GPU-native한 클러스터로 발전시켜 온 과정을 공유하겠습니다.
1단계. GPU-aware한 클러스터 구축하기
그럼 GPU-aware한 쿠버네티스를 구축하기 위해서는 무엇이 필요할까요? GPU 한 장이 파드에서 쓰이기까지는 총 네 스텝을 거쳐야 합니다.

Step 1. 하드웨어 / 드라이버
서버에 GPU 디바이스가 탑재되어 있고, 운영체제가 이를 사용할 수 있도록 NVIDIA 커널 드라이버가 준비되어야 합니다. NVIDIA 커널 드라이버가 없으면 운영체제부터 GPU를 장치로 인식하지 못하게 됩니다.
Step 2. NVIDIA Container Toolkit
NVIDIA Container Toolkit은 컨테이너 런타임과 NVIDIA 드라이버 사이를 연결합니다. 이를 통해 할당된 GPU를 컨테이너가 실제로 사용할 수 있게 되는데요. 노드가 GPU를 인식하더라도 이 연결이 없으면 컨테이너에서는 GPU를 사용할 수 없습니다.
Step 3. NVIDIA Device Plugin
여기까지 와도 쿠버네티스 API는 아직 GPU를 자원으로 인식하지 못하는데요. NVIDIA Device Plugin은 노드의 GPU를 kubelet에 등록합니다. kubelet은 이를 노드의 Capacity와 Allocatable에 nvidia.com/gpu라는 자원으로 반영하고, 스케줄러는 이 값을 기준으로 GPU를 요청한 파드에 배치합니다.

Step 4. 파드 계층
마지막은 사용자 영역입니다. 파드 스펙에서 nvidia.com/gpu를 요청하면, 앞에서 준비된 경로를 통해 컨테이너에 GPU가 연결됩니다.
각 스텝의 역할은 다르지만, 어느 한 곳에 문제가 생겼을 때 나타나는 결과는 비슷합니다. 노드에는 GPU가 있는데 쿠버네티스 자원으로 나타나지 않거나, 파드는 배치됐지만 컨테이너에서 GPU를 사용하지 못하게 됩니다.
실제 운영에서 드라이버와 Container Toolkit, Device Plugin을 각각 설치하고 호환되는 버전과 설정을 유지하는 것은 분명 운영 부담인데요. GPU 노드가 늘어날수록 모든 노드에서 동일한 구성을 유지하기 어려워지고, 구성 차이가 장애로 이어질 가능성도 커집니다.
앞선 네 스텝을 거치면 GPU는 쿠버네티스의 스케줄링 대상이 되고, 컨테이너에서도 사용할 수 있게 됩니다. 이 글에서 말하는 GPU-aware한 상태입니다.
2단계. GPU-aware만으로 충분할까?
GPU 메모리는 어떻게 관리될까요?
GPU-aware한 상태가 되었다면 GPU 운영의 모든 문제가 해결된 걸까요? GPU가 보이고 사용할 수 있다는 것과, 그 내부 자원까지 쿠버네티스가 제어한다는 것은 다른 이야기입니다.
이 차이를 이해하려면 CPU와 메모리, GPU가 서로 다른 제어 경로를 거친다는 점부터 살펴볼 필요가 있습니다.
CPU와 시스템 메모리는 리눅스 커널의 cgroup을 통해 컨테이너 단위로 제어가 가능합니다. 컨테이너가 사용할 수 있는 CPU 시간과 메모리 한도를 정하면, 한도를 넘었을 때 커널이 개입합니다. 쿠버네티스의 CPU와 메모리 자원 관리도 이 기능을 바탕으로 동작해요.

반면 GPU 메모리는 쿠버네티스가 사용하는 cgroup 기반의 자원 제어 경로에 포함되지 않아요. 앞서 설명했던 것과 같이 컨테이너가 GPU를 사용하기 위해서는 NVIDIA Container Toolkit의 도움이 필요합니다.
NVIDIA Container Toolkit은 컨테이너 런타임과 연동해, 할당된 GPU 디바이스와 필요한 드라이버 라이브러리를 컨테이너에 연결하는데요. 이를 통해 CUDA 애플리케이션은 /dev/nvidia* 디바이스와 libcuda.so를 사용해 호스트의 GPU에 접근할 수 있습니다.
# 컨테이너에 연결되는 대표적인 GPU 인터페이스
/dev/nvidiactl
/dev/nvidia0
/dev/nvidia-uvm
libcuda.so이 구조에서 Device Plugin과 kubelet은 어떤 GPU를 컨테이너에 할당할지 결정하고, Container Toolkit은 선택된 디바이스를 컨테이너가 사용할 수 있도록 연결합니다. 하지만 쿠버네티스가 제어할 수 있는 경계는 여기까지입니다.
nvidia.com/gpu: 1은 GPU 장치 하나를 할당한다는 의미이지, 그 안에서 사용할 VRAM 크기나 연산 비율까지 지정한다는 뜻은 아닙니다. 즉, 쿠버네티스는 CPU와 메모리를 cgroup으로 제어하듯 GPU 내부 자원을 직접 제어하지는 못합니다.
이 한계는 VRAM을 사용할 때 분명히 드러납니다. 쿠버네티스에는 memory: 4Gi처럼 컨테이너 메모리를 요청하고 제한하는 방법이 있지만, vram: 40Gi처럼 GPU 메모리를 요청하는 표준 자원은 없어요.
그래서 VRAM이 부족해졌을 때도 쿠버네티스가 이를 제어할 수 없습니다. 애플리케이션은 CUDA OOM 같은 오류를 만나고, 실제 운영에서 그 원인을 쿠버네티스 이벤트만으로 진단하기는 어려워요. 파드가 종료된 뒤 GPU 상태가 정상으로 돌아왔는지는 쿠버네티스의 기본 이벤트와 자원 상태만으로 충분히 확인할 수 없습니다.
이처럼 GPU-aware한 상태는 GPU 운영의 출발점이었지만, VRAM은 쿠버네티스가 GPU를 인식하는 것과 운영 가능한 상태로 이해하는 것이 다르다는 점을 보여 주는 대표적인 예였습니다. GPU를 할당할 수 있다는 것이 그 안의 메모리 사용량까지 같은 방식으로 제한할 수 있다는 뜻은 아니었습니다.
GPU를 사용할 수 있게 된 뒤, 실제 서비스를 운영하기 위해서는 다음 세 가지 질문에도 답해야 했습니다.
질문 1. Node Ready는 GPU Ready인가?

쿠버네티스의 Node Ready는 노드와 kubelet의 기본적인 동작 상태를 보여 주지만, 개별 GPU와 GPU 스택의 정상 상태까지 의미하지는 않습니다. 노드가 Ready이고 파드가 Running이어도 특정 GPU에서는 이미 ERR! 상태나 Xid 오류가 발생했을 수 있습니다.
따라서 쿠버네티스가 보여 주는 상태와 별개로, 드라이버와 CUDA 실행 경로가 정상인지, 운영 중인 GPU 내부에서 어떤 오류가 발생하고 있는지 판단할 수 있어야 했습니다.
질문 2. 언제 GPU 노드를 서비스에 투입할 수 있는가?
GPU의 상태를 확인할 수 있게 된 것과, 그 노드를 실제 서비스에 투입해도 된다고 판단하는 것은 또 다른 문제였습니다. 새로운 GPU 노드가 클러스터에 연결되고 자원으로 등록됐다는 사실만으로는 토스증권의 서비스 워크로드를 받을 준비가 끝났다고 볼 수 없었습니다.
OS·커널·드라이버와 CUDA 버전이 토스증권 GPU 클러스터의 표준 구성에 맞는지, GPU가 부하를 견디는지, 쿠버네티스에 정상적으로 편입됐는지, 실제 CUDA 연산과 모델 서빙이 동작하는지 확인해야 했습니다. 신규 장비뿐 아니라 장애 점검을 마치고 돌아온 장비에도 동일하게 적용할 수 있는 명확한 투입 기준이 필요했습니다.
질문 3. GPU 한 장은 서비스에 적절한 제공 단위인가?
서빙 워크로드를 효율적으로 배치하려면 단순히 GPU를 “쓸 수 있다”에서 한 걸음 더 나아가야 합니다. 서비스마다 필요한 연산량과 VRAM, 격리 수준이 다르기 때문에, 플랫폼은 물리 GPU 한 장보다 더 세밀한 제공 단위를 가져야 하는데요. 그러나 0.5코어처럼 쪼개서 요청 가능한 CPU와는 달리, nvidia.com/gpu 같은 확장 리소스는 정수 단위로만 요청할 수 있습니다.[4]
GPU를 더 작은 단위로 나눈 뒤에도 해결할 문제가 남아 있었습니다. 분할한 자원을 여러 물리 GPU에 분산할지, 한 GPU부터 채울지에 따라 Full GPU 확보와 구성 변경의 유연성이 달라지기 때문입니다. 서비스에 맞는 제공 단위뿐 아니라, 그 단위를 어떻게 배치할지도 함께 결정해야 했습니다.
이 세 질문에 답하기 위해서는 GPU를 단순히 인식하고 할당하는 데서 나아가, GPU의 상태를 판단하고, 서비스 투입 기준을 세우며, 자원을 나누고 배치하는 방식을 보완해야 했습니다. 다음 단계에서는 이 세 질문에 답하기 위해 무엇을 바꿨는지 살펴보겠습니다.
3단계. 클러스터를 GPU-native하게 만들기
1. Node Ready와 GPU Ready 구분하기

쿠버네티스의 노드 상태 조건인 Node Ready=True는 kubelet이 정상적으로 상태를 보고하고, 노드가 파드를 실행할 기본 조건을 갖췄다는 뜻입니다. Pod Running 역시 파드의 실행 단계를 나타낼 뿐, 그 안에서 GPU 워크로드가 정상적으로 동작한다는 것을 보장하지는 않습니다.
그래서 저희는 GPU Ready를 별도의 운영 기준으로 정의했습니다. GPU 실행 경로가 모두 동작하는지, 기대한 GPU 자원이 노출되는지, 개별 장치의 상태 조회가 정상인지, 복구가 필요한 오류가 없는지를 함께 확인하는 기준입니다.
GPU Ready를 판단하려면 실행 전 스택 검증, 실행 중 상태 관측, 장애 신호와 대응의 연결이라는 세 가지 관점이 필요했습니다.
nvidia-validator — GPU 스택 상태 검증
먼저 GPU 워크로드에 필요한 스택이 정상인지 확인해야 했습니다. GPU 파드가 실행되려면 드라이버, Container Toolkit, Device Plugin, CUDA가 각각 정상적으로 동작해야 합니다. nvidia-validator는 이 요소들이 기본 검증을 통과했는지 확인합니다.
문제는 nvidia-validator가 통과했다고 해서 항상 “모든 GPU가 정상”이라고 말할 수는 없었다는 점이었어요. 실제로 운영 중인 H100 노드 하나에서 dcgm-exporter가 nvml error: unknown error로 CrashLoopBackOff에 빠진 적이 있었습니다.
노드에서 nvidia-smi를 확인해 보니 H100 GPU 네 장 중 한 장이 ERR! 상태였는데요.
| 0 NVIDIA H100 NVL On | 00000000:49:00.0 Off |
| 1 ERR! On | 00000000:61:00.0 Off | <
당시 nvidia-validator는 이 노드를 정상으로 보고 있었습니다. nvidia-validator의 드라이버 검증이 nvidia-smi 명령의 종료 코드에 의존하고 있었기 때문인데요. 개별 GPU가 ERR!로 표시돼도 명령 자체는 exit 0이었던 거죠. 기본 검증의 성공과 저희가 판단하는 개별 GPU의 정상 상태가 같지 않았던 것입니다.
# Before — 종료 코드만 본다 (GPU 한 장이 ERR!여도 exit 0 = 통과)
if chroot /host nvidia-smi > /dev/null 2>&1; then
touch /run/nvidia/validations/driver-ready
fi그래서 NVIDIA의 기본 검증 체계는 유지하면서, 저희 운영 기준으로 GPU readiness를 판단하는 별도 DaemonSet을 구성했습니다. readiness를 결정하는 DaemonSet에서는 nvidia-smi 출력의 ERR!, 치명적인 Xid, NVIDIA Device Plugin이 보여 주는 GPU 수와 실제 PCI 디바이스 수의 차이를 함께 보도록 했어요.
# After — 출력과 커널 로그까지 본다
SMI_OUTPUT=$(chroot /host nvidia-smi 2>&1); SMI_EXIT=$?
if [ $SMI_EXIT -ne 0 ]; then
rm -f /run/nvidia/validations/driver-ready # 명령 자체가 실패
elif echo "$SMI_OUTPUT" | grep -qi "ERR!"; then
rm -f /run/nvidia/validations/driver-ready # 개별 GPU가 ERR!
elif chroot /host dmesg -T | tail -500 | grep -qE "Xid.*: (109|120|79|119|63|64)"; then
rm -f /run/nvidia/validations/driver-ready # 치명적 Xid 발생
else
touch /run/nvidia/validations/driver-ready # 통과
fi이렇게 보완한 판정 결과는 전부 노드별 메트릭으로 노출됩니다. 아래는 저희가 실제 대시보드에서 보는 지표들입니다.
gpu_operator_node_driver_readygpu_operator_node_toolkit_readygpu_operator_node_plugin_readygpu_operator_node_cuda_readygpu_operator_node_driver_validationgpu_operator_nvidia_pci_devices_totalgpu_operator_node_device_plugin_devices_totalgpu_operator_node_*_validation_last_success_ts_seconds이 중 드라이버, Container Toolkit, Device Plugin, CUDA 네 개의 readiness 신호가 모두 1이어야 GPU 실행 경로가 정상이라고 판단했습니다. 물리 GPU와 노출된 자원의 수, 마지막 검증 성공 시각은 장치 누락이나 검증 중단을 발견하기 위한 보조 기준으로 사용했습니다.

avg_over_time(gpu_operator_node_driver_validation{node=~"$node"}[1m]) == 1
and avg_over_time(gpu_operator_node_cuda_ready{node=~"$node"}[1m]) == 1
and avg_over_time(gpu_operator_node_plugin_ready{node=~"$node"}[1m]) == 1
and avg_over_time(gpu_operator_node_toolkit_ready{node=~"$node"}[1m]) == 1
이렇게 실행 전 GPU 스택의 상태를 확인할 수 있게 되었습니다. 그러나 검증 시점에 GPU 스택이 정상이라는 것과, 워크로드가 실행되는 동안 GPU가 어떤 상태인지를 아는 것은 다른 문제였습니다.
DCGM Exporter — GPU 상태 메트릭 상시 관측
워크로드가 실행되기 시작하면 GPU 상태는 계속해서 변하는데요.
DCGM Exporter는 GPU 사용률과 VRAM, 온도, 전력, ECC·Xid 오류, PCIe·NVLink 상태를 Prometheus 메트릭으로 노출합니다. 쿠버네티스 이벤트나 Node Ready만으로는 보이지 않는 GPU 내부 상태를 관측 체계 안으로 끌어오는 역할을 수행해요.
저희는 메트릭의 개수를 늘리는 것보다, 운영자가 답해야 하는 질문에 맞춰 필요한 지표를 묶는 데 집중했습니다.
DCGM_FI_DEV_GPU_UTIL, DCGM_FI_PROF_SM_ACTIVE,
DCGM_FI_PROF_SM_OCCUPANCYDCGM_FI_DEV_FB_USED, DCGM_FI_DEV_FB_FREE, DCGM_FI_DEV_FB_TOTALDCGM_EXP_GPU_HEALTH_STATUS각 메트릭이 답하는 질문은 서로 다릅니다. 예를 들어 DCGM_FI_DEV_GPU_UTIL은 GPU에서 작업이 실행되고 있는지를 보여 주지만, 서빙 워크로드가 연산 유닛을 얼마나 활용하는지 판단하려면 DCGM_FI_PROF_SM_ACTIVE 같은 프로파일링 지표를 함께 봐야 했습니다.

이처럼 DCGM Exporter를 통해 운영 중인 GPU의 내부 상태를 파악할 수 있게 되었습니다.
하지만 GPU 상태를 볼 수 있다는 것과, 장애를 제때 알아차려 대응하는 것은 다른 문제였습니다. 마지막으로 GPU의 장애 신호를 실제 대응으로 연결해야 했습니다.
Xid Error — 장애 신호를 알럿으로
Xid는 NVIDIA 드라이버가 GPU 관련 오류를 감지해 운영체제의 커널 로그에 남기는 오류 보고입니다. 문제는 이 신호가 쿠버네티스 이벤트에서는 관측되지 않는다는 것인데요. Node Ready여도, Pod Running이어도, 특정 GPU에서는 이미 오류가 발생했을 수 있다는 겁니다.

이는 실제 운영에서 중요한 차이점이었습니다. 서빙 워크로드가 GPU에 배치된 뒤 CUDA 오류나 NVML 오류로 실패하면, 쿠버네티스 이벤트만 봐서는 원인이 파드 문제인지, 노드 문제인지, 특정 GPU 디바이스 문제인지 알 수 없는 거죠.
그렇다고 Xid가 보이면 곧바로 노드를 장애로 판단할 수 있는 것도 아닙니다. 예를 들어 Xid 31은 GPU 메모리 페이지 폴트를 나타냅니다. 주로 애플리케이션의 잘못된 메모리 접근으로 발생하지만, 드라이버나 하드웨어 문제일 가능성도 있습니다.
그래서 Xid가 발생했는지뿐 아니라, 어떤 워크로드에서 발생했는지, 같은 GPU에서 반복되는지, readiness 신호와 함께 봤을 때 워크로드를 계속 받아도 되는 상태인지를 함께 봐야 했습니다.
저희는 이러한 판단을 실제 대응으로 연결하기 위해 Xid를 단순 로그가 아니라 노드·GPU·워크로드 정보를 함께 전달하는 알럿으로 구성했습니다.

실제로 운영 중인 H100 GPU 한 장에서 HBM의 복구 불가능한 메모리 오류인 DBE(Double Bit Error)가 발생한 적이 있었습니다. 해당 GPU를 사용하던 vLLM 엔진이 두 차례 종료됐고, Xid 알럿과 GPU readiness 신호도 같은 시간대에 변했습니다. 워크로드는 다른 노드로 재스케줄돼 서비스는 계속됐지만, 파드가 종료됐다는 사실만으로는 애플리케이션 문제인지 GPU 하드웨어 문제인지 구분하기 어려웠습니다.

저희는 이미 Xid 기반으로 가시성을 구성해 놓았기 때문에, 이때 발생한 신호를 하나의 장애 흐름으로 읽을 수 있었습니다. HBM에서 복구 불가능한 DBE가 발생했고(Xid 48·171), 오류가 GPU 전체에 확산되지 않고 해당 vLLM 워크로드에 격리됐습니다(Xid 94). 이후 row remapping이 기록됐고(Xid 63), 드라이버는 GPU를 비우고 리셋해야 한다는 복구 동작을 요구했습니다(Xid 154).
Xid를 단순 오류 카운트가 아니라 격리와 복구, 재투입 여부를 결정하는 운영 신호로 사용한 사례였습니다.
요약하자면
쿠버네티스의 Node Ready와 GPU Ready는 다릅니다. GPU를 쿠버네티스 자원으로 등록한 뒤에도 운영자는 아래 세 가지를 확인할 수 있어야 합니다.
이 세 가지를 확인할 수 있게 되면서 Node Ready와 GPU Ready를 구분해 운영할 수 있게 되었습니다.
여기까지가 GPU 상태를 관측하고, 장애 신호를 운영 판단으로 연결하는 과정이었습니다. 그러나 GPU의 상태를 판단할 수 있게 된 것과, 신규 또는 복구된 GPU 노드를 실제 서비스에 투입해도 된다고 판단하는 것은 또 다른 문제였습니다.
2. 검증된 GPU만 서비스에 투입하기
토스증권은 Blackwell 시리즈를 도입하면서 앞에서 정리한 GPU 상태 판단 기준을 신규 장비의 투입 과정에도 적용해야 했습니다.
새로운 GPU 아키텍처가 들어오면 하드웨어만 바뀌는 것이 아닌데요. 드라이버, CUDA, NVIDIA Device Plugin, GPU Operator, DCGM, MIG 구성, 실제 서빙 워크로드까지 모두 다시 맞춰 봐야 합니다. 그래서 저희는 운영 중 정립한 판단 기준을 신규 GPU 노드의 투입 전 검증 절차로 묶고, 이를 GPU 시범운행(preflight)이라는 절차로 구현했습니다.
GPU 시범운행은 GPU 자체의 동작 여부만 확인하는 데 그치지 않습니다. 표준 스펙, GPU 스택, 하드웨어, 쿠버네티스 편입 상태, 실제 CUDA와 모델 서빙을 차례로 검증해, 이 장비가 토스증권의 서비스 워크로드를 받을 수 있는 상태인지 판단하는 최종 투입 기준입니다.
검증은 다음과 같은 순서로 진행됩니다.

[PASS] vectoradd — Test PASSED
[PASS] LLM 토큰 (서비스 가용): "토스증권 - 투자의 힘을 모두에게"
→ "전하다""투자의 힘을 모두에게"라는 토스증권 미션을 담은 프롬프트에 새 GPU가 다음 토큰을 이어서 출력하면 최종 검증이 통과됩니다. 드라이버가 로드되는 것만으로 실제 서비스 워크로드가 동작한다고 볼 수 없기 때문에, CUDA 커널과 작은 LLM 서빙까지 통과 조건으로 삼았습니다.
각 관문의 결과는 메트릭으로 수집되며, 모든 검증을 통과한 노드에만 preflight=passed 라벨이 붙습니다.


저희는 이 라벨을 서비스 스케줄링 게이트로 사용합니다. 검증을 통과한 노드는 cordon을 해제해 워크로드를 받을 수 있게 하고, 하나라도 실패한 노드는 cordon 상태로 유지합니다.
신규 GPU 노드와 하드웨어 점검 후 복귀한 노드는 모든 관문을 통과해야만 스케줄 대상이 됩니다.
이렇게 노드 투입 기준을 세운 뒤, 마지막으로 남은 문제는 한정된 GPU를 서비스가 필요로 하는 크기에 맞춰 어떻게 효율적으로 제공할 것인가였습니다.
3. GPU를 서비스에 맞는 단위로 제공하기
토스증권은 GPU를 서비스에 필요한 단위로 제공하기 위해 MIG(Multi-Instance GPU)를 선택했습니다. 이 이야기는 이전에 GPU를 밀도 있게 쓰는 방법 — 토스증권의 GPU 가상화(MIG) 도입기[5]에서 자세히 다뤘습니다. MIG는 GPU를 하드웨어 수준에서 여러 개의 독립 인스턴스로 쪼개, 조각 하나하나가 연산 유닛과 메모리를 물리적으로 격리한 채 별도의 GPU처럼 동작할 수 있게 합니다.
하드웨어 기반의 가상화인 MIG에는 여러 장점이 있지만, 문제도 있는데요. MIG 인스턴스를 수동으로 관리하기 위해 노드마다 접속해 필요한 크기의 인스턴스를 직접 만들고, 구성이 바뀔 때마다 다시 설정해야 한다는 운영 비용입니다. 노드가 늘어날수록 이 비용은 같이 증가하고요.
이러한 문제를 피하기 위해 MIG Manager를 사용했습니다. 원하는 MIG 구성을 ConfigMap에 정의하고, 노드 라벨로 적용할 구성을 선택하면, 각 노드의 GPU가 선언한 구성에 맞게 자동으로 설정됩니다. 노드마다 접속해 MIG 인스턴스를 수동으로 구성하던 부담을 줄일 수 있었습니다.
하지만 GPU를 원하는 크기로 쪼개는 것과, 만들어진 조각을 효율적으로 배치하는 것은 또 다른 문제였는데요. MIG Manager는 MIG 구성을 만들고, 워크로드에 어떤 인스턴스를 할당할지는 NVIDIA Device Plugin이 결정합니다.
실제 Device Plugin의 getPreferredAllocation 함수에서도 사용 가능한 디바이스와 요청 수량을 받아, 설정된 정책에 따라 할당할 디바이스를 선택하는 흐름을 확인할 수 있습니다.
// getPreferredAllocation runs an allocation algorithm over the inputs.
// The algorithm chosen is based both on the incoming set of available devices and various config settings.
func (r *nvmlResourceManager) getPreferredAllocation(available, required []string, size int) ([]string, error) {
// If all of the available devices are full GPUs without replicas, then
// calculate an aligned allocation across those devices.
if r.Devices().AlignedAllocationSupported() && !AnnotatedIDs(available).AnyHasAnnotations() {
return r.alignedAlloc(available, required, size)
}
// Otherwise, apply the configured allocation policy for replicated/MIG resources.
policy := spec.AllocationPolicyDistributed
if r.config.Flags.Plugin != nil && r.config.Flags.Plugin.SharedDevicesAllocationPolicy != nil {
policy = *r.config.Flags.Plugin.SharedDevicesAllocationPolicy
}
return r.greedyAlloc(available, required, size, comparatorForPolicy(policy))
}
서비스의 자원 요구 사항에 따라 Full GPU와 여러 MIG 프로파일 사이를 유연하게 전환하려면, 사용 중인 MIG 인스턴스가 어느 물리 GPU에 배치되는지도 중요했습니다. 하지만 Device Plugin의 기본 정책인 Distributed Allocation은 MIG 조각을 여러 물리 GPU에 골고루 배치합니다.
그 결과, 사용하는 MIG 조각이 몇 개뿐이어도 여러 GPU가 부분적으로 점유됐습니다. Full GPU를 확보하거나 MIG 프로파일을 변경하려면 여러 GPU를 함께 비워야 했고, 원하는 구성으로 유연하게 전환하기 어려웠습니다.
그래서 MIG 인스턴스를 한 물리 GPU부터 채워, 완전히 비어 있는 GPU를 남기는 Packed Allocation이 필요했습니다.
이를 위해 사용자가 GPU 디바이스의 배치 정책을 선택할 수 있도록 allocationPolicy 개념과 Packed Allocation을 구현해 NVIDIA k8s-device-plugin에 제안했습니다.[1] 이 변경은 업스트림에 머지됐으며, v0.20.0부터 sharedDevicesAllocationPolicy 설정(packed/distributed)으로 제공됩니다.
마치며 — GPU를 쿠버네티스 운영 체계 안으로
GPU를 인식하고 할당할 수 있는 GPU-aware한 클러스터를 만드는 일은 시작이었습니다. 안정적이고 효율적으로 운영하려면 GPU가 정상인지 판단하고, 검증된 노드만 서비스에 투입하며, 워크로드에 맞게 자원을 나누고 배치할 수 있어야 했습니다.
저희는 GPU 스택 검증과 DCGM·Xid 관측으로 Node Ready와 GPU Ready를 구분했고, GPU 시범운행이라는 절차로 서비스 투입 기준을 세웠습니다. MIG로 서비스에 필요한 단위의 GPU를 제공하고, Packed Allocation을 구현해 업스트림에 기여하며 배치 방식도 운영 요구에 맞게 보완했습니다. 이렇게 GPU-aware한 클러스터에 운영 조건을 하나씩 갖춰 가는 과정이 저희가 말하는 GPU-native로의 전환이었습니다.
이 과정에서는 쿠버네티스 운영 경험과 GPU·모델 서빙에 대한 이해가 함께 필요했습니다. Data Infra 팀과 ML Platform 팀이 각자의 경험을 바탕으로 문제를 함께 풀어 갈 수 있었던 이유이기도 합니다. 이 글이 쿠버네티스에서 GPU를 운영하는 분들께, 현재 클러스터에 어떤 운영 기준이 더 필요한지 점검하는 데 도움이 되었으면 합니다.
부록 - 쿠버네티스의 일등시민은 누구인가
본문에서는 CPU·메모리와 GPU가 서로 다른 방식으로 관리된다는 점을 살펴봤습니다. 이 차이는 쿠버네티스 코드에서는 어떻게 나타날까요?
쿠버네티스가 표준 컨테이너 자원을 정의한 코드[6]를 보면, 다음과 같은 목록이 있습니다.
// pkg/apis/core/helper/helpers.go
var standardContainerResources = sets.New(
core.ResourceCPU,
core.ResourceMemory,
core.ResourceEphemeralStorage,
)CPU, 메모리, 그리고 ephemeral-storage(임시 디스크). GPU는 어디에도 보이지 않습니다.
그럼 우리가 쓰는 nvidia.com/gpu는 쿠버네티스 입장에서 뭘까요? 이름에 벤더 도메인(nvidia.com/)이 붙은 데서 짐작할 수 있듯, 쿠버네티스가 기본으로 아는 표준 컨테이너 자원이 아니라 바깥에서 들여온 확장 리소스(extended resource)[7]입니다.
❯ kubectl describe node gpu-node-1
Name: gpu-node-1
Roles: b300
...
Capacity:
cpu: 172
ephemeral-storage: 929830304Ki
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 4226790036Ki
nvidia.com/gpu: 8
pods: 110
Allocatable:
cpu: 171500m
ephemeral-storage: 855857864924
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 4135461524Ki
nvidia.com/gpu: 8
pods: 110여기서 보이는 nvidia.com/gpu: 8이 바로 device plugin이 노드에 등록한 결과입니다.
device plugin이 "이 노드에 GPU가 N개 있다"고 알려 주기 전까지는 쿠버네티스에 등장하지 않고, 등장한 뒤에도 쿠버네티스는 그 자원을 "몇 개 있는가"로만 다루게 됩니다.
그래서 GPU는 CPU·메모리처럼 기본 제공되는 관리 계약을 그대로 받지 못해요. 스케줄링 대상이 되려면 device plugin이 먼저 노드의 GPU 개수를 알려 줘야 하고, 격리와 관측, 장애 감지는 별도의 구성으로 메워야 합니다.
