Dev Stories

GPU 자원, 생각한 대로 나눠지지 않는다: 멀티노드와 파티셔닝에서 만난 인프라의 벽

안녕하세요, KT IT부문 AX플랫폼본부에서 LLM 및 Azure OpenAI 모델 서빙/회수를 담당하고 있는 최원석입니다.

지난 편에서는 새 모델을 서빙할 때 버전 호환성 때문에 겪었던 문제 두 가지를 공유했습니다. 이번 편은 조금 다른 결의 문제입니다 — 모델은 문제없이 로드되는데, GPU 자원을 원하는 대로 나누거나 확장하려는 순간 인프라 레이어의 구조적 제약에 부딪히는 경우입니다.

셀프서빙 환경이라도 "GPU 한 장에 여러 모델을 태우고 싶다"거나 "모델이 너무 커서 여러 노드에 걸쳐야 한다"는 요구는 계속 생길 겁니다. 이 두 가지를 실제로 시도하면서 겪었던 이야기를 공유합니다.

들어가며


GPU는 한정된 자원입니다. 모델 서빙 파트가 자원을 미리 잘 나눠두고 확장 경로까지 설계해두면 좋겠지만, 실제로는 모델마다 요구하는 GPU 규모가 제각각이라 상황에 맞춰 그때그때 재구성이 필요합니다. 작은 모델 여러 개를 GPU 한 장에 욱여넣어야 할 때도 있고, 반대로 GPU 한 장으로는 부족해 여러 노드에 걸쳐 모델을 펼쳐야 할 때도 있습니다.

문제는 이 두 방향(자원을 잘게 쪼개는 것과 여러 노드로 확장하는 것) 모두 클러스터 고유의 구성을 모르면 겉보기엔 표준적인 절차를 그대로 따라도 조용히 실패한다는 점입니다.


사례 1. H100 한 장으로 여러 모델 올리기 — AKS MIG 설정


왜 MIG인가 — GPU 낭비 문제
Kubernetes에서 GPU를 요청하는 기본 단위는 nvidia.com/gpu: 1, 즉 물리 GPU 한 장 전체입니다. H100 NVL은 메모리만 94GB인데, 파라미터 수가 작은 모델은 이 중 10~15GB만 써도 서빙이 됩니다. 나머지 80GB는 그냥 낭비됩니다.

MIG(Multi-Instance GPU)는 이 문제를 해결합니다. H100의 하드웨어 파티셔닝 기능으로 물리 GPU를 최대 7개 슬라이스로 분할하고, 각 슬라이스를 독립된 GPU 인스턴스로 노출합니다. 슬라이스 간에는 메모리와 L2 캐시, 연산 유닛이 완전히 격리되어 있어서 한 모델의 부하가 다른 슬라이스에 영향을 주지 않습니다.

클러스터 MIG 구성 구조

저희 AKS 클러스터는 GPU Operator + MIG Manager 조합으로 MIG를 관리합니다.

  1. 노드에 nvidia.com/mig.config={프리셋 키} 레이블을 붙입니다.
  2. MIG Manager가 레이블 변화를 감지합니다.
  3. ConfigMap에 정의된 해당 프리셋의 파티션 구성을 GPU에 적용합니다.
  4. 적용이 완료되면 노드에 nvidia.com/mig-1g.12gb: 7 같은 형태로 슬라이스 리소스가 노출됩니다.

여기서 반드시 알아야 할 함정이 있습니다. 저희 클러스터는 NVIDIA 기본 ConfigMap 대신 커스텀 ConfigMap을 마운트해서 사용합니다. NVIDIA 공식 문서나 인터넷에 나오는 표준 키 이름(all-1g.12gb 등)은 이 클러스터에서 인식되지 않습니다.
​# 틀린 예 — NVIDIA 공식 문서 기준 키, 이 클러스터에서 인식 안 됨
kubectl label node {노드명} nvidia.com/mig.config=all-1g.12gb --overwrite

# 맞는 예 — 커스텀 ConfigMap 기준 키
kubectl label node {노드명} nvidia.com/mig.config=h100-all-1g --overwrite
12345

틀린 키를 넣으면 MIG Manager가 레이블 변화를 감지는 하되 ConfigMap에서 해당 키를 찾지 못해 아무 동작도 하지 않습니다. 에러 로그도 거의 안 남아서 "왜 적용이 안 되지?"를 한참 찾게 되는, 가장 조용하게 실패하는 지점이었습니다.


적용 검증

​kubectl get node {노드명} -o json \
  | jq '.status.capacity | with_entries(select(.key | startswith("nvidia")))'
12

정상 적용 시 아래처럼 슬라이스 수가 capacity에 잡힙니다.

​{ "nvidia.com/mig-1g.12gb": "7" }
1


이후 Pod spec에서 해당 리소스명으로 요청하면 슬라이스 하나를 할당받습니다. 소형 모델 요청이 들어올 때마다 노드를 추가하기 전에, MIG 분할부터 검토해볼 만합니다.



