Published on

KServe+Istio로 LLM 카나리·A/B 배포 실전

Authors

서빙 중인 LLM을 바꾸는 순간(모델 버전 교체, 양자화 적용, 프롬프트 템플릿 변경, 런타임 파라미터 튜닝)은 곧바로 품질과 비용, 지연시간에 영향을 줍니다. 문제는 LLM이 전통적인 분류 모델보다 회귀 테스트가 까다롭고, 동일 입력에도 비결정성이 존재하며, 장애가 나면 사용자 체감이 즉각적이라는 점입니다.

이 글은 KServe + Istio 조합으로 LLM을 카나리 배포(점진적 전환) 또는 A/B 테스트(집단 분리) 하는 실전 패턴을 다룹니다. 핵심은 다음 3가지를 한 덩어리로 묶어 운영하는 것입니다.

  • KServe가 제공하는 모델 서빙 추상화(InferenceService, Revision)
  • Istio가 제공하는 트래픽 분할(가중치), 조건부 라우팅(헤더), 관측 지표
  • 릴리즈에 필요한 가드레일(실패 시 롤백, 오류율/지연시간 기준, 비용 폭주 방지)

추가로 LLM 운영에서 자주 발생하는 무한 루프/재시도 폭주 같은 이슈는 호출 체인의 안전장치와도 연결됩니다. 도구 호출을 붙여 운영한다면 LangChain 도구호출 무한루프 차단 7가지도 함께 참고하면 좋습니다.

전체 아키텍처: KServe와 Istio의 역할 분리

KServe가 하는 일

  • 모델을 컨테이너로 서빙하거나(커스텀), 표준 런타임(Triton, vLLM 등)을 통해 서빙
  • InferenceService 단위로 엔드포인트를 제공
  • 리비전(Revision) 개념을 통해 새 버전 롤아웃 시점에 새로운 백엔드(서브셋) 를 구성

Istio가 하는 일

  • 외부 트래픽을 Ingress Gateway로 받고, 내부 서비스로 라우팅
  • VirtualService가중치 기반 분할 또는 헤더/쿠키 기반 라우팅
  • DestinationRulesubset(버전) 을 정의하고, 트래픽 정책(예: 연결 풀, outlier detection)을 적용

즉, KServe가 “버전이 다른 백엔드 풀”을 만들고, Istio가 “어떤 요청을 어느 풀로 보낼지”를 결정합니다.

사전 준비 체크리스트

  • Kubernetes 클러스터에 Istio 설치 및 Ingress Gateway 활성화
  • KServe 설치(보통 Knative 모드 또는 RawDeployment 모드 중 하나)
  • 네임스페이스에 Istio 사이드카 주입 설정

예시(네임스페이스 라벨):

kubectl label namespace ml istio-injection=enabled

KServe 설치 방식에 따라 리비전과 서비스 이름이 달라질 수 있으니, 아래 실습은 원리 중심으로 이해하고, 실제 리소스 이름은 kubectl get svc -n ml로 확인하며 맞추는 것을 권장합니다.

1) 베이스라인 LLM InferenceService 배포

아래는 예시용 InferenceService입니다. 실제로는 vLLM 런타임, Triton, 커스텀 서버(FastAPI 등)로 바꿔 사용합니다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llm-chat
  namespace: ml
spec:
  predictor:
    containers:
      - name: predictor
        image: ghcr.io/example/llm-server:1.0.0
        ports:
          - containerPort: 8080
        env:
          - name: MODEL_ID
            value: "my-llm-v1"
          - name: MAX_TOKENS
            value: "512"
        resources:
          requests:
            cpu: "2"
            memory: "8Gi"
          limits:
            cpu: "4"
            memory: "16Gi"

적용:

kubectl apply -f llm-chat-v1.yaml

KServe가 만든 서비스/리비전을 확인합니다.

kubectl get inferenceservice -n ml
kubectl get svc -n ml | grep llm-chat
kubectl get pods -n ml | grep llm-chat

