목표와 현재 결과

Pod를 강제로 삭제했을 때 Deployment가 스스로 복구하는지, 그리고 잘못된 설정을 배포한 뒤 이전 revision으로 되돌릴 수 있는지 확인했다.

Pod 삭제부터 rollout 완료까지 101초가 걸렸고 restart 없이 1/1 Running에 도달했다. 기동 로그의 Loading weights took 0.77 seconds는 모델을 다시 내려받지 않고 hostPath cache를 재사용했다는 뜻이다.

같은 로그에서 vLLM이 보고한 memory 배분을 확보했다. weight 1.95 GiB, KV cache 6.63 GiB, KV cache 용량 193,216 토큰이다. 이 값으로 나눈 토큰당 KV 비용이 약 36 KiB로, 모델 선정 단계에서 계산했던 값과 일치했다.

rollout 실패 감지와 rollout undo 복구도 확인했다. 다만 주입한 결함의 종류는 의도와 달랐다. args가 --옵션=값 결합 형식이라 지정한 index가 모델이 아닌 다음 옵션을 덮었고, 그 결과 모델 적재 실패가 아니라 argument 파싱 실패로 CrashLoopBackOff가 됐다.

replica 확장에서는 예상과 다른 실패 양상이 나왔다. --gpu-memory-utilization=0.60인 상태로 replica를 2로 늘리자 두 번째 Pod가 Pending이 되지 않고 정상적으로 노드에 배정된 뒤 Startup probe failed: connection refused를 반복했다. 스케줄러는 nvidia.com/gpu.shared slot 개수만 보고 GPU memory는 모르기 때문이다. 예산을 0.22로 낮춘 뒤에는 두 replica가 모두 1/1 Running이 됐다.

같은 조건으로 replica 1개와 2개의 응답 시간을 비교했다. 평균은 2.215초와 2.427초로 replica 1개 쪽이 빨랐고, 16건 전체 소요도 1개가 3.39초 짧았다. time-slicing으로 같은 GPU를 나눠 쓰므로 replica 확장이 처리량을 늘리지 못한다는 방향이 구조적 이유와 함께 확인됐다. 다만 각 구성을 한 번씩만 측정했으므로 차이의 크기와 응답 시간 분포 차이는 확정하지 않는다.

1. Pod 재생성과 복구 시간

복구 시간을 재려면 삭제 시각과 rollout 완료 시각을 함께 잡아야 한다.

OLD_POD=$(kubectl -n llm get pod -l app=vllm,version=ver-0 \
  -o jsonpath='{.items[0].metadata.name}')
echo "old=$OLD_POD"
START=$(date +%s)
kubectl -n llm delete pod "$OLD_POD"
kubectl -n llm rollout status deployment/vllm-qwen --timeout=20m
END=$(date +%s)
echo "recovery_seconds=$((END - START))"
key해석
삭제한 Podvllm-qwen-865f7b887b-jpl7g기존 replica
rolloutsuccessfully rolled outReplicaSet이 자동으로 보충
복구 시간101초삭제부터 Ready 판정까지

1-1. cache 재사용 확인

새 Pod와 기동 로그를 확인했다.

kubectl -n llm get pod,pvc -o wide
kubectl -n llm logs deployment/vllm-qwen --tail=100 | grep -iE "download|loading|kv cache"
key해석
새 Podvllm-qwen-865f7b887b-7lg621/1 Running, restart 0
IP10.42.0.58이전 Pod와 다른 주소
ReplicaSet hash865f7b887b삭제한 Pod와 동일. 설정 변경 없이 보충만 발생
weight 적재0.77초다운로드 없이 로컬 cache에서 읽음
모델 적재 전체4.59초weight 읽기와 GPU 배치 포함
engine 초기화14.06초KV cache 생성과 warmup 포함

Loading weights took 0.77 seconds가 cache 재사용의 직접 근거다. 로그에 다운로드 진행 표시가 없고, 3B AWQ weight 약 2GiB를 네트워크로 받았다면 이 시간에 끝날 수 없다. HF Hub 경고는 인증 없이 접속했다는 안내일 뿐 실제 다운로드가 일어났다는 뜻이 아니다.

