본문 바로가기

AI 개발

K8s AI 워크로드 2편—LLM 추론 서버 배포, vLLM·TGI·Triton 실전 Deployment 완전 가이드

반응형

 

GPU 노드 세팅이 끝났으면 이제 LLM을 올릴 차례입니다. 근데 일반 웹 컨테이너 올리듯 하면 90초 만에 OOMKilled 납니다. 모델이 뜨는 데만 20분인데 readinessProbe가 30초 만에 죽입니다. AI 추론 서버는 시작부터 다르게 접근해야 합니다.


📌 핵심 요약
→ 2026 기준 vLLM = 프로덕션 표준 (Meta·Mistral·Cohere·IBM 모두 사용)
→ TGI는 2025년 12월 유지보수 모드 전환 — 신규 배포 권장 안 함
→ Triton = LLM 엔진 아님, TensorRT-LLM 백엔드 필요한 플랫폼
→ 모델 가중치 PVC 캐싱 필수 — 없으면 재시작마다 140GB 다운로드
→ startupProbe → readinessProbe → livenessProbe 3단계 분리 — 하나로 퉁치면 죽음
→ /dev/shm emptyDir 필수 — Tensor Parallelism 시 없으면 OOM
→ ConfigMap으로 모델 설정 관리, Secret으로 HF 토큰 격리
→ PodDisruptionBudget: 롤링 업데이트 중 최소 1개 항상 살아있게 보장

실전1 — 프레임워크 선택: 뭘 올려야 하나

vLLM은 독립형 LLM 추론 엔진으로 내장 프로덕션 HTTP 서버를 갖춘 반면, TGI는 2025년 12월부터 유지보수 모드로 전환되어 버그 수정만 계속되며 신규 배포는 vLLM이나 SGLang을 권장합니다. Triton은 LLM 엔진이 아니라 오케스트레이션 플랫폼으로, LLM 서빙에는 TensorRT-LLM 백엔드가 별도로 필요합니다.

[2026 LLM 추론 프레임워크 선택 매트릭스]

프레임워크      처리량   설정 복잡도  OpenAI API  멀티모달  추천 상황
──────────────────────────────────────────────────────────────────────
vLLM            ★★★★★   낮음         ✅ 내장      ✅        대부분의 프로덕션
TensorRT-LLM    ★★★★★+  매우 높음    ❌(Triton)  △         NVIDIA H100 최고 성능
SGLang          ★★★★    낮음         ✅ 내장      △         구조화 출력·긴 컨텍스트
TGI             ★★★     낮음         ✅           △         ❌ 유지보수 모드, 비추천
Triton(단독)    ★★★     매우 높음    ❌           ✅         멀티모델 파이프라인

→ 신규 프로젝트: vLLM 먼저 시작
→ H100 최대 성능 필요: TensorRT-LLM + Triton (단, ML 인프라팀 있어야 함)
→ TGI 이미 운영 중: 유지하되 신규 모델은 vLLM으로
→ Stripe: vLLM 전환 후 추론 비용 73% 절감 (하루 5천만 API 호출)

실전2 — 모델 가중치 로딩 전략: PVC + Init Container 패턴

모델 캐시에 반드시 PVC를 사용해야 합니다. 없으면 Pod 재시작마다 모델을 다시 다운로드하게 되며, 재시작마다 140GB 다운로드는 프로덕션 장애 직전의 상황입니다.

# model-pvc.yaml — 모델 가중치 영구 저장
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: model-cache-pvc
  namespace: ml-serving
spec:
  accessModes:
    - ReadWriteOnce         # 단일 노드 마운트
  storageClassName: gp3     # EKS: gp3 SSD, GKE: pd-ssd
  resources:
    requests:
      storage: 200Gi        # 70B 모델 기준 (FP16 = 140GB+)
# model-downloader-job.yaml — 첫 배포 시 1회만 실행하는 다운로더
# Init Container 대신 별도 Job으로 다운로드 → 동시 다운로드 충돌 방지
apiVersion: batch/v1
kind: Job
metadata:
  name: model-downloader
  namespace: ml-serving
