본문 바로가기

AI 개발

K8s AI 워크로드 3편—KEDA 스케일링과 멀티테넌시, HPA가 LLM에 왜 안 되는지부터

반응형

 

CPU 40%에 스케일 아웃 트리거 맞춰놨는데 추론 큐가 쌓이고 있습니다. LLM은 CPU를 안 씁니다. GPU를 씁니다. 그리고 GPU는 뜨는 순간 VRAM을 다 먹습니다. 기본 HPA로 LLM 스케일링 하려는 건 처음부터 틀렸습니다.


📌 핵심 요약
→ HPA CPU/메모리 기반 스케일링은 LLM에 무의미 — GPU bound 워크로드
→ KEDA: Prometheus 쿼리로 추론 큐 깊이·TTFT·KV 캐시로 스케일링 트리거
→ vLLM 핵심 메트릭 3종: num_requests_waiting / gpu_cache_usage_perc / time_to_first_token
→ 배치 추론: Job + parallelism으로 병렬 처리, CronJob으로 야간 스케줄
→ Kueue: 팀 간 GPU 공정 배분 표준 (2026 CNCF 커뮤니티 표준으로 채택)
→ ResourceQuota + LimitRange: 팀별 GPU 상한 강제 + 기본값 자동 주입
→ Scale-to-zero: GPU 노드 유휴 시 완전 종료 → 비용 50~65% 절감
→ 배치 vs 실시간 분리 아키텍처: 같은 클러스터에서 두 워크플로우 격리 운영

실전1 — HPA가 LLM에 안 되는 이유

LLM 추론 엔진은 부팅 시점에 모델 가중치를 GPU VRAM에 전부 로드합니다. 그래서 전통적인 HPA 메모리 메트릭은 항상 높게 찍히고, 이게 잘못된 스케일 이벤트를 계속 트리거합니다.

[HPA CPU/메모리 트리거의 문제]

vLLM Pod 기동
  → GPU VRAM 전체 선점 (모델 가중치 로드)
  → 메모리 사용률 즉시 90%+

HPA가 보는 것:
  → "메모리 90% → 스케일 아웃!"
  → 새 Pod 뜸 → 또 VRAM 90% → 또 스케일 아웃...
  → 무한 스케일 아웃

실제로 필요한 것:
  → 추론 큐가 쌓이는가? (사용자가 기다리는가?)
  → TTFT가 SLA를 넘는가?
  → KV 캐시가 포화 상태인가?

→ 결론: GPU bound 워크로드에는 CPU/메모리 HPA 쓰지 말 것

실전2 — KEDA 설치 + vLLM 메트릭 기반 ScaledObject

# KEDA 설치
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda \
  --namespace keda \
  --create-namespace \
  --set prometheus.metricServer.enabled=true

# 설치 확인
kubectl get pods -n keda
# keda-operator-xxx              Running
# keda-operator-metrics-adapter  Running

vLLM이 KEDA 스케일링 트리거로 활용하는 핵심 메트릭 3가지는 추론 큐 깊이인 vllm:num_requests_waiting, KV 캐시 점유율인 vllm:gpu_cache_usage_perc, 그리고 사용자가 가장 먼저 체감하는 vllm:time_to_first_token_seconds입니다.

# keda-scaledobject.yaml — vLLM 큐 깊이 기반 스케일링
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-inference-scaler
  namespace: ml-serving