ReplicaSet hash가 삭제 전과 같은 865f7b887b인 것도 확인 포인트다. 설정이 바뀌지 않았으므로 새 ReplicaSet이 생기지 않고 기존 ReplicaSet이 replica만 보충했다.

1-2. 복구 시간의 구성

101초 중 로그에서 설명되는 구간은 일부다.

구간시간근거
weight 적재0.77초Loading weights took
모델 적재 전체4.59초Model loading took
engine 초기화14.06초init engine ... took
로그로 설명되는 합계19초위 세 구간
나머지82초Pod 종료, 스케줄링, 컨테이너 기동, probe 감지

복구 시간의 대부분이 모델 적재가 아니라 그 앞뒤에 있다. 모델이 3B AWQ로 작고 cache까지 재사용되기 때문에 적재 자체는 20초 안에 끝난다. 나머지 82초의 내역을 더 쪼개려면 Pod 이벤트 타임스탬프와 probe 설정을 함께 봐야 하며, 이번 측정에서는 확인하지 않았다.

1-3. vLLM이 보고한 memory 배분

같은 로그에 GPU memory 배분이 그대로 남는다.

key해석
장치 전체22.04 GiB기동 시점 인식된 GPU memory
기동 시 여유21.85 GiB다른 workload 없음
--gpu-memory-utilization 목표0.48.81 GiB이 instance에 허용한 예산
weight1.95 GiBAWQ 4-bit 3B 실측
peak activation0.16 GiB
non-torch0.06 GiB
KV cache6.63 GiB예산에서 위 항목을 뺀 나머지
KV cache 용량193,216 토큰

네 항목의 합은 8.80 GiB로 목표 예산 8.81 GiB와 일치한다. --gpu-memory-utilization이 GPU 연산 사용률이 아니라 이 instance가 잡을 memory 예산이라는 점이 수치로 확인된다.

토큰당 KV 비용을 역산하면 모델 선정 단계의 계산과 대조할 수 있다.

항목
KV cache memory6.63 GiB = 7,119,708,979 bytes
KV cache 용량193,216 토큰
역산한 토큰당 KV36,848 bytes = 약 36.0 KiB
계산으로 예측했던 값2 × 36층 × KV head 2 × head_dim 128 × 2 bytes = 36,864 bytes = 36 KiB
차이0.04%

모델을 고를 때 세운 KV cache 계산식이 실측과 사실상 일치했다. KV head 수가 적은 모델을 고른 근거가 실제 배분에서 그대로 재현된 셈이다.

이 값은 앞선 부하 시험의 KV cache 사용률도 설명한다. 전체 용량이 193,216 토큰인데 동시 16건이 각각 프롬프트 37 토큰에 생성 상한 512 토큰이면 최대 8,784 토큰, 약 4.5%다. 관측된 최대 2.9%가 그 안에 들어온다.

1-4. 재시작 후 GPU 경로 재확인

k3s가 재시작된 뒤에는 Pod만 보지 말고 드라이버부터 순서대로 확인한다. 드라이버가 깨진 상태에서 Pod 상태만 들여다보면 원인을 찾기 어렵다.

nvidia-smi
key해석
Driver Version595.71.05드라이버 정상 인식
CUDA Version13.2
GPUNVIDIA L4
Memory-Usage9192MiB / 23034MiBvLLM 한 process가 점유
GPU-Util0%요청 없는 유휴 상태
온도·전력54C, 35W / 72W유휴 기준값
MIG M.N/AL4는 MIG를 지원하지 않는다

장치 전체가 23034MiB로 보고된다. vLLM이 기동 로그에서 보고한 22.04 GiB와 같은 값이며, 단위 표기만 다르다.

