GPU 추론 최적화를 위한 HAMi + KubeRay 구축기

문한성·2026년 5월 25일

"GPU 한 장에 워크로드 하나만 올리고 있다면, 당신은 절반의 비용을 버리고 있는 것입니다."

LLM과 대형 비전 모델을 자체 인프라에서 운영하기 시작하면 가장 먼저 마주치는 벽이 GPU 활용률입니다. nvidia-smi 를 켜놓고 며칠만 지켜보면 평균 utilization 이 20~30 % 를 넘기지 못하는 노드가 대부분이죠. 그렇다고 한 노드에 여러 Pod 를 떨궈도, 기본 nvidia.com/gpu 리소스는 GPU 단위 정수 할당만 지원하기 때문에 작은 모델은 계속 한 카드를 통째로 잡아먹습니다.

이 글은 그 문제를 풀기 위해 우리 팀이 HAMi (Heterogeneous AI Manager) 로 vGPU 분할을, KubeRay 로 분산 추론을 구축한 과정을 정리한 기록입니다. 결과적으로 동일한 카드 수로 처리량은 약 3 배 늘었고, p95 지연은 30 % 줄었습니다. 무엇이 효과적이었고 어떤 곳에서 발이 걸렸는지를 — 결정에 깔린 이유를 중심으로 — 공유합니다.


0. 한 줄 요약

  • HAMi vGPU 로 GPU 의 메모리와 코어를 워크로드 단위로 잘라 쓰고
  • KubeRay 로 텐서/파이프라인 병렬을 선언적으로 다루며
  • vLLM + Continuous Batching 으로 토큰 처리량을 끌어올린다.

각각이 따로는 평범한 도구지만, 세 개를 한 클러스터에서 같은 GPU 풀 위에 올리는 것 이 이 구성의 핵심입니다.

diagram-1


1. 왜 HAMi 인가 — MIG, Time-Slicing 과의 비교

1.1 출발점의 후보들

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 였습니다.

diagram-2

WHY (HAMi 채택 근거):
1. 한 카드 안에서 메모리(MB) 와 SM 코어(%) 를 독립적으로 자를 수 있다. vLLM 처럼 메모리는 많이 쓰지만 코어는 덜 쓰는 워크로드와, OCR 처럼 그 반대인 워크로드를 같은 카드에 공존시킬 수 있습니다.
2. 카드 모델에 의존하지 않으므로 3090 부터 A6000 까지 같은 리소스 모델 (nvidia.com/gpumem, nvidia.com/gpucores) 로 표현 가능합니다.
3. CUDA API hijack 방식이므로 애플리케이션 코드는 한 줄도 수정할 필요가 없다.

1.2 설치와 가장 자주 밟는 함정

diagram-3

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 가 인계받지만 메모리 분할을 무시하기 때문에 사고로 직결됩니다.

1.3 Pod 매니페스트에서의 사용

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 는 이를 거부하고 다른 노드를 찾습니다.


2. KubeRay — 분산 추론을 선언적으로

2.1 왜 Ray 인가

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 만으로 일회성 배치 작업부터 상시 서빙까지 한 패턴으로 다룹니다.

2.2 KubeRay 설치

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

2.3 RayService — vLLM 70B 모델 서빙

diagram-4

RayService추론 엔드포인트를 무중단으로 업그레이드 하기 위해 만들어진 CRD 입니다. 새 버전 RayCluster 를 옆에 띄우고, health check 가 통과하면 트래픽을 옮긴 뒤 구버전을 정리합니다.

diagram-5

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

2.4 vLLM 애플리케이션 코드

# 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_sizeray_actor_options.num_gpus반드시 같은 값이어야 합니다. 다르면 Ray 가 GPU 를 덜 잡거나 더 잡아서 NCCL init 단계에서 행이 걸립니다. 처음 셋업 때 이 한 줄에 반나절을 썼습니다.


3. HAMi × KubeRay — 같은 카드 위에 함께 살게 하기

3.1 자원 모델의 조화

