- Published on
BentoML로 GPU 추론 API 배포 - 콜드스타트 0초
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스나 오토스케일 환경에서 GPU 추론 API를 운영하면, 첫 요청이 느려지는 콜드스타트가 거의 필연적으로 따라옵니다. 원인은 단순합니다. 컨테이너가 뜨고, 파이썬 런타임이 초기화되고, 모델 가중치가 디스크에서 로드되고, CUDA 컨텍스트가 만들어지고, 커널이 워밍업되는 과정이 한 번에 몰리기 때문입니다.
이 글에서는 BentoML을 기준으로 콜드스타트를 체감상 0초에 가깝게 만드는 패턴을 정리합니다. 핵심은 “첫 요청에 모델 로딩과 GPU 초기화를 절대 맡기지 않는다”입니다. 즉, 프로세스 시작 시점에 모든 준비를 끝내고, 트래픽이 오기 전에 워커를 준비시키며, 쿠버네티스 프로브와 오토스케일 정책을 그에 맞게 설계합니다.
콜드스타트의 정체: 무엇이 느린가
콜드스타트는 보통 아래 비용이 합쳐진 결과입니다.
- 컨테이너 시작: 이미지 pull, 엔트리포인트 실행
- 파이썬 import 비용: torch, transformers, tokenizers 등 무거운 모듈
- 모델 로딩: 가중치 파일을 디스크에서 읽고 CPU 메모리에 적재
- GPU 초기화: CUDA 컨텍스트 생성, 메모리 할당, 커널 로딩
- 첫 추론 워밍업: cudnn autotune, 그래프 캐시, JIT 등
따라서 “0초”는 마법이 아니라, 첫 요청 전에 준비를 끝내고 준비 완료된 파드만 트래픽에 붙인다는 운영 설계의 결과입니다.
BentoML에서 목표 아키텍처
BentoML은 서비스 정의와 패키징, 컨테이너화, 런타임 서빙까지 한 흐름으로 제공합니다. 콜드스타트 0초에 가까운 구성을 위해서는 다음이 중요합니다.
- 서비스 프로세스 시작 시점에 모델을 로드하고 GPU로 올림
- 워커 수를 고정하거나 최소 워커를 유지해 스케일 다운을 제한
- readinessProbe는 “진짜로 추론 가능한 상태”에서만 성공
- livenessProbe는 과도하게 공격적으로 두지 않기
- 노드/파드 레벨에서 GPU 스케줄링과 메모리 한도를 명확히
쿠버네티스 프로브가 꼬이면 준비 안 된 파드에 트래픽이 붙거나, 반대로 정상 파드가 죽었다고 판단해 재시작 루프에 빠질 수 있습니다. 관련해서는 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅도 함께 참고하면 좋습니다.
BentoML 서비스 코드: 프로세스 시작 시 모델 로딩
아래 예시는 PyTorch 기반 모델을 BentoML 서비스로 감싸고, 모듈 import 시점 또는 서비스 초기화 시점에 모델을 로딩하는 패턴입니다. 중요한 점은 요청 핸들러 내부에서 모델 로딩을 하지 않는 것입니다.
# service.py
import os
import time
import bentoml
import torch
MODEL_ID = os.environ.get("MODEL_ID", "my_torch_model")
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# 프로세스 시작 시 모델 로딩
model_ref = bentoml.models.get(MODEL_ID)
model = torch.jit.load(model_ref.path_of("model.pt"), map_location=DEVICE)
model.eval()
# GPU 워밍업: 첫 요청 전에 커널/컨텍스트 준비
@torch.inference_mode()
def warmup():
if DEVICE != "cuda":
return
x = torch.randn(1, 3, 224, 224, device=DEVICE)
for _ in range(3):
_ = model(x)
torch.cuda.synchronize()
warmup()
@bentoml.service(
traffic={
# 워커를 여러 개 띄워 첫 요청 대기열을 줄임
# 실제 값은 GPU 메모리와 모델 크기에 맞게 조정
"timeout": 30,
"max_concurrency": 64,
}
)
class InferenceService:
@bentoml.api
def predict(self, batch: list[list[float]]):
# 예시: 간단한 텐서 입력
t0 = time.time()
x = torch.tensor(batch, device=DEVICE, dtype=torch.float32)
with torch.inference_mode():
y = model(x)
if DEVICE == "cuda":
torch.cuda.synchronize()
return {"latency_ms": int((time.time() - t0) * 1000), "output": y.detach().cpu().tolist()}
포인트
bentoml.models.get로 모델 아티팩트를 가져오고, 프로세스 시작 시 로딩합니다.warmup()에서 더미 추론을 몇 번 수행해 CUDA 초기화 비용을 선반영합니다.torch.cuda.synchronize()로 워밍업이 실제로 완료되도록 보장합니다.
이렇게 하면 첫 요청이 모델 로딩을 기다리지 않기 때문에, “콜드스타트”의 대부분이 사라집니다.
bentofile로 이미지 빌드 최적화
이미지 빌드 단계에서 의존성 설치가 느리거나, 런타임에서 추가 다운로드가 발생하면 결국 첫 기동이 느려집니다. 특히 transformers 계열은 토크나이저나 모델 파일을 런타임에 받는 실수를 자주 합니다.
BentoML의 bentofile.yaml 예시입니다.
# bentofile.yaml
service: "service.py:InferenceService"
labels:
owner: "ml-platform"
stage: "prod"
include:
- "service.py"
python:
packages:
- torch
- bentoml
- numpy
docker:
python_version: "3.11"
system_packages:
- "git"
체크리스트
- 모델 파일은 반드시 Bento 모델 스토어에 포함시키고, 컨테이너 런타임에 외부 다운로드를 없앱니다.
- 가능하면 CUDA 런타임이 포함된 베이스 이미지를 사용하고, 드라이버/런타임 불일치를 줄입니다.
콜드스타트 0초의 핵심: “미리 켜두기” 전략
완전한 의미의 0초는 “스케일 투 제로”와 양립하기 어렵습니다. 대신 실무에서는 다음 중 하나를 선택합니다.
- 최소 레플리카 1 이상 유지: 트래픽이 없어도 파드를 죽이지 않음
- GPU 노드 상시 유지: 노드 스케일 다운을 제한
- 프리웜 잡 또는 사이드카로 주기적 워밍업: readiness 유지
쿠버네티스에서 가장 현실적인 해법은 minReplicas를 1 이상으로 유지하고, HPA는 부하 시에만 늘리는 방식입니다.
Kubernetes 배포: readinessProbe를 “진짜 준비됨”으로 만들기
모델 로딩과 워밍업이 끝나기 전에는 절대 트래픽을 받으면 안 됩니다. 따라서 readinessProbe는 단순히 HTTP 서버가 열렸는지가 아니라, 모델이 GPU에서 추론 가능한지를 기준으로 해야 합니다.
가장 쉬운 방법은 별도의 /readyz 엔드포인트를 두고, 내부에서 준비 플래그를 확인하는 것입니다.
# service.py 일부 발췌
READY = False
def init_all():
global READY
# 모델 로딩 + warmup 수행
warmup()
READY = True
init_all()
@bentoml.service
class InferenceService:
@bentoml.api(route="/readyz")
def readyz(self):
return {"ready": READY}
그리고 Deployment에 프로브를 설정합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: gpu-inference
spec:
replicas: 1
selector:
matchLabels:
app: gpu-inference
template:
metadata:
labels:
app: gpu-inference
spec:
containers:
- name: api
image: your-registry/gpu-inference:latest
ports:
- containerPort: 3000
resources:
limits:
nvidia.com/gpu: 1
memory: "8Gi"
cpu: "2"
requests:
nvidia.com/gpu: 1
memory: "8Gi"
cpu: "1"
readinessProbe:
httpGet:
path: /readyz
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 12
livenessProbe:
httpGet:
path: /readyz
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
프로브가 503을 반환하며 파드가 재시작되는 상황은 흔합니다. readiness와 liveness의 역할을 혼동하거나, 초기 로딩 시간을 고려하지 않아서 생깁니다. 비슷한 케이스의 디버깅 흐름은 K8s CrashLoopBackOff와 livenessProbe 503 해결법에서 더 자세히 다뤘습니다.
트래픽 처리: 동시성, 배치, 큐잉
콜드스타트를 줄였더라도, 첫 요청이 몰릴 때 지연이 튀면 “콜드스타트처럼” 보일 수 있습니다. GPU 추론 서버는 보통 다음을 같이 봐야 합니다.
- max concurrency: 워커가 동시에 처리할 요청 수
- 배치 추론: 여러 요청을 모아 한 번에 GPU에 태우기
- 큐 제한: 요청이 무한정 쌓이면 타임아웃과 재시도로 폭발
BentoML은 런타임 설정으로 동시성 제어가 가능하지만, 모델 특성상 GPU 메모리 사용량이 급증할 수 있습니다. 따라서 부하 테스트로 최적점을 찾아야 합니다.
메모리와 OOM: GPU만 보면 안 된다
GPU 추론에서 장애의 상당수는 CPU 메모리에서도 발생합니다.
- 토크나이저/전처리에서 큰 임시 객체 생성
- 배치 크기 증가로 입력 텐서가 커짐
- 모델 로딩 시 CPU 메모리에 한 번 올렸다가 GPU로 복사
특히 파드 메모리 limit이 타이트하면, 모델 로딩 중 순간 피크로 OOMKilled가 발생할 수 있습니다. 자바 사례지만 “덤프 기반으로 원인을 좁히고 튜닝하는 사고방식”은 동일합니다. 메모리 분석 흐름이 필요하면 Spring Boot OutOfMemoryError 덤프 분석·튜닝 7단계도 참고할 만합니다.
실무 팁은 다음과 같습니다.
- 파드 메모리 limit은 “평균”이 아니라 “피크” 기준으로 잡기
- 모델 로딩과 워밍업 시점의 RSS를 관측해 headroom 확보
- 배치 크기 상한을 두고, 입력 크기 제한을 API 레벨에서 강제
운영 팁: 관측과 재현이 곧 성능
콜드스타트 문제는 “측정하지 않으면” 해결이 어렵습니다. 다음을 로그와 메트릭으로 남기면 원인 분리가 빨라집니다.
- 프로세스 시작 시각, 모델 로딩 완료 시각, 워밍업 완료 시각
- 첫 요청 도착 시각과 해당 요청의 latency
- GPU 메모리 사용량, GPU utilization
- 파드 재시작 횟수와 원인 이벤트
또한 장애 재현을 위해서는 다음 시나리오가 중요합니다.
- 파드 0에서 1로 증가시키는 롤아웃
- 노드 교체 또는 드레인 이후 재스케줄링
- 이미지 캐시가 없는 신규 노드에서의 최초 기동
정리: 콜드스타트 0초를 만드는 체크리스트
- 요청 핸들러에서 모델 로딩 금지, 프로세스 시작 시 로딩
- GPU 워밍업을 기동 단계에 포함하고 완료를 동기화
- readinessProbe는 “진짜 추론 가능” 상태에서만 성공
- 최소 레플리카를 유지해 스케일 투 제로를 피하거나, 프리웜 전략 채택
- 메모리 피크를 고려해 requests와 limits를 보수적으로 설정
- 기동 단계 타임라인을 메트릭으로 남겨 원인 분리를 자동화
이 패턴대로 구성하면, 사용자 입장에서 첫 요청이 느려지는 현상을 대부분 제거할 수 있고, 배포/롤링 업데이트 시에도 안정적으로 GPU 추론 API를 운영할 수 있습니다.