여기서 중요한 것은 “v1 백엔드가 준비된 상태” 를 만들고, 이후 v2 백엔드를 추가한 뒤 Istio에서 트래픽을 나누는 것입니다.

2) 카나리 배포: 가중치 기반 트래픽 분할

목표

  • 처음엔 v2로 1%만 보내고
  • 품질/지연시간/오류율이 안정적이면 5% → 20% → 50% → 100%로 확장
  • 문제가 생기면 즉시 0%로 내려 롤백

v2 InferenceService(또는 리비전) 준비

v2는 모델/양자화/프롬프트/런타임 파라미터가 달라진 버전이라고 가정합니다.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llm-chat-v2
  namespace: ml
spec:
  predictor:
    containers:
      - name: predictor
        image: ghcr.io/example/llm-server:2.0.0
        ports:
          - containerPort: 8080
        env:
          - name: MODEL_ID
            value: "my-llm-v2"
          - name: MAX_TOKENS
            value: "512"
          - name: TEMPERATURE
            value: "0.7"
kubectl apply -f llm-chat-v2.yaml

이제 Istio에서 하나의 “논리 엔드포인트” 로 들어온 요청을 v1/v2로 분배합니다.

DestinationRule: subset 정의

서비스 이름은 환경마다 다릅니다. 여기서는 내부 서비스가 llm-chat-predictor.ml.svc.cluster.localllm-chat-v2-predictor.ml.svc.cluster.local 로 노출된다고 가정하고, “단일 호스트로 묶는” 방식 대신 VirtualService에서 두 호스트로 분기하는 단순 패턴을 씁니다(실무에서 이해하기 쉽습니다).

아래는 outlier detection을 적용해 불량 파드 자동 격리를 유도하는 예시입니다.

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: llm-chat-dr
  namespace: ml
spec:
  host: llm-chat-predictor.ml.svc.cluster.local
  trafficPolicy:
    outlierDetection:
      consecutive5xxErrors: 5
      interval: 5s
      baseEjectionTime: 30s
      maxEjectionPercent: 50

v2에도 동일하게:

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: llm-chat-v2-dr
  namespace: ml
spec:
  host: llm-chat-v2-predictor.ml.svc.cluster.local
  trafficPolicy:
    outlierDetection:
      consecutive5xxErrors: 5
      interval: 5s
      baseEjectionTime: 30s
      maxEjectionPercent: 50

VirtualService: 가중치 분할

Ingress Gateway를 통해 들어오는 트래픽을 v1과 v2로 분할합니다.

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: llm-chat-vs
  namespace: ml
spec:
  hosts:
    - llm.example.com
  gateways:
    - istio-system/istio-ingressgateway
  http:
    - name: canary-split
      route:
        - destination:
            host: llm-chat-predictor.ml.svc.cluster.local
            port:
              number: 80
          weight: 99
        - destination:
            host: llm-chat-v2-predictor.ml.svc.cluster.local
            port:
              number: 80
          weight: 1
      timeout: 30s
      retries:
        attempts: 2
        perTryTimeout: 15s
        retryOn: "5xx,connect-failure,refused-stream"

이제 v2로 1%가 흐릅니다. 운영에서는 다음을 함께 봅니다.

  • v2의 5xx 증가 여부
  • p95/p99 지연시간 상승 여부
  • 토큰 사용량(=비용) 급증 여부
  • 응답 품질(오프라인 평가 + 온라인 지표)

LLM API 호출에서 429가 늘어나는 경우는 단순 TPM 초과가 아닌 다양한 원인이 있을 수 있어, 외부 API를 함께 쓰는 구조라면 OpenAI Responses API 429인데 TPM만 넘는 6가지 원인 같은 체크리스트도 도움이 됩니다.

3) A/B 테스트: 헤더 기반 라우팅으로 집단 분리