KubeRay 의 worker Pod 는 결국 일반 K8s Pod 입니다. resources.limits 에 HAMi 의 gpumem/gpucores 를 그대로 적으면 HAMi scheduler 가 vGPU 단위로 배치하고, Ray 는 그 vGPU 를 1 개 GPU 로 인식합니다.

diagram-6

같은 카드에 메모리/코어가 남는 한 작은 워크로드를 끼워 넣을 수 있습니다. 우리 환경 기준 평균 GPU 메모리 활용률이 38 % → 81 % 로 뛰었습니다.

3.2 노드 어피니티 — 카드별 가용량 분리

이종 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

3.3 GPU monitoring — 누가 얼마나 쓰는지 보이게 하기

HAMi 는 hami-device-pluginvGPU 단위 메트릭을 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)

세 패널을 한 보드에 띄워 두면 "이 카드는 메모리는 차 있는데 코어는 비어 있다" 는 패턴이 보이고, 그게 다음에 들어갈 워크로드의 자리가 됩니다.


4. 처리량을 끌어올린 세 가지 vLLM 튜닝

여기서부터는 GPU 자원이 아니라 추론 엔진 안쪽 이야기입니다. 같은 GPU 에서 처리량을 끌어올린 가장 효과가 컸던 세 가지 변경.

4.1 Continuous Batching

diagram-7

전통적인 정적 배치는 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 까지 끌어올려야 한다는 점만 기억하면 됩니다.

4.2 PagedAttention 과 KV cache

diagram-8

vLLM 의 핵심 기여인 PagedAttention 은 KV cache 를 OS 페이지처럼 블록 단위로 관리합니다. 메모리 단편화가 거의 없어, 같은 카드에 시퀀스를 더 많이 동시에 띄울 수 있습니다. 이건 옵션을 켜고 끄는 게 아니라 vLLM 을 쓰는 순간 자동 적용되는 효과입니다.

TIP: enforce_eager=False (기본값) 으로 두면 CUDA Graph 가 컴파일되어 첫 요청만 느리고 이후는 빠릅니다. 디버깅 시에만 True 로.

4.3 Speculative Decoding (선택)

diagram-9

대형 모델 출력을 작은 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,
)

5. 배포: ArgoCD + Image Updater 와의 결합

diagram-10

서빙 환경은 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

운영 원칙:

  • Staging 은 Image Updater 가 본 태그 갱신 → ArgoCD 자동 sync.
  • Production 은 동일 매니페스트지만 sync 만 수동. 모델 가중치 교체는 PR review + 사람이 ArgoCD UI 에서 누르는 한 번의 확인 단계를 거치게 했습니다.

6. 보안 — vGPU 가 격리하지 못하는 것

diagram-11

vGPU 는 메모리/코어를 논리적으로 분할하지만 HW 격리는 아닙니다. 같은 카드에 올라간 Pod 는 PCIe 버스를 공유하고, 드라이버 버그가 있으면 서로 영향을 줄 수 있습니다. 우리는 다음을 적용했습니다.

항목적용
mTLSIstio Sail v1.24.3, namespace 전체 STRICT 모드
NetworkPolicyllm-serving ns 는 istio-ingressgateway 에서만 inbound 허용
Secret 관리HuggingFace token / Harbor robot account 모두 Vault → ExternalSecret 으로 주입, Git 에 절대 commit 금지
Pod Securityrestricted PSS, runAsNonRoot, readOnlyRootFilesystem=true (vLLM cache 디렉터리만 emptyDir 마운트)
테넌트 분리외부 고객 워크로드와 내부 워크로드는 다른 노드풀 — 같은 카드 공유 금지

마지막 항목이 가장 중요합니다. HAMi 가 아무리 좋아도 신뢰 경계가 다른 워크로드를 한 카드에 묶지 않는다 는 규칙은 깨면 안 됩니다.


7. 운영 중 만난 문제와 그 답

7.1 NCCL init hang — IB / RoCE 가 없는 환경

물리 머신 간 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"

7.2 OOM 이 일부 노드에서만 발생