sudo systemctl status k3s --no-pager
kubectl get nodes -o wide
key해석
k3s serviceactive (running)재시작 후 정상
k3s versionv1.36.2+k3s1
Nodegpu-k8s, Ready, control-plane단일 노드
OSUbuntu 26.04 LTS
kernel7.0.0-1008-gcpGCP 전용 커널
container runtimecontainerd://2.3.2-k3s2k3s embedded containerd
INTERNAL-IP10.178.0.3
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.allocatable.nvidia\.com/gpu\.shared}{"\n"}{end}'
kubectl -n llm get deployment,pod -o wide
kubectl -n llm rollout status deployment/vllm-qwen --timeout=20m
확인 순서결과
드라이버 인식595.71.05, CUDA 13.2
k3s 서비스와 Nodeactive (running), Ready
nvidia.com/gpu.shared4로 다시 노출
vLLM Deployment1/1, restart 0

네 단계가 모두 통과했으므로 재시작이 GPU 경로에 영향을 남기지 않았다. 이 순서를 지키면 실패 지점이 드라이버인지, 자원 광고인지, workload인지 한 번에 좁혀진다.

2. 잘못된 설정 주입과 rollback

실패한 배포를 감지하고 되돌릴 수 있는지 확인하기 위해 의도적으로 잘못된 설정을 넣었다.

2-1. 결함 주입과 실패 확인

먼저 현재 args 배열을 조회했다.

kubectl -n llm get deployment vllm-qwen \
  -o jsonpath='{.spec.template.spec.containers[0].args}{"\n"}'
index
0--model=Qwen/Qwen2.5-3B-Instruct-AWQ
1--served-model-name=qwen2.5-3b
2--gpu-memory-utilization=0.40
3--max-model-len=4096
4--max-num-seqs=16
5--enforce-eager

이 manifest는 --옵션=값 결합 형식을 쓴다. 옵션과 값이 각각 별도 원소인 배열이었다면 모델 값은 index 1에 있겠지만, 여기서는 index 0 하나에 옵션과 값이 함께 들어 있다.

존재하지 않는 모델을 넣기 위해 index 1을 교체했다.

kubectl -n llm patch deployment vllm-qwen --type=json \
  -p='[{"op":"replace","path":"/spec/template/spec/containers/0/args/1","value":"Qwen/does-not-exist"}]'

이 patch가 실제로 덮은 것은 모델 값이 아니라 index 1--served-model-name=qwen2.5-3b다. 결합 형식이므로 모델은 index 0 안에 옵션과 함께 들어 있고, 배열에 모델 값만 담긴 원소는 존재하지 않는다.

구분
의도한 결함존재하지 않는 모델 이름
실제 주입된 결함옵션 형식이 아닌 문자열 Qwen/does-not-exist
덮어쓴 값--served-model-name=qwen2.5-3b
결과모델 적재 실패가 아니라 argument 파싱 실패

args index는 배열 형식에 따라 가리키는 대상이 달라진다

--옵션 값 분리 형식이라면 모델 값이 index 1에 오지만, --옵션=값 결합 형식에서는 index 1이 그다음 옵션이다. 같은 index를 지정해도 어떤 배열이냐에 따라 전혀 다른 인자를 덮는다. patch 전에 조회한 배열을 그대로 세어 대상 index를 확인해야 한다.

결함의 종류는 달라졌지만 이 절의 목적은 충족했다. 확인하려던 것은 “잘못된 설정이 배포됐을 때 rollout이 실패로 감지되고 rollback으로 되돌아오는가”이며, 두 동작 모두 아래에서 관측된다. 다만 “존재하지 않는 모델을 지정했을 때 모델 적재 단계에서 어떻게 실패하는가”는 이번에 확인되지 않았다.

rollout 상태와 Pod, 로그를 확인했다.

kubectl -n llm rollout status deployment/vllm-qwen --timeout=5m
kubectl -n llm get pods
kubectl -n llm logs deployment/vllm-qwen --tail=50
key해석
rollouterror: timed out waiting for the condition5분 안에 Ready 도달 실패
새 Podvllm-qwen-59559b485c-tx6lf새 ReplicaSet hash. 설정 변경이 반영됨
상태CrashLoopBackOff, restart 4기동 즉시 종료가 반복
실패 원인unrecognized arguments: Qwen/does-not-existargument 파싱 단계에서 종료

