1. 기준선
낮은 동시성에서 TTFT·ITL·성공 output tokens/s를 기록한다.
GPU 사용률은 원인이 아니라 상태다 — 먼저 요청이 어느 층에서 얼마나 기다렸는지를 찾는다
SLOService Level ObjectiveTTFTTime To First TokenITLInter-Token Latencyadmission queue진입 대기열load shedding부하 차단KServe controller는 request path 바깥에 있다. controller가 predictor replica를 유지하고, 실제 요청은 Gateway와 Service를 지나 준비된 Pod로 간다. timeout을 진단할 때는 각 경계에서 소비한 시간을 같은 request ID와 시간 축으로 이어야 한다.
LLM 요청 비용은 요청 개수보다 token 양에 크게 좌우된다. 긴 prompt는 prefill 시간을, 긴 답변은 decode 시간과 KV cache를 오래 사용한다.
TTFT가 길다고 모두 GPU 연산이 느린 것은 아니다. queue에서 오래 기다렸을 수도 있고, 긴 prompt의 prefill이 원인일 수도 있다. ITL은 첫 token 뒤 decode 경쟁을 더 잘 보여 준다.
아래 예제는 Standard InferenceService와 namespace ai-serving, 서비스 corp-chat-spark를 가정한다.
이름은 실제 환경에 맞게 바꾼다. 별도 표시가 없는 명령은 조회만 하며, 장애 중에도 먼저 실행해 현재 상태를
보존할 수 있다.
잘못된 cluster를 보는 실수를 막고 InferenceService의 Ready condition부터 확인한다.
SERVING_NS=ai-servingISVC_NAME=corp-chat-spark
kubectl config current-contextkubectl -n "$SERVING_NS" get inferenceservice "$ISVC_NAME" -o widekubectl -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 40Pending이면 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=500kubectl -n "$SERVING_NS" logs "$PREDICTOR_POD" -c kserve-container \ --previous --tail=200kubectl -n "$SERVING_NS" logs "$PREDICTOR_POD" -c storage-initializer \ --tail=200container 이름은 첫 번째 명령의 출력으로 확인한다. --previous나 storage-initializer가 없는 정상 Pod에서는
NotFound가 나올 수 있다.
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 prometheuskubectl -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 engine | running·waiting request, queue time, KV cache, preemption, prefix cache hit | model·replica·revision |
| 실행 기반 | desired·ready replica, restart, GPU memory·activity, XID·ECC·link | InferenceService·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분 구간에 겹쳐 본다.
| 질문 | 대표 신호 | 해석 |
|---|---|---|
| 지금 기다리는가 | waiting request·queue time | 계속 상승하면 유입이 처리량을 넘는다 |
| 첫 token이 왜 늦나 | TTFT·prefill time | queue와 긴 입력을 나눈다 |
| 생성이 왜 끊기나 | ITL·decode time | decode 경쟁과 긴 출력을 찾는다 |
| cache가 버티는가 | KV cache usage·preemption | 둘이 함께 높으면 cache 압력이 크다 |
| 실제 일을 얼마나 했나 | prompt·generation token counter | requests/s보다 실제 계산량에 가깝다 |
| replica가 실제로 떴나 | InferenceService Ready·Deployment available·Pod event | 선언과 실행 가능 자원을 구분한다 |
| GPU가 건강한가 | DCGM memory·activity·XID·ECC·link | engine 병목과 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.poolkubectl describe node "$PREDICTOR_NODE"kubectl get pod -A --field-selector "spec.nodeName=$PREDICTOR_NODE" -o widekubectl -n gpu-operator get pod -o wide --field-selector "spec.nodeName=$PREDICTOR_NODE"DCGM의 XID·ECC 시계열과 Pod restart가 같은 시각에 움직이면 새 요청을 먼저 다른 정상 endpoint로 보내고
node를 격리한다. kubectl delete pod나 cordon은 증거 수집 전의 첫 명령이 아니다.
| 요청 class | SLO의 중심 | 과부하 때 동작 |
|---|---|---|
| 짧은 대화형 요청 | 성공률·TTFT·ITL | 짧게 기다린 뒤 429·503, 입력·출력 상한 |
| 긴 생성 요청 | 성공률·전체 deadline | 별도 endpoint·낮은 동시성, 짧은 요청 예약량 보호 |
deadline의 포함 관계는 고정한다.
호출자 전체 deadline > LiteLLM · Gateway budget > model 실행 budgetconnect 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 health | Pod·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 max_parallel_requests·RPM·TPM | team·key와 공개 model별 공정성, 과도한 호출 차단 | pinned version에서 429·대기 동작과 여러 replica의 counter 공유 방식을 시험한다 |
| Gateway pending queue | backend 전체 동시 실행 C, 짧은 burst Q, overflow 503 | Gateway 구현별 확장 API이며 proxy replica마다 counter가 분산될 수 있다 |
vLLM --max-num-seqs와 waiting | GPU batch에 실제로 넣을 sequence 수와 engine scheduling | 외부 queue의 크기 제한을 대신하지 않는다 |
Redis·Kafka 같은 영속 queue는 POST → job ID → poll/webhook으로 API를 비동기로 바꿀 때 사용한다. 대화형
streaming API가 queue 순번과 예상 시간을 제공해야 한다면 별도 admission service가 필요하다. 이 경우는
batch API 설계이며 이 장의 동기식 경로와 분리한다.
C, Q, Wq를 정한다부하 시험에서 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/v1alpha1kind: BackendTrafficPolicymetadata: name: corp-chat-capacity namespace: ai-servingspec: targetRefs: - group: gateway.networking.k8s.io kind: HTTPRoute name: corp-chat circuitBreaker: maxParallelRequests: 24 maxPendingRequests: 12cluster가 이 API와 필드를 지원하는지 먼저 확인한다. 운영 반영은 manifest를 Git에 넣고 Argo CD가 적용하게
하며, 아래 dry-run과 diff로 schema와 실제 변경 범위를 검증한다.
kubectl get gatewayclasskubectl get crd backendtrafficpolicies.gateway.envoyproxy.iokubectl explain backendtrafficpolicy.spec.circuitBreakerkubectl -n ai-serving get httproute corp-chat -o yaml
kubectl apply --server-side --dry-run=server -f corp-chat-capacity.yamlkubectl diff -f corp-chat-capacity.yamlkubectl -n ai-serving get backendtrafficpolicy corp-chat-capacity -o yamlmaxParallelRequests에 도달한 요청은 pending으로 가고, maxPendingRequests까지 차면 overflow가 503으로
끝난다. LiteLLM·client는 첫 token 전의 이 실패만 지수 backoff와 jitter로 제한적으로 재시도해야 한다.
Gateway circuit breaker는 queue의 개수를 제한할 뿐 개별 요청의 queue 체류 시간만 따로 보장하지 않는다.
첫 token timeout에는 Gateway queue + vLLM queue + prefill이 모두 들어가게 하고, streaming 전체 deadline과
분리해 시험한다.
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: 36vLLM의 --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=4096Gateway C, LiteLLM 상한, vLLM 값을 한 번에 바꾸지 않는다. 먼저 vLLM의 안전 동시성을 찾고, 그 합보다
약간 보수적으로 Gateway C를 둔 뒤, LiteLLM에서 tenant 공정성을 제한한다.
max_tokens·n의 기본값과 상한을 둔다.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도 결과에
기록한다.
증설·상한 변경·route 제외는 Git의 원하는 상태로 남긴다. 긴급하게 kubectl로 바꿨다면 incident timeline에
명령과 시각을 기록하고, Argo CD가 되돌리기 전에 같은 변경을 Git에 반영하거나 임시 변경을 명시적으로 원복한다.
max-num-seqs·batch·scheduler argument의 현재 의미.max_parallel_requests와 routing 설정.BackendTrafficPolicy 예제.