- Published on
Ray Serve로 FastAPI+LLM 카나리 배포 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스처럼 보이지만 실제로는 GPU, 모델 로딩 시간, 프롬프트 보안, 비용 같은 운영 이슈가 촘촘히 얽힌 것이 LLM API 배포입니다. 특히 FastAPI로 빠르게 만든 LLM 엔드포인트를 운영에 올릴 때는, 새 모델이나 새 프롬프트 템플릿을 한 번에 100% 트래픽에 노출하면 장애 반경이 커집니다. 이때 Ray Serve의 강점은 애플리케이션 레벨에서 트래픽 분할과 버전 공존을 자연스럽게 제공한다는 점입니다.
이 글에서는 Ray Serve로 FastAPI 스타일의 HTTP API를 만들고, LLM 백엔드를 카나리 배포로 점진적으로 전환하는 구성과 코드를 다룹니다. 또한 LLM 특유의 리스크인 Chain-of-Thought 누출, 비용 폭증, 지연시간 편차를 카나리 단계에서 어떻게 관측하고 제어할지까지 연결합니다.
- 프롬프트 방어 관점은 Chain-of-Thought 누출 막는 프롬프트 방어 7선 참고
- 비용 최적화는 LangChain RAG 캐시로 LLM 비용 70% 줄이기 참고
왜 Ray Serve로 카나리 배포를 하나
카나리 배포의 목적은 단순히 "점진적"이 아니라, 실패를 빠르게 감지하고 격리하는 것입니다. LLM API에서는 다음 문제가 자주 터집니다.
- 모델 버전 변경으로 응답 품질이 변동하거나 특정 도메인에서만 망가짐
- 토큰 사용량 증가로 비용이 선형이 아니라 계단식으로 폭증
- GPU 메모리 패턴 변화로 특정 입력에서만 OOM
- 스트리밍 응답, 타임아웃, 재시도 정책이 얽혀 지연시간 꼬리 지표가 악화
Ray Serve는 다음 기능으로 이 문제를 다루기 좋습니다.
- Deployment 단위로 레플리카 수, 리소스, 롤링 업데이트 제어
- Application graph 로 라우터와 백엔드를 분리하고, 트래픽 분할을 라우팅 레이어에서 수행
- Ray의 액터 모델 덕분에 모델 로딩을 프로세스 생명주기와 묶어 콜드스타트/재로딩 비용을 줄이기 쉬움
아키텍처: FastAPI 스타일 API + Ray Serve 라우터
구성은 크게 2단입니다.
- HTTP 엔드포인트:
/v1/chat/completions같은 FastAPI 스타일 스펙 - 백엔드: LLM 호출을 수행하는
LLMBackendV1,LLMBackendV2
라우터가 요청을 받아 일부는 V2로 보내고 나머지는 V1로 보내는 방식이 카나리의 핵심입니다. 또한 라우터에서 다음을 수행하면 운영이 쉬워집니다.
- 요청별
request_id생성 및 로깅 - 사용자 또는 테넌트 기준의 sticky routing
- 모델별 토큰 사용량, 지연시간, 오류율 메트릭 기록
프로젝트 스캐폴딩
아래는 가장 단순한 형태의 파일 구성 예시입니다.
app.py: Ray Serve 애플리케이션 정의requirements.txt:ray[serve],fastapi, LLM SDK 등
requirements.txt 예시는 다음처럼 시작할 수 있습니다.
ray[serve]==2.10.0
fastapi==0.110.0
uvicorn==0.27.1
pydantic==2.6.4
httpx==0.27.0
prometheus-client==0.20.0
핵심 코드: 카나리 라우터와 두 개의 LLM 백엔드
아래 코드는 Ray Serve의 @serve.deployment 를 이용해 라우터와 백엔드를 분리합니다. 부등호 문자가 본문에 노출되면 MDX에서 빌드 에러가 날 수 있으니, 타입 힌트 제네릭은 피하고 필요한 경우 인라인 코드로 감싸는 습관을 권장합니다.
# app.py
import os
import time
import uuid
import random
from typing import Optional
from fastapi import FastAPI, Header
from pydantic import BaseModel
from ray import serve
from prometheus_client import Counter, Histogram
app = FastAPI()
REQS = Counter(
"llm_requests_total",
"Total requests",
["route", "backend", "status"],
)
LAT = Histogram(
"llm_request_latency_seconds",
"Latency seconds",
["route", "backend"],
)
class ChatRequest(BaseModel):
user_id: str
prompt: str
temperature: Optional[float] = 0.2
class ChatResponse(BaseModel):
request_id: str
model: str
text: str
latency_ms: int
@serve.deployment
class LLMBackendV1:
def __init__(self):
# 예시: 모델 로딩을 여기서 수행
self.model_name = os.getenv("MODEL_V1_NAME", "gpt-4o-mini")
async def generate(self, req: ChatRequest) -> str:
# 실제로는 OpenAI, vLLM, TGI, Bedrock 등 호출
await self._fake_latency()
return f"[v1:{self.model_name}] {req.prompt}"
async def _fake_latency(self):
# v1은 상대적으로 안정적
time.sleep(0.05)
@serve.deployment
class LLMBackendV2:
def __init__(self):
self.model_name = os.getenv("MODEL_V2_NAME", "gpt-4.1-mini")
async def generate(self, req: ChatRequest) -> str:
# v2는 새 모델/프롬프트로 품질 개선을 노리지만 변동 가능
await self._fake_latency()
return f"[v2:{self.model_name}] {req.prompt}"
async def _fake_latency(self):
# v2는 예시로 지연시간 변동을 크게
time.sleep(0.02 + random.random() * 0.2)
@serve.deployment
@serve.ingress(app)
class CanaryRouter:
def __init__(self, v1, v2):
self.v1 = v1
self.v2 = v2
self.canary_percent = float(os.getenv("CANARY_PERCENT", "5"))
self.sticky = os.getenv("CANARY_STICKY", "1") == "1"
def _choose_backend(self, user_id: str) -> str:
# sticky 라우팅: 같은 user_id는 항상 동일 백엔드로
if self.sticky:
# 간단한 해시 기반 분기
bucket = (sum(user_id.encode("utf-8")) % 100) + 1
return "v2" if bucket <= self.canary_percent else "v1"
# 비 sticky: 요청마다 확률
return "v2" if random.random() * 100 < self.canary_percent else "v1"
@app.post("/v1/chat/completions", response_model=ChatResponse)
async def chat(self, body: ChatRequest, x_request_id: Optional[str] = Header(default=None)):
request_id = x_request_id or str(uuid.uuid4())
backend = self._choose_backend(body.user_id)
t0 = time.time()
try:
if backend == "v2":
text = await self.v2.generate.remote(body)
else:
text = await self.v1.generate.remote(body)
latency_ms = int((time.time() - t0) * 1000)
REQS.labels(route="chat", backend=backend, status="200").inc()
LAT.labels(route="chat", backend=backend).observe(latency_ms / 1000.0)
return ChatResponse(
request_id=request_id,
model=backend,
text=text,
latency_ms=latency_ms,
)
except Exception:
latency_ms = int((time.time() - t0) * 1000)
REQS.labels(route="chat", backend=backend, status="500").inc()
LAT.labels(route="chat", backend=backend).observe(latency_ms / 1000.0)
raise
def build_app():
v1 = LLMBackendV1.bind()
v2 = LLMBackendV2.bind()
return CanaryRouter.bind(v1, v2)
serve_app = build_app()
포인트는 다음입니다.
- 라우터가
CANARY_PERCENT로 트래픽 비율을 제어 CANARY_STICKY로 사용자별 고정 분기 여부 선택- 백엔드가 분리되어 있어 V2 장애가 나도 V1을 그대로 유지 가능
실행: 로컬에서 Ray Serve로 띄우기
로컬 테스트는 다음처럼 가능합니다.
export CANARY_PERCENT=10
export CANARY_STICKY=1
ray start --head
serve run app:serve_app --host 0.0.0.0 --port 8000
호출 예시는 다음과 같습니다.
curl -s -X POST http://localhost:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'x-request-id: test-001' \
-d '{"user_id":"alice","prompt":"요약해줘"}'
카나리 단계에서 반드시 봐야 할 지표
LLM 카나리는 단순히 5% 트래픽을 보내는 것으로 끝나지 않습니다. 최소한 아래를 분리해서 봐야 합니다.
1) 오류율: HTTP 500만 보면 안 됨
- upstream 타임아웃
- 모델 서버 429, 503
- 스트리밍 중 끊김
- 응답 스키마 파손
가능하면 라우터에서 오류를 분류해 라벨로 남기세요. 예를 들어 status="timeout", status="rate_limited" 같은 식입니다.
2) 지연시간: p50이 아니라 p95, p99
LLM은 꼬리가 길어지기 쉽습니다. 카나리 승격 조건을 p95 기반으로 잡는 것이 안전합니다.
3) 비용: 토큰 수, 캐시 히트율
새 프롬프트가 토큰을 늘리면 비용이 바로 증가합니다. RAG를 쓰는 경우 캐시가 없으면 카나리에서 비용이 먼저 터집니다. 비용 최적화 전략은 LangChain RAG 캐시로 LLM 비용 70% 줄이기 글의 접근을 함께 고려해볼 만합니다.
4) 보안: Chain-of-Thought, 시스템 프롬프트 누출
카나리에서 종종 "품질 개선"을 위해 프롬프트를 바꾸다가 내부 지시문이 노출됩니다. 프롬프트 방어 체크리스트는 Chain-of-Thought 누출 막는 프롬프트 방어 7선 을 함께 적용하는 것이 좋습니다.
운영 팁: 라우팅을 사용자 단위로 고정하라
LLM 품질 비교나 A/B 실험을 하려면 요청 단위 랜덤은 통계적으로는 편하지만 운영적으로는 불리할 때가 많습니다.
- 같은 사용자가 어떤 날은 V1, 어떤 날은 V2를 타면 "품질이 들쭉날쭉"하다는 불만이 생김
- 장애 분석 시 재현이 어려움
그래서 카나리 초반에는 sticky routing 을 권장합니다. 위 코드처럼 사용자 ID를 버킷팅하면, 카나리 퍼센트가 바뀌더라도 특정 사용자가 어느 버전에 속하는지 예측이 가능합니다.
승격 전략: 5%에서 100%까지의 체크포인트
현실적인 승격 시나리오는 다음처럼 단계화하는 것이 좋습니다.
1%또는 내부 직원만: 기능 동작, 스키마 호환, 기본 지표5%: p95 지연시간, 오류율, 토큰/비용 변화 확인20%: GPU 메모리, 동시성에서의 큐잉, 레이트리밋 정책 확인50%: 운영 임계치 근접 구간에서 안정성 확인100%: V1 유지 기간을 두고 롤백 경로 확보
여기서 중요한 건 "승격"보다 "즉시 후퇴"입니다. CANARY_PERCENT 를 환경 변수로 두면 가장 단순한 형태의 후퇴가 가능합니다. 더 발전시키면 Ray Serve의 설정을 GitOps로 관리하면서 점진적 변경을 자동화할 수 있습니다.
롤백 설계: V2가 망가져도 V1은 살아 있어야 한다
카나리 배포의 롤백은 보통 두 가지입니다.
- 트래픽을
0%로 낮추기 - V2 레플리카를
0으로 줄이거나 배포 자체를 내리기
또한 LLM 백엔드가 외부 API라면, 장애 시 재시도 폭풍이 발생할 수 있어 라우터에서 타임아웃과 서킷 브레이커를 고려해야 합니다. 간단히는 httpx.Timeout 과 실패 횟수 기반 차단을 둘 수 있습니다.
GPU 리소스와 동시성: 레플리카 설계가 품질을 좌우한다
LLM 서빙에서 레플리카는 단순히 QPS를 늘리는 수단이 아니라, 다음을 좌우합니다.
- 모델 로딩 횟수와 GPU 메모리 사용량
- 동시성 증가 시 토큰 생성 속도 저하 여부
- 배치 처리 또는 continuous batching 적용 가능성
Ray Serve에서는 Deployment에 리소스 요구량을 명시할 수 있습니다. 예시는 다음과 같습니다.
@serve.deployment(
ray_actor_options={"num_gpus": 1, "num_cpus": 2},
autoscaling_config={
"min_replicas": 1,
"max_replicas": 4,
"target_ongoing_requests": 8,
},
)
class LLMBackendV2:
...
위 설정에서 target_ongoing_requests 는 과도한 큐잉을 막는 데 도움이 됩니다. 다만 LLM은 요청당 처리 시간이 길고 편차가 커서, 단순한 동시 요청 수 기준이 항상 최적은 아닙니다. 카나리 단계에서 p95 꼬리가 늘어나면, 레플리카 증가보다 먼저 타임아웃, 최대 토큰, 스트리밍 정책을 점검하는 것이 더 효과적일 때가 많습니다.
FastAPI와의 관계: FastAPI를 "그대로" 올리는 게 목표가 아니다
Ray Serve에서 serve.ingress(FastAPI()) 를 쓰면 FastAPI 라우팅을 그대로 쓸 수 있지만, 운영 관점에서 중요한 것은 다음입니다.
- 라우터 레이어에서 관측과 정책을 통제
- 백엔드는 모델 버전별로 분리해 독립적으로 스케일/교체
즉, FastAPI는 "HTTP 스펙을 표현하는 도구"로 두고, 배포와 트래픽 제어는 Ray Serve의 그래프/디플로이먼트로 가져오는 것이 카나리 배포에 유리합니다.
체크리스트: 배포 전에 이것만은 확인
- 라우터에서
request_id를 생성하고 로그에 남기는가 - sticky 라우팅이 필요한 구간과 아닌 구간을 구분했는가
- p95, p99 지연시간과 오류율을 V1/V2로 분리해 관측하는가
- 토큰 사용량과 비용 지표를 카나리 단계에서 비교하는가
- 프롬프트 변경 시 누출 방어를 적용했는가
- 롤백은
CANARY_PERCENT=0같은 한 줄 변경으로 가능한가
마무리
Ray Serve는 LLM 서빙에서 자주 필요한 "버전 공존"과 "점진적 트래픽 분할"을 애플리케이션 레벨에서 단순하게 구현하게 해줍니다. FastAPI 스타일의 엔드포인트를 유지하면서도, 라우터와 백엔드를 분리해 카나리 배포를 만들면 다음이 쉬워집니다.
- 새 모델과 프롬프트를 작은 반경에서 검증
- 지연시간 꼬리와 비용 증가를 조기에 감지
- 문제 발생 시 즉시 트래픽 후퇴 및 격리
다음 단계로는 라우터에 사용자 세그먼트 기반 정책, 품질 평가용 섀도 트래픽, 그리고 프롬프트/모델 버전 메타데이터를 응답 헤더에 포함해 관측성을 강화하는 것을 추천합니다.