콘텐츠로 이동
Study Note온프렘 GPU 플랫폼

5. LLM 서빙 용량·SLO·타임아웃 운영

GPU 사용률은 원인이 아니라 상태다 — 먼저 요청이 어느 층에서 얼마나 기다렸는지를 찾는다

이 장에서 처음 나오는 말5개
SLOService Level Objective
이용자에게 제공할 성공률·응답 시간을 측정 가능한 목표로 정한 것이다.
TTFTTime To First Token
요청 뒤 첫 출력 token까지 걸린 시간이다. queue와 prefill 영향을 크게 받는다.
ITLInter-Token Latency
첫 token 이후 인접한 출력 token 사이의 시간으로 생성 중 끊김을 보여 준다.
admission queue진입 대기열
model server로 보낼 수 있는 동시 요청을 넘은 burst를 짧고 유한하게 기다리게 하는 경계다.
load shedding부하 차단
처리할 수 없는 요청을 오래 기다리게 하지 않고 경계에서 빠르게 거절하는 정책이다.
사내 앱에서 LiteLLM·Gateway·KServe predictor·vLLM을 거쳐 GPU에 닿는 요청 경로 옆에, KServe controller가 replica를 유지하고 GPU Operator·DCGM이 장치와 health를 맡는 것을 점선으로 붙인 그림

KServe controller는 request path 바깥에 있다. controller가 predictor replica를 유지하고, 실제 요청은 Gateway와 Service를 지나 준비된 Pod로 간다. timeout을 진단할 때는 각 경계에서 소비한 시간을 같은 request ID와 시간 축으로 이어야 한다.

LLM 요청 비용은 요청 개수보다 token 양에 크게 좌우된다. 긴 prompt는 prefill 시간을, 긴 답변은 decode 시간과 KV cache를 오래 사용한다.

요청이 도착해 vLLM queue에서 GPU 차례를 기다리고, prefill로 입력 token을 계산해 첫 token(TTFT)이 나온 뒤 decode로 한 token씩 생성해 응답이 끝나는 단계

TTFT가 길다고 모두 GPU 연산이 느린 것은 아니다. queue에서 오래 기다렸을 수도 있고, 긴 prompt의 prefill이 원인일 수도 있다. ITL은 첫 token 뒤 decode 경쟁을 더 잘 보여 준다.

운영자는 위에서 아래로 좁혀 간다

섹션 제목: “운영자는 위에서 아래로 좁혀 간다”

아래 예제는 Standard InferenceService와 namespace ai-serving, 서비스 corp-chat-spark를 가정한다. 이름은 실제 환경에 맞게 바꾼다. 별도 표시가 없는 명령은 조회만 하며, 장애 중에도 먼저 실행해 현재 상태를 보존할 수 있다.

1. context와 상위 상태를 고정한다

섹션 제목: “1. context와 상위 상태를 고정한다”

잘못된 cluster를 보는 실수를 막고 InferenceService의 Ready condition부터 확인한다.

터미널 창
SERVING_NS=ai-serving
ISVC_NAME=corp-chat-spark
kubectl config current-context
kubectl -n "$SERVING_NS" get inferenceservice "$ISVC_NAME" -o wide
kubectl -n "$SERVING_NS" get inferenceservice "$ISVC_NAME" \
-o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\t"}{.reason}{"\t"}{.message}{"\n"}{end}'

Ready=False면 먼저 condition의 reason과 message를 읽는다. 이 단계에서 GPU 사용률부터 보면 model download 실패나 scheduling 실패를 놓치기 쉽다.

KServe가 붙이는 label로 현재 predictor Pod를 찾는다. Pod 이름을 manifest에 고정하지 않는다.

터미널 창
kubectl -n "$SERVING_NS" get deployment,service,pod \
-l "serving.kserve.io/inferenceservice=$ISVC_NAME" -o wide
PREDICTOR_POD=$(kubectl -n "$SERVING_NS" get pod \
-l "serving.kserve.io/inferenceservice=$ISVC_NAME" \
-o jsonpath='{.items[0].metadata.name}')
kubectl -n "$SERVING_NS" get pod "$PREDICTOR_POD" \
-o jsonpath='{.metadata.name}{"\t"}{.spec.nodeName}{"\t"}{.status.phase}{"\n"}'
kubectl -n "$SERVING_NS" describe pod "$PREDICTOR_POD"
kubectl -n "$SERVING_NS" get events --sort-by=.lastTimestamp | tail -n 40