가중치 카나리는 “전체 사용자 중 일부”에 무작위로 섞이지만, A/B는 보통 사용자 집단을 안정적으로 고정해야 합니다.

  • 내부 QA/스태프만 v2로 보내기
  • 특정 테넌트(고객사)만 v2로 보내기
  • 특정 실험 그룹 쿠키가 있는 요청만 v2로 보내기

헤더 기반 라우팅 예시

예를 들어 클라이언트가 x-llm-variant: v2 헤더를 보내면 v2로 라우팅합니다.

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: llm-chat-ab-vs
  namespace: ml
spec:
  hosts:
    - llm.example.com
  gateways:
    - istio-system/istio-ingressgateway
  http:
    - name: route-v2-by-header
      match:
        - headers:
            x-llm-variant:
              exact: "v2"
      route:
        - destination:
            host: llm-chat-v2-predictor.ml.svc.cluster.local
            port:
              number: 80
    - name: default-to-v1
      route:
        - destination:
            host: llm-chat-predictor.ml.svc.cluster.local
            port:
              number: 80

이 방식의 장점은 실험군을 확실히 통제할 수 있고, 장애 시에도 헤더를 제거하면 즉시 v1로 복귀 가능하다는 점입니다.

쿠키 기반(고정) 라우팅 팁

쿠키로 고정하려면 애플리케이션/게이트웨이에서 실험군 쿠키를 발급하고, Istio는 쿠키 값을 헤더처럼 매칭하면 됩니다.

주의할 점은 “사용자 식별자 해시 기반” 같은 고급 분배는 Istio 단독으로는 한계가 있어, 보통은 엣지(예: NGINX, EnvoyFilter, 애플리케이션) 에서 실험군을 결정하고 헤더로 내려주는 패턴이 운영 난이도가 낮습니다.

4) LLM 배포에서 특히 중요한 관측 포인트

카나리/A/B는 트래픽을 나누는 것만으로 끝나지 않습니다. LLM은 실패 모드가 다양해 관측 지표를 촘촘히 잡아야 합니다.

필수 지표

  • 인프라 지표: CPU/GPU 사용률, GPU 메모리, OOM, 스케줄링 대기
  • 네트워크/서빙 지표: RPS, p95/p99, 5xx, 429, 타임아웃
  • LLM 도메인 지표: 평균 출력 토큰, 입력 토큰, 토큰/초, 응답 길이 분포
  • 품질 지표: 사용자 피드백(thumbs up/down), 후속 이탈률, 재질문율

KServe/Envoy 레벨 로그에 실험 태그 남기기

A/B를 하면 “이 응답이 v1인지 v2인지”가 로그/트레이스에 남아야 합니다. 가장 단순한 방법은:

  • 클라이언트가 x-llm-variant 헤더를 보내고
  • 서버가 해당 헤더를 로그 필드에 포함

또는 응답 헤더에 x-served-by: v2 같은 값을 넣어 디버깅을 쉽게 만들 수 있습니다.

5) 실패 시나리오와 롤백 전략

즉시 롤백(가중치 0)

VirtualService에서 v2 weight를 0으로 내리면 됩니다.

# 핵심 부분만 발췌
route:
  - destination:
      host: llm-chat-predictor.ml.svc.cluster.local
    weight: 100
  - destination:
      host: llm-chat-v2-predictor.ml.svc.cluster.local
    weight: 0

흔한 장애 패턴

  • v2가 더 느려져 p99가 급등(특히 컨텍스트 길이 증가, KV cache 압박)
  • 특정 입력에서만 비정상적으로 긴 출력 생성(비용 폭주)
  • 도구 호출/재시도 루프가 결합되어 트래픽 증폭
  • 429 또는 upstream timeout이 늘면서 재시도가 중첩되어 더 악화

이때는 Istio retries를 과하게 켜지 않는 것이 중요합니다. LLM은 요청이 무겁고, 재시도는 비용과 지연을 동시에 악화시킬 수 있습니다. “재시도 2회”도 상황에 따라 과할 수 있으니, 서빙 특성에 맞춰 조정하세요.