spec:
  template:
    spec:
      restartPolicy: OnFailure
      tolerations:
      - key: "nvidia.com/gpu"
        operator: "Exists"
        effect: "NoSchedule"
      containers:
      - name: downloader
        image: python:3.11-slim
        command:
        - /bin/sh
        - -c
        - |
          pip install huggingface_hub -q
          python -c "
          from huggingface_hub import snapshot_download
          snapshot_download(
            repo_id='meta-llama/Llama-3-8B-Instruct',
            local_dir='/model-cache/Llama-3-8B-Instruct',
            token='$(HF_TOKEN)',
            ignore_patterns=['*.pt', '*.bin']  # safetensors만 다운로드
          )
          print('✅ 모델 다운로드 완료')
          "
        env:
        - name: HF_TOKEN
          valueFrom:
            secretKeyRef:
              name: hf-credentials
              key: token
        volumeMounts:
        - name: model-cache
          mountPath: /model-cache
      volumes:
      - name: model-cache
        persistentVolumeClaim:
          claimName: model-cache-pvc
# S3 기반 모델 로딩 (온프레미스 또는 S3에 모델 보유 시)
# init-container로 S3 → PVC 복사
initContainers:
- name: model-loader
  image: amazon/aws-cli:latest
  command:
  - /bin/sh
  - -c
  - |
    if [ ! -f /model-cache/Llama-3-8B/config.json ]; then
      echo "모델 없음, S3에서 다운로드 시작..."
      aws s3 sync s3://my-model-bucket/Llama-3-8B/ /model-cache/Llama-3-8B/ \
        --no-progress \
        --exclude "*.pt"    # 구버전 포맷 제외
      echo "✅ 다운로드 완료"
    else
      echo "✅ 모델 캐시 존재, 스킵"
    fi
  env:
  - name: AWS_REGION
    value: "ap-northeast-2"
  volumeMounts:
  - name: model-cache
    mountPath: /model-cache
→ Job vs Init Container 다운로드 차이:
   Job:            1회 실행 후 완료 → 여러 Pod가 동시 다운로드 충돌 없음 (권장)
   Init Container: Pod마다 실행 → 첫 번째 Pod는 다운로드, 나머지는 캐시 확인
→ accessModes ReadWriteOnce: 단일 노드 마운트 → 여러 노드에 배포 시 ReadWriteMany 필요
   ReadWriteMany: EFS(AWS), Filestore(GCP), NFS — 속도는 느리지만 공유 가능
→ 모델 파일 형식: safetensors 우선 (.bin, .pt 제외) — 로딩 속도 + 보안

실전3 — vLLM 프로덕션 Deployment: 전체 YAML

# vllm-deployment.yaml — 프로덕션 완전 예시
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-llama3-8b
  namespace: ml-serving
  labels:
    app: vllm-inference
    model: llama3-8b