Pending이면 FailedScheduling, resource request, node selector·taint를 본다. Running이지만 Ready가 아니면 readiness와 model load log를 본다. 재시작 중이면 이전 container log도 놓치지 않는다.

터미널 창
kubectl -n "$SERVING_NS" get pod "$PREDICTOR_POD" \
-o jsonpath='{.spec.containers[*].name}{"\n"}{.spec.initContainers[*].name}{"\n"}'
kubectl -n "$SERVING_NS" logs "$PREDICTOR_POD" -c kserve-container \
--since=10m --tail=500
kubectl -n "$SERVING_NS" logs "$PREDICTOR_POD" -c kserve-container \
--previous --tail=200
kubectl -n "$SERVING_NS" logs "$PREDICTOR_POD" -c storage-initializer \
--tail=200

container 이름은 첫 번째 명령의 출력으로 확인한다. --previous나 storage-initializer가 없는 정상 Pod에서는 NotFound가 나올 수 있다.

3. 실제 runtime의 metric 이름을 확인한다

섹션 제목: “3. 실제 runtime의 metric 이름을 확인한다”

dashboard보다 운영 image의 /metrics가 기준이다. KServe runtime이 8080을 쓰는 예제이며, 위에서 출력한 container port가 8000이면 두 명령의 port도 바꾼다.

터미널 창
kubectl -n "$SERVING_NS" get pod "$PREDICTOR_POD" \
-o jsonpath='{range .spec.containers[*]}{.name}{"\t"}{.ports[*].containerPort}{"\n"}{end}'
kubectl -n "$SERVING_NS" port-forward "pod/$PREDICTOR_POD" 18080:8080

다른 terminal에서 필요한 시계열이 실제로 노출되는지 확인한다.

터미널 창
curl -fsS http://127.0.0.1:18080/metrics | \
rg '^vllm:(num_requests|kv_cache|time_to_first_token|inter_token|request_queue|request_prefill|request_decode)'

Prometheus service 이름은 설치마다 다르므로 먼저 찾은 뒤 port-forward한다.

터미널 창
kubectl -n monitoring get service | rg -i prometheus
kubectl -n monitoring port-forward service/prometheus-k8s 9090:9090
줄핵심 지표반드시 나눌 차원
이용자 영향성공률, 429·5xx·timeout, p50·p95·p99, 취소율service·model·stream 여부
생성 경험TTFT, ITL, stream duration, 입력·출력 token 수prompt·output 길이 구간
model enginerunning·waiting request, queue time, KV cache, preemption, prefix cache hitmodel·replica·revision
실행 기반desired·ready replica, restart, GPU memory·activity, XID·ECC·linkInferenceService·node·GPU UUID

request_id는 metric label로 넣지 않는다. 요청마다 새 시계열이 생기기 때문이다. metric에는 값의 수가 제한된 service, model, replica, status를 두고, request ID는 LiteLLM·Gateway·vLLM log와 trace를 연결하는 데 쓴다.

운영 image에서 아래 이름이 확인됐을 때 사용할 최소 PromQL은 다음과 같다. pod·model_name label은 실제 scrape 결과에 맞춘다.

sum by (pod, model_name) (vllm:num_requests_running)
sum by (pod, model_name) (vllm:num_requests_waiting)
max by (pod, model_name) (vllm:kv_cache_usage_perc)
sum by (model_name) (rate(vllm:generation_tokens_total[5m]))
histogram_quantile(
0.95,
sum by (le, model_name) (rate(vllm:time_to_first_token_seconds_bucket[5m]))
)
histogram_quantile(
0.95,
sum by (le, model_name) (rate(vllm:request_queue_time_seconds_bucket[5m]))
)

metric 하나의 순간값으로 판단하지 않고 LiteLLM·Gateway 오류율, waiting, TTFT, KV cache를 같은 5분 구간에 겹쳐 본다.

5. 신호 조합으로 다음 명령을 고른다

