이 글에서 다루는 것

MLflow 2.x의 alias 기반 모델 버전 관리로 Champion/Challenger 패턴을 구현하고, FastAPI와 연동한 zero-downtime hot swap 구조를 설계하는 과정

선수지식


이 단계에서 해결하려는 문제

MLflow 2.x부터 Staging/Production Stage가 deprecated됩니다. 대신 Registered Model Alias 방식이 공식 권장 패턴이 되었습니다.

이 변화는 단순한 API 교체가 아닙니다. 모델 버전 관리의 책임 구조가 바뀌는 것입니다.

이 글은 Stage 방식의 한계를 분석하고, alias 기반 Champion/Challenger 패턴과 FastAPI와 연동한 zero-downtime hot swap 구조를 설명합니다.


🎯 핵심 요약

  • Stage 방식 한계: 고정된 Stage 이름(Staging/Production)은 운영 유연성 부족
  • alias 방식 전환: @champion, @challenger 등 의미 있는 이름으로 버전 지정
  • Champion/Challenger 패턴: 운영 모델(A)과 검증 모델(B)을 동시에 서빙
  • hot swap 구조: FastAPI /variant/{alias}/reload -> MLflow alias 조회 -> 모델 교체
  • zero-downtime: 모델 교체 시 서비스 중단 없음 (메모리 내 교체)
  • MLflow FQDN
    • dev: http://mlflow-dev-service.mlflow-dev.svc.cluster.local:5000
    • prod: http://mlflow-prod-service.mlflow-prod.svc.cluster.local:5000

1️⃣ Stage 방식의 한계

MLflow 1.x 시대에는 모델 버전에 Stage를 부여했습니다.

# 구방식 (deprecated)
client.transition_model_version_stage(
    name="best_model",
    version=3,
    stage="Production",  # None / Staging / Production / Archived
)

이 방식에는 다음 문제가 있습니다.

고정된 Stage 이름

Stage는 None, Staging, Production, Archived 네 가지만 존재합니다. 운영에서 필요한 champion, challenger, shadow, rollback 같은 개념을 표현할 수 없습니다.

동시 서빙 불편

Production Stage에는 하나의 버전만 있는 것이 일반적입니다. Champion/Challenger 패턴에서 두 모델을 동시에 서빙하려면 Stage 이름만으로는 구분이 어렵습니다.


2️⃣ alias 방식으로 전환

alias는 특정 모델 버전에 이름을 부여하는 방식입니다.

from mlflow.tracking import MlflowClient

client = MlflowClient()

# alias 설정: version 3에 champion alias 부여
client.set_registered_model_alias(
    name="best_model",
    alias="champion",
    version=3,
)

# alias 설정: version 4에 challenger alias 부여
client.set_registered_model_alias(
    name="best_model",
    alias="challenger",
    version=4,
)

alias로 모델을 로드할 때는 다음 URI를 사용합니다.

import mlflow.pyfunc

# champion 모델 로드
model = mlflow.pyfunc.load_model("models:/best_model@champion")

# challenger 모델 로드
model = mlflow.pyfunc.load_model("models:/best_model@challenger")

alias의 장점:

models:/best_model@champion     → 현재 운영 모델
models:/best_model@challenger   → 검증 중인 모델
models:/best_model@shadow       → Shadow 테스트 모델
models:/best_model@rollback     → 롤백 대상 모델

이름이 명시적이므로 운영 의도가 코드에 직접 드러납니다.


3️⃣ Champion/Challenger 패턴

Champion은 현재 운영 중인 모델, Challenger는 검증 중인 새 모델입니다.

트래픽 분배 예시 (A/B 테스트)
요청 → FastAPI
          ↓
   client_id 해시 기반 분기
          ↓
   90% → champion (alias=A)
   10% → challenger (alias=B)

이 구조의 핵심:

  • Champion을 교체하지 않고 Challenger와 동시에 운영
  • Challenger 성능이 충분하면 alias를 재지정해서 Champion으로 승격
  • 서비스 중단 없이 모델 교체

alias 재지정으로 승격

# Challenger(version 4)를 Champion으로 승격
client.set_registered_model_alias(
    name="best_model",
    alias="champion",
    version=4,
)

# 기존 Champion(version 3)은 rollback alias로 유지
client.set_registered_model_alias(
    name="best_model",
    alias="rollback",
    version=3,
)

alias만 재지정하면 됩니다. 모델 파일은 그대로입니다.


4️⃣ FastAPI와의 hot swap 구조

전체 흐름

[alias 재지정]
MLflow Registry: best_model@champion → version 4

      ↓

[hot swap 요청]
POST /variant/A/reload
  Header: x-token: <RELOAD_SECRET_TOKEN>

      ↓

[FastAPI 처리]
1. x-token 검증
2. MLflow에서 alias=A 조회
3. mlflow.pyfunc.load_model(model_uri)
4. app.state.models["A"] 교체
5. Slack 알림 전송

      ↓

