Kubernetes에 vLLM 올렸더니 Pod가 계속 죽는다면 — GPU 할당·모델 로딩 Probe·/dev/shm·KV 캐시·대기열 오토스케일링, LLM 서빙 매니페스트 7가지 함정

Kubernetes에 vLLM으로 LLM 서빙할 때 7가지 함정

로컬에서 vllm serve 한 줄이면 잘 돌던 모델을 Kubernetes에 올리는 순간 이야기가 달라집니다. Pod는 Running인데 GPU를 못 잡고, 모델 가중치를 반쯤 읽다가 재시작되고, 겨우 뜬 서버는 첫 요청에서 CUDA OOM을 냅니다. 오토스케일링을 걸었더니 CPU 사용률이 안 올라서 영원히 1대로 버팁니다.

이 블로그에서는 그동안 맥·DGX Spark 같은 로컬 LLM 서빙 하드웨어를 주로 비교했는데, 이번에는 반대편, 쿠버네티스 GPU 노드에서 vLLM을 운영할 때 실제로 터지는 지점을 정리합니다. 앞서 쓴 CrashLoopBackOff 점검 가이드의 LLM 서빙 실전편이라고 보면 됩니다.

0. 출발점: 최소 매니페스트

vLLM은 OpenAI 호환 API 서버를 내장하고 있어서, 공식 이미지(vllm/vllm-openai)에 모델 이름만 넘기면 8000번 포트로 /v1/chat/completions를 제공합니다. 아래가 이 글에서 계속 고쳐 나갈 출발점입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-qwen
spec:
  replicas: 1
  selector:
    matchLabels: { app: vllm-qwen }
  template:
    metadata:
      labels: { app: vllm-qwen }
    spec:
      containers:
      - name: vllm
        image: vllm/vllm-openai:latest   # 운영에서는 버전 태그 고정
        args: ["--model", "Qwen/Qwen3-8B", "--port", "8000"]
        ports:
        - containerPort: 8000

이대로 배포하면 거의 확실하게 아래 7가지 중 몇 개를 만납니다.

vLLM on Kubernetes 7가지 함정: 증상 원인 처방

1. GPU를 안 잡는다: resources에 GPU를 명시하지 않았다

GPU 노드에 스케줄됐다고 해서 컨테이너가 GPU를 쓸 수 있는 게 아닙니다. 쿠버네티스는 요청한 만큼만 장치를 붙여 줍니다. 증상은 로그의 No CUDA GPUs are available 같은 메시지입니다.

        resources:
          limits:
            nvidia.com/gpu: 1

이 리소스 이름은 NVIDIA device plugin(또는 GPU Operator)이 노드에 등록해 줍니다. kubectl describe node <gpu-node>의 Allocatable에 nvidia.com/gpu가 안 보인다면 매니페스트가 아니라 클러스터 쪽 문제입니다. GPU 노드에 taint를 걸어 두었다면 tolerations도 같이 넣어야 Pending에서 벗어납니다(이건 Pod Pending 글의 taint 항목 그대로입니다).

2. 로딩 중에 계속 재시작된다: liveness가 모델 로딩을 못 기다린다

8B 모델도 가중치 다운로드와 GPU 적재, CUDA 그래프 캡처까지 수십 초에서 수 분이 걸립니다. 70B급이면 10분을 넘기기도 합니다. 일반 웹앱처럼 liveness를 10~30초 뒤부터 걸면, 서버는 포트를 열기도 전에 죽습니다. 로그를 보면 항상 "Loading safetensors checkpoint shards: 62%"쯤에서 끊겨 있습니다.

vLLM 서버는 준비가 끝나면 /health에 200을 돌려줍니다. 로딩 시간은 startupProbe로 흡수하고, liveness는 그 뒤에만 동작하게 합니다.

        startupProbe:
          httpGet: { path: /health, port: 8000 }
          periodSeconds: 10
          failureThreshold: 60        # 최대 10분 로딩 허용
        readinessProbe:
          httpGet: { path: /health, port: 8000 }
          periodSeconds: 5
        livenessProbe:
          httpGet: { path: /health, port: 8000 }
          periodSeconds: 15
          timeoutSeconds: 5
          failureThreshold: 3

