목표와 현재 결과

kube-prometheus-stack에 Prometheus, Grafana, Alertmanager, kube-state-metrics, node-exporter를 구성하고 NVIDIA DCGM Exporter와 vLLM ServiceMonitor를 연결했다. Prometheus active target 15개가 모두 up으로 확인됐고, GPU 쪽에서는 modelName="NVIDIA L4"와 실제 UUID, 전력 40.796W까지 읽혔다.

동시 요청 32건으로 2분 32초간 부하를 걸었다. 대기 요청은 --max-num-seqs의 상한인 16으로 포화했지만 같은 구간의 GPU utilization은 평균 약 31.8%, KV cache 사용률은 약 2.2%에 머물렀다. 큐가 가득 찬 상태에서 GPU에 약 67%, KV cache에 약 97%의 여유가 남아 있었다는 뜻이다. 자원 두 축이 모두 비어 있으므로 이 구성의 처리량 제약은 GPU 연산도 memory도 아닌 배치 상한이며, 다음 조치는 --max-num-seqs 상향이다.

1. Prometheus stack 설치

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm upgrade -i kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace
key해석
releasekube-prometheus-stack신규 설치, 기존 release 없음
namespacemonitoring--create-namespace로 함께 생성
statusdeployedchart 적용 성공
revision1첫 release revision

로컬 RTX 4090 환경에서는 node-exporter가 host root의 mount propagation이 private이라 기동에 실패해 Helm value를 재정의해야 했다. GCP VM에서는 그 조건에 걸리지 않아 별도 조치 없이 기동됐다.

2. DCGM Exporter 설치

helm repo add gpu-helm-charts https://nvidia.github.io/dcgm-exporter/helm-charts
helm repo update
helm upgrade -i dcgm-exporter gpu-helm-charts/dcgm-exporter \
  --namespace monitoring \
  --set runtimeClassName=nvidia \
  --set serviceMonitor.enabled=true \
  --set serviceMonitor.additionalLabels.release=kube-prometheus-stack
설정목적
runtimeClassNamenvidia컨테이너에 드라이버와 /dev/nvidia* 주입
serviceMonitor.enabledtruePrometheus scrape 대상으로 자동 등록
serviceMonitor.additionalLabels.releasekube-prometheus-stackOperator의 serviceMonitorSelector와 일치

GPU 자원은 요청하지 않았다. DCGM Exporter는 GPU로 연산하지 않고 상태만 읽으므로 nvidia.com/gpu.shared를 요청하면 공유 slot 4개 중 하나를 관측 도구가 영구 점유하게 되고, Device Plugin이 배정한 GPU 하나만 보이게 되어 노드의 나머지 GPU를 관측하지 못한다.

kubectl -n monitoring rollout status daemonset/dcgm-exporter --timeout=5m
kubectl -n monitoring get pod -l app.kubernetes.io/name=dcgm-exporter -o wide
key해석
rolloutsuccessfully rolled outDaemonSet 배포 완료
Poddcgm-exporter-v6kx41/1 Running, restart 0
IP10.42.0.31Prometheus가 scrape할 Pod 주소
NODEgpu-k8sGPU가 장착된 단일 노드

3. GPU metric 수집 확인

Running 상태만으로는 GPU 접근 성공을 판단할 수 없다. RuntimeClass 지정이 누락되면 Pod는 정상 기동하면서 metric만 비어 있는 상태가 되므로 실제 값을 확인했다.

kubectl -n monitoring port-forward daemonset/dcgm-exporter 9400:9400
curl -s http://127.0.0.1:9400/metrics | grep -E "DCGM_FI_DEV_(GPU_UTIL|FB_USED|FB_FREE|POWER_USAGE)"
metric해석
modelNameNVIDIA L4실제 장치 모델을 읽음
UUIDGPU-ea090ae7-d01b-d9e2-560d-6f1fb084150c물리 GPU 식별자까지 접근
DCGM_FI_DEV_FB_USED9194 MiBvLLM이 선점한 GPU memory
DCGM_FI_DEV_FB_FREE13371 MiB남은 GPU memory
DCGM_FI_DEV_GPU_UTIL0 %조회 시점 idle
DCGM_FI_DEV_POWER_USAGE40.796 Widle 상태 전력
항목
관측된 총 memory22565 MiB
사용 비율40.7%
nvidia-smi EngineCore 대조값9184 MiB
차이10 MiB

모델명과 UUID, 전력까지 읽혔다는 것은 컨테이너가 NVML을 통해 실제 드라이버에 닿았다는 뜻이다. RuntimeClass가 동작하지 않았다면 DCGM 초기화 단계에서 실패했을 것이다. GPU 자원을 요청하지 않고도 드라이버 접근이 성립한다는 점이 이 절의 확인 사항이다.