섹션 제목: “5. 신호 조합으로 다음 명령을 고른다”
질문대표 신호해석
지금 기다리는가waiting request·queue time계속 상승하면 유입이 처리량을 넘는다
첫 token이 왜 늦나TTFT·prefill timequeue와 긴 입력을 나눈다
생성이 왜 끊기나ITL·decode timedecode 경쟁과 긴 출력을 찾는다
cache가 버티는가KV cache usage·preemption둘이 함께 높으면 cache 압력이 크다
실제 일을 얼마나 했나prompt·generation token counterrequests/s보다 실제 계산량에 가깝다
replica가 실제로 떴나InferenceService Ready·Deployment available·Pod event선언과 실행 가능 자원을 구분한다
GPU가 건강한가DCGM memory·activity·XID·ECC·linkengine 병목과 hardware 이상을 나눈다

vLLM metric 이름은 release에 따라 바뀔 수 있다. 문서 예제를 그대로 dashboard에 박지 말고 운영 image의 /metrics 출력과 dashboard를 같은 revision으로 관리한다.

GPU나 node가 의심되면 predictor가 실제로 올라간 node와 그 node의 모든 workload를 확인한다.

터미널 창
PREDICTOR_NODE=$(kubectl -n "$SERVING_NS" get pod "$PREDICTOR_POD" \
-o jsonpath='{.spec.nodeName}')
kubectl get node "$PREDICTOR_NODE" \
-L nvidia.com/gpu.product,nvidia.com/mig.strategy,accelerator.pool
kubectl describe node "$PREDICTOR_NODE"
kubectl get pod -A --field-selector "spec.nodeName=$PREDICTOR_NODE" -o wide
kubectl -n gpu-operator get pod -o wide --field-selector "spec.nodeName=$PREDICTOR_NODE"

DCGM의 XID·ECC 시계열과 Pod restart가 같은 시각에 움직이면 새 요청을 먼저 다른 정상 endpoint로 보내고 node를 격리한다. kubectl delete pod나 cordon은 증거 수집 전의 첫 명령이 아니다.

요청 classSLO의 중심과부하 때 동작
짧은 대화형 요청성공률·TTFT·ITL짧게 기다린 뒤 429·503, 입력·출력 상한
긴 생성 요청성공률·전체 deadline별도 endpoint·낮은 동시성, 짧은 요청 예약량 보호

deadline의 포함 관계는 고정한다.

호출자 전체 deadline > LiteLLM · Gateway budget > model 실행 budget

connect timeout·첫 token timeout·stream idle timeout·전체 생성 deadline을 분리한다. client가 연결을 끊으면 vLLM request도 취소되어야 한다. 응답을 받을 곳이 없는데 GPU가 계속 token을 만들면 포화가 더 심해진다.

함께 변한 지표우선 의심할 것첫 대응
waiting·queue time·TTFT 상승유입량이 처리량 초과concurrency·queue 상한, 빠른 429·503
queue는 낮고 prefill·TTFT 상승긴 prompt·tokenization·CPU입력 token 상한·prefix 정규화
ITL·decode 상승, GPU activity 높음생성 동시성 과다output 상한·짧은 요청 예약량
KV cache 고수위·preemption 증가context·동시 sequence 과다동시성·context 제한, cache 설정 재시험
vLLM 정상, Gateway timeout 증가proxy deadline·buffering·connection경계별 timeout·stream 전달 확인
queue 증가, GPU activity 낮음CPU·network·process healthPod·route·host 상태 확인
XID·ECC·restart 동시 발생GPU·driver·runtime 장애route 제외 후 hardware triage

GPU utilization 95% 자체는 장애가 아니다. 이용자 SLO 악화, queue 누적, 오류율 가운데 둘 이상이 함께 움직일 때 포화로 판단한다.

앞단에는 짧고 유한한 대기열을 둔다

섹션 제목: “앞단에는 짧고 유한한 대기열을 둔다”

“앞단에 대기큐를 둔다”는 말은 요청 body를 Kafka나 Redis에 넣고 같은 HTTP stream을 계속 붙잡아 두라는 뜻이 아니다. 동기식 LLM 요청에서는 model별 실행 상한 C, 대기 상한 Q, 대기 시간 budget Wq를 정하고 넘친 요청을 429나 503으로 빠르게 돌려주는 admission control이 먼저다.

