배경
로컬 RTX 4090과 WSL2에서 검증한 GPU serving 흐름을 GCP NVIDIA L4 환경으로 옮겼다. 클라우드 GPU에서는 호스트 driver, 시간당 과금, 단일 물리 GPU의 공유 방식과 복구 절차가 로컬과 달라 같은 명령을 복사하는 것만으로는 충분하지 않았다.
특히 조건 하나가 전체 설계를 바꿨다. 물리 GPU가 하나뿐인데 그 위에 여러 서빙 인스턴스를 올려야 한다. 로컬에서는 GPU를 통째로 쓰면 됐지만 여기서는 24GB를 나눠 써야 하고, 그 나눔이 Kubernetes 스케줄러에게는 보이지 않는다. 모델 선정부터 확장 실패 양상까지 이 글의 상당 부분이 이 제약에서 나왔다.
이 글은 전체 구축의 완료를 선언하지 않는다. 실제로 구성하고 확인한 범위만 결과 중심으로 정리하고, 아직 실행하지 않은 항목은 다음 방향에 구분해 둔다.
무엇을 만들었나
단일 L4 노드 위에 OpenAI 호환 API를 제공하는 서빙 환경을 세우고, 그 위에서 운영과 성능을 실측했다.
GCP NVIDIA L4
→ NVIDIA driver
→ k3s와 NVIDIA RuntimeClass
→ NVIDIA Device Plugin time-slicing
→ vLLM Deployment
→ OpenAI 호환 API
→ Prometheus·DCGM 관측
→ 복구·확장·추론 최적화 실측앞의 다섯 단계는 환경을 만드는 일이고, 뒤의 셋은 만들어진 환경을 재고 판단하는 일이다. 문서도 그렇게 나뉜다.
실행 환경
| 구성 요소 | 값 |
|---|---|
| GPU | NVIDIA L4 23,034 MiB, driver 595.71.05, CUDA 13.2 |
| MIG | 미지원. L4는 물리 분할이 불가능하다 |
| OS | Ubuntu 26.04 LTS, kernel 7.0.0-1008-gcp |
| Kubernetes | k3s v1.36.2+k3s1, 단일 노드 control-plane |
| container runtime | containerd 2.3.2-k3s2 (k3s 내장) |
| GPU runtime | RuntimeClass handler nvidia |
| Device Plugin | Helm chart, app 0.19.3, time-slicing nvidia.com/gpu.shared: 4 |
| serving engine | vLLM 0.26.0 |
| 기준 모델 | Qwen/Qwen2.5-3B-Instruct-AWQ (AWQ 4-bit) |
| 모델 cache | hostPath /opt/hf-cache |
| API | ClusterIP Service, 클러스터 내부 DNS |
읽기 전에 알아 두면 좋은 것
문서 전체에 반복해서 나오는 개념 넷이다.
time-slicing은 메모리를 나누지 않는다
nvidia.com/gpu.shared: 4는 스케줄 slot을 4개로 광고할 뿐 각 Pod에 6GB를 떼어 주지 않는다. 네 Pod가 같은 24GB 풀에서 각자 잡는다.
여기서 이 프로젝트에서 가장 자주 부딪힌 결과가 나온다. GPU 메모리가 부족해도 Pod는 Pending이 되지 않는다. 스케줄러는 slot 개수만 세므로 배치는 성공하고, 부족은 컨테이너가 뜬 뒤 기동 실패로만 드러난다.
GPU 접근은 세 층으로 나뉜다
runtimeClassName → 컨테이너를 어떤 런타임으로 만들 것인가
NVIDIA_VISIBLE_DEVICES → 어느 GPU가 실제로 보일 것인가
resources.limits → 몇 slot을 배정받을 것인가 (스케줄링 회계)셋을 하나로 뭉뚱그리면 “RuntimeClass를 줬는데 왜 GPU가 안 보이지” 또는 “요청하지도 않았는데 왜 보이지”에서 막힌다.
--gpu-memory-utilization은 연산이 아니라 메모리 예산이다
이름과 달리 GPU 사용률이 아니라 이 인스턴스가 잡을 메모리의 상한이다. 0.40이면 장치의 40%를 예산으로 잡고, 그 안에서 weight·activation을 뺀 나머지가 전부 KV cache로 간다. 4분할을 강제하려면 이 값을 직접 낮춰야 한다.
병목은 GPU가 아니라 배치 상한일 수 있다
부하가 걸려도 GPU 사용률과 KV cache가 함께 낮으면 자원이 남는 것이다. 그때 대기 요청이 --max-num-seqs 값에서 멈춰 있다면 병목은 그 admission 상한이다. 04에서 실측으로 확인한다.
문서 구성
환경 구축
| 순서 | 문서 | 내용 |
|---|---|---|
| 01 | L4와 k3s GPU stack 구성 | driver, RuntimeClass, Device Plugin과 time-slicing |
| 02 | vLLM 배포와 GPU memory 조정 | 0.5B API 검증, 3B AWQ 배포, memory 실측 |
| 03 | Service 구성과 클러스터 내부 호출 검증 | ClusterIP, EndpointSlice, 3B AWQ API 검증 |
| 04 | 관측 stack 구성과 부하 구간 판독 | Prometheus, DCGM Exporter, 부하 구간 지표 판독 |
운영과 측정
| 순서 | 문서 | 내용 |
|---|---|---|
| 05 | 복구, rollback, replica 확장 검증 | 복구 시간, cache 재사용, KV cache 계산 검증, rollback, 확장 실패 양상 |
| 06 | N-gram speculative decoding 적용과 측정 | off/on 비교, 채택률 판독, forward 횟수 검산, 함께 비활성화되는 기능 |
| 07 | 서빙을 멈추지 않고 LoRA adapter 반영하기 | 런타임 adapter 로딩, 서빙과 학습의 GPU 공존, 세 번에 걸친 학습 실패와 원인 분리 |
확인된 결과
| 문서 | 확인한 것 |
|---|---|
| 01 | driver부터 Pod의 CUDA 접근까지 GPU 경로 연결. 단일 L4를 nvidia.com/gpu.shared: 4로 노출 |
| 02 | 3B AWQ Deployment 1/1 Ready, /health 200. --gpu-memory-utilization을 두 배로 올리면 EngineCore memory도 약 두 배 (4,648 → 9,184 MiB) |
| 03 | ClusterIP와 내부 DNS로 /v1/models와 Chat Completions 호출 검증 |
| 04 | Prometheus·DCGM·vLLM target 전부 up. 동시성 32에서 대기 요청이 16으로 포화하는 동안 GPU 31.8%, KV cache 2.2% — 병목이 자원이 아니라 배치 상한임을 확정 |
| 05 | Pod 삭제 후 101초 자동 복구, cache 재사용으로 weight 적재 0.77초. KV cache 계산식이 실측과 0.04% 차이로 일치 |
| 05 | GPU memory 부족이 Pending이 아니라 startup probe 실패로 나타남. 0.22로 낮춘 뒤 2 replica 모두 1/1 Running |
| 06 | speculative decoding으로 평균 2.392 → 1.250초, 1.91배 단축. 출력은 off와 동일 |
| 06 | 채택률 85%. 채택 수로 역산한 forward 감소 2.0배가 측정된 1.91배와 5% 내 일치 |
| 06 | 동시성 1 → 4 → 16 → 32에서 배속이 1.91 → 1.85 → 1.76 → 1.77배. 대가는 있으나 이득을 뒤집지 않고 포화 뒤에는 더 깎이지 않음 |
| 06 | 감소의 원인이 draft가 아니라 스케줄러 층. 채택률이 네 동시성에서 85.0%로 불변 |
| 06 | 동시성 16이 포화점. on으로 초당 11.1건이 이 GPU 한 장의 처리 능력 |
| 07 | 서빙 Pod를 내리지 않고 LoRA adapter를 학습해 반영. 재기동은 준비 patch 1회뿐 |
| 07 | AWQ base에 LoRA adapter가 붙음. 학습이 서빙과 같은 GPU의 남는 memory에 들어감 (9,452 MiB, 사전 계산 대비 1.6% 차이) |
| 07 | 학습 중에도 서빙 점유가 5,118 MiB에서 불변. 예산을 미리 잡고 늘리지 않는 성질이 학습 부하 아래서도 유지 |
| 07 | 세 번째 학습에서 데이터의 형식이 서빙 응답에 나타남 (4문항 중 3개). 앞의 두 번은 loss 곡선만으로는 실패가 보이지 않았음 |
특히 넷은 처음 예상과 달랐던 것이라 따로 적어 둔다.
- GPU memory 부족은 Pending이 아니라 기동 실패 반복으로 나타난다
- time-slicing 환경에서 replica를 늘려도 처리량이 늘지 않는다
- speculative decoding을 켜면 스케줄러 기능 셋이 함께 꺼진다
- 그 대가는 동시성이 올라가도 이득을 뒤집을 만큼 크지 않다
- 서빙을 내리지 않고도 같은 GPU에서 학습하고 그 결과를 실시간 반영할 수 있다
- 떨어지는 loss 곡선이 학습되고 있다는 근거가 되지 못한다다음 방향
품질 쪽이 먼저다. 07에서 adapter 반영 경로는 확인했지만 응답 품질은 판단하지 않았다. 12건짜리 데이터와 결함 있는 평가 문항으로는 그 이상을 말할 수 없고, 평가셋 설계부터 다시 해야 한다.
그다음은 function calling과 인프라 신뢰성을 같은 환경 위에서 이어간다.
아래는 아직 프로젝트 결과로 확정하지 않는다.
- 배속 감소가 async scheduling 때문인지 배치 상한 때문인지의 구분
- 반복이 없는 prompt에서의 speculative 채택률
- adapter를 붙였을 때의 지연 변화
- 맥락이 충분한 평가셋에서의 adapter 품질
- replica 1개와 2개 차이의 크기와 응답 시간 분포 차이
- 외부 접근 방식 (SSH 터널, NodePort, Tailscale 중 미결정)
- PDB, 장애 주입, canary
- 비용 관리