
"GPU 한 장에 워크로드 하나만 올리고 있다면, 당신은 절반의 비용을 버리고 있는 것입니다."
LLM과 대형 비전 모델을 자체 인프라에서 운영하기 시작하면 가장 먼저 마주치는 벽이 GPU 활용률입니다. nvidia-smi 를 켜놓고 며칠만 지켜보면 평균 utilization 이 20~30 % 를 넘기지 못하는 노드가 대부분이죠. 그렇다고 한 노드에 여러 Pod 를 떨궈도, 기본 nvidia.com/gpu 리소스는 GPU 단위 정수 할당만 지원하기 때문에 작은 모델은 계속 한 카드를 통째로 잡아먹습니다.
이 글은 그 문제를 풀기 위해 우리 팀이 HAMi (Heterogeneous AI Manager) 로 vGPU 분할을, KubeRay 로 분산 추론을 구축한 과정을 정리한 기록입니다. 결과적으로 동일한 카드 수로 처리량은 약 3 배 늘었고, p95 지연은 30 % 줄었습니다. 무엇이 효과적이었고 어떤 곳에서 발이 걸렸는지를 — 결정에 깔린 이유를 중심으로 — 공유합니다.
각각이 따로는 평범한 도구지만, 세 개를 한 클러스터에서 같은 GPU 풀 위에 올리는 것 이 이 구성의 핵심입니다.
GPU 공유에는 크게 세 가지 길이 있습니다.
| 방식 | 장점 | 한계 |
|---|---|---|
| MIG (NVIDIA Multi-Instance GPU) | HW 격리, 안정성 최고 | A100/H100 등 일부 카드만 지원, 분할 형상 제한적 (1g.5gb, 2g.10gb…) |
| Time-Slicing (NVIDIA Device Plugin) | 설정 간단 | 메모리 분리 X, OOM 시 같이 죽음, fair scheduling 불가 |
| HAMi vGPU | 모든 NVIDIA 카드 지원, 메모리/코어 분리 분할, CUDA hook 기반 | 커널 hijack 방식이라 일부 드라이버 조합 검증 필요 |
우리 환경은 RTX 3090 · RTX A6000 · Tesla T4 가 섞인 이종 풀입니다. MIG 는 처음부터 후보에서 빠졌고, Time-Slicing 은 한 Pod 의 OOM 이 옆 Pod 까지 죽이는 사례를 PoC 단계에서 두 번 겪어 폐기했습니다. 남은 것은 HAMi 였습니다.
WHY (HAMi 채택 근거):
1. 한 카드 안에서 메모리(MB) 와 SM 코어(%) 를 독립적으로 자를 수 있다. vLLM 처럼 메모리는 많이 쓰지만 코어는 덜 쓰는 워크로드와, OCR 처럼 그 반대인 워크로드를 같은 카드에 공존시킬 수 있습니다.
2. 카드 모델에 의존하지 않으므로 3090 부터 A6000 까지 같은 리소스 모델 (nvidia.com/gpumem,nvidia.com/gpucores) 로 표현 가능합니다.
3. CUDA API hijack 방식이므로 애플리케이션 코드는 한 줄도 수정할 필요가 없다.
HAMi 설치 자체는 Helm chart 한 줄이지만, 실전에서 발이 걸리는 곳이 명확합니다.
# kubernetes/staging/apps/hami/values.yaml
scheduler:
# ★ 필수: false 로 두면 default-scheduler 가 nvidia.com/gpu 만 보고
# gpumem/gpucores 를 무시한 채 Pod 를 띄워 OOM 을 유발한다.
kubeScheduler:
enabled: true
defaultMem: 0 # 0 = unlimited (Pod 가 명시한 값만 사용)
defaultCores: 0
policy:
gpuSchedulerPolicy: binpack # 같은 카드에 모아넣기 (idle GPU 확보)
nodeSchedulerPolicy: spread # 노드 간엔 분산
devicePlugin:
deviceSplitCount: 10 # 한 카드를 최대 10 개 vGPU 로
deviceMemoryScaling: 1.0
deviceCoreScaling: 1.0
kubeScheduler.enabled: true 가 빠진 채로 일주일을 운영하다, 메모리 합이 카드 용량을 초과해 OOM-killer 가 vLLM 워커를 죽이는 사고를 한 번 겪었습니다. 이 줄은 "vGPU 리소스를 정직하게 계산해서 Pod 를 배치해 줘" 라는 선언입니다.
TIP: HAMi 의 vGPU 스케줄러는 default-scheduler 와 별도 프로세스로 동작합니다.
kubectl get pods -n kube-system | grep hami에서hami-scheduler가 떠 있는지 매일 한 번씩 확인하는 cron 알림을 걸어둡니다. 죽으면 default-scheduler 가 인계받지만 메모리 분할을 무시하기 때문에 사고로 직결됩니다.
apiVersion: v1
kind: Pod
metadata:
name: vllm-worker-llama3-8b
spec:
containers:
- name: vllm
image: vllm/vllm-openai:v0.6.3
resources:
limits:
nvidia.com/gpu: 1 # 카드 1장 (실제론 vGPU)
nvidia.com/gpumem: 18000 # 18 GiB 만 잡음
nvidia.com/gpucores: 70 # SM 70% 만 사용
핵심은 nvidia.com/gpu: 1 이 물리 카드 1장이 아니라 vGPU 1개를 의미한다는 것입니다. 한 카드에 위 Pod 가 2 개 들어가면 메모리 36 GiB, 코어 140 % 를 요청하게 되는데, HAMi scheduler 는 이를 거부하고 다른 노드를 찾습니다.
LLM 한 모델이 GPU 한 장에 안 들어가기 시작하면 Tensor Parallel (TP) 과 Pipeline Parallel (PP) 가 필요해집니다. 직접 NCCL 통신을 짜는 길도 있지만, 우리 팀은 Ray 를 선택했습니다.
WHY:
- vLLM 이 Ray 를 native distributed backend 로 지원 합니다.
tensor_parallel_size=4한 줄로 4 장 카드에 모델이 자동 분할됩니다.- Ray actor model 은 GPU 1 장 = 1 actor 라는 멘탈 모델이 직관적이고, 장애 복구/재시작 정책을 actor 단위로 걸 수 있습니다.
- KubeRay operator 는 RayCluster / RayJob / RayService 세 가지 CRD 만으로 일회성 배치 작업부터 상시 서빙까지 한 패턴으로 다룹니다.
helm repo add kuberay https://ray-project.github.io/kuberay-helm/
helm install kuberay-operator kuberay/kuberay-operator \
--version 1.2.2 \
-n kuberay-system --create-namespace
RayService 는 추론 엔드포인트를 무중단으로 업그레이드 하기 위해 만들어진 CRD 입니다. 새 버전 RayCluster 를 옆에 띄우고, health check 가 통과하면 트래픽을 옮긴 뒤 구버전을 정리합니다.
apiVersion: ray.io/v1
kind: RayService
metadata:
name: llama3-70b
namespace: llm-serving
spec:
serviceUnhealthySecondThreshold: 900
deploymentUnhealthySecondThreshold: 600
serveConfigV2: |
applications:
- name: llama3
import_path: llm_app:deployment
runtime_env:
working_dir: "https://harbor.internal/private-registry/llm-apps/v1.tar.gz"
deployments:
- name: VLLMDeployment
num_replicas: 2
ray_actor_options:
num_gpus: 4 # 한 replica = 4 GPU (TP=4)
rayClusterConfig:
rayVersion: '2.35.0'
headGroupSpec:
rayStartParams:
dashboard-host: '0.0.0.0'
template:
spec:
containers:
- name: ray-head
image: rayproject/ray:2.35.0-py311
resources:
requests:
cpu: 2
memory: 8Gi
workerGroupSpecs:
- groupName: gpu-workers
replicas: 8 # 워커 8개 = 8 GPU
minReplicas: 4
maxReplicas: 16
rayStartParams:
num-gpus: "1"
template:
spec:
containers:
- name: ray-worker
image: rayproject/ray:2.35.0-py311-cu121
resources:
limits:
nvidia.com/gpu: 1
nvidia.com/gpumem: 22000 # 22 GiB
nvidia.com/gpucores: 90
tolerations:
- key: nvidia.com/gpu
operator: Exists
# llm_app.py
from ray import serve
from vllm import LLM, SamplingParams
from fastapi import FastAPI
app = FastAPI()
@serve.deployment(
autoscaling_config={
"min_replicas": 2,
"max_replicas": 8,
"target_ongoing_requests": 4,
},
ray_actor_options={"num_gpus": 4}, # TP=4
)
@serve.ingress(app)
class VLLMDeployment:
def __init__(self):
self.llm = LLM(
model="meta-llama/Llama-3-70B-Instruct",
tensor_parallel_size=4, # Ray 가 4 GPU 에 자동 분할
gpu_memory_utilization=0.85,
max_model_len=8192,
enforce_eager=False, # CUDA Graph 활성
)
@app.post("/v1/chat/completions")
async def chat(self, request: dict):
prompt = self._format(request["messages"])
params = SamplingParams(
temperature=request.get("temperature", 0.7),
max_tokens=request.get("max_tokens", 512),
)
# vLLM 내부 Continuous Batching 이 동시 요청 자동 병합
result = self.llm.generate([prompt], params)[0]
return {"choices": [{"message": {"content": result.outputs[0].text}}]}
deployment = VLLMDeployment.bind()
TIP:
tensor_parallel_size와ray_actor_options.num_gpus는 반드시 같은 값이어야 합니다. 다르면 Ray 가 GPU 를 덜 잡거나 더 잡아서 NCCL init 단계에서 행이 걸립니다. 처음 셋업 때 이 한 줄에 반나절을 썼습니다.
KubeRay 의 worker Pod 는 결국 일반 K8s Pod 입니다. resources.limits 에 HAMi 의 gpumem/gpucores 를 그대로 적으면 HAMi scheduler 가 vGPU 단위로 배치하고, Ray 는 그 vGPU 를 1 개 GPU 로 인식합니다.
같은 카드에 메모리/코어가 남는 한 작은 워크로드를 끼워 넣을 수 있습니다. 우리 환경 기준 평균 GPU 메모리 활용률이 38 % → 81 % 로 뛰었습니다.
이종 GPU 클러스터에서는 모델별로 갈 카드를 명시해줘야 합니다. 70B 모델을 T4 위에 띄우려고 시도하다 OOM 으로 무한 재시작 루프에 빠진 경험 때문에 만든 정책입니다.
# RayService workerGroupSpec.template.spec
nodeSelector:
gpu.cubox.ai/family: ampere # A6000 / 3090 만 허용
# 카드 라벨링
# kubectl label node gpu-node-01 gpu.cubox.ai/family=ampere
# kubectl label node gpu-node-04 gpu.cubox.ai/family=turing # T4
HAMi 는 hami-device-plugin 이 vGPU 단위 메트릭을 Prometheus 로 노출 합니다. NVIDIA DCGM exporter 의 노드 단위 지표와 합쳐 두 층으로 봅니다.
# Grafana panel queries
# 1. 노드별 물리 GPU 사용률
sum(DCGM_FI_DEV_GPU_UTIL) by (Hostname, gpu)
# 2. Pod 별 vGPU 메모리 사용량
sum(hami_container_vgpu_memory_used_bytes) by (pod, namespace) / 1024 / 1024
# 3. 카드별 vGPU 점유 수
count(hami_container_vgpu_memory_allocated_bytes) by (Hostname, deviceuuid)
세 패널을 한 보드에 띄워 두면 "이 카드는 메모리는 차 있는데 코어는 비어 있다" 는 패턴이 보이고, 그게 다음에 들어갈 워크로드의 자리가 됩니다.
여기서부터는 GPU 자원이 아니라 추론 엔진 안쪽 이야기입니다. 같은 GPU 에서 처리량을 끌어올린 가장 효과가 컸던 세 가지 변경.
전통적인 정적 배치는 8 개 요청을 모은 뒤 한꺼번에 처리합니다. 가장 긴 요청이 끝날 때까지 짧은 요청도 같이 기다리죠. Continuous Batching 은 토큰 한 스텝마다 끝난 요청을 빼고 새 요청을 끼워 넣습니다. vLLM 은 기본 켜져 있고, 우리는 max_num_batched_tokens 만 조정했습니다.
LLM(
model="meta-llama/Llama-3-70B-Instruct",
tensor_parallel_size=4,
max_num_batched_tokens=8192, # 한 step 에 처리할 토큰 상한
max_num_seqs=128, # 동시 진행 시퀀스 최대
)
max_num_batched_tokens 를 8192 로 올린 뒤 토큰 처리량이 약 2.4 배 가 됐습니다. 메모리 여유가 줄어드니 gpu_memory_utilization 도 0.90 까지 끌어올려야 한다는 점만 기억하면 됩니다.
vLLM 의 핵심 기여인 PagedAttention 은 KV cache 를 OS 페이지처럼 블록 단위로 관리합니다. 메모리 단편화가 거의 없어, 같은 카드에 시퀀스를 더 많이 동시에 띄울 수 있습니다. 이건 옵션을 켜고 끄는 게 아니라 vLLM 을 쓰는 순간 자동 적용되는 효과입니다.
TIP:
enforce_eager=False(기본값) 으로 두면 CUDA Graph 가 컴파일되어 첫 요청만 느리고 이후는 빠릅니다. 디버깅 시에만 True 로.
대형 모델 출력을 작은 draft 모델로 미리 추측하고, 대형 모델은 검증만 하는 방식. Llama-3-70B + Llama-3-8B draft 조합에서 지연이 약 1.5 배 빨라졌습니다. 다만 작은 모델 품질이 떨어지면 reject 비율이 올라 오히려 느려지므로 PoC 필수.
LLM(
model="meta-llama/Llama-3-70B-Instruct",
speculative_model="meta-llama/Llama-3-8B-Instruct",
num_speculative_tokens=5,
use_v2_block_manager=True,
)
서빙 환경은 Git → ArgoCD Sync 가 단일 경로입니다. RayService 도 예외 없이 매니페스트만 Git 에 올리면 됩니다.
# kubernetes/production/apps/llm-serving/argocd-application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: llm-serving-prod
namespace: argocd
annotations:
# Harbor 에 올라온 vLLM 새 태그를 자동 추적 (semver)
argocd-image-updater.argoproj.io/image-list: vllm=harbor.internal/private-registry/vllm
argocd-image-updater.argoproj.io/vllm.update-strategy: semver
argocd-image-updater.argoproj.io/vllm.allow-tags: regexp:^v0\\.6\\..*$
spec:
project: llm
source:
repoURL: https://github.com/example/infrastructure
path: kubernetes/production/apps/llm-serving
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: llm-serving
syncPolicy:
# ★ Production 은 manual sync — 자동 배포 금지
automated: null
운영 원칙:
vGPU 는 메모리/코어를 논리적으로 분할하지만 HW 격리는 아닙니다. 같은 카드에 올라간 Pod 는 PCIe 버스를 공유하고, 드라이버 버그가 있으면 서로 영향을 줄 수 있습니다. 우리는 다음을 적용했습니다.
| 항목 | 적용 |
|---|---|
| mTLS | Istio Sail v1.24.3, namespace 전체 STRICT 모드 |
| NetworkPolicy | llm-serving ns 는 istio-ingressgateway 에서만 inbound 허용 |
| Secret 관리 | HuggingFace token / Harbor robot account 모두 Vault → ExternalSecret 으로 주입, Git 에 절대 commit 금지 |
| Pod Security | restricted PSS, runAsNonRoot, readOnlyRootFilesystem=true (vLLM cache 디렉터리만 emptyDir 마운트) |
| 테넌트 분리 | 외부 고객 워크로드와 내부 워크로드는 다른 노드풀 — 같은 카드 공유 금지 |
마지막 항목이 가장 중요합니다. HAMi 가 아무리 좋아도 신뢰 경계가 다른 워크로드를 한 카드에 묶지 않는다 는 규칙은 깨면 안 됩니다.
물리 머신 간 TP 를 시도하면 NCCL 이 InfiniBand 를 먼저 찾고 hang 이 걸립니다. 우리 노드는 100G Ethernet 만 있어 다음 env 를 추가했습니다.
env:
- name: NCCL_IB_DISABLE
value: "1"
- name: NCCL_P2P_DISABLE
value: "0"
- name: NCCL_SOCKET_IFNAME
value: "eth0"
gpu_memory_utilization=0.90 이 동일 카드 모델이라도 펌웨어/드라이버 조합에 따라 통과 못 하는 경우가 있습니다. 처음엔 카드별 환경 변수를 다르게 줬는데, 카드 단위로 작은 안전 마진(0.85) 을 일괄 적용하고 KV cache 압박 시 vLLM 의 preemption 에 맡기는 쪽이 더 안정적이었습니다.
dashboard-host: 0.0.0.0 만으로는 부족하고, Istio VirtualService 의 port.name: http-dashboard 형식까지 맞춰야 했습니다 (Istio 가 L7 라우팅을 결정하는 키). port name 을 dashboard 로만 적었더니 L4 로 떨어져 WebSocket 이 끊기는 증상.
기본값으로는 idle_timeout=600s. 트래픽 급증 시 새 worker 가 뜨는 데 10 분이 걸려 SLO 를 깼습니다.
rayClusterConfig:
enableInTreeAutoscaling: true
autoscalerOptions:
upscalingMode: Aggressive
idleTimeoutSeconds: 120
다만 너무 공격적이면 GPU 가 비싼 자원이라 비용이 튑니다. Aggressive + idle 120s 가 우리 환경의 균형점이었습니다.
| 지표 | Before (NVIDIA Device Plugin only) | After (HAMi + KubeRay + vLLM) | 개선 |
|---|---|---|---|
| GPU 메모리 평균 활용률 | 38 % | 81 % | +113 % |
| 토큰 처리량 (tok/s) | 1,420 | 4,180 | ×2.94 |
| Llama-3-70B p95 latency (256 tok) | 4.8 s | 3.4 s | −29 % |
| 동시 처리 가능한 모델 수 | 4 | 11 | +175 % |
| 동일 처리량 기준 필요 GPU 수 | 12 | 4 | −67 % |
같은 카드에 모델 4개가 11개로 늘었다는 게 가장 체감이 큰 부분입니다. 비용으로 환산하면 신규 카드 구매 8장이 잠시 보류됐고, 그 자원으로 다른 실험에 GPU 를 할당할 수 있게 됐습니다.
nvidia-smi -pl 로 카드당 전력을 제한해 데이터센터 PUE 영향 측정.LLM 추론은 모델 자체 못지않게 자원 활용률 이 비용을 결정합니다. 우리에게 효과적이었던 세 줄 요약:
- HAMi 로 GPU 를 메모리·코어 두 축으로 자른다. 한 카드 안에 여러 워크로드를 공존시키되,
kubeScheduler.enabled: true는 잊지 말 것.- KubeRay 로 분산 추론을 선언적으로 다룬다.
RayService의 무중단 업그레이드는 운영에서 가장 자주 쓰는 기능.- vLLM Continuous Batching + PagedAttention 이 처리량을 책임진다.
max_num_batched_tokens부터 튜닝하라.
세 도구를 한 클러스터에 올리고 ArgoCD + Vault + Istio 의 운영 규율을 그대로 적용한 결과, 같은 카드 수로 약 3 배의 추론 처리량이 나왔습니다. 이종 GPU 풀에서 LLM 을 운영하는 팀이라면 한 번쯤 시도해볼 가치가 있다고 생각합니다.
| 영역 | 기술 |
|---|---|
| GPU 가상화 | HAMi v2.8.0 (kubeScheduler.enabled=true) |
| 분산 추론 오케스트레이션 | KubeRay v1.2.2 · Ray v2.35.0 |
| 추론 엔진 | vLLM v0.6.3 (Continuous Batching · PagedAttention · Speculative Decoding) |
| 서빙 패턴 | RayService (무중단 업그레이드) · Autoscaler Aggressive 120s |
| Kubernetes | v1.30.4 |
| Service Mesh | Istio (Sail Operator) v1.24.3 — mTLS STRICT |
| Ingress | Istio Gateway + MetalLB (L2) + cert-manager (DNS-01) |
| GitOps | ArgoCD Multi-Source + Image Updater (Staging Auto / Prod Manual) |
| Secret | Vault HA (Raft 3-node) + ExternalSecrets |
| Registry | Harbor (S3 backend + Cosign 서명) · Kaniko in-cluster build |
| 모니터링 | Prometheus + DCGM exporter + HAMi metrics + Grafana + Loki + Tempo |
| Storage | Longhorn (3 replicas) — model cache · KV snapshot |