FB_USEDvLLM 배포와 GPU memory 조정에서 nvidia-smi로 측정한 EngineCore memory와 10MiB 차이로 일치해, 두 경로가 같은 장치를 보고 있음을 교차 확인했다.

4. vLLM metric 이름 확인

metric 이름은 vLLM version마다 다르다. 문서나 기억에 있는 이름을 그대로 쓰지 않고 실제 노출 목록을 먼저 조회했다.

curl -s http://127.0.0.1:8000/metrics | grep -oE "^vllm:[a-z_]+" | sort -u

이 구성에서 실제로 사용할 metric은 다음과 같다.

metrictype확인 목적
vllm:num_requests_runninggauge현재 배치에 들어간 요청 수
vllm:num_requests_waitinggauge큐에서 대기 중인 요청 수
vllm:num_requests_waiting_by_reasongauge대기 사유를 capacitydeferred로 구분
vllm:kv_cache_usage_percgaugeKV cache 점유율
vllm:generation_tokens_totalcounter생성 토큰 누적. rate()로 처리량 계산
vllm:time_to_first_token_seconds_buckethistogramTTFT 분위수 계산용

목록에 vllm:gpu_cache_usage_perc는 존재하지 않는다. KV cache 지표의 실제 이름은 vllm:kv_cache_usage_perc이며, 이 차이가 6절과 7절의 관측 누락으로 이어졌다.

5. Prometheus active target 검증

curl -s 'http://127.0.0.1:9090/api/v1/targets?state=active' \
  | jq -r '.data.activeTargets[] | "\(.labels.job)\t\(.health)\t\(.scrapeUrl)"'
주요 jobtarget 수health의미
dcgm-exporter1upGPU metrics 수집 경로 연결
vllm-qwen1upvLLM metrics 수집 경로 연결
kubelet3모두 upmetrics, cadvisor, probes endpoint 정상
node-exporter1upnode metrics 수집 정상
전체 active target15모두 upscrape 실패 대상 없음

ServiceMonitor 생성 여부만이 아니라 Prometheus target health까지 확인해 GPU 하드웨어 → DCGM → Exporter → PrometheusvLLM → /metrics → Prometheus 두 경로가 실제로 이어진 것을 검증했다.

6. 부하 시험

6-1. 설계 조건

지표가 움직이는지 확인하려면 세 조건이 필요했다.

조건이유
동시성이 --max-num-seqs를 초과현재 값이 16이라 그 이하면 전부 즉시 배치되어 큐가 생기지 않음
부하가 scrape 주기보다 충분히 김scrape 간격이 15초라 짧은 버스트는 기록에 남지 않음
관측 터미널이 부하 루프와 분리부하 루프가 터미널을 점유해 같은 창에서 지표를 볼 수 없음

6-2. 지속 부하 실행

동시성 32로 2분간 유지하는 방식을 사용했다. 각 워커는 요청이 끝나면 곧바로 다음 요청을 보낸다.

DUR=120; CONC=32
END=$((SECONDS + DUR))
echo "start $(date +%T)"
for i in $(seq 1 $CONC); do
  ( while [ $SECONDS -lt $END ]; do
      curl -s http://127.0.0.1:8000/v1/chat/completions \
        -H "Content-Type: application/json" \
        -d '{"model":"qwen2.5-3b","messages":[{"role":"user","content":"쿠버네티스 스케줄러가 Pod를 배치하는 과정을 자세히 설명해줘."}],"max_tokens":512}' \
        -o /dev/null
    done ) &
done
wait
echo "end $(date +%T)"

6-3. idle 기준선

부하 직전 idle 기준선을 먼저 남겼다.

date +%T
curl -s http://127.0.0.1:8000/metrics \
  | grep -E "^vllm:(num_requests_running|num_requests_waiting|gpu_cache_usage_perc)"
nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader
항목해석
num_requests_running0부하 전 처리 중 요청 없음
num_requests_waiting0부하 전 대기 요청 없음
GPU utilization0 %idle
GPU memory9192 MiB3절의 DCGM 9194와 일치
KV cache출력 없음grep에 사용한 이름이 존재하지 않아 누락

이 시점에 KV cache 줄이 나오지 않은 것을 확인했어야 했지만 넘어갔다.

6-3-1. 존재하지 않는 metric 이름으로 조회한 누락

위 입력에 쓴 vllm:gpu_cache_usage_perc는 존재하지 않는 이름이다. 실제 이름은 vllm:kv_cache_usage_perc다.

구분
사용한 이름vllm:gpu_cache_usage_perc
실제 이름vllm:kv_cache_usage_perc
증상오류 없이 해당 줄만 출력되지 않음
영향부하 구간 KV cache 관측 누락, 병목 원인 특정 실패