한 가지 더. 긴 생성 요청이 몰려 응답이 느려질 때 liveness의 timeout이 너무 짧으면, 과부하 상태의 정상 서버를 죽여서 상황을 더 나쁘게 만듭니다. timeout은 넉넉하게, failureThreshold는 3 이상을 권합니다.

3. 텐서 병렬(TP)에서 멈추거나 죽는다: /dev/shm이 64MB다

GPU 2장 이상에 모델을 나눠 올리는 --tensor-parallel-size 2부터는 GPU 사이 통신(NCCL)에 공유 메모리를 씁니다. 그런데 컨테이너의 /dev/shm은 기본 64MB라서, 초기화에서 멈추거나 bus error로 죽습니다. 메모리 기반 emptyDir를 /dev/shm에 마운트하면 해결됩니다.

        volumeMounts:
        - name: dshm
          mountPath: /dev/shm
      volumes:
      - name: dshm
        emptyDir:
          medium: Memory
          sizeLimit: 8Gi

이 공간은 컨테이너 메모리 사용량에 잡힙니다. 메모리 limit을 너무 빠듯하게 잡았다면 같이 늘려야 OOMKilled(Exit 137)를 피합니다.

vLLM GPU 메모리 구성: 가중치, KV 캐시, 활성화, 여유

4. 시작 직후 CUDA OOM: GPU 메모리를 어떻게 쓰는지 몰랐다

vLLM은 기동할 때 --gpu-memory-utilization(기본 0.9) 비율만큼 GPU 메모리를 미리 잡고, 가중치를 올린 뒤 남는 공간을 거의 전부 KV 캐시로 씁니다. KV 캐시가 곧 동시에 처리할 수 있는 토큰의 양입니다. 그래서 두 가지 실수가 흔합니다.

  • 모델의 최대 컨텍스트를 그대로 쓰는 경우: 모델이 128K 컨텍스트를 지원한다고 기본값을 그대로 두면, 요청 하나가 최대 길이일 때 필요한 KV 캐시를 확보하지 못해 기동이 실패합니다. 실제 서비스에 필요한 길이로 --max-model-len을 줄이세요(예: 8192, 16384).
  • GPU를 다른 프로세스와 나눠 쓰는 경우: 같은 GPU에 다른 컨테이너가 있으면 0.9를 잡는 순간 OOM입니다. GPU 하나에는 vLLM 하나를 원칙으로 하고, 꼭 나눠야 한다면 비율을 명시적으로 낮춥니다.
        args:
        - --model=Qwen/Qwen3-8B
        - --max-model-len=16384
        - --gpu-memory-utilization=0.90
        - --tensor-parallel-size=1

기동 로그에 KV 캐시 용량과 "최대 동시 처리 가능 요청 수"가 찍히니, 배포 후 한 번은 꼭 확인하세요. 이 숫자가 다음 항목의 오토스케일링 기준이 됩니다.

5. 재시작할 때마다 10분 다운로드: 모델 캐시가 없다

아무 설정 없이 띄우면 Pod가 뜰 때마다 Hugging Face에서 수십 GB를 다시 받습니다. 재시작이 곧 장애 시간입니다. 모델 파일은 PVC에 캐시하고, 비공개·게이트 모델이라면 토큰을 Secret으로 넘깁니다.

        env:
        - name: HF_HOME
          value: /models
        - name: HF_TOKEN
          valueFrom:
            secretKeyRef: { name: hf-token, key: token }
        volumeMounts:
        - name: model-cache
          mountPath: /models
      volumes:
      - name: model-cache
        persistentVolumeClaim:
          claimName: vllm-model-cache

여러 Pod가 같은 캐시를 읽으려면 ReadWriteMany 스토리지가 필요하고, 망분리 환경이라면 아예 사내 오브젝트 스토리지에서 받아 오는 initContainer를 두고 --model에 로컬 경로를 넘기는 방식이 안전합니다. 이건 금융권 망분리 글에서 다룬 "외부 다운로드가 막혀 있다"는 조건과 같은 이야기입니다.

