반응형
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 충돌 주의
관련글
반응형
'AI 개발' 카테고리의 다른 글
| Antigravity SDK 심화편—Managed Agents API·GCP 엔터프라이즈 연동·CI/CD 파이프라인 실전 구축: Antigravity 2.0 (0) | 2026.05.23 |
|---|---|
| K8s AI 워크로드 4편—프로덕션 관찰가능성·카나리 배포·비용 최적화, 운영에서 살아남는 법 (0) | 2026.05.23 |
| K8s AI 워크로드 2편—LLM 추론 서버 배포, vLLM·TGI·Triton 실전 Deployment 완전 가이드 (0) | 2026.05.23 |
| K8s AI 워크로드 1편—GPU 노드 설정과 인프라 기초, 비싼 GPU 낭비 없이 쓰는 법 (0) | 2026.05.23 |
| AWS Kiro 3편—AgentCore 연동과 CDK 자동화, 코드 한 줄 없이 서버리스 풀스택 배포까지 (0) | 2026.05.22 |