Published on

Ray Serve로 멀티모델 A/B·카나리 배포 실전

Authors

서빙 환경에서 모델을 바꾸는 일은 단순히 새 체크포인트를 올리는 작업이 아닙니다. 운영 중인 트래픽을 끊지 않으면서, 성능과 비용을 비교하고, 문제 발생 시 즉시 되돌릴 수 있어야 합니다. 특히 LLM이나 멀티모달처럼 모델이 무겁고 지연 시간이 민감한 경우, 배포 전략은 곧 안정성 전략입니다.

Ray Serve는 배포 단위(Deployment)를 조합해 애플리케이션을 만들고, 리소스 스케줄링과 스케일링을 Ray 클러스터에 위임할 수 있어 멀티모델 운영에 잘 맞습니다. 이 글에서는 Ray Serve로 멀티모델 A/B 테스트와 카나리 배포를 구현하는 패턴을 “실제로 운영 가능한 형태”로 정리합니다.

Ray Serve로 멀티모델 배포를 설계하는 관점

멀티모델 A/B·카나리 배포를 설계할 때 핵심은 다음 4가지입니다.

  1. 라우팅(traffic shaping): 요청을 어떤 기준으로 어떤 모델로 보낼지
  2. 격리(isolation): 모델별 리소스, 장애 영향 범위를 어떻게 분리할지
  3. 관측(observability): 모델별 지연, 에러율, 품질 지표를 어떻게 수집할지
  4. 롤백(rollback): 문제가 생겼을 때 즉시 원복 가능한가

Ray Serve는 Deployment를 여러 개 띄우고, 그 앞단에 Router 역할의 Deployment를 둬서 트래픽 분배를 구현하는 방식이 가장 직관적입니다.

기본 구성: Router + Model Deployments

아래는 가장 흔한 형태의 토폴로지입니다.

  • router: 요청을 받아 model_v1, model_v2 중 하나로 전달
  • model_v1: 기존 안정 버전
  • model_v2: 신규 후보 버전(카나리 또는 A/B)

예시 코드: Ray Serve 애플리케이션

주의: MDX 빌드 에러를 피하기 위해 부등호 문자는 코드 블록 안에서만 사용합니다.

# app.py
import os
import time
import random
import hashlib
from typing import Dict, Any

from ray import serve


def stable_bucket(user_key: str, salt: str = "exp1") -> float:
    """0.0 ~ 1.0 균등 분포에 가깝게 만드는 안정 해시 버킷."""
    h = hashlib.sha256(f"{salt}:{user_key}".encode()).hexdigest()
    # 앞 8자리만 사용
    v = int(h[:8], 16)
    return (v % 10_000) / 10_000.0


@serve.deployment(
    ray_actor_options={"num_cpus": 1},
    autoscaling_config={
        "min_replicas": 1,
        "max_replicas": 10,
        "target_ongoing_requests": 8,
    },
)
class ModelV1:
    def __init__(self):
        self.version = "v1"

    async def __call__(self, request) -> Dict[str, Any]:
        payload = await request.json()
        t0 = time.time()
        # TODO: 실제 추론 코드로 교체
        time.sleep(0.02)
        return {
            "model": self.version,
            "input": payload,
            "latency_ms": int((time.time() - t0) * 1000),
        }


@serve.deployment(
    ray_actor_options={"num_cpus": 1, "num_gpus": 1},
    autoscaling_config={
        "min_replicas": 0,
        "max_replicas": 4,
        "target_ongoing_requests": 2,
    },
)
class ModelV2:
    def __init__(self):
        self.version = "v2"

    async def __call__(self, request) -> Dict[str, Any]:
        payload = await request.json()
        t0 = time.time()
        # TODO: 실제 추론 코드로 교체
        time.sleep(0.05)
        # 가끔 에러를 내는 상황을 가정
        if random.random() < 0.01:
            raise RuntimeError("synthetic failure")
        return {
            "model": self.version,
            "input": payload,
            "latency_ms": int((time.time() - t0) * 1000),
        }


@serve.deployment(ray_actor_options={"num_cpus": 0.5})
class Router:
    def __init__(self, v1, v2):
        self.v1 = v1
        self.v2 = v2

        # 카나리 비율(0.0 ~ 1.0). 운영에서는 환경변수/동적 설정으로 관리
        self.canary_ratio = float(os.getenv("CANARY_RATIO", "0.05"))

    async def __call__(self, request):
        # 사용자 단위 고정 라우팅을 위해 키를 추출
        user_key = request.headers.get("x-user-id") or request.client.host
        b = stable_bucket(user_key)

        # 카나리 라우팅
        if b < self.canary_ratio:
            return await self.v2.remote(request)
        return await self.v1.remote(request)