[결과]
서비스 중단 없이 version 4 모델로 교체 완료

model_loader.py (핵심)

# services/model_loader.py
def load_model_by_alias(alias: str):
    mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
    client = MlflowClient()

    model_uri = f"models:/{settings.model_name}@{alias}"
    model = mlflow.pyfunc.load_model(model_uri)

    version_info = client.get_model_version_by_alias(settings.model_name, alias)

    return {
        "model": model,
        "info": {
            "model_name": settings.model_name,
            "alias": alias,
            "version": version_info.version,
            "run_id": version_info.run_id,
            "model_uri": model_uri,
        },
    }
    # ... (에러 처리 생략)

reload.py (핵심)

# routes/reload.py
@router.post("/variant/{alias}/reload")
def reload_model(alias: str, request: Request, x_token: str | None = Header(default=None)):
    if x_token != settings.reload_secret_token:
        raise HTTPException(status_code=403, detail="Invalid token")

    new_entry = load_model_by_alias(alias)
    if new_entry is None:
        raise HTTPException(status_code=500, detail=f"모델 로딩 실패: alias={alias}")

    # 메모리 내 교체 (zero-downtime)
    request.app.state.models[alias] = new_entry
    # ... (로깅, Slack 알림 생략)

    return {"status": "reloaded", "alias": alias, "version": new_entry["info"]["version"]}

5️⃣ alias 정보 조회: mlflow_meta.py

현재 어떤 버전이 어떤 alias를 가지고 있는지 확인하는 유틸리티입니다.

# utils/mlflow_meta.py
def get_alias_info(model_name: str, alias: str, tracking_uri: str) -> dict:
    mlflow.set_tracking_uri(tracking_uri)
    client = MlflowClient()
    version_info = client.get_model_version_by_alias(model_name, alias)
    return {
        "model_name": model_name,
        "alias": alias,
        "version": version_info.version,
        "run_id": version_info.run_id,
        "model_uri": f"models:/{model_name}@{alias}",
        "status": version_info.status,
    }
    # ... (list_aliases 함수 생략)

6️⃣ Airflow DAG에서 alias 자동 관리

E2E 파이프라인에서 모델 학습 후 alias를 자동으로 관리합니다.

# dags/mlops_lib/core/registry.py (핵심 부분)
def promote_to_champion(model_name: str, version: str, tracking_uri: str):
    mlflow.set_tracking_uri(tracking_uri)
    client = MlflowClient()

    # 기존 champion을 rollback으로 보존
    try:
        old_champion = client.get_model_version_by_alias(model_name, "champion")
        client.set_registered_model_alias(name=model_name, alias="rollback", version=old_champion.version)
    except Exception:
        pass

    # 새 버전을 champion으로 승격
    client.set_registered_model_alias(name=model_name, alias="champion", version=version)

DAG에서 Promotion/Shadow 분기 후 호출합니다.

if should_promote:
    promote_to_champion(model_name, version, tracking_uri)
    requests.post(f"{fastapi_url}/variant/A/reload", headers={"x-token": reload_token})
else:
    assign_as_challenger(model_name, version, tracking_uri)

7️⃣ 운영 표준 확인 루틴

# 현재 champion alias 버전 확인
curl -s http://mlflow-dev-service.mlflow-dev.svc.cluster.local:5000/api/2.0/mlflow/registered-models/alias \
  -G -d name=best_model -d alias=champion \
  | jq '.model_version | {version, run_id, status}'

# FastAPI 현재 로딩 모델 확인
curl -sk https://fastapi.local/models | jq .

# hot swap 테스트 (alias A 교체)
curl -sk -X POST https://fastapi.local/variant/A/reload \
  -H "x-token: <RELOAD_SECRET_TOKEN>" | jq .

🧩 트러블슈팅

(1) MlflowException: Registered model alias ... not found

alias가 설정되지 않은 상태에서 로드를 시도한 경우입니다. set_registered_model_alias로 먼저 등록합니다.

(2) hot swap 후에도 이전 버전이 계속 사용됨

app.state.models 교체는 정상이지만 예측에서 계속 이전 모델을 쓰는 경우입니다. predict.py에서 모듈 레벨 전역 변수에 모델을 캐싱하고 있지 않은지 확인합니다. 매 요청마다 request.app.state.models에서 조회해야 합니다.

(3) MLflow Tracking Server 연결 실패

K8s 내부에서 FQDN을 정확히 써야 합니다: <service>.<namespace>.svc.cluster.local:<port>


설계 판단 (Why This Way?)

Stage의 고정된 4가지 상태 대신 alias를 사용하여 champion/challenger/shadow/rollback 같은 운영 의도를 자유롭게 표현하고, FastAPI 메모리 내 모델 교체(hot swap)로 Pod 재시작 없이 zero-downtime 모델 전환을 구현합니다.


다음에 읽을 글

GitOps 기반 E2E ML Platform - 설계 의도 — Level 5 시작: 플랫폼 전체 설계 철학과 목표