이 유형의 실패는 오류를 내지 않는다. grep도 PromQL도 존재하지 않는 이름에 대해 빈 결과를 돌려주므로, 값이 0인 것인지 조회 자체가 잘못된 것인지 구분되지 않는다. 4절에서 실제 노출 목록을 이미 조회해 두고도 부하 시험 명령에 반영하지 않은 것이 원인이다.

수집 명령이 틀렸을 뿐 Prometheus는 scrape를 정상적으로 마쳤으므로 값 자체는 저장되어 있다. 올바른 이름으로 같은 구간을 다시 조회했다.

curl -s --get http://127.0.0.1:9090/api/v1/query_range \
  --data-urlencode 'query=vllm:kv_cache_usage_perc' \
  --data-urlencode 'start=2026-08-03T14:40:00Z' \
  --data-urlencode 'end=2026-08-03T14:46:00Z' \
  --data-urlencode 'step=15' \
  | jq -r '.data.result[].values[] | "\(.[0]|todate)  \(.[1])"'
항목해석
단위1이 100%이름은 perc지만 값은 0~1 비율이다
최대0.02872.9%
최소 (0 제외)0.00820.8%
평균 (0 제외)0.02172.2%
상승 시각14:42:00대기 요청 상승과 2초 차이
0 복귀 시각14:44:30대기 해소 뒤 잔여 요청까지 처리 완료
남은 여유97%KV cache는 상한 근처에 가지 않았다

이름은 perc지만 백분율이 아니다

vllm:kv_cache_usage_perc1이 100%인 비율로 노출된다. 0.0287을 백분율로 읽으면 0.03%가 되어 실제보다 100배 작게 해석된다. 대시보드나 alert 임계값을 만들 때 * 100을 붙일지 먼저 확인해야 한다.

큐가 16건으로 가득 찬 구간에서도 KV cache는 최대 2.9%만 사용됐다. --max-num-seqs=16 상한 때문에 동시에 처리하는 요청이 16건으로 묶여 있었고, 각 요청의 context가 --max-model-len=4096보다 훨씬 짧아 실제 점유가 미미했다는 뜻이다. 이 값이 7-3의 병목 판별을 확정한다.

7. 부하 구간 지표 판독

부하가 끝난 뒤 query_range로 구간 전체를 조회했다. 순간값 조회로는 이미 지나간 구간을 볼 수 없다.

curl -s --get http://127.0.0.1:9090/api/v1/query_range \
  --data-urlencode 'query=vllm:num_requests_waiting' \
  --data-urlencode "start=$(date -d '10 minutes ago' +%s)" \
  --data-urlencode "end=$(date +%s)" \
  --data-urlencode 'step=15' \
  | jq -r '.data.result[].values[] | "\(.[0]|todate)  \(.[1])"'
curl -s --get http://127.0.0.1:9090/api/v1/query_range \
  --data-urlencode 'query=DCGM_FI_DEV_GPU_UTIL' \
  --data-urlencode "start=$(date -d '10 minutes ago' +%s)" \
  --data-urlencode "end=$(date +%s)" \
  --data-urlencode 'step=15' \
  | jq -r '.data.result[].values[] | "\(.[0]|todate)  \(.[1])"'

7-1. 시각 대조

사건시각부하 시작 기준출처
부하 시작14:41:50기준클라이언트
대기 요청 상승14:41:58+8초vLLM
KV cache 상승14:42:00+10초vLLM
GPU utilization 상승14:42:33+43초DCGM
대기 요청 해소14:44:13+143초vLLM
부하 종료14:44:22+152초클라이언트
KV cache 0 복귀14:44:30+160초vLLM
GPU utilization 종료14:45:03+193초DCGM

부하 종료보다 대기 해소가 9초 빠른 것은 워커가 종료 조건에 도달하면 새 요청을 보내지 않기 때문이다. 남은 in-flight 요청이 처리되는 동안 큐가 먼저 비워진다. 그 뒤 KV cache가 14:44:30에 0으로 돌아가면서 실제 처리가 끝난 시점이 드러난다.

대기 해소보다 GPU utilization 종료가 50초 늦은 것은 두 가지가 겹친 결과다. 큐가 비어도 이미 배치에 들어간 16건은 계속 토큰을 생성하므로 GPU가 그만큼 더 일하고, 여기에 DCGM 쪽 지연이 더해진다. 큐가 비었다는 것이 GPU가 놀고 있다는 뜻은 아니다.