6) 실전 운영 팁: 모델 버전만이 아니라 “프롬프트/설정”도 버전이다

LLM은 모델 바이너리만 바뀌는 게 아니라 다음도 자주 바뀝니다.

  • 시스템 프롬프트
  • 안전 필터 정책
  • 디코딩 파라미터(temperature, top-p)
  • RAG 설정(리트리버, 인덱스 버전)

따라서 v2를 만들 때는 이미지 태그만 올리는 게 아니라, 환경 변수/ConfigMap/Secret까지 함께 버전화해야 “재현 가능한 실험”이 됩니다.

GitOps(Argo CD)로 운영한다면 릴리즈 중 OutOfSyncHealth Degraded를 어떻게 해석할지도 중요합니다. 관련 이슈는 Argo CD Sync 실패 - OutOfSync·Health Degraded 8가지에서 점검 포인트를 참고할 수 있습니다.

7) 요청 라우팅 테스트 방법

헤더 기반 A/B 테스트 호출

아래처럼 헤더를 붙여 v2로 강제 라우팅이 되는지 확인합니다.

curl -sS https://llm.example.com/v1/chat/completions \
  -H 'content-type: application/json' \
  -H 'x-llm-variant: v2' \
  -d '{"messages":[{"role":"user","content":"Hello"}]}'

헤더 없이 호출하면 v1로 가야 합니다.

curl -sS https://llm.example.com/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","content":"Hello"}]}'

카나리 분할 검증

가중치 분할은 단건 요청으로 체감하기 어렵습니다. 요청을 여러 번 보내고, 서버 로그나 응답 헤더(x-served-by)로 분포를 확인합니다.

for i in $(seq 1 200); do
  curl -sS https://llm.example.com/v1/chat/completions \
    -H 'content-type: application/json' \
    -d '{"messages":[{"role":"user","content":"ping"}]}' \
    -o /dev/null
done

8) 보안과 멀티테넌시: 실험 기능이 곧 공격면이 된다

A/B를 헤더로 나누면, 악의적 사용자가 임의로 x-llm-variant: v2를 붙여 실험군으로 들어올 수 있습니다. 다음 중 하나로 방어합니다.

  • Ingress에서 해당 헤더를 외부 요청에서 제거하고, 내부 인증된 서비스만 주입
  • 실험군 헤더를 쓰되, 서명된 토큰이나 특정 클라이언트 인증이 있을 때만 인정
  • 테넌트 ID 기반으로 라우팅하고, 테넌트 ID는 인증 컨텍스트에서만 나오게 구성

JWT 기반으로 클레임을 신뢰하는 구조라면 kid 악용과 키 회전 검증도 함께 챙겨야 합니다. 관련 체크는 JWT kid 헤더 악용 방지 - JWK 키회전 검증 7단계를 참고하세요.

정리: KServe는 버전, Istio는 트래픽, 성공은 관측과 롤백

  • KServe로 v1/v2 서빙 단위를 만들고
  • Istio VirtualService로 가중치 카나리 또는 헤더 기반 A/B를 구현하며
  • LLM 특화 지표(토큰, 지연, 비용, 품질)를 함께 관측하고
  • 문제가 생기면 weight를 0으로 즉시 롤백하는 체계를 갖추면

LLM 릴리즈를 “한 번에 갈아끼우는 이벤트”가 아니라 통제 가능한 실험으로 바꿀 수 있습니다.

다음 단계로는 (1) Prometheus/Grafana에서 v1/v2 라벨로 대시보드 분리, (2) 자동 승격/자동 롤백(예: Flagger, Argo Rollouts 연동), (3) 오프라인 평가 파이프라인과 온라인 실험의 지표 정합성 확보까지 확장해보면 운영 난이도가 크게 내려갑니다.