0. Contents
1. Overview
사람이 하는 일은 ① ② 뿐이다. 번호를 누르면 해당 절로 이동하고, 리소스 칸을 누르면 설명이 열린다
결과
kukie-gpu의 L40S 에서gpt-oss-20b를 띄웠다- 노드 0대에서 시작해 14분 11초 만에
Ready가 됐고 사람 개입은 없었다 - 부하 측정과 429 는 아직 없다. 직접 만드는 이유의 핵심인 두 기능은 다음 구현 대상이다
2. Install — 차트와 GHCR
설치 값
helm upgrade --install kukie-gpu charts/kukie-gpu --namespace kukie-gpu \
--set dcgm.enabled=false --set rbac.enabled=false --set preflight.enabled=false --set namespaces.create=false \
--set oven.enabled=true --set oven.image.tag=0.3.0 \
--set oven.image.digest=sha256:13f621dd04412df0f76651eb94a2a562d2ed3694770ea2614f91d13939f2a40c \
--set 'oven.imagePullSecrets[0].name=ghcr-pull' --wait --timeout 120s
kukie-gpu 의 1층은 아직 손으로 적용한 상태이고 Kukie GPU 화면이 쓰고 있어, 오븐만 별도 namespace 에 올렸다. 오븐이 뜨면 ServedModel 을 기다린다.
CRD 와 권한의 복사본
- 차트는 Go 코드에서 생성한 CRD 와 Role 의 복사본을
files/oven/에서 읽는다 make manifests가 함께 갱신하고, CI 와 단위 테스트가 원본과 다르면 실패시킨다
릴리스
- 태그
v0.3.0을 달면 이미지를 먼저 올린다 - 방금 만든 이미지의 digest 를 values 에 채운 뒤 차트를 올린다
- 받는 쪽은 차트만 정하면 이미지까지 고정된다
uninstall 동작
처음에는 CRD 만 남겼다. kind 에서 namespace 와 함께 ServedModel 이 사라지는 것을 보고 ② 를 더했다
임시 kind 에서 확인하다 찾은 문제다. CRD 만 남겼더니 차트가 만든 llm namespace 가 지워지며 ServedModel 과 PVC 가 함께 사라졌다. 오븐을 켰을 때 그 namespace 도 남기도록 고쳤다.
3. ServedModel — 모델 하나의 선언
왼쪽 표시한 줄이 오른쪽 결과를 만든다. 오른쪽 값은 첫 GPU 실기에서 만들어진 실제 객체다
CRD 로 만든 이유: 상태를 한곳에
- 스크립트나 Job 체인은 끝나면 상태가 남지 않는다
- CRD 는 원하는 상태와 현재 상태를 한 객체에 둔다
- Kukie 화면은 이 객체 하나를 읽으면 된다
단계와 이유
| phase | 뜻 | reason 예시 |
|---|---|---|
| Pending | 만들어짐, 또는 예전 리소스를 기다림 | Created, WaitingForOldResources |
| Downloading | 다운로드 중 | JobRunning, Retrying |
| Loading | vLLM 이 /health 에 답하기를 기다림 | Starting |
| Ready | /health 통과 | Healthy |
| Measuring | 측정 요청을 받음. Job 은 구현 전 | MeasureJobNotImplemented |
| Failed | 사용자가 봐야 하는 실패 | Unschedulable, NameConflict, DownloadFailed |
Failed 는 끝이 아니다
- 노드가 없으면
Failed/Unschedulable이다 - 노드가 뜨면 사람 손 없이 다음 단계로 간다. 실기에서 그대로 확인했다
4. Reconcile — 만들기가 아니라 맞추기
Reconcile 은 ServedModel, 자식 리소스, Pod 가 바뀔 때 다시 돈다. 같은 입력이면 몇 번을 돌아도 결과가 같다
실행 순서의 이유
- 이름 검사가 맨 앞이다. 뒤의 apply 는
ForceOwnership이라 남의 객체도 가져올 수 있다 - 소유자 참조까지 붙으면
ServedModel을 지울 때 그 객체가 함께 지워진다 - 다운로드가 끝나기 전에는 엔진을 만들지 않는다. PVC 가 ReadWriteOnce 라 동시에 쓰지 못한다
4.1 Name Check — 남의 것과 예전 것
같은 이름의 객체가 있을 때 세 경우를 구분해야 한다. reconcile 에서 가장 경계 조건이 많은 부분이다.
| 객체 | 판정 | 처리 |
|---|---|---|
이 ServedModel 소유 | 소유자 UID 일치 | 그대로 맞춘다 |
같은 이름의 지워진 ServedModel 소유 | 소유자 이름은 같고 UID 가 다름 | WaitingForOldResources, 5초마다 확인 |
| 그 외 | 소유자가 다르거나 없음 | Failed/NameConflict, 아무것도 만들지 않음 |
PVC 는 레이블로 판정
retainOnDelete인 PVC 는 소유자가 없다- 같은 이름으로 다시 만들 때 그 파일을 쓰는 것이 목적이므로 레이블
serving.kukie.io/servedmodel을 본다 - 그래서 레이블을 첫 apply 부터 붙인다
사람이 지운 PVC
- 우리 PVC 를 사람이 지우면 vLLM 이 도는 동안 pvc-protection 이 붙잡아 계속 삭제 중으로 남는다
- 이것까지 기다리면 영원히 멈춘다. 그래서 다른 소유자의 PVC 만 기다린다
4.2 PVC — server-side apply 와 field manager
kubectl get pvc --show-managed-fields 로 읽은 값이다. 오븐의 두 매니저와 쿠버네티스 컨트롤러가 필드를 나눠 갖는다
- Server-side apply 는 매니저가 이번에 말하지 않은 필드의 소유를 놓는다
- PVC 는 스펙과 메타데이터의 적용 시점이 달라 매니저를 둘로 나눴다
4.3 Volume — EBS CSI 와 태그 경계
애드온 기본 클래스를 쓴 이유
- 이 클러스터 root 에는 Kubernetes provider 가 없다
- 애드온 설정
defaultStorageClass.enabled면 provider 없이 기본 클래스ebs-csi-default-sc가 생긴다 - 만들어지는 볼륨은 gp3 이고
WaitForFirstConsumer다
권한을 좁힌 이유
허용 쪽은 실제 볼륨으로, 거부 쪽은 시뮬레이터로 확인했다. 다른 클러스터의 볼륨을 실제로 지워 보지는 않았다
- 이 계정에는 다른 클러스터의 CSI 볼륨이 이미 있었다
- AWS 정책만 붙이면 GPU 클러스터의 컨트롤러가 그 볼륨까지 지울 수 있다
- 애드온이
controller.extraVolumeTags로 볼륨에Environment=gpu를 붙이고, 경계가 그 태그만 허용한다 - 허용은 실제 볼륨의 생성부터 삭제까지로, 거부는 IAM 시뮬레이터로 확인했다
고객 클러스터에는 넣지 않는다
- 스토리지 드라이버는 클라우드마다 다르고 IAM 권한이 필요하다
- 애드온은 PVC 만 요청하고, preflight 가 빠진 것을 알려 준다
4.4 Download — 다운로드 Job
이름에 출처 해시
- Job 이름은
gpt-oss-20b-download-e599ed24처럼 출처 해시를 포함한다 - 끝난 Job 을 지우지 않고 “이 출처는 받았다” 는 표시로 쓴다
- 출처가 바뀌면 이름이 달라져 새 Job 이 돌고, 옛 Job 은 지운다
시간 상한과 재시도
backoffLimit: 2, 기본 2시간 상한(--download-deadline)- 멈춘 다운로드가 GPU 노드를 계속 잡지 못하게 하려는 것이다
같은 노드 조건
- Job 도 엔진과 같은 GPU 노드 조건을 쓴다
- EBS 볼륨은 처음 쓰는 Pod 의 AZ 에 묶이므로, 일반 노드에서 받으면 엔진이 다른 AZ 에 뜰 수 있다
4.5 Engine — vLLM 기동
모델 파일은 볼륨에서만
- 다운로드가 끝난 뒤에만 Deployment 와 Service 를 만든다
HF_HUB_OFFLINE=1로 엔진이 허깅페이스에 다시 접속하지 못하게 한다
기동 대기: 최대 40분
- 큰 모델은 적재에 오래 걸려
startupProbe를 10초 간격 240회로 둔다 /health가 답하면Ready다. 이번 실기에서는 194초가 걸렸다
출처가 바뀔 때
- 볼륨이 ReadWriteOnce 라 vLLM 을 먼저 0대로 내리고 새 다운로드를 시작한다
- 그동안 서빙이 멈춘다. 옛 파일이 남으므로
storage.size는 두 모델을 합친 크기여야 한다
4.6 Status — 상태 기록과 실패의 구분
| 경우 | 처리 | 이유 |
|---|---|---|
| 일시적 API 오류 | phase 유지, 재시도, observedGeneration 유지 | 아직 이 spec 을 처리하지 못했다 |
| API 서버의 거부(invalid, forbidden) | Failed/ApplyRejected | 다시 해도 같으므로 사용자가 봐야 한다 |
| 스케줄 불가, 이미지 문제, CrashLoop | 쿠버네티스 문구 그대로 Failed | 패턴표 분류는 구현 전이다 |
5. First GPU Run — 14분 11초
이번 1회 측정값이다. 모델 크기, NAT 속도, 노드 상태에 따라 달라진다
| 시점 | 경과 |
|---|---|
| 노드 합류 | 76초 |
| 노드 Ready | 86초 |
| PVC Bound, 다운로드 Pod 생성 | 104초 |
| 다운로드 시작(이미지 받기 끝) | 335초 |
| 다운로드 끝, vLLM 생성 | 657초 |
| vLLM 실행 | 667초 |
Ready | 851초 |
확인한 것
- 모델 파일을 받은 볼륨이 vLLM 에 그대로 붙었고, 요청 2건에 정상으로 답했다
- GPU 는 L40S 로 잡혔고 메모리는
--gpu-memory-utilization 0.92대로 41GiB 를 미리 잡았다 ServedModel을 지우자 Deployment, Service, Job 이 사라지고retainOnDeletePVC 가 남았다- 실험 비용은 GPU 약 27분, 약 $1 이다
시간 분포
- 이미지와 모델을 받는 데 약 10분이 들었다
- 둘 다 있는 상태의 재시작은 171초다
- 콜드 스타트를 줄이려면 이 10분을 먼저 줄여야 한다
새 노드의 일시적 경고
- 새 노드에 CSI 드라이버가 뜨기 전에
ProvisioningFailed(no topology key found for node) 가 한 번 났다 - 드라이버가 뜨자 혼자 복구됐다. 지금 status 는 이벤트에만 있는 이 경고를 보여 주지 않는다
6. Review — 리뷰에서 고친 것
오퍼레이터 PR 은 리뷰 6라운드를 거쳤다. 지적은 대부분 경계 조건이었다.
| 지적 | 고친 것 |
|---|---|
| 레이블 없는 우리 PVC 를 남의 것으로 판정 | 첫 apply 부터 레이블 |
| 예전 다운로드 Job 을 “이미 받음” 으로 착각해 빈 PVC 로 vLLM 기동 | 다른 소유자의 Job 을 기다림 |
| 사람이 지운 PVC 에 영원히 대기 | 다른 소유자의 PVC 만 기다림 |
restartPolicy: Never Pod 의 실패 원인이 Pod reason 에 없음 | 컨테이너 종료 코드를 읽음 |
스토리지 없는 클러스터에서 Downloading 에 멈춘 채 원인을 안 보임 | 스케줄 불가 사유를 status 로 |
| 사용자가 준 리소스와 기본값이 어긋나 거부 | 키별 병합 |
CI 에서 드러난 테스트 경쟁
- 컨트롤러는 Job 을 만든 뒤 status 를 쓴다
- 테스트가 그 틈을 읽어 CI 에서 0.003초 만에 실패했고 로컬에서는 통과했다
- 상태가 바뀔 때까지 기다린 뒤 검사하도록 고쳤다
7. Limits — 확인 못 한 것과 남은 일
확인 못 한 것
| 항목 | 상태 |
|---|---|
| 처리량 | 요청 2건뿐이다. 첫 토큰 시간도 각 1회라 측정값이 아니다 |
| 다른 AZ 의 노드 | 이번에는 볼륨과 노드가 모두 2b 였다. 노드 그룹은 2a·2b 에 걸쳐 있어 어긋날 수 있다 |
| 노드부터 새로 켠 재사용 | 171초는 노드가 떠 있고 이미지도 있을 때의 값이다 |
현재 한계
- 쓰기 권한이 ClusterRole 이다. 공개 전에 namespace 로 좁힌다
- 다운로드 진행률은 0 과 100 뿐이다
- 실패 원인은 쿠버네티스 문구 그대로다
- 측정과 어드미션은 필드만 있다
남은 일
- 측정 Job 으로
gpt-oss-20b의 SLA 안 최대 동시 요청을 잰다 - 그 값을
status.baseline에 기록한다 - 어드미션 위치를 정하고 429 를 잇는다
관련 문서
- Kukie Serving.01 Baseline on One L40S — 수동 측정
- Kukie Serving.02 GPU Add-on Design — 설계와 직접 구현의 이유
- CRD —
ServedModel의 형식