앞서 확정하지 못했던 상승 시점의 35초 차이는 KV cache 값을 확보하면서 설명이 된다. 같은 vLLM target에서 나오는 대기 요청과 KV cache는 2초 차이로 함께 움직이고, DCGM에서 나오는 GPU utilization만 양쪽 가장자리에서 일정하게 뒤처진다.

구간vLLM 지표DCGM 지표차이
상승14:42:0014:42:3333초
하강14:44:3014:45:0333초

양쪽 모두 33초로 같다. 부하가 실제로 늦게 도달한 것이라면 상승과 하강의 지연이 대칭으로 같을 이유가 없다. 일정한 차이는 exporter 사이의 scrape·샘플링 정렬 문제이며, 지표 자체의 지연이 아니다. 서로 다른 exporter의 값을 초 단위로 대조할 때는 이 오프셋을 감안해야 한다.

7-2. 구간 통계

지표
부하 지속 시간152초
동시 요청 수32
대기 요청 최대16
대기 요청 최소 (0 제외)12
대기 요청 평균 (0 제외)15.4
GPU utilization 최대33 %
GPU utilization 최소 (0 제외)30 %
GPU utilization 평균 (0 제외)31.8 %
관측 구간 GPU 여유67 %
KV cache 최대2.9 %
KV cache 평균 (0 제외)2.2 %
관측 구간 KV cache 여유97 %

대기 요청 최대치가 정확히 16으로 고정된 것은 --max-num-seqs=16 상한이 그대로 관측된 결과다. 동시 32건 중 16건이 배치에 들어가고 나머지 16건이 대기열에 남는 구조다.

7-3. 병목 판별

큐가 포화한 구간의 세 값이 함께 성립한다.

지표의미
num_requests_waiting16요청이 대기하고 있다
DCGM_FI_DEV_GPU_UTIL31.8 %GPU에 약 67% 여유가 있다
vllm:kv_cache_usage_perc2.2 %KV cache에 약 97% 여유가 있다
GPU utilizationKV cache대기 요청병목 위치
높음무관있음GPU 연산
낮음높음있음KV cache 용량
낮음낮음있음배치 상한 또는 클라이언트
낮음낮음없음부하 미도달

이번 관측은 세 번째 행이다. 자원 두 축이 모두 비어 있는데 요청이 대기한다는 것은, 남은 제약이 자원이 아니라 입장 수를 정한 설정뿐이라는 뜻이다. 대기 최대치가 정확히 16으로 고정된 것과 --max-num-seqs=16이 일치하므로 그 설정이 원인이다. 클라이언트 병목이었다면 대기 자체가 쌓이지 않았을 것이므로 함께 배제된다.

따라서 다음 조치는 --max-num-seqs 상향이다. KV cache가 2.9%밖에 쓰이지 않았으므로 동시 처리 요청을 크게 늘려도 memory 여유는 충분하다. 다만 이번 부하는 요청당 context가 짧았고, 긴 prompt나 긴 응답에서는 요청당 KV 소비가 커지므로 상향 후에는 KV cache를 다시 측정해야 한다.

한편 이 구성에서 replica 추가는 처리량 대책이 되지 않는다. time-slicing으로 단일 물리 GPU를 공유하므로 replica를 늘리면 같은 GPU 시간을 나눠 갖게 되고, 모델 가중치가 replica 수만큼 중복 적재되며 KV cache 풀도 그만큼 쪼개진다.

8. GPU metric의 Pod 귀속 한계

Prometheus를 통해 조회하면 DCGM 지표에도 podnamespace 라벨이 붙는다.

curl -s --get http://127.0.0.1:9090/api/v1/query \
  --data-urlencode 'query=DCGM_FI_DEV_GPU_UTIL' | jq '.data.result'
label해석
poddcgm-exporter-v6kx4GPU 소비 workload가 아니라 exporter 자신
namespacemonitoringexporter가 속한 namespace
gpu0물리 GPU 번호

이 라벨은 ServiceMonitor가 scrape 대상 Pod 정보를 붙인 것이지 GPU를 사용하는 workload를 가리키지 않는다. 반면 vLLM 지표에는 workload Pod가 정상적으로 붙는다.

지표 계열Pod 귀속결과
DCGMexporter 자신GPU 사용량을 workload별로 나눌 수 없음
vLLM실제 serving Pod요청과 KV cache는 Pod 단위 구분 가능

replica를 늘렸을 때 각 Pod의 GPU 사용량을 분리하려면 GPU 지표만으로는 부족하고 vLLM 쪽 지표와 대조해야 한다. time-slicing 환경의 관측이 갖는 구조적 한계이며, DCGM_EXPORTER_KUBERNETES와 kubelet PodResources API 기반 귀속이 공유 자원 이름에서 어떻게 동작하는지는 별도 확인이 필요하다.