이용자 요청이 LiteLLM의 key·team quota를 지나 Gateway admission에서 실행 C 안쪽이면 짧게 유지되는 vLLM waiting을 거쳐 prefill·decode로 가고, 대기 Q까지 가득 차면 429·503으로 빠르게 실패하는 흐름
제어점맡길 일주의할 점
LiteLLM max_parallel_requests·RPM·TPMteam·key와 공개 model별 공정성, 과도한 호출 차단pinned version에서 429·대기 동작과 여러 replica의 counter 공유 방식을 시험한다
Gateway pending queuebackend 전체 동시 실행 C, 짧은 burst Q, overflow 503Gateway 구현별 확장 API이며 proxy replica마다 counter가 분산될 수 있다
vLLM --max-num-seqs와 waitingGPU batch에 실제로 넣을 sequence 수와 engine scheduling외부 queue의 크기 제한을 대신하지 않는다

Redis·Kafka 같은 영속 queue는 POST → job ID → poll/webhook으로 API를 비동기로 바꿀 때 사용한다. 대화형 streaming API가 queue 순번과 예상 시간을 제공해야 한다면 별도 admission service가 필요하다. 이 경우는 batch API 설계이며 이 장의 동기식 경로와 분리한다.

부하 시험에서 replica 하나가 SLO를 지키는 최대 동시성을 C_safe, Ready replica 수를 R이라고 하자. 처음에는 전체 실행 상한을 floor(C_safe × R × 0.8) 정도로 두어 장애·분포 오차의 여유를 남긴다.

대기열은 “메모리가 버티는 크기”가 아니라 기다려도 SLO를 지키는 크기여야 한다. 안정 처리량이 초당 6개이고 대기 budget이 2초라면 Q의 출발값은 6 × 2 = 12다. burst 시험에서 p95 queue time과 overflow 비율을 보고 줄이거나 늘린다. proxy가 여러 replica이면 counter가 process별인지 확인하고 전체 C·Q가 의도보다 배수로 커지지 않게 환산한다.

다음은 HTTPRoute/corp-chat과 같은 namespace에 두는 Envoy Gateway 전용 예다. 숫자 24와 12는 예시이며 반드시 위의 부하 시험 결과로 교체한다. 표준 Gateway API 필드가 아니므로 다른 Gateway 구현에는 그대로 적용되지 않는다.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: corp-chat-capacity
namespace: ai-serving
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: corp-chat
circuitBreaker:
maxParallelRequests: 24
maxPendingRequests: 12

cluster가 이 API와 필드를 지원하는지 먼저 확인한다. 운영 반영은 manifest를 Git에 넣고 Argo CD가 적용하게 하며, 아래 dry-run과 diff로 schema와 실제 변경 범위를 검증한다.

터미널 창
kubectl get gatewayclass
kubectl get crd backendtrafficpolicies.gateway.envoyproxy.io
kubectl explain backendtrafficpolicy.spec.circuitBreaker
kubectl -n ai-serving get httproute corp-chat -o yaml
kubectl apply --server-side --dry-run=server -f corp-chat-capacity.yaml
kubectl diff -f corp-chat-capacity.yaml
kubectl -n ai-serving get backendtrafficpolicy corp-chat-capacity -o yaml

maxParallelRequests에 도달한 요청은 pending으로 가고, maxPendingRequests까지 차면 overflow가 503으로 끝난다. LiteLLM·client는 첫 token 전의 이 실패만 지수 backoff와 jitter로 제한적으로 재시도해야 한다. Gateway circuit breaker는 queue의 개수를 제한할 뿐 개별 요청의 queue 체류 시간만 따로 보장하지 않는다. 첫 token timeout에는 Gateway queue + vLLM queue + prefill이 모두 들어가게 하고, streaming 전체 deadline과 분리해 시험한다.

LiteLLM과 vLLM에도 방어선을 맞춘다

섹션 제목: “LiteLLM과 vLLM에도 방어선을 맞춘다”

LiteLLM에는 model deployment별 동시성 상한을 둔다. 이 값은 team·key의 RPM·TPM과 별개다.

model_list:
- model_name: corp-chat
litellm_params:
model: openai/spark-chat
api_base: http://corp-chat.ai-serving.svc.cluster.local/openai/v1
api_key: none
max_parallel_requests: 36

vLLM의 --max-num-seqs는 한 scheduler iteration에서 처리할 최대 sequence 수다. --max-model-len과 --max-num-batched-tokens도 model·GPU 조합별 부하 시험 결과로 고정한다. 아래 값 역시 시작 예시다.

args:
- --max-num-seqs=12
- --max-model-len=8192
- --max-num-batched-tokens=4096