spec:
  scaleTargetRef:
    name: vllm-llama3-8b          # 2편에서 만든 Deployment

  pollingInterval: 15              # 15초마다 메트릭 확인
  cooldownPeriod: 300              # 스케일 인 후 5분 대기

  minReplicaCount: 1               # 최소 1개 유지 (0이면 cold start)
  maxReplicaCount: 8               # GPU 노드 수 이내로

  # 스케일 속도 제어 — 급격한 변화 방지
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 60    # 1분 관찰 후 스케일 아웃
          policies:
          - type: Pods
            value: 2                        # 한 번에 최대 2개 추가
            periodSeconds: 90
        scaleDown:
          stabilizationWindowSeconds: 300   # 5분 관찰 후 스케일 인
          policies:
          - type: Pods
            value: 1                        # 한 번에 1개씩 제거
            periodSeconds: 60

  triggers:
  # ── 트리거 1: 추론 큐 깊이 ────────────────────────────
  # 대기 요청이 5개 이상이면 스케일 아웃 시작
  - type: prometheus
    metadata:
      serverAddress: http://prometheus-server.monitoring.svc:9090
      metricName: vllm_requests_waiting
      query: |
        sum(vllm:num_requests_waiting{
          namespace="ml-serving",
          deployment="vllm-llama3-8b"
        })
      threshold: "5"               # Pod당 5개 이상 대기 시 스케일 아웃

  # ── 트리거 2: KV 캐시 포화도 ──────────────────────────
  # KV 캐시 80% 이상이면 스케일 아웃 (OOM 전에 미리)
  - type: prometheus
    metadata:
      serverAddress: http://prometheus-server.monitoring.svc:9090
      metricName: vllm_cache_usage
      query: |
        avg(vllm:gpu_cache_usage_perc{
          namespace="ml-serving"
        }) * 100
      threshold: "80"

  # ── 트리거 3: TTFT p95 SLA 위반 ───────────────────────
  # 500ms SLA 위반 시 즉시 스케일 아웃
  - type: prometheus
    metadata:
      serverAddress: http://prometheus-server.monitoring.svc:9090
      metricName: vllm_ttft_p95
      query: |
        histogram_quantile(0.95,
          rate(vllm:time_to_first_token_seconds_bucket{
            namespace="ml-serving"
          }[2m])
        )
      threshold: "0.5"             # 500ms 초과 시 트리거
# Scale-to-zero 설정 (비용 최적화 — 개발·스테이징 환경)
# minReplicaCount: 0 으로 변경 + ScaledObject에 추가
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-dev-scaler
  namespace: ml-dev                # 개발 환경만
spec:
  scaleTargetRef:
    name: vllm-llama3-8b-dev
  minReplicaCount: 0               # 요청 없으면 완전 종료
  maxReplicaCount: 2
  cooldownPeriod: 600              # 10분 유휴 후 0으로
  triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prometheus-server.monitoring.svc:9090
      metricName: vllm_requests_waiting
      query: |
        sum(vllm:num_requests_waiting{namespace="ml-dev"})
      threshold: "1"
→ Scale-to-zero 트레이드오프:
   비용: GPU 노드 idle 제거 → 50~65% 비용 절감
   단점: cold start (7B 모델 30~60초, PVC 캐시 있을 때)
   → 프로덕션: minReplicaCount=1 (항상 1개 warm)
   → 개발/스테이징: minReplicaCount=0 (비용 최우선)
→ 트리거 3개 중 하나라도 threshold 초과 시 스케일 아웃
→ pollingInterval 너무 짧으면 Prometheus 부하 → 15~30초 권장

실전3 — 배치 추론 vs 실시간 추론 분리 아키텍처

[두 워크로드의 근본적인 차이]

실시간 추론 (Online)              배치 추론 (Offline)
──────────────────────────        ──────────────────────────
항상 떠있는 Deployment            요청 시 뜨는 Job
레이턴시 SLA 엄격 (p95 500ms)    처리량 최우선 (SLA 없음)
요청 단위 처리                    수천 건 일괄 처리
KEDA 자동 스케일                  parallelism 고정 병렬
PriorityClass: 높음               PriorityClass: 낮음
T4 / A10G 노드                   A100 / H100 노드
# batch-inference-job.yaml — 대량 배치 추론
apiVersion: batch/v1
kind: Job
metadata:
  name: batch-embedding-job-20260522
  namespace: ml-batch
spec:
  # 10,000개 데이터를 10개 병렬 워커가 나눠서 처리
  completions: 10                  # 총 10개 완료해야 Job 완료
  parallelism: 10                  # 동시에 10개 Pod 실행
  completionMode: Indexed          # Pod마다 INDEX 환경변수 제공

  backoffLimit: 3                  # 실패 시 최대 3회 재시도
  activeDeadlineSeconds: 7200      # 2시간 초과 시 강제 종료

  template:
    spec:
      restartPolicy: OnFailure
      priorityClassName: training-normal    # 추론 서버보다 낮은 우선순위

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

      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: workload-class
                operator: In
                values: ["training"]        # 배치는 학습 노드 사용

      containers:
      - name: batch-worker
        image: my-registry/batch-inference:v1.2.0
        env:
        - name: JOB_INDEX
          valueFrom:
            fieldRef:
              fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
        - name: TOTAL_WORKERS
          value: "10"
        - name: INPUT_S3_PATH
          value: "s3://my-bucket/batch-input/"
        - name: OUTPUT_S3_PATH
          value: "s3://my-bucket/batch-output/"
        command:
        - python
        - batch_inference.py
        - --worker-index=$(JOB_INDEX)
        - --total-workers=$(TOTAL_WORKERS)
        - --input-path=$(INPUT_S3_PATH)
        - --output-path=$(OUTPUT_S3_PATH)
        resources:
          requests:
            nvidia.com/gpu: "1"
            cpu: "8"
            memory: "32Gi"
          limits:
            nvidia.com/gpu: "1"
            cpu: "8"
            memory: "32Gi"