spec:
  replicas: 2
  selector:
    matchLabels:
      app: vllm-inference
  # 롤링 업데이트 전략
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1           # 한 번에 1개씩 새 Pod 추가
      maxUnavailable: 0     # 기존 Pod 먼저 죽이지 않음

  template:
    metadata:
      labels:
        app: vllm-inference
        model: llama3-8b
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8000"
        prometheus.io/path: "/metrics"

    spec:
      priorityClassName: inference-critical   # 1편에서 정의한 우선순위
      serviceAccountName: inference-server

      tolerations:
      - key: "nvidia.com/gpu"
        operator: "Exists"
        effect: "NoSchedule"
      - key: "dedicated"
        operator: "Equal"
        value: "gpu"
        effect: "NoSchedule"

      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: workload-class
                operator: In
                values: ["inference"]
        # 동일 노드에 같은 모델 2개 금지 → 가용성 보장
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchLabels:
                app: vllm-inference
            topologyKey: kubernetes.io/hostname

      # ── Init Container: 모델 캐시 확인 ─────────────────
      initContainers:
      - name: model-cache-check
        image: busybox:1.36
        command:
        - /bin/sh
        - -c
        - |
          echo "모델 캐시 확인..."
          if [ ! -f /model-cache/Llama-3-8B-Instruct/config.json ]; then
            echo "❌ 모델 없음 — model-downloader Job 먼저 실행하세요"
            exit 1
          fi
          echo "✅ 모델 캐시 확인 완료"
        volumeMounts:
        - name: model-cache
          mountPath: /model-cache
          readOnly: true

      containers:
      - name: vllm
        # 버전 고정 필수 — latest 쓰면 재시작마다 다른 버전
        image: vllm/vllm-openai:v0.4.2
        command: ["python", "-m", "vllm.entrypoints.openai.api_server"]
        args:
        - "--model=/model-cache/Llama-3-8B-Instruct"
        - "--host=0.0.0.0"
        - "--port=8000"
        - "--tensor-parallel-size=1"      # GPU 1개 → 1, 4개 병렬이면 4
        - "--gpu-memory-utilization=0.90" # VRAM 90% 사용 (OOM 여유 10%)
        - "--max-model-len=8192"          # KV 캐시 길이 제한
        - "--enable-prefix-caching"       # 프리픽스 캐싱 활성화 (처리량 향상)
        - "--enable-chunked-prefill"      # 청크 프리필 (레이턴시 개선)
        - "--disable-log-requests"        # 프로덕션: 요청 로그 끔 (성능)
        - "--served-model-name=llama3-8b" # OpenAI API model 파라미터 값

        env:
        - name: HUGGING_FACE_HUB_TOKEN
          valueFrom:
            secretKeyRef:
              name: hf-credentials
              key: token
        - name: CUDA_VISIBLE_DEVICES
          value: "0"
        - name: VLLM_WORKER_MULTIPROC_METHOD
          value: "spawn"

        ports:
        - name: http
          containerPort: 8000
          protocol: TCP

        resources:
          requests:
            cpu: "8"
            memory: "32Gi"
            nvidia.com/gpu: "1"
          limits:
            cpu: "8"
            memory: "32Gi"
            nvidia.com/gpu: "1"   # GPU: requests = limits 필수

        # ── Probe 3단계 분리 — 핵심 ─────────────────────
        # 70B 모델은 로딩만 20분 → initialDelaySeconds 크게 설정
        startupProbe:              # 시작 완료 확인 (이 기간은 liveness 실행 안 됨)
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 60
          periodSeconds: 10
          failureThreshold: 120   # 60 + (10×120) = 1260초 = 21분 허용
          successThreshold: 1

        readinessProbe:            # 트래픽 받을 준비 됐는지
          httpGet:
            path: /health
            port: 8000
          periodSeconds: 5
          failureThreshold: 3
          successThreshold: 1

        livenessProbe:             # 살아있는지 (죽으면 재시작)
          httpGet:
            path: /health
            port: 8000
          periodSeconds: 10
          failureThreshold: 6     # 60초 무응답 시 재시작

        volumeMounts:
        - name: model-cache
          mountPath: /model-cache
          readOnly: true
        - name: shm                # Tensor Parallelism용 공유메모리 — 필수
          mountPath: /dev/shm

      volumes:
      - name: model-cache
        persistentVolumeClaim:
          claimName: model-cache-pvc
      - name: shm
        emptyDir:
          medium: Memory
          sizeLimit: 16Gi          # GPU VRAM의 절반 정도 설정
→ startupProbe가 핵심: 이 기간 동안 livenessProbe 실행 안 됨
  → 모델 로딩 중 liveness가 죽이는 사고 방지
→ failureThreshold 계산:
  7B 모델:  initialDelaySeconds=60, failureThreshold=30  (약 5분)
  70B 모델: initialDelaySeconds=60, failureThreshold=120 (약 21분)
→ /dev/shm emptyDir: tensor-parallel-size > 1이면 반드시 필요
  없으면 GPU 워커 간 통신 실패 → OOMKilled
→ --gpu-memory-utilization=0.90: 0.95 이상이면 OOM 위험
  KV 캐시 + 모델 가중치 + 오버헤드 합산으로 계산

실전4 — ConfigMap·Secret: 모델 설정 관리

# model-config.yaml — ConfigMap으로 모델 설정 분리
apiVersion: v1
kind: ConfigMap
metadata:
  name: vllm-config
  namespace: ml-serving
data:
  # 모델별 설정을 ConfigMap으로 관리 → 코드 변경 없이 설정 변경
  MODEL_PATH: "/model-cache/Llama-3-8B-Instruct"
  TENSOR_PARALLEL_SIZE: "1"
  GPU_MEMORY_UTILIZATION: "0.90"
  MAX_MODEL_LEN: "8192"
  MAX_NUM_SEQS: "256"          # 동시 처리 최대 시퀀스 수
  SERVED_MODEL_NAME: "llama3-8b"

---
# hf-secret.yaml — HuggingFace 토큰 Secret 분리
apiVersion: v1
kind: Secret
metadata:
  name: hf-credentials
  namespace: ml-serving
type: Opaque
stringData:
  token: "hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"  # 실제 배포 시 Sealed Secret 또는 ESO 사용

---
# network-policy.yaml — 추론 서버 네트워크 격리
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: inference-network-policy
  namespace: ml-serving
spec:
  podSelector:
    matchLabels:
      app: vllm-inference
  policyTypes:
  - Ingress
  - Egress
  ingress:
  # API Gateway / 모니터링 스택만 인바운드 허용
  - from:
    - namespaceSelector:
        matchLabels:
          name: api-gateway
    - namespaceSelector:
        matchLabels:
          name: monitoring
    ports:
    - protocol: TCP
      port: 8000
  egress:
  # HuggingFace 토큰 검증·아웃바운드 제한
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 169.254.169.254/32   # AWS 메타데이터 서버 차단
    ports:
    - protocol: TCP
      port: 443

실전5 — Service + PodDisruptionBudget

# vllm-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: vllm-inference-svc
  namespace: ml-serving
  labels:
    app: vllm-inference
spec:
  selector:
    app: vllm-inference
  ports:
  - name: http
    port: 80
    targetPort: 8000
    protocol: TCP
  type: ClusterIP   # 외부 노출은 Ingress/Gateway에서 담당

---
# pdb.yaml — 롤링 업데이트 중 서비스 끊김 방지
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: vllm-pdb
  namespace: ml-serving
spec:
  minAvailable: 1       # 최소 1개 항상 Running 유지
  selector:
    matchLabels:
      app: vllm-inference
→ PDB 없이 롤링 업데이트 시:
   replicas=2 → 한 번에 2개 다 죽고 재시작 가능 → 서비스 다운
→ PDB minAvailable: 1 적용 시:
   1개 살아있는 상태에서만 다음 Pod 교체 → 다운타임 없음
→ GPU 노드가 1개뿐이면 maxUnavailable: 0 + maxSurge: 1 조합 불가
   → 먼저 새 노드 확보 후 교체하는 blue/green 방식 필요

실전6 — TGI 기존 운영 중이라면: 마이그레이션 패턴

# TGI Deployment (기존 운영 중인 팀 참고용 — 신규는 vLLM 권장)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tgi-llama3-8b
  namespace: ml-serving
spec:
  replicas: 1
  template:
    spec:
      tolerations:
      - key: "nvidia.com/gpu"
        operator: "Exists"
        effect: "NoSchedule"
      containers:
      - name: tgi
        image: ghcr.io/huggingface/text-generation-inference:2.4.0  # 버전 고정
        args:
        - "--model-id=/model-cache/Llama-3-8B-Instruct"
        - "--port=8080"
        - "--max-total-tokens=8192"
        - "--max-input-tokens=4096"
        - "--max-batch-prefill-tokens=16384"
        - "--num-shard=1"
        resources:
          limits:
            nvidia.com/gpu: "1"
        # TGI는 /health가 아닌 별도 엔드포인트
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 120
          periodSeconds: 10
        volumeMounts:
        - name: model-cache
          mountPath: /model-cache
        - name: shm
          mountPath: /dev/shm
      volumes:
      - name: model-cache
        persistentVolumeClaim:
          claimName: model-cache-pvc
      - name: shm
        emptyDir:
          medium: Memory
          sizeLimit: 8Gi
[TGI → vLLM 마이그레이션 전략]

1단계: vLLM을 별도 Deployment로 배포 (TGI와 공존)
2단계: Service 가중치 조정 (TGI 90% → vLLM 10% → 점진 증가)
3단계: 메트릭 비교 (TTFT, 처리량, 오류율)
4단계: vLLM 100% 전환 후 TGI Deployment 제거

→ OpenAI 호환 API 덕분에 클라이언트 코드 변경 없음
→ 포트만 다르게 설정 → Ingress weight 조절로 트래픽 분산
→ TGI v3 여전히 성능은 나쁘지 않음 — 급하게 마이그레이션 불필요

실전7 — 배포 후 검증: API 동작 확인

# 1. Pod 상태 확인
kubectl get pods -n ml-serving -w
# NAME                           READY   STATUS    RESTARTS
# vllm-llama3-8b-xxx-yyy        0/1     Init:0/1  0        ← init container 실행 중
# vllm-llama3-8b-xxx-yyy        0/1     Running   0        ← 모델 로딩 중
# vllm-llama3-8b-xxx-yyy        1/1     Running   0        ← 완료

# 2. 로그로 로딩 상태 확인
kubectl logs -n ml-serving -l app=vllm-inference -f
# INFO:     Loading model weights...
# INFO:     GPU blocks: 1024, CPU blocks: 512
# INFO:     Application startup complete.  ← 이게 나와야 정상

# 3. 포트 포워딩으로 직접 테스트
kubectl port-forward -n ml-serving svc/vllm-inference-svc 8080:80 &

# 4. OpenAI 호환 API 테스트
curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3-8b",
    "messages": [{"role": "user", "content": "K8s가 뭔가요?"}],
    "max_tokens": 200,
    "temperature": 0.7
  }'

# 5. 모델 목록 확인
curl http://localhost:8080/v1/models
# {"data":[{"id":"llama3-8b","object":"model",...}]}

# 6. GPU 사용률 확인
kubectl exec -n ml-serving -it $(kubectl get pod -n ml-serving -l app=vllm-inference -o name | head -1) \
  -- nvidia-smi
# GPU 메모리 사용량, 온도, 전력 소비 확인
→ 가장 흔한 배포 실패 3가지:
   1) OOMKilled 즉시 발생
      → --gpu-memory-utilization 낮춤 (0.90 → 0.85)
      → /dev/shm emptyDir 추가 확인
      → 모델이 GPU VRAM보다 큼 → 양자화 적용 (--quantization awq)

   2) startupProbe failureThreshold 초과 → CrashLoopBackOff
      → 모델 크기별 로딩 시간 계산 후 failureThreshold 늘림
      → 7B: 5분, 13B: 10분, 70B: 25분 기준

   3) Pod는 Running인데 API 500
      → kubectl logs 확인 → 모델 경로 틀림 or HF 토큰 만료
      → /v1/models 호출로 모델 인식 여부 먼저 확인

✅ 2편 핵심 정리
✅ 2026 프레임워크 선택: vLLM(기본) → TensorRT-LLM(H100 최적화) → TGI(유지보수 모드, 비추천)
✅ 모델 가중치 PVC 캐싱 필수 — 재시작마다 140GB 다운로드는 장애
✅ startupProbe 3단계 분리 — 로딩 중 liveness가 죽이는 사고 방지
✅ /dev/shm emptyDir: Tensor Parallelism 사용 시 반드시 추가
✅ PodDisruptionBudget: 롤링 업데이트 중 최소 1개 항상 유지
✅ NetworkPolicy: API Gateway·모니터링만 인바운드 허용

❌ image: latest 사용 금지 — 재시작마다 다른 버전 뜰 수 있음
❌ readinessProbe 단독으로 쓰면 모델 로딩 중 죽임 — startupProbe 필수
❌ --gpu-memory-utilization 0.95+ 금지 — OOM 여유 없음
❌ TGI 신규 배포 비추천 — 유지보수 모드, vLLM으로 시작할 것

관련글

 

반응형