6. CPU 기준 HPA는 아무 의미가 없다: 대기 요청 수로 스케일하라

LLM 서빙은 GPU가 바쁘고 CPU는 한가합니다. CPU 사용률 기반 HPA를 걸면 요청이 수백 개 밀려도 스케일 아웃이 안 일어납니다. 봐야 할 건 대기열입니다. vLLM은 /metrics로 Prometheus 메트릭을 내보내는데, 그중 대기 중인 요청 수(vllm:num_requests_waiting)가 가장 직관적인 신호입니다.

# Prometheus Adapter 또는 KEDA로 외부 메트릭을 HPA에 연결한 뒤
metrics:
- type: Pods
  pods:
    metric:
      name: vllm_num_requests_waiting
    target:
      type: AverageValue
      averageValue: "5"     # Pod당 대기 요청 5개를 넘으면 증설

주의할 점은 스케일 아웃이 느리다는 것입니다. 새 Pod는 GPU 노드 확보, 모델 로딩까지 수 분이 걸립니다. 순간 폭주를 오토스케일링으로 받기보다는 최소 레플리카를 넉넉히 두고, 스케일 인은 천천히(behavior.scaleDown.stabilizationWindowSeconds를 길게) 설정하는 편이 사용자 체감에 낫습니다.

7. 롤링 업데이트에서 GPU가 모자란다

Deployment의 기본 롤링 전략은 새 Pod를 먼저 띄우고(maxSurge 25%) 옛 Pod를 내립니다. GPU가 딱 맞게 있는 클러스터에서는 새 Pod가 GPU를 못 받아 Pending에 걸리고, 롤아웃이 멈춥니다.

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 0
      maxUnavailable: 1

이렇게 하면 하나씩 내리고 올립니다. 레플리카가 1대뿐이라면 이 방식은 곧 다운타임이니, 서비스 중인 모델이라면 최소 2대를 유지하거나 업데이트 시간을 정해 두는 게 현실적입니다.

정리: 배포 전 체크리스트

  1. resources.limits에 nvidia.com/gpu, GPU 노드 taint에 맞는 tolerations
  2. startupProbe로 모델 로딩 시간 흡수, liveness timeout은 넉넉하게
  3. TP 2 이상이면 /dev/shm에 메모리 emptyDir
  4. --max-model-len은 실제 필요한 길이로, GPU 하나에 vLLM 하나
  5. 모델은 PVC 캐시(또는 사내 스토리지), 토큰은 Secret
  6. 오토스케일링은 CPU가 아니라 대기 요청 수 기준, 스케일 인은 천천히
  7. 롤링 업데이트는 maxSurge 0으로 GPU 부족 방지

로컬 서빙과 쿠버네티스 서빙의 차이는 결국 하나입니다. 로컬에서는 "모델이 뜨느냐"만 보면 되지만, 쿠버네티스에서는 뜨는 데 걸리는 시간, 메모리를 나누는 방식, 부하를 세는 기준까지 플랫폼에 알려 줘야 합니다. 위 7가지만 매니페스트에 반영해도 "GPU 서버는 비싸고 자주 죽는다"는 인상이 꽤 달라질 겁니다.

※ 옵션 이름과 메트릭 이름은 vLLM 버전에 따라 바뀔 수 있으니, 사용하는 버전의 vllm serve --help와 /metrics 출력으로 확인하세요. GPU 메모리 수치는 이해를 돕기 위한 예시입니다.

댓글

이 블로그의 인기 게시물

금융권 망분리 환경에서 클로드 코드 도입할 때 실제로 걸리는 5가지 — 보안팀 질문과 대응 설정

맥 스튜디오 M5 Ultra 512GB, 3,000만원을 어떻게 결제할까 — 카드 한도 상향·18개월 무이자·잔존가치 금융리스 총정리

Kubernetes Pod Pending 해결법: FailedScheduling 원인별 실전 점검 가이드