gpu_memory_utilization=0.90 이 동일 카드 모델이라도 펌웨어/드라이버 조합에 따라 통과 못 하는 경우가 있습니다. 처음엔 카드별 환경 변수를 다르게 줬는데, 카드 단위로 작은 안전 마진(0.85) 을 일괄 적용하고 KV cache 압박 시 vLLM 의 preemption 에 맡기는 쪽이 더 안정적이었습니다.

7.3 Ray dashboard 가 안 보임

dashboard-host: 0.0.0.0 만으로는 부족하고, Istio VirtualService 의 port.name: http-dashboard 형식까지 맞춰야 했습니다 (Istio 가 L7 라우팅을 결정하는 키). port name 을 dashboard 로만 적었더니 L4 로 떨어져 WebSocket 이 끊기는 증상.

7.4 KubeRay autoscaler 가 너무 보수적

기본값으로는 idle_timeout=600s. 트래픽 급증 시 새 worker 가 뜨는 데 10 분이 걸려 SLO 를 깼습니다.

rayClusterConfig:
  enableInTreeAutoscaling: true
  autoscalerOptions:
    upscalingMode: Aggressive
    idleTimeoutSeconds: 120

다만 너무 공격적이면 GPU 가 비싼 자원이라 비용이 튑니다. Aggressive + idle 120s 가 우리 환경의 균형점이었습니다.


8. 효과 측정 — 숫자로 본 변화

diagram-12

지표Before (NVIDIA Device Plugin only)After (HAMi + KubeRay + vLLM)개선
GPU 메모리 평균 활용률38 %81 %+113 %
토큰 처리량 (tok/s)1,4204,180×2.94
Llama-3-70B p95 latency (256 tok)4.8 s3.4 s−29 %
동시 처리 가능한 모델 수411+175 %
동일 처리량 기준 필요 GPU 수124−67 %

같은 카드에 모델 4개가 11개로 늘었다는 게 가장 체감이 큰 부분입니다. 비용으로 환산하면 신규 카드 구매 8장이 잠시 보류됐고, 그 자원으로 다른 실험에 GPU 를 할당할 수 있게 됐습니다.


9. 다음에 다루고 싶은 것들

  1. Quantization (AWQ / GPTQ) — 70B 모델을 24 GiB 카드 한 장에 올리는 길.
  2. Multi-LoRA serving — LoRA adapter 만 hot-swap 으로 갈아끼우는 방식. vLLM 0.6 부터 지원.
  3. Speculative decoding 풀 도입 — 8B draft 모델을 별도 풀로 운영하고 70B 본 모델과 메시 통신.
  4. GPU power cappingnvidia-smi -pl 로 카드당 전력을 제한해 데이터센터 PUE 영향 측정.
  5. Cross-AZ Ray — 단일 노드 펑크 시 다른 AZ 의 worker 로 fallback. KubeRay v1.3 의 multi-cluster 모드 검토 중.

10. 정리

LLM 추론은 모델 자체 못지않게 자원 활용률 이 비용을 결정합니다. 우리에게 효과적이었던 세 줄 요약:

  1. HAMi 로 GPU 를 메모리·코어 두 축으로 자른다. 한 카드 안에 여러 워크로드를 공존시키되, kubeScheduler.enabled: true 는 잊지 말 것.
  2. KubeRay 로 분산 추론을 선언적으로 다룬다. RayService 의 무중단 업그레이드는 운영에서 가장 자주 쓰는 기능.
  3. 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
Kubernetesv1.30.4
Service MeshIstio (Sail Operator) v1.24.3 — mTLS STRICT
IngressIstio Gateway + MetalLB (L2) + cert-manager (DNS-01)
GitOpsArgoCD Multi-Source + Image Updater (Staging Auto / Prod Manual)
SecretVault HA (Raft 3-node) + ExternalSecrets
RegistryHarbor (S3 backend + Cosign 서명) · Kaniko in-cluster build
모니터링Prometheus + DCGM exporter + HAMi metrics + Grafana + Loki + Tempo
StorageLonghorn (3 replicas) — model cache · KV snapshot
profile
기록하고 공유하려고 노력하는 DevOps 엔지니어

0개의 댓글