로그에 --model 옵션이 향후 제거 예정이라는 경고도 함께 나왔다. 현재 manifest는 결합 형식 --model=...을 쓰고 있으므로, 다음 수정에서 positional argument 형식으로 바꿀지 검토 대상이다.

재조회하면 상태 표기가 Running으로 보이기도 한다.

kubectl get po -n llm
key해석
STATUSRunning재시작 직후 프로세스가 살아 있는 순간
READY0/1probe 통과 전이므로 트래픽 대상이 아님
RESTARTS5앞선 조회보다 1 증가

CrashLoopBackOffRunning은 같은 Pod의 서로 다른 순간이다. STATUS만 보고 정상으로 판단하면 안 되고 READYRESTARTS를 함께 봐야 한다. 여기서는 0/1과 증가하는 재시작 횟수가 실패를 가리킨다.

2-2. rollback

rollout undo로 직전 revision으로 되돌렸다.

kubectl -n llm rollout history deployment/vllm-qwen
kubectl -n llm rollout undo deployment/vllm-qwen
kubectl -n llm rollout status deployment/vllm-qwen --timeout=20m
key해석
revision 수3초기 배포, 이전 변경, 이번 결함 주입
CHANGE-CAUSE전부 <none>변경 사유가 기록되지 않음
rollbackrolled back직전 revision으로 복귀
rolloutsuccessfully rolled out정상 Pod 재기동 완료

CHANGE-CAUSE가 모두 비어 있어 revision 번호만으로는 각 배포가 무엇을 바꾼 것인지 알 수 없다. 실패 상황에서 되돌릴 대상을 고를 때 판단 근거가 없다는 뜻이며, kubernetes.io/change-cause annotation을 기록하도록 배포 방식을 바꿀 여지가 있다.

경고 문구도 남았다. 이 Deployment는 원래 kubectl apply로 관리되는데 rollout undolast-applied-configuration annotation을 갱신하지 않는다. 이후 같은 manifest로 apply하면 rollback으로 되돌린 내용이 다시 덮일 수 있다. 선언형 관리와 명령형 rollback을 섞을 때 생기는 문제이므로, 실제 운영에서는 rollback 후 manifest 쪽도 함께 맞춰야 한다.

3. replica 확장

3-1. 확장 전 상태

확장 전에 현재 memory 예산과 실제 점유량을 확인했다.

kubectl -n llm get deployment vllm-qwen   -o jsonpath='{.spec.template.spec.containers[0].args}{"
"}'
nvidia-smi --query-gpu=memory.total,memory.used,memory.free --format=csv
key해석
--gpu-memory-utilization0.60앞선 측정 시점의 0.40에서 올라간 값
예산 환산13,820 MiB23034 × 0.60
실제 사용13,728 MiB예산과 거의 일치
남은 여유8,838 MiB두 번째 instance가 쓸 수 있는 전부

여기서 이미 결과가 정해진다. 같은 설정으로 replica를 하나 더 띄우면 그 Pod도 13,820 MiB를 요구하는데 남은 것은 8,838 MiB뿐이다.

3-2. 두 번째 Pod는 Pending이 아니었다