사례 2. ND96 두 대로 대형 모델 서빙하기 — livenessProbe와 reconciler 사이에서


환경 개요

항목
헤더 2
모델
Motif-3 (rc3), BF16 기준 약 542GB
아키텍처
GDN hybrid (Transformer + SSM linear attention 혼합)
노드
ND96 × 2 (H100 SXM × 8장, 640GB per node)
병렬화 구성
TP=2, DP=8, local_ranks_per_node=4


PP를 배제한 이유: SSM recurrent state 문제

멀티노드 서빙에서 가장 자연스러운 선택은 PP(Pipeline Parallel)입니다. 그런데 Motif-3는 PP를 쓸 수 없었습니다. Transformer 레이어와 SSM 계열 linear attention 레이어가 교대로 배치된 GDN hybrid 구조인데, SSM 레이어는 recurrent state(이전 스텝의 hidden state)를 다음 레이어로 넘겨야 합니다. vLLM의 PP 구현은 이 state가 stage 경계를 넘는 전달 경로를 지원하지 않아, PP로 분할하면 state가 유실되어 출력이 깨집니다.


그래서 TP=2, DP=8 조합으로 구성했습니다 — 노드 내 GPU 8장을 4개 TP 그룹으로 나누고, 두 노드에 걸쳐 DP=8로 동일 모델을 복제해 요청을 분산하는 구조입니다.



핸드셰이크 타임아웃 — 인터프리터 레벨 개입으로 해결

기동 직후 HANDSHAKE timeout exceeded: 120s 에러가 났습니다. 542GB 모델을 Blob Storage에서 읽고 CUDA 커널을 컴파일하는 시간이 vLLM 내부 기본 핸드셰이크 타임아웃(120초)을 초과한 겁니다. 문제는 이 값이 CLI 옵션이나 환경변수로 노출되지 않는다는 점이었습니다.


해결 방법은 sitecustomize.py를 이용한 Python 인터프리터 레벨 monkey-patch였습니다. 이 파일은 Python이 기동할 때 site 모듈 초기화 직후, 어떤 사용자 코드보다 먼저 실행됩니다.

​# sitecustomize.py
import vllm.distributed.parallel_state as ps

def _patched_timeout():
    return 7200.0  # 120s → 2시간

ps._get_default_timeout = _patched_timeout
1234567

이 파일을 이미지의 Python site-packages 경로에 넣어두면 vLLM 코드가 import되기 전에 패치가 적용됩니다.


진짜 문제: Helm 차트 하드코딩과 reconciler의 충돌

타임아웃을 해결하고 나니 pod가 계속 재시작됐습니다. failureThreshold: 3(30초 무응답 시 kill)이 차트 템플릿에 직접 박혀 있었고, 모델 로딩에 수십 분이 걸리는 상황에서는 기동 자체가 불가능한 설정이었습니다.


values.yaml로 오버라이드가 안 돼서 kubectl patch로 직접 값을 바꿔봤습니다.

​kubectl patch deployment motif-3-leader -n model-serving \
  --type=json \
  -p='[{"op":"replace","path":"/spec/template/spec/containers/0/livenessProbe/failureThreshold","value":1080}]'
123


patch는 성공하지만, 10~20초 후 값이 다시 3으로 돌아옵니다. 원인은 Kubernetes operator의 reconcile loop입니다. 커스텀 리소스를 관리하는 컨트롤러가 주기적으로 실제 상태를 desired state(Helm 차트가 정의한 상태)와 비교하고 차이가 있으면 되돌리기 때문에, kubectl patch로 바꾼 값은 desired state에 반영되지 않아 곧바로 revert됩니다. 근본적으로 바꾸려면 차트 템플릿 자체를 수정해야 했는데, 이건 서빙팀 권한 밖이었습니다.


현실적인 결론

멀티노드 시도는 인프라 레이어 제약으로 중단하고, ND96 단일 노드에서 TP=8로 전환했습니다. 542GB 모델을 640GB 노드에 올리면 KV 캐시에 약 70GB를 쓸 수 있어 안정적으로 운영 가능한 수준이었고, 현재 이 구성으로 서빙 중입니다.



마치며


두 사례 모두 "모델이 문제"가 아니라 "우리 클러스터가 어떻게 구성되어 있는지"가 관건이었습니다. MIG는 커스텀 ConfigMap 키 이름을, 멀티노드는 차트에 하드코딩된 값과 컨트롤러의 reconcile 동작 방식을 몰랐다면 계속 헤맸을 문제들입니다.


셀프서빙으로 전환되어도 이런 인프라 레벨의 제약은 그대로 남아있을 가능성이 높습니다. GPU 자원을 나누거나 여러 노드에 걸쳐 배포하시기 전에, 클러스터 고유의 설정(커스텀 키 이름, 차트에 하드코딩된 값 등)부터 먼저 확인해보시길 권합니다.


읽어주셔서 감사합니다!


최원석

KT IT부문 AX플랫폼본부 플랫폼개발담당에서

LLM 및 Azure OpenAI 모델의 서빙/회수 운영을 담당하고 있습니다.