Gateway C, LiteLLM 상한, vLLM 값을 한 번에 바꾸지 않는다. 먼저 vLLM의 안전 동시성을 찾고, 그 합보다 약간 보수적으로 Gateway C를 둔 뒤, LiteLLM에서 tenant 공정성을 제한한다.

  1. 대기열을 유한하게 만든다 — service별 concurrency와 queue 상한을 두고 넘친 요청은 빠르게 돌려준다.
  2. 한 요청의 최대 비용을 제한한다 — 입력 token·max_tokens·n의 기본값과 상한을 둔다.
  3. 긴 요청을 격리한다 — 별도 endpoint와 낮은 동시성으로 짧은 대화형 요청의 예약량을 보호한다.
  4. 반복 계산을 줄인다 — 공통 prefix를 정규화하고 prefix cache hit와 TTFT 개선을 함께 검증한다.
  5. tensor parallel과 replica를 비교한다 — 같은 GPU 수에서 TP를 낮춘 여러 replica가 더 나은지 실제 분포로 시험한다.
  6. vLLM 값을 한 번에 하나씩 바꾼다 — batched token·sequence·chunked prefill·memory 값을 바꾸며 TTFT·ITL·tokens/s를 기록한다.
  7. 작은 model로 보낼 일을 분리한다 — 품질 contract를 통과한 단순 요청만 작은 model·quantized revision으로 보낸다.

autoscaling을 붙여도 비어 있는 GPU가 없으면 replica는 늘지 않는다. 상시 inference의 minimum replica와 전용 node pool을 먼저 확보한다.

부하 시험으로 안전선을 만든다

섹션 제목: “부하 시험으로 안전선을 만든다”

1. 기준선

낮은 동시성에서 TTFT·ITL·성공 output tokens/s를 기록한다.

2. 계단 부하

동시성을 단계적으로 올려 queue가 계속 줄지 않는 지점과 첫 SLO 위반을 찾는다.

3. 긴 요청 혼합

실제 비율의 긴 prompt·긴 output을 섞어 짧은 요청에 미치는 영향을 본다.

4. 지속 부하

KV cache·preemption·temperature·XID와 burst 뒤 queue 회복을 확인한다.

안전 동시성은 GPU가 죽지 않은 최대값이 아니다. TTFT·ITL SLO를 지키고 burst 뒤 queue가 다시 줄어드는 최대값이다. model·vLLM image·GPU가 바뀌면 다시 측정한다.

vLLM의 공식 benchmark CLI로 계단 부하를 만들 수 있다. 운영 Gateway를 무심코 때리지 않도록 별도 성능 시험 endpoint와 API key를 사용하고, 배포 model과 같은 tokenizer revision을 지정한다.

터미널 창
vllm bench serve \
--backend openai-chat \
--base-url https://perf-llm.internal/openai \
--endpoint /v1/chat/completions \
--model corp-chat \
--tokenizer org/model-revision \
--dataset-name random \
--random-input-len 1024 \
--random-output-len 256 \
--num-prompts 300 \
--request-rate 2

--request-rate를 2, 4, 6처럼 단계적으로 올리되 각 단계가 queue를 비우고 안정 상태로 돌아온 뒤 다음 단계로 간다. 실제 설치 버전의 vllm bench serve --help로 option을 확인하고 benchmark client version도 결과에 기록한다.

  1. 0~2분 · 영향 범위를 고정한다 — 위의 context·condition·Pod 조회 명령으로 service·model·revision과 시작 시각을 적는다.
  2. 2~4분 · 경계를 나눈다 — LiteLLM 429, Gateway 503·timeout, vLLM waiting·TTFT를 같은 구간에서 비교한다.
  3. 4~6분 · 포화면 새 일을 줄인다 — 긴 output·낮은 priority 요청과 자동 retry를 제한하고 queue overflow를 허용한다.
  4. 6~8분 · 실행 기반을 확인한다 — Ready replica·Pod event·node·DCGM XID/ECC를 확인하고 unhealthy endpoint를 route에서 뺀다.
  5. 8~10분 · 회복을 검증한다 — waiting이 지속해서 줄고 신규 요청의 TTFT·오류율이 SLO 안으로 돌아오는지 본다.

증설·상한 변경·route 제외는 Git의 원하는 상태로 남긴다. 긴급하게 kubectl로 바꿨다면 incident timeline에 명령과 시각을 기록하고, Argo CD가 되돌리기 전에 같은 변경을 Git에 반영하거나 임시 변경을 명시적으로 원복한다.