나만의 컨테이너로 엔드포인트 배포하기
나만의 컨테이너로 엔드포인트 배포하기
vLLM이나 SGLang 같은 고성능 인퍼런스 엔진이 지원하지 않는 모델이거나, 커스텀 추론 로직을 쓰고 싶거나, 특정 Python 의존성이 필요한 경우가 있어요. 그럴 때 Inference Endpoints에 커스텀 Docker 컨테이너를 직접 배포할 수 있어요. 이 글에서는 FastAPI 서버를 만들어 SmolLM3-3B 모델을 서빙하고, 그걸 컨테이너로 만들어 엔드포인트에 배포하는 흐름을 처음부터 따라가 볼게요.
1. 추론 서버 만들기
먼저 uv로 프로젝트를 초기화해요. pip이나 conda를 써도 되지만, 여기서는 uv를 기준으로 진행할게요.
uv init inference-server
main.py는 /repository에서 모델을 불러오고, FastAPI 앱을 띄우며, /health와 /generate 라우트를 노출하는 역할을 해요.
[!IMPORTANT] Inference Endpoints는 모델 아티팩트를 초고속으로 내려받는 방법이 따로 있어요. 그래서 코드 안에서 모델을 내려받는 로직은 넣지 않는 게 좋아요. 엔드포인트 생성 시 고른 모델이 컨테이너 안의
/repository에 마운트되니까, 모델은 항상/repository에서 불러와야 해요. Hugging Face Hub에서 직접 내려받으면 안 됩니다.
의존성 설치
코드를 쓰기 전에 필요한 의존성을 설치해요.
uv add transformers torch "fastapi[standard]"
DEVICE와 DTYPE 전역 변수는 실제 GPU/CPU 하드웨어 가용성에 따라 동적으로 정해집니다.
설정 추가
import logging
import time
from contextlib import asynccontextmanager
from typing import Optional
import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from transformers import AutoModelForCausalLM, AutoTokenizer
# ------------------------------------------------------
# Config + Logging
# ------------------------------------------------------
MODEL_ID = "/repository"
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
DTYPE = torch.bfloat16 if torch.cuda.is_available() else torch.float32
ModelManager 구현
모델과 토크나이저를 그냥 전역 객체로 두면 생명주기 관리가 어려워져요. 작은 ModelManager 클래스 하나로, 서버 시작 때 모델을 가속기에 적극적으로 로드하고 서버 종료 시 메모리에서 안전하게 해제하도록 관리해요.
def __init__(self, model_id: str, device: str, dtype: torch.dtype):
self.model_id = model_id
self.device = device
self.dtype = dtype
self.model: Optional[AutoModelForCausalLM] = None
self.tokenizer: Optional[AutoTokenizer] = None
요청·응답 스키마 정의
max_new_tokens의 기본값은 128이고 최대 512까지 늘릴 수 있어요. 이렇게 상한을 둔 게 요청 하나가 잡아먹을 수 있는 최대 메모리를 실용적으로 제한하는 방법이에요.
# ------------------------------------------------------
# Schemas
# ------------------------------------------------------
class GenerateRequest(BaseModel):
prompt: str = Field(..., min_length=1, description="Plain-text prompt")
max_new_tokens: int = Field(
128,
ge=1,
le=MAX_NEW_TOKENS,
description="Upper bound on generated tokens",
)
class GenerateResponse(BaseModel):
response: str
input_token_count: int
output_token_count: int
여기에 temperature, top_p 같은 모델이 지원하는 추가 설정을 넣어 확장해도 좋아요.
서버 라우트 구현
/health 라우트에서는 ModelManager를 통해 모델과 토크나이저가 준비됐는지 확인해요. 만약 ModelNotLoadedError가 나면 상태 코드 503으로 에러를 돌려줍니다. Inference Endpoints(그리고 대부분의 다른 플랫폼)는 readiness probe가 매초 /health를 찔러 정상 여부를 확인하거든요. 이 패턴을 쓰면 모델·토크나이저 초기화가 끝나기 전에는 서버가 준비되지 않았음을 분명히 알려줄 수 있어요.
# ------------------------------------------------------
# Routes
# ------------------------------------------------------
@app.get("/health")
def health():
try:
model_manager.get()
except ModelNotLoadedError as exc:
...
로컬 실행
로컬에서 실행할 때는 /repository가 없으니, 그 부분만 실제 모델 ID로 바꿔요. 그리고 실수로 안 바꿨다가 다시 Hub에서 내려받으려 하지 않도록 주의해서, 꼭 다시 되돌려 두셔야 해요.
- MODEL_ID = "/repository"
+ MODEL_ID = "HuggingFaceTB/SmolLM3-3B"
전체 서버 코드
핵심만 모아 보면 이런 구조예요. 모델 매니저를 만들고, 라이프사이클(startup + shutdown)에서 로드·해제를 관리하며, 요청 처리 시 torch.inference_mode() 안에서 생성을 수행해요.
model_manager = ModelManager(MODEL_ID, DEVICE, DTYPE)
# ------------------------------------------------------
# Lifespan (startup + shutdown)
# ------------------------------------------------------
...
add_generation_prompt=True,
)
else:
input_text = request.prompt
inputs = tokenizer(input_text, return_tensors="pt").to(DEVICE)
try:
with torch.inference_mode():
outputs = model.generate(**inputs, max_new_tokens=request.max_new_tokens)
2. Docker 이미지 빌드
서버를 컨테이너로 패키징할 때는 표준적인 Dockerfile을 쓰면 돼요. Base PyTorch 이미지(CUDA·cuDNN 포함)를 쓰고, uv 바이너리를 복사하며, 권한 있는 사용자로 안 돌도록 하고, uv로 의존성 설치 후 올바른 포트를 노출하고 서버를 실행하는 흐름이에요.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
[!TIP] 모델 가중치는 이미지에 넣지 않는 게 좋아요. Inference Endpoints가 선택한 모델을
/repository에 마운트해 주니까, 이미지에는 코드와 의존성만 담으면 됩니다.
3. 이미지 빌드·푸시
이미지를 빌드해 Inference Endpoints가 접근할 수 있는 레지스트리(Docker Hub, Amazon ECR, Azure ACR, Google GCR)에 푸시해요.
docker build -t your-username/smollm-endpoint:v0.1.0 . --platform linux/amd64
docker push your-username/smollm-endpoint:v0.1.0
[!NOTE] 왜
--platform linux/amd64를 붙일까요? Mac에서 빌드하면 기본적으로arm64로 만들어져요. 그런데 arm64는 Inference Endpoints가 지원하지 않는 아키텍처라서,x86아키텍처를 명시하려고 이 플래그를 붙이는 거예요.
4. 엔드포인트 만들기
- Inference Endpoints 대시보드에서 Deploy를 클릭해요.
- 모델 저장소로
HuggingFaceTB/SmolLM3-3B를 골라요 (컨테이너 안/repository에 마운트돼요). - 컴퓨팅·네트워킹·컨테이너 설정을 정하는 구성 페이지가 나와요.
- 하드웨어를 골라요. 이 데모에선 제안된 L4를 쓰면 돼요.
- Custom Container 아래에 이미지 URL(예:
your-username/smollm-endpoint:v0.1.0)과 컨테이너가 노출하는 포트(여기선8000)를 입력해요. - **“Create Endpoint”**를 클릭하면 플랫폼이 컨테이너 이미지를 pull하고, 모델을
/repository에 마운트하고, FastAPI 서버를 시작해요. - 짧은 초기화 시간이 지나 상태가 Running으로 바뀌면 커스텀 컨테이너가 요청을 서빙하기 시작해요.
배포가 끝나면 엔드포인트는 이런 형태의 URL로 제공돼요.
https://random-number.region.endpoints.huggingface.cloud/
테스트용 최소 Python 클라이언트는 이렇게 써볼 수 있어요.
from huggingface_hub import get_token
import requests
url = "https://random-number.region.endpoints.huggingface.cloud/generate"
prompt = "What is an Inference Endpoint?"
data = {"prompt": prompt, "max_new_tokens": 512}
response = requests.post(
url=url,
json=data,
headers={
"Authorization": f"Bearer {get_token()}",
"Content-Type": "application/json",
},
).json()
print(f"Input:\n{prompt}\n\nOutput:\n{response['response']}")
엔드포인트의 Logs 탭을 열면 들어오는 POST 요청과 모델 응답이 찍히는 걸 볼 수 있어요.
5. 다음 단계
여기까지 따라왔다면 커스텀 컨테이너 배포의 기본 흐름은 다 익힌 거예요. 여기서 한걸음 더 나아가 오디오 모델이나 이미지 생성 모델처럼 완전히 다른 모델로도 같은 데모를 확장해 보면 재미있어요.
더 알아보기
- 엔드포인트 만들기 가이드에서 기본 생성 절차를 복습해 보세요
- SmolLM3 모델 설정 문서에서 추가 생성 파라미터를 확인해 보세요