Kubernetes에 vLLM 올렸더니 Pod가 계속 죽는다면 — GPU 할당·모델 로딩 Probe·/dev/shm·KV 캐시·대기열 오토스케일링, 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가지 중 몇 개를 만납니다.
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)를 피합니다.
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대를 유지하거나 업데이트 시간을 정해 두는 게 현실적입니다.
정리: 배포 전 체크리스트
resources.limits에nvidia.com/gpu, GPU 노드 taint에 맞는 tolerations- startupProbe로 모델 로딩 시간 흡수, liveness timeout은 넉넉하게
- TP 2 이상이면 /dev/shm에 메모리 emptyDir
--max-model-len은 실제 필요한 길이로, GPU 하나에 vLLM 하나- 모델은 PVC 캐시(또는 사내 스토리지), 토큰은 Secret
- 오토스케일링은 CPU가 아니라 대기 요청 수 기준, 스케일 인은 천천히
- 롤링 업데이트는 maxSurge 0으로 GPU 부족 방지
로컬 서빙과 쿠버네티스 서빙의 차이는 결국 하나입니다. 로컬에서는 "모델이 뜨느냐"만 보면 되지만, 쿠버네티스에서는 뜨는 데 걸리는 시간, 메모리를 나누는 방식, 부하를 세는 기준까지 플랫폼에 알려 줘야 합니다. 위 7가지만 매니페스트에 반영해도 "GPU 서버는 비싸고 자주 죽는다"는 인상이 꽤 달라질 겁니다.
※ 옵션 이름과 메트릭 이름은 vLLM 버전에 따라 바뀔 수 있으니, 사용하는 버전의 vllm serve --help와 /metrics 출력으로 확인하세요. GPU 메모리 수치는 이해를 돕기 위한 예시입니다.



댓글
댓글 쓰기