app = Router.bind(ModelV1.bind(), ModelV2.bind())

이 구조의 장점은 단순합니다.

  • 라우팅 로직이 Router 한 곳에 모여서 실험/배포 정책을 바꾸기 쉽습니다.
  • ModelV1, ModelV2의 리소스 요구사항을 완전히 다르게 줄 수 있습니다.
  • min_replicas0으로 두면 카나리 모델을 “필요할 때만” 켤 수도 있습니다.

A/B 테스트: 사용자 고정 분배와 실험 설계

A/B 테스트에서 자주 터지는 문제는 “요청 단위 랜덤 분배”입니다. 이렇게 하면 같은 사용자가 요청할 때마다 다른 모델을 만나고, 품질 피드백/전환율 같은 지표가 섞입니다.

위 예제처럼 x-user-id 기반으로 안정 해시를 만들면 다음이 가능해집니다.

  • 사용자 단위로 일관된 모델 경험 제공
  • 실험군/대조군의 지표를 정확히 분리

A/B 분배를 50:50으로 바꾸기

CANARY_RATIO=0.5로 두면 사실상 A/B 테스트가 됩니다. 다만 운영에서는 “카나리”와 “A/B”가 목적과 안전장치가 다르므로, 아래처럼 정책을 분리하는 걸 권합니다.

  • 카나리: 에러율/지연/자원 사용량 중심의 안전 배포
  • A/B: 품질/전환율 중심의 제품 실험

즉, 카나리로 안정성 검증을 통과한 뒤 A/B로 품질 비교를 하는 순서가 일반적으로 안전합니다.

카나리 배포: 단계적 확대와 자동 롤백 기준

카나리 배포는 “새 모델을 소량 트래픽에만 노출하고, 지표가 괜찮으면 점진적으로 늘리는” 전략입니다.

권장 스텝 예시:

  • 1%: 기능/호환성/치명적 에러 체크
  • 5%: 지연/메모리/스파이크 관찰
  • 20%: 비용과 안정성 추정
  • 50%: 사실상 대규모 리허설
  • 100%: 완전 전환

여기서 중요한 건 롤백 조건을 수치로 명문화하는 것입니다.

  • 5xx 비율이 기준 대비 +X%p 상승
  • p95 latency가 기준 대비 +Y ms 상승
  • OOM 또는 재시작 빈도 증가

LLM을 로컬에서 굴리는 경우 OOM이 가장 흔한 장애 원인입니다. 모델별로 KV 캐시나 양자화 설정이 달라지면 카나리에서만 터지기도 합니다. 로컬 LLM 메모리 튜닝 관점은 Transformers 로컬 LLM OOM - 4bit+KV 캐시 튜닝도 함께 참고하면 좋습니다.

라우팅 정책 고도화: 헤더 기반, 기능 플래그, 페일오버

운영에서는 단순 비율 분배만으로 부족합니다. 다음 3가지를 추가하면 “실전형”이 됩니다.

1) 헤더 기반 강제 라우팅

  • QA 팀: 무조건 v2로 보내서 검증
  • 특정 테넌트: 계약상 항상 v1 고정
# Router.__call__ 내부에 추가할 수 있는 예시
forced = request.headers.get("x-force-model")
if forced == "v2":
    return await self.v2.remote(request)
if forced == "v1":
    return await self.v1.remote(request)

2) 기능 플래그 연동

CANARY_RATIO를 환경변수로만 두면 변경 시 재배포가 필요할 수 있습니다. 실전에서는 다음 중 하나로 바꿉니다.

  • Redis/Consul/etcd에서 ratio를 주기적으로 폴링
  • LaunchDarkly 같은 플래그 시스템 연동
  • 내부 Admin API로 ratio 업데이트

핵심은 “배포 없이 트래픽 비율을 조정”하는 것입니다.

3) 페일오버(failover) 전략

카나리 모델이 실패하면 즉시 v1로 되돌리는 로직을 Router에 넣을 수 있습니다. 다만 무조건 페일오버는 지표를 왜곡할 수 있으니, 운영 목적에 따라 선택하세요.

try:
    return await self.v2.remote(request)
except Exception:
    # 페일오버: 사용자 경험은 살리되, 관측/알람은 반드시 남겨야 함
    return await self.v1.remote(request)

리소스 격리와 스케일링: 멀티모델 운영의 현실