kubectl -n llm scale deployment/vllm-qwen --replicas=2
kubectl -n llm get pods -o wide -w
kubectl -n llm describe pod vllm-qwen-57758995fd-m5jz7 | grep -A10 Events
단계결과
Scheduled성공. 노드 배정에 문제 없음
이미지3회 pull, 매번 약 1.06초
Created, Startedx3. 컨테이너는 계속 다시 만들어짐
Startup probeconnection refused x21
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"	"}{.status.allocatable.nvidia\.com/gpu\.shared}{"
"}{end}'

자원 부족이 Pending으로 나타나지 않는다

스케줄러는 nvidia.com/gpu.shared slot 개수만 센다. slot이 4이고 두 Pod가 각각 1을 요청하므로 스케줄링 단계에서는 아무 문제가 없고, Pod는 정상적으로 노드에 배정된다.

GPU memory는 스케줄러가 모르는 값이다. 부족은 컨테이너가 시작된 뒤 vLLM이 memory를 잡으려 할 때 드러나고, 프로세스가 listen하지 못해 Startup probe failed: connection refused로만 보인다.

따라서 time-slicing 환경에서 GPU memory 초과 배치는 Pending이 아니라 기동 실패 반복으로 나타난다. Pending만 자원 부족의 신호로 알고 있으면 원인을 컨테이너나 probe 설정에서 찾게 된다.

describe의 이벤트를 보지 않고 get pods만 봤다면 0/1과 재시작 횟수만 보여 원인을 알 수 없었다. slot 잔량이 4로 남아 있다는 점이 “slot이 아니라 memory 문제”라는 것을 가른다.

3-3. memory 예산을 낮춘 뒤 재확장

--gpu-memory-utilization을 낮춰 두 instance가 함께 들어갈 수 있게 했다. args가 --옵션=값 결합 형식이므로 index를 지정하지 않고 접두사로 찾아 배열 전체를 교체했다.

NEW_ARGS=$(kubectl -n llm get deployment vllm-qwen -o json   | jq -c '[.spec.template.spec.containers[0].args[]
            | if startswith("--gpu-memory-utilization=") then "--gpu-memory-utilization=0.22" else . end]')
echo "$NEW_ARGS"
key해석
변경 전--gpu-memory-utilization=0.60instance당 약 13,820 MiB
변경 후--gpu-memory-utilization=0.22instance당 약 5,067 MiB
2 replica 합계10,135 MiB장치 23,034 MiB 안에 들어감
나머지 원소그대로 유지접두사로 찾은 원소만 교체됨

0.22는 이 GPU를 slot 4개로 나눠 쓰는 것을 전제로 잡은 값이다. 장치 23,034 MiB를 4등분하면 instance당 약 5,758 MiB이고, 0.22는 그보다 낮은 약 5,067 MiB로 CUDA context 몫을 남겨둔 예산이다. 이번에는 2 replica만 띄웠으므로 여유가 더 남는다.

patch 전에 echo로 확인하는 이유가 여기서 드러난다. 다른 원소가 그대로이고 목표 원소만 바뀐 것을 눈으로 보고 적용할 수 있다.

kubectl -n llm patch deployment vllm-qwen --type=json   -p="[{\"op\":\"replace\",\"path\":\"/spec/template/spec/containers/0/args\",\"value\":$NEW_ARGS}]"
kubectl get po -n llm
key해석
Pod 수2둘 다 1/1 Running
restart0기동 실패 없음
ReplicaSet hash854dfd9bfbargs 변경으로 새 ReplicaSet 생성
기동 간격1분순차 기동

57758995fd에서 854dfd9bfb로 hash가 바뀐 것이 args 변경이 반영됐다는 표시다. 같은 hash에서 replica만 늘렸다면 3-2의 실패가 반복됐을 것이다.

3-4. replica 1개와 2개 비교

확장 효과를 보려면 replica 수만 바꾸고 나머지 조건을 같게 둬야 한다. 동시 요청 16건, max_tokens=512, 같은 프롬프트로 두 구성에서 각각 측정했다.

for i in $(seq 1 16); 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":"긴 설명을 해줘."}],"max_tokens":512}'     -o /dev/null -w "%{time_total}
" &
done
wait
지표replica 1replica 2차이
평균2.2152.4271개가 0.212초 빠름
중앙값2.1932.3521개가 0.159초 빠름
최소0.8221.3231개가 빠름
최대4.3473.1402개가 빠름
최대 − 최소3.5251.8161개가 약 1.9배 넓음
최대 ÷ 최소5.292.37
전체 소요35.4438.831개가 3.39초 짧음

3-5. 판독

replica를 2개로 늘렸는데 평균과 중앙값이 모두 느려졌고, 16건 전체 소요도 1개 쪽이 짧았다.

이 방향은 구조에서 예상되는 결과다. time-slicing은 같은 물리 GPU를 시간으로 나누므로 replica를 늘려도 연산 능력이 늘지 않는다. 여기에 두 instance를 오가는 전환 비용과 모델 가중치 중복 적재가 더해진다. vLLM은 이미 단일 instance 안에서 요청을 batching하므로 replica로 쪼개면 그 배칭 효율을 스스로 깎는 셈이기도 하다. replica 확장이 이 구성에서 처리량 레버가 아니라는 결론은 이 구조적 이유와 측정 방향이 함께 뒷받침한다.

다만 두 값의 차이 폭 자체는 단정할 수 없다.

주장근거판정
확장해도 처리량이 늘지 않는다구조적 이유 + 측정 방향확정
1개가 약 0.21초, 약 9% 빠르다각 구성 1회 측정미확정

같은 구성을 반복 측정하지 않았으므로 구성 내부의 흔들림 폭을 모른다. 그 폭이 0.21초보다 크다면 두 구성의 차이는 노이즈에 묻힌다. 차이의 크기를 쓰려면 구성을 번갈아 여러 회 측정해 회차 간 편차부터 확인해야 한다.

3-6. 분포 차이는 확정하지 않는다

응답 시간의 퍼진 정도는 두 구성에서 다르게 관측됐다.

구성관측된 범위최대 ÷ 최소
replica 10.82 ~ 4.355.29
replica 21.32 ~ 3.142.37

replica 1에서는 --max-num-seqs=16 상한과 동시 요청 수가 같아 16건이 한 배치에 묶이고, replica 2에서는 8건씩 나뉘어 배치가 작아진다. 배치 크기가 완료 시각의 퍼짐에 영향을 준다는 설명이 가능하다.

이 관측을 결과로 쓰지 않는다

세 가지 이유로 확정하지 않는다.

첫째, 최소와 최대는 표본 16개의 꼬리값이라 가장 크게 흔들리는 통계다. 각 구성에서 한 번씩만 측정했으므로 다시 재면 다른 값이 나올 수 있다.

둘째, curl 16개를 백그라운드로 띄우는 방식이라 클라이언트 쪽 프로세스 기동 스큐가 측정에 섞인다. 0.82초가 서버가 빨라서인지 그 프로세스가 먼저 떠서인지 이 데이터로는 구분되지 않는다.

셋째, 배치 크기 외에 응답 길이 차이만으로도 비슷한 퍼짐이 나올 수 있다. max_tokens는 상한일 뿐이고 실제 생성 길이는 요청마다 다르다.

확인하려면 클라이언트 스큐가 없는 부하 도구로 동시성을 고정하고, 각 구성을 번갈아 여러 회 측정한 뒤 vllm:time_to_first_token_secondsvllm:e2e_request_latency_seconds 분위수를 비교해야 한다.

4. 확인된 범위

항목결과
Pod 삭제 후 자동 복구확인, 101초
hostPath cache 재사용확인, weight 적재 0.77초
복구 후 GPU 재할당확인, 1/1 Running
KV cache 계산식 검증확인, 토큰당 약 36 KiB로 예측과 일치
rollout 실패 감지확인, timed out waiting for the condition
rollback 복구확인, rollout undo 후 정상 기동
재시작 후 GPU 경로확인, 드라이버 595.71.05, slot 4 재노출
GPU memory 초과 시 증상확인, Pending이 아니라 startup probe 실패
memory 예산 조정 후 2 replica확인, 0.22에서 둘 다 1/1 Running
replica 확장의 처리량 효과확인, 늘지 않음. 방향만 확정

아래는 이번 측정에서 확인하지 않았다.

- 복구 시간 101초 중 82초의 세부 내역
- 실패한 rollout 동안 기존 Pod가 트래픽을 계속 처리했는지
- 존재하지 않는 모델 이름을 주입했을 때의 실패 양상
- change-cause를 기록하는 배포 방식
- replica 1개와 2개 차이의 크기. 각 구성 1회 측정이라 노이즈와 구분되지 않음
- 응답 시간 분포 차이. 꼬리값 기반이고 클라이언트 기동 스큐가 섞여 있음
- 지속 부하와 다른 동시성에서의 확장 효과

두 번째 항목은 이번 조회에서 Pod가 하나만 표시됐으나 그것이 배포 전략 때문인지 조회 시점 때문인지 구분하지 않았다. strategy와 Pod 이벤트를 함께 확인해야 답할 수 있다.