# batch_inference.py — 워커 코드 (인덱스 기반 데이터 분할)
import os
import boto3
import json
from openai import OpenAI

# Kubernetes Job Index로 데이터 분할
worker_index = int(os.environ["JOB_INDEX"])
total_workers = int(os.environ["TOTAL_WORKERS"])

# S3에서 입력 데이터 로드
s3 = boto3.client("s3")
all_items = json.loads(
    s3.get_object(Bucket="my-bucket", Key="batch-input/items.json")["Body"].read()
)

# 이 워커가 처리할 슬라이스 계산
chunk_size = len(all_items) // total_workers
start_idx  = worker_index * chunk_size
end_idx    = start_idx + chunk_size if worker_index < total_workers - 1 else len(all_items)
my_items   = all_items[start_idx:end_idx]

print(f"[Worker {worker_index}] {len(my_items)}개 처리 시작 ({start_idx}~{end_idx})")

# 클러스터 내 vLLM 서버 호출 (실시간 서버와 같은 엔진, 다른 Namespace)
client = OpenAI(
    base_url="http://vllm-batch-svc.ml-batch.svc.cluster.local/v1",
    api_key="none"
)

results = []
for item in my_items:
    resp = client.chat.completions.create(
        model="llama3-8b",
        messages=[{"role": "user", "content": item["prompt"]}],
        max_tokens=500,
        temperature=0.0,           # 배치는 재현성 중요
    )
    results.append({
        "id": item["id"],
        "output": resp.choices[0].message.content
    })

# 결과 S3 업로드
s3.put_object(
    Bucket="my-bucket",
    Key=f"batch-output/worker-{worker_index}.json",
    Body=json.dumps(results, ensure_ascii=False)
)
print(f"[Worker {worker_index}] 완료")
# batch-cronjob.yaml — 야간 정기 배치
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-embedding-update
  namespace: ml-batch
spec:
  schedule: "0 2 * * *"            # 매일 새벽 2시 (KST 기준 UTC+9)
  timeZone: "Asia/Seoul"
  concurrencyPolicy: Forbid         # 이전 Job 실행 중이면 건너뜀
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1

  jobTemplate:
    spec:
      completions: 5
      parallelism: 5
      template:
        spec:
          restartPolicy: OnFailure
          tolerations:
          - key: "nvidia.com/gpu"
            operator: "Exists"
            effect: "NoSchedule"
          containers:
          - name: embedding-updater
            image: my-registry/embedding-update:latest
            resources:
              limits:
                nvidia.com/gpu: "1"
→ Indexed Job 핵심: JOB_COMPLETION_INDEX로 데이터 자동 분할
→ completionMode: Indexed가 없으면 워커가 어떤 슬라이스 처리할지 모름
→ activeDeadlineSeconds 필수: 장기 실행 Job이 GPU 점거 방지
→ 배치 전용 vLLM 인스턴스 분리 권장:
   실시간: --max-num-seqs=256, --enable-prefix-caching
   배치:   --max-num-seqs=512, --disable-log-requests (로그 줄여서 처리량 확보)

실전4 — Kueue: 팀 간 GPU 공정 배분

Kueue는 2026년 CNCF 배치 워크로드 관리 커뮤니티 표준으로 부상했습니다. ClusterQueue와 LocalQueue로 팀별 전용 할당량을 만들어 ML 연구팀이 GPU를 독점하는 걸 막고, 연구팀 실험이 일찍 끝나면 그 GPU를 추론팀이 빌려 쓸 수 있게 합니다.

# kueue-setup.yaml — 전체 클러스터 GPU 풀 + 팀별 할당