멀티모델 운영에서 자주 발생하는 병목은 “특정 모델이 GPU를 독점해서 다른 모델까지 밀어내는” 상황입니다. Ray Serve에서는 Deployment별로 ray_actor_options를 분리해 다음을 통제할 수 있습니다.

  • num_gpus: 모델별 GPU 점유
  • num_cpus: 토크나이저/전처리 CPU 사용량
  • resources: 커스텀 리소스 태그로 특정 노드에만 배치

운영 팁:

  • 카나리 모델은 max_replicas를 낮게 잡아 비용 폭주를 막습니다.
  • target_ongoing_requests를 모델별로 다르게 둬서 지연 특성에 맞게 스케일링합니다.
  • 모델 로딩 시간이 긴 경우, min_replicas=0은 콜드스타트가 커질 수 있으니 카나리 단계에서는 1로 두는 것도 방법입니다.

관측(Observability): 모델별 지표를 반드시 분리하라

A/B·카나리에서 가장 중요한 것은 “모델별로 분리된 지표”입니다.

최소한 다음은 분리해서 봐야 합니다.

  • 모델 버전별 RPS, p50/p95/p99 latency
  • 모델 버전별 5xx, 타임아웃, 예외 종류
  • GPU 메모리 사용량, OOM 횟수, 재시작 횟수
  • (가능하면) 품질 지표: 클릭률, 전환, 휴먼 레이팅, 자동 평가 점수

실전에서는 Router에서 응답에 modelexperiment_bucket 같은 메타데이터를 포함시키고, API Gateway나 로그 파이프라인에서 이를 태깅해 메트릭을 분리합니다.

또 하나의 흔한 장애 포인트는 “외부 API 호출 실패”입니다. 예를 들어 LLM API를 함께 쓰는 하이브리드 구조라면, 실험군에서만 특정 파라미터가 달라져 400이 터질 수도 있습니다. 그런 류의 디버깅 감각은 OpenAI Responses API 400 에러 10분 해결 같은 케이스가 도움이 됩니다.

배포 방법: serve run과 설정 분리

개발/스테이징에서는 serve run이 빠릅니다.

serve run app:app

운영에서는 환경별 설정(카나리 비율, 리소스, 오토스케일 파라미터)을 코드에서 분리하는 편이 안전합니다. Ray Serve는 YAML 기반 설정도 지원하므로, 다음처럼 “코드=로직, 설정=운영 파라미터”로 나누면 변경 이력이 명확해집니다.

# serve-config.yaml (예시)
applications:
  - name: multimodel
    import_path: app:app
    route_prefix: /
    runtime_env: {}
    deployments: []
serve deploy serve-config.yaml

프로덕션에서는 GitOps로 CANARY_RATIO 변경을 PR로 남기거나, 별도 플래그 시스템으로 변경 이력을 남기는 것을 권합니다.

실전 체크리스트: 사고를 줄이는 운영 규칙

1) 모델 호환성

  • 입력 스키마가 동일한가
  • 출력 포맷이 동일한가(클라이언트 파서가 깨지지 않는가)
  • 토큰 제한, 타임아웃 정책이 동일한가

2) 안전장치

  • 카나리 상한선(예: 20%)을 먼저 걸어두고 시작
  • max_concurrency 또는 큐잉 정책으로 과부하 시 빠르게 실패
  • 페일오버 시에도 에러 로그와 메트릭은 반드시 남기기

3) 롤백 플랜

  • CANARY_RATIO=0으로 즉시 차단 가능한가
  • 이전 모델 아티팩트가 즉시 재배포 가능한가
  • 라우터/게이트웨이 캐시로 인해 전환이 지연되지 않는가

4) 장애 대응

서빙 프로세스가 재시작 루프에 빠지면 카나리든 A/B든 의미가 없어집니다. 노드/서비스 레벨에서 재시작 루프를 빠르게 잡는 방법은 systemd 서비스 재시작 루프, 10분 디버깅도 함께 보면 좋습니다.

마무리: Ray Serve에서 A/B와 카나리는 Router가 핵심

Ray Serve로 멀티모델 A/B·카나리 배포를 구현하는 가장 실용적인 패턴은 Router Deployment를 두고, 그 뒤에 버전별 모델 Deployment를 붙이는 방식입니다. 이 구조는 다음을 동시에 만족시킵니다.

  • 트래픽 분배 정책을 코드로 명확히 표현
  • 모델별 리소스/스케일링을 독립적으로 제어
  • 관측과 롤백을 단순화

다음 단계로는 (1) 동적 설정 저장소 연동, (2) 모델별 품질 자동 평가 파이프라인, (3) 멀티리전 또는 멀티클러스터 라우팅까지 확장할 수 있습니다. 하지만 그 모든 확장의 출발점은 “모델별 지표 분리”와 “즉시 롤백 가능한 라우팅”입니다.