- Published on
Ray Serve+KServe로 LLM 롤링배포·A/B 테스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LLM은 단순히 Deployment 하나 올려 끝나는 문제가 아닙니다. 모델 크기와 콜드스타트, GPU 스케줄링, 토크나이저/프롬프트 변경, 안전 필터 정책, 비용 최적화까지 함께 움직입니다. 이때 KServe는 Kubernetes 네이티브한 추론 배포 표준(리비전, 오토스케일, 트래픽 분산)을 제공하고, Ray Serve는 Python 레벨에서 그래프형 파이프라인, 동적 라우팅, 가중치 기반 분기 같은 “애플리케이션 서빙” 기능이 강합니다.
이 글에서는 두 가지를 조합해 다음을 구현하는 패턴을 설명합니다.
- KServe로 모델 리비전 관리 및 롤링 배포
- Ray Serve로 A/B 테스트(가중치 라우팅) 및 실험 로직
- 장애 시 빠른 롤백과 관측 지표 기반의 점진적 전환
왜 Ray Serve와 KServe를 같이 쓰나
KServe가 잘하는 것
- Kubernetes 상에서 표준적인 추론 리소스 모델 제공
InferenceService기반 리비전 관리와 트래픽 전환- Knative 기반 오토스케일(설정에 따라 scale-to-zero 포함)
- Ingress, 인증/인가, 네트워크 정책 등 클러스터 운영 요소와의 결합
Ray Serve가 잘하는 것
- Python 코드로 라우팅, 전처리, 후처리, 캐시, 배치, 폴백 등을 “서빙 코드”로 작성
- 여러 배포(Deployment)를 연결한 DAG 구성
- 요청 단위로 실험군 분기, 사용자 세그먼트 라우팅, 헤더 기반 라우팅 등 구현이 쉬움
함께 쓰는 대표 구조
- 외부 트래픽은 KServe Ingress로 받고
- KServe는 “서빙 엔드포인트”를 안정적으로 제공
- 내부적으로 Ray Serve가 실험 로직과 파이프라인을 담당
실무에서는 두 가지 방식이 자주 나옵니다.
- KServe로 LLM 모델 서버 자체를 리비전 관리하고, 상위 API 서버(또는 Ray Serve)가 가중치 라우팅
- Ray Serve 클러스터가 모델을 직접 로드하고 서빙하며, KServe는 상위 게이트웨이 역할(또는 반대로 KServe만 사용)
이 글은 1번 패턴을 중심으로 설명합니다. 운영 책임 경계가 명확하고, 모델 서버 교체나 롤백이 빠르기 때문입니다.
아키텍처 개요: KServe 리비전 + Ray Serve 라우팅
구성요소는 다음과 같습니다.
InferenceServiceA: LLM v1 (예:gpt-foo-1)InferenceServiceB: LLM v2 (예:gpt-foo-2)- Ray Serve API:
/v1/chat/completions같은 OpenAI 호환 API를 제공 - Ray Serve는 요청을 받아 A 또는 B로 프록시하고, 응답을 표준 포맷으로 반환
핵심은 트래픽 분산 기준을 Ray Serve가 결정한다는 점입니다.
- 사용자 단위 고정(A/B sticky) 분기
- 헤더 기반 실험(예:
x-experiment: llm-v2) - 가중치 기반 분산(예: 90% v1, 10% v2)
KServe는 각 모델 서버를 독립적으로 롤링 배포하고 헬스체크/오토스케일을 맡습니다.
KServe로 LLM 리비전(버전) 운영하기
KServe는 기본적으로 InferenceService 단위로 배포를 정의합니다. LLM 서빙은 구현 방식이 다양한데, 여기서는 예시로 “컨테이너 이미지에 모델 서버를 포함한 방식”을 가정합니다.
예시: InferenceService 두 개로 v1/v2 분리
아래 YAML은 개념 예시입니다. 실제로는 사용하는 런타임(예: vLLM, TGI, Triton 등)에 맞춰 컨테이너와 포트를 조정하세요.
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: llm-v1
spec:
predictor:
containers:
- name: llm
image: your-registry/llm-server:v1
ports:
- containerPort: 8080
resources:
limits:
nvidia.com/gpu: 1
cpu: "4"
memory: "16Gi"
---
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: llm-v2
spec:
predictor:
containers:
- name: llm
image: your-registry/llm-server:v2
ports:
- containerPort: 8080
resources:
limits:
nvidia.com/gpu: 1
cpu: "4"
memory: "16Gi"
이렇게 두 개로 분리하면 다음이 쉬워집니다.
- v2를 별도 배포로 올리고 워밍업
- v1은 그대로 유지한 채 일부 트래픽만 v2로 유도
- 문제가 있으면 v2만 내리거나 라우팅을 v1로 즉시 복구
롤링 배포 관점에서 체크할 것
- GPU 노드가 부족하면 스케줄링이 지연되고, v2가 준비되기 전에 트래픽이 가면 오류가 납니다. Ray Serve 라우팅에서 “준비된 백엔드만 선택”하도록 해야 합니다.
- 이미지 pull 병목이 자주 발생합니다. 특히 EKS 환경에서 대규모 롤아웃 시
ImagePullBackOff와 레지스트리 rate limit가 터질 수 있습니다. 이 이슈는 배포 신뢰성을 크게 떨어뜨리므로 미리 대비하세요. 관련해서는 EKS에서 ECR ImagePullBackOff 429 해결법도 함께 참고하면 좋습니다.
Ray Serve로 A/B 테스트 라우팅 구현
Ray Serve는 Python 코드로 라우팅을 구현할 수 있어 실험 설계가 유연합니다. 아래는 “가중치 기반 + 사용자 단위 sticky”를 같이 적용한 예시입니다.
예시: 요청을 v1 또는 v2 KServe 엔드포인트로 프록시
llm-v1과llm-v2는 KServe가 제공하는 내부 DNS로 접근한다고 가정합니다.- 사용자 식별자는
x-user-id헤더로 받는다고 가정합니다.
import hashlib
import os
from typing import Dict, Any
import httpx
from ray import serve
KSERVE_V1_URL = os.environ.get("KSERVE_V1_URL", "http://llm-v1.default.svc.cluster.local:8080")
KSERVE_V2_URL = os.environ.get("KSERVE_V2_URL", "http://llm-v2.default.svc.cluster.local:8080")
AB_WEIGHT_V2 = float(os.environ.get("AB_WEIGHT_V2", "0.1")) # 10% to v2
TIMEOUT_S = float(os.environ.get("UPSTREAM_TIMEOUT_S", "30"))
def pick_variant(user_id: str, weight_v2: float) -> str:
# sticky routing: hash user_id to [0, 1)
h = hashlib.sha256(user_id.encode("utf-8")).hexdigest()
bucket = int(h[:8], 16) / 0xFFFFFFFF
return "v2" if bucket < weight_v2 else "v1"
@serve.deployment
class ChatCompletionsRouter:
def __init__(self):
self.client = httpx.AsyncClient(timeout=TIMEOUT_S)
async def __call__(self, request) -> Dict[str, Any]:
body = await request.json()
user_id = request.headers.get("x-user-id", "anonymous")
forced = request.headers.get("x-llm-variant") # optional override
variant = forced if forced in ("v1", "v2") else pick_variant(user_id, AB_WEIGHT_V2)
upstream = KSERVE_V2_URL if variant == "v2" else KSERVE_V1_URL
# OpenAI-like path example
url = f"{upstream}/v1/chat/completions"
try:
resp = await self.client.post(url, json=body)
resp.raise_for_status()
data = resp.json()
# attach debug metadata (optional)
data.setdefault("_meta", {})
data["_meta"].update({"variant": variant})
return data
except httpx.HTTPError as e:
# fallback: if v2 fails, retry v1 once
if variant == "v2":
resp2 = await self.client.post(f"{KSERVE_V1_URL}/v1/chat/completions", json=body)
resp2.raise_for_status()
data = resp2.json()
data.setdefault("_meta", {})
data["_meta"].update({"variant": "v1", "fallback_from": "v2"})
return data
raise e
app = ChatCompletionsRouter.bind()
이 코드의 포인트는 다음입니다.
- sticky A/B: 동일 사용자는 계속 같은 variant로 가도록 해 실험 노이즈를 줄입니다.
- 강제 라우팅: 장애 분석이나 QA를 위해 헤더로
v2를 강제할 수 있습니다. - 폴백: v2가 실패하면 v1로 1회 폴백해 사용자 영향도를 줄입니다.
롤링 배포 시나리오: 0%에서 100%까지 점진 전환
실무에서 안전한 전환은 보통 아래 순서로 진행합니다.
1) v2 배포 및 준비 확인
- KServe로
llm-v2를 배포 - readiness가
True가 될 때까지 외부 트래픽 유입 금지 - 워밍업 요청(프롬프트 1~2개)으로 모델 로딩/커널 컴파일/캐시를 미리 태움
2) Ray Serve에서 가중치 1% 또는 내부 사용자만
AB_WEIGHT_V2=0.01처럼 아주 낮게 시작- 또는
x-llm-variant: v2헤더를 내부 테스트 트래픽에만 주입
3) 관측 지표 확인 후 점진 증량
- p95 지연시간, 오류율, 토큰당 비용, GPU utilization 등을 기준으로
- 1% → 5% → 10% → 25% → 50% → 100%
4) 문제 발생 시 즉시 롤백
- Ray Serve 환경변수에서
AB_WEIGHT_V2=0으로 즉시 복귀 - 또는 v2 InferenceService를 scale down
이 방식의 장점은 “모델 배포(인프라)”와 “실험 라우팅(애플리케이션)”을 분리해 장애 범위를 줄인다는 점입니다.
A/B 테스트에서 자주 놓치는 운영 포인트
세션 고정 기준을 명확히
x-user-id가 없다면 IP 기반 고정은 NAT 환경에서 왜곡될 수 있습니다.- 가능하면 인증된 사용자 ID, 디바이스 ID, 또는 익명 세션 토큰을 사용하세요.
프롬프트/토크나이저 차이로 인한 비교 불가능성
v2가 모델만 바뀐 게 아니라 프롬프트 템플릿, 시스템 메시지, 안전 필터 정책이 바뀌면 실험 결과 해석이 어려워집니다. 실험 단위(모델 vs 프롬프트 vs 파라미터)를 분리해 한 번에 하나씩 바꾸는 게 좋습니다.
관측: 라우팅 메타데이터를 로그에 남겨라
- 요청 로그에
variant,model_version,fallback여부를 반드시 남기세요. - 추후 “v2에서만 발생하는 오류”를 빠르게 필터링할 수 있습니다.
네트워킹과 인그레스: KServe 경로에서 생기는 함정
KServe를 붙이면 Ingress, Service mesh, NetworkPolicy 등과 상호작용이 늘어납니다. 특히 “Pod는 외부 egress는 되는데 ingress만 안 된다” 같은 문제는 모델 서빙에서 치명적입니다. 내부 통신은 되는데 외부에서만 404 또는 timeout이 나면 라우팅/게이트웨이 계층을 의심해야 합니다.
- 인그레스 컨트롤러의 대상 서비스 포트
- KServe가 생성하는 서비스 이름과 포트
- 네임스페이스 간 NetworkPolicy
이런 류의 트러블슈팅 관점은 EKS에서 Pod egress는 되는데 ingress만 실패할 때 글의 체크리스트가 그대로 도움이 됩니다.
배포 자동화 팁: 이미지/캐시 최적화로 롤아웃 실패 줄이기
LLM 컨테이너는 크기가 커서 배포 때마다 이미지 pull이 병목이 됩니다. 롤링 배포가 “느린 정도”를 넘어 “실패”로 이어지면 A/B 테스트 자체가 불가능해집니다.
- 멀티스테이지 빌드로 불필요한 레이어 제거
- 모델 가중치/토크나이저는 가능한 별도 볼륨 또는 캐시 전략 고려
- CI에서 레이어 캐시를 적극 활용
특히 GitHub Actions 기반으로 이미지를 자주 말아 올리는 팀이라면 GitHub Actions로 Docker 멀티스테이지·캐시 튜닝을 같이 보면 배포 속도와 안정성이 눈에 띄게 좋아집니다.
실무 권장 체크리스트
안정성
- v2 실패 시 v1 폴백(단, 무한 재시도 금지)
- 업스트림 타임아웃과 서킷브레이커 개념 도입
- 모델 워밍업 잡 또는 초기 트래픽 제한
비용
- A/B 비율을 올리기 전에 GPU utilization을 확인
- 토큰 길이 분포가 v2에서 달라지면 비용이 급증할 수 있음
품질
- 동일 프롬프트 세트로 오프라인 평가 후 온라인 A/B
- 온라인에서는 사용자 세그먼트별로 분리 분석
운영
- 라우팅 가중치는 코드 배포 없이 환경변수 또는 ConfigMap으로 조절
- 롤백은 “모델 롤백”과 “트래픽 롤백”을 분리해 더 빠르게
마무리
Ray Serve와 KServe를 결합하면, KServe의 Kubernetes 표준 배포/오토스케일 장점 위에 Ray Serve의 유연한 라우팅과 파이프라인 구성을 얹을 수 있습니다. 결과적으로 LLM 운영에서 가장 중요한 두 가지인 안전한 롤링 배포와 신뢰할 수 있는 A/B 테스트를 동시에 달성하기 쉬워집니다.
정리하면 다음 전략이 실무에서 가장 효율적입니다.
- KServe로 v1/v2를 분리 배포해 리비전 운영을 단순화
- Ray Serve에서 sticky 가중치 라우팅으로 실험을 통제
- 관측 메타데이터를 남기고, 문제 시 트래픽을 즉시 v1로 복구
다음 단계로는 Ray Serve 라우터에 배치 처리, 응답 캐시, 안전 필터, 툴 호출 같은 컴포넌트를 DAG로 확장해 “LLM 앱 서버”로 발전시키는 것을 추천합니다.