# ── 1. ClusterQueue: 클러스터 전체 GPU 풀 정의 ──────────
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: gpu-cluster-queue
spec:
  namespaceSelector: {}             # 모든 Namespace 허용
  preemption:
    reclaimWithinCohort: Any        # 같은 Cohort 내 초과 할당량 회수 가능
    withinClusterQueue: LowerPriority

  resourceGroups:
  - coveredResources: ["nvidia.com/gpu", "cpu", "memory"]
    flavors:
    - name: a100-gpu-flavor
      resources:
      - name: "nvidia.com/gpu"
        nominalQuota: 16            # 클러스터 전체 A100 16개
      - name: "cpu"
        nominalQuota: "128"
      - name: "memory"
        nominalQuota: "512Gi"

---
# ── 2. LocalQueue: 팀별 Namespace에 할당 ─────────────────
# 추론팀: GPU 6개 보장, 최대 10개까지 빌리기 가능
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: inference-queue
  namespace: ml-serving
spec:
  clusterQueue: gpu-cluster-queue

---
# 학습팀: GPU 8개 보장
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: training-queue
  namespace: ml-training
spec:
  clusterQueue: gpu-cluster-queue

---
# 실험팀: GPU 2개, 나머지는 idle GPU 있을 때만
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: experiment-queue
  namespace: ml-notebook
spec:
  clusterQueue: gpu-cluster-queue
# Kueue 사용 Job (학습팀)
apiVersion: batch/v1
kind: Job
metadata:
  name: llm-finetune-job
  namespace: ml-training
  labels:
    kueue.x-k8s.io/queue-name: training-queue   # ← 이 레이블이 핵심
spec:
  suspend: true                    # Kueue가 GPU 확인 후 직접 실행
  template:
    spec:
      containers:
      - name: trainer
        image: pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime
        resources:
          requests:
            nvidia.com/gpu: "4"
          limits:
            nvidia.com/gpu: "4"
      restartPolicy: OnFailure
[Kueue 동작 원리]

팀A(학습): nominalQuota=8GPU, 현재 8GPU 사용 중
팀B(실험): nominalQuota=2GPU, 현재 0GPU 사용 (idle)

팀A가 GPU 4개 추가 요청 시:
→ 자기 할당량 초과 but 팀B가 idle
→ Cohort 내 빌리기(Borrowing): 팀B의 idle 2GPU 임시 대여
→ 팀B 실험 Job이 들어오면 팀A에서 회수(Preemption)

→ 결과: 팀B idle GPU가 낭비 없이 팀A가 사용
→ 팀B 실험 시작하면 자동 회수 → 공정한 배분

실전5 — ResourceQuota + LimitRange: 팀별 GPU 상한 강제

# resource-quota.yaml — 팀별 GPU 하드 상한
apiVersion: v1
kind: ResourceQuota
metadata:
  name: ml-serving-quota
  namespace: ml-serving
spec:
  hard:
    requests.nvidia.com/gpu: "8"   # 이 Namespace 최대 GPU 8개
    limits.nvidia.com/gpu: "8"
    requests.cpu: "64"
    requests.memory: "256Gi"
    persistentvolumeclaims: "20"   # PVC 최대 20개
    pods: "50"

---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: ml-training-quota
  namespace: ml-training
spec:
  hard:
    requests.nvidia.com/gpu: "16"
    limits.nvidia.com/gpu: "16"
    requests.cpu: "128"
    requests.memory: "512Gi"

---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: ml-notebook-quota
  namespace: ml-notebook
spec:
  hard:
    requests.nvidia.com/gpu: "2"   # 실험팀: GPU 2개만
    limits.nvidia.com/gpu: "2"
    requests.cpu: "16"
    requests.memory: "64Gi"
# limit-range.yaml — GPU 기본값 자동 주입 + Pod당 상한
# requests 없이 Pod 배포 시 자동으로 기본값 적용
apiVersion: v1
kind: LimitRange
metadata:
  name: gpu-limit-range
  namespace: ml-serving
spec:
  limits:
  - type: Container
    default:                       # requests 없으면 이 값으로 자동 설정
      nvidia.com/gpu: "1"
      cpu: "4"
      memory: "16Gi"
    defaultRequest:
      nvidia.com/gpu: "1"
      cpu: "4"
      memory: "16Gi"
    max:                           # 컨테이너 1개당 최대
      nvidia.com/gpu: "4"          # 단일 컨테이너 GPU 4개 상한
      cpu: "32"
      memory: "128Gi"
    min:
      cpu: "100m"
      memory: "128Mi"
→ ResourceQuota: Namespace 전체 합계 제한 (팀 단위)
→ LimitRange: 개별 Container 제한 (Pod 단위)
→ 두 개 함께 쓰면:
   - 팀 전체 GPU 총량 제한 (ResourceQuota)
   - 단일 Pod가 GPU 독점 방지 (LimitRange max)
   - requests 없는 Pod 자동 기본값 (LimitRange default)
→ LimitRange가 없으면 requests 안 쓴 Pod가 ResourceQuota 우회 가능

실전6 — 배치 vs 실시간: 같은 클러스터에서 완전 격리

# 실시간 추론 전용 vLLM (ml-serving Namespace)
# 항상 켜져 있음, KEDA로 스케일
---
# 배치 전용 vLLM (ml-batch Namespace)
# Job이 뜰 때만 기동, 끝나면 종료
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-batch-server
  namespace: ml-batch
spec:
  replicas: 0                      # 기본은 0 — KEDA ScaledObject가 제어
  template:
    spec:
      tolerations:
      - key: "nvidia.com/gpu"
        operator: "Exists"
        effect: "NoSchedule"
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: workload-class
                operator: In
                values: ["training"]   # 배치는 학습 노드 사용

      containers:
      - name: vllm-batch
        image: vllm/vllm-openai:v0.4.2
        args:
        - "--model=/model-cache/Llama-3-8B-Instruct"
        - "--max-num-seqs=512"         # 배치: 동시 처리 최대화
        - "--disable-log-requests"     # 배치: 로그 최소화
        - "--gpu-memory-utilization=0.95"  # 배치: VRAM 최대 활용
        resources:
          limits:
            nvidia.com/gpu: "1"
# 배치 서버를 Job 실행 전후로 자동 기동/종료하는 KEDA
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-batch-scaler
  namespace: ml-batch
spec:
  scaleTargetRef:
    name: vllm-batch-server
  minReplicaCount: 0               # 기본 0 — 배치 Job 없으면 꺼짐
  maxReplicaCount: 4
  cooldownPeriod: 120
  triggers:
  # S3 SQS 큐에 배치 요청이 들어오면 서버 기동
  - type: aws-sqs-queue
    metadata:
      queueURL: https://sqs.ap-northeast-2.amazonaws.com/123456789/batch-queue
      queueLength: "1"
      awsRegion: ap-northeast-2
[배치 vs 실시간 격리 전략 요약]

           실시간(ml-serving)      배치(ml-batch)
─────────────────────────────────────────────────
노드        inference 전용          training 전용
Replicas   항상 1+ (KEDA 조절)      0 ~ N (KEDA, SQS 트리거)
GPU 설정   utilization=0.90        utilization=0.95
배치 크기  작음 (낮은 레이턴시)      큼 (높은 처리량)
로그       활성화                  비활성화
스케줄러   KEDA + Prometheus       KEDA + SQS 또는 CronJob
비용       GPU 항상 켜짐            Job 완료 후 자동 종료

✅ 3편 핵심 정리
✅ HPA CPU/메모리 트리거는 LLM에 무용지물 — KEDA + Prometheus 커스텀 메트릭 필수
✅ KEDA 트리거 3종: 큐 깊이 / KV 캐시 / TTFT p95 — 셋 중 하나만 넘어도 스케일 아웃
✅ Scale-to-zero: 개발 환경 minReplicaCount=0 → 50~65% GPU 비용 절감
✅ 배치 Job: Indexed 모드 + parallelism으로 데이터 자동 분할 병렬 처리
✅ Kueue: 팀 간 GPU 할당량 공정 배분 + idle GPU 빌리기·회수 자동화
✅ ResourceQuota + LimitRange 병행: 팀 총량 제한 + Pod 단위 상한 + 기본값 자동 주입

❌ Scale-to-zero 프로덕션 비추천 — 첫 요청 cold start 30초~5분
❌ Kueue suspend: true 없으면 Kueue가 Job 제어 못함
❌ KEDA pollingInterval 너무 짧으면 Prometheus 부하 — 15초 이상 권장
❌ 배치·실시간 같은 Namespace에서 운영 시 PriorityClass 충돌 주의

관련글

 

반응형