LLM을 한 대만 굴릴 때는 없던 문제들이, 두 대가 되는 순간 한꺼번에 생깁니다.
어느 쪽으로 보낼지, 한 대가 죽으면 어떻게 할지, 프롬프트에 섞여 들어온 개인정보를 어디서 막을지 여러 문제가 발생하죠.
이걸 애플리케이션마다 구현하는 대신 게이트웨이 한 겹으로 모으는 게 LiteLLM Proxy가 하는 일입니다.
이 글은 그 게이트웨이를 Kubernetes 위에 직접 세우고, 마스킹 · 라우팅 · Failover · Auto Routing + 모니터링이 정말 그렇게 동작하는지 하나씩 확인한 기록입니다.
| 환경 | Kubernetes · NVIDIA L40S 1장(HAMi 분할) |
| 구성 | LiteLLM Proxy + vLLM ×3 + Presidio ×2 |
| 모델 | Qwen2.5-0.5B / Qwen2.5-1.5B |
최종 구성
┌──────────────────────────────┐
Client ────────▶ │ LiteLLM Proxy :4000 │
│ │
│ 마스킹 · 라우팅 · Failover │
│ · Auto Routing │
└───┬──────────────────────┬───┘
│ │
┌────────────────┴──────┐ │
▼ ▼ ▼
vllm-a :8000 vllm-b :8000 vllm-c :8000
Qwen2.5-0.5B Qwen2.5-0.5B Qwen2.5-1.5B
└──── 그룹: qwen-fast ─────┘ 그룹: qwen-quality
│
Presidio ×2 (분석 + 치환, GPU 불필요)용어부터
처음 보는 이름이 여럿이라 먼저 짧게 정리합니다.
| 이름 | 무엇인가 |
|---|---|
| vLLM | LLM 추론 서버. OpenAI 호환 API(/v1/chat/completions)를 제공합니다. 실제로 GPU를 쓰는 쪽 |
| LiteLLM Proxy | 여러 LLM 앞에 서는 게이트웨이. 역시 OpenAI 호환이라 클라이언트는 바뀔 게 없습니다 |
| HAMi | GPU 한 장을 여러 파드가 나눠 쓰게 해주는 스케줄러. 물리 1장을 N개 슬라이스로 광고합니다 |
| Presidio | Microsoft의 PII 탐지·치환 엔진. LiteLLM이 마스킹을 여기에 위임합니다 |
| 모델 그룹 | LiteLLM에서 같은 이름으로 묶인 백엔드 묶음. 라우팅·Failover의 단위 |
왜 백엔드가 2 + 1인가
같은 모델을 서빙하는 두 대(vllm-a, vllm-b)는 로드밸런싱과 Failover를 관찰할 대상입니다. 한 대만 있으면 "분산됐다"를 볼 수 없고, 죽였을 때 갈 곳도 없습니다.
더 큰 모델 한 대(vllm-c)는 Auto Routing이 고를 상위 티어입니다. 티어가 하나뿐이면 자동 라우팅은 고를 게 없어 볼 것도 없습니다.
0. 사전 준비
시작 전에 네 가지를 확인합니다. 하나라도 안 되면 뒤에서 막힙니다.
GPU와 분할 방식
kubectl get node -o json | python3 -c "
import json,sys
for n in json.load(sys.stdin)['items']:
a = n['status']['allocatable']
g = {k:v for k,v in a.items() if 'nvidia' in k}
if g: print(n['metadata']['name'], g)
"gpu2-gpu-pool-mk7jk-hgbdd {'nvidia.com/gpu': '10'}물리 GPU는 L40S 한 장(45 GiB) 인데 nvidia.com/gpu가 10으로 나옵니다. HAMi가 10분할해서 광고하고 있다는 뜻입니다.
HAMi가 없다면
순정 NVIDIA device plugin은 GPU를 통째로만 줍니다(nvidia.com/gpu: 1= 물리 1장). 이 경우 GPU가 3장 있어야 이 실습을 그대로 할 수 있고, 1장뿐이라면 vLLM을 1대만 띄우고 라우팅·Failover 부분은 건너뛰어야 합니다. 뒤에 나오는nvidia.com/gpumem줄도 HAMi 전용이라 지워야 합니다.
모니터링 스택
kubectl get prometheus -A
kubectl -n monitoring get svc | grep prometheuskube-prometheus-stack이 이미 있다고 가정합니다. ServiceMonitor CRD가 없으면 05장이 동작하지 않습니다.
Prometheus가 어떤 ServiceMonitor를 주워가는지도 미리 봅니다. 이게 비어 있으면 전 네임스페이스를 다 수집한다는 뜻이라 편합니다.
kubectl -n monitoring get prometheus \
-o jsonpath='{.items[0].spec.serviceMonitorSelector}'{} ← 비어 있음 = 라벨 조건 없이 전부 수집비어 있지 않다면, 뒤에서 만들 ServiceMonitor에 거기 맞는 라벨을 달아야 합니다. 흔한 예로 release: kube-prometheus-stack.
인터넷 접근
모델을 Hugging Face에서 받고, 컨테이너 이미지도 밖에서 당겨옵니다.
kubectl create ns llm-lab
kubectl -n llm-lab run nettest --image=busybox:1.36 --restart=Never -- \
sh -c 'wget -q -T 8 --spider https://huggingface.co && echo HF-OK'
sleep 15 && kubectl -n llm-lab logs nettest
kubectl -n llm-lab delete pod nettest폐쇄망이라면 모델을 미리 받아 PVC에 넣고 --model에 로컬 경로를 주는 방식으로 바꿔야 합니다.
디스크
vLLM 이미지가 10 GB 남짓입니다. 모델까지 하면 노드에 여유가 20 GB는 있어야 합니다.
kubectl get node <GPU노드> -o jsonpath='{.status.allocatable.ephemeral-storage}'1. vLLM 백엔드 세 대 띄우기
가장 오래 걸리는 단계라 먼저 시작해두고 나머지를 준비하는 게 좋습니다.
매니페스트
01-vllm.yaml로 저장합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-a
namespace: llm-lab
labels: {app: vllm, backend: a}
spec:
replicas: 1
selector: {matchLabels: {app: vllm, backend: a}}
template:
metadata:
labels: {app: vllm, backend: a}
spec:
containers:
- name: vllm
image: vllm/vllm-openai:v0.10.1.1
args:
- --model=Qwen/Qwen2.5-0.5B-Instruct
- --served-model-name=qwen-small
- --max-model-len=4096
- --gpu-memory-utilization=0.70
- --disable-log-requests
env:
- {name: HF_HOME, value: /models}
ports:
- {name: http, containerPort: 8000}
resources:
limits:
nvidia.com/gpu: "1" # 슬라이스 1개
nvidia.com/gpumem: "8000" # 그 슬라이스에 8000 MiB (HAMi 전용)
volumeMounts:
- {name: models, mountPath: /models}
- {name: shm, mountPath: /dev/shm}
readinessProbe:
httpGet: {path: /health, port: 8000}
initialDelaySeconds: 60
periodSeconds: 10
failureThreshold: 60 # 모델 다운로드가 길어 넉넉히
volumes:
- name: models
emptyDir: {sizeLimit: 20Gi}
- name: shm
emptyDir: {medium: Memory, sizeLimit: 2Gi} # vLLM에 필요
---
apiVersion: v1
kind: Service
metadata:
name: vllm-a
namespace: llm-lab
labels: {app: vllm, backend: a}
spec:
selector: {app: vllm, backend: a}
ports: [{name: http, port: 8000, targetPort: 8000}]각 줄이 하는 일
--served-model-name— 클라이언트에게 보여줄 모델 이름. 실제 HF 경로(Qwen/Qwen2.5-...)와 분리해두면 나중에 모델을 갈아끼워도 호출부가 안 바뀝니다.--max-model-len— 컨텍스트 길이. 길수록 KV 캐시를 많이 먹으므로 슬라이스가 작으면 줄여야 합니다.--gpu-memory-utilization=0.70— 여기가 헷갈리는 지점. 이건 컨테이너에 보이는 메모리 기준입니다. 8 GiB 슬라이스에 0.70이면 약 5.6 GiB를 씁니다. 물리 45 GiB의 70%가 아닙니다.emptyDiron/dev/shm— vLLM이 공유 메모리를 씁니다. 기본 64 MB로는 부족해 터집니다.failureThreshold: 60— 모델을 처음 받으면 수 분이 걸립니다. 기본값이면 readiness가 먼저 포기합니다.
b와 c 만들기
b는 a와 완전히 같고 이름·라벨만 다릅니다. c는 더 큰 모델이라 메모리도 더 줍니다.
# b: a를 그대로 복제
sed 's/vllm-a/vllm-b/g; s/backend: a/backend: b/g' 01-vllm.yaml > 01-vllm-b.yaml
# c: 1.5B 모델 + 12 GiB
sed 's/vllm-a/vllm-c/g; s/backend: a/backend: c/g;
s|Qwen/Qwen2.5-0.5B-Instruct|Qwen/Qwen2.5-1.5B-Instruct|;
s/served-model-name=qwen-small/served-model-name=qwen-large/;
s/gpumem: "8000"/gpumem: "12000"/' 01-vllm.yaml > 01-vllm-c.yaml
kubectl apply -f 01-vllm.yaml -f 01-vllm-b.yaml -f 01-vllm-c.yaml진행 확인
kubectl -n llm-lab get pod -wContainerCreating이 한동안 이어집니다. 이미지 10 GB를 받는 중입니다. HAMi가 스케줄에 관여하는 것도 이벤트로 볼 수 있습니다.
kubectl -n llm-lab get events --sort-by=.lastTimestamp | tail -5Normal FilteringSucceed pod/vllm-b-… find fit node(gpu2-gpu-pool-…), 1 nodes fit
Normal BindingSucceed pod/vllm-b-… Successfully binding node […]
Normal Pulling pod/vllm-b-… Pulling image "vllm/vllm-openai:v0.10.1.1"FilteringSucceed / BindingSucceed가 HAMi 스케줄러가 남기는 로그입니다. 이게 안 보이면 HAMi가 아니라 기본 스케줄러가 붙었다는 뜻입니다.
슬라이스가 제대로 배정됐나
kubectl -n llm-lab get pod -o json | python3 -c "
import json,sys
for p in json.load(sys.stdin)['items']:
for c in p['spec']['containers']:
g = {k:v for k,v in c.get('resources',{}).get('limits',{}).items() if 'nvidia' in k}
if g: print(p['metadata']['name'][:30], g)
"vllm-a-8b74859f7-z4wb7 {'nvidia.com/gpu': '1', 'nvidia.com/gpumem': '8k'}
vllm-b-99bc4dfc6-9mfng {'nvidia.com/gpu': '1', 'nvidia.com/gpumem': '8k'}
vllm-c-696b7f95dd-npk2f {'nvidia.com/gpu': '1', 'nvidia.com/gpumem': '12k'}물리 GPU 1장에 세 개의 추론 서버가 올라갔습니다. 8 + 8 + 12 = 28 GiB로 45 GiB 안에 들어갑니다.
2. 마스킹용 Presidio 올리기
LiteLLM 자체에는 정규식 마스킹이 없습니다. Microsoft Presidio에 위임하는 구조입니다.
- analyzer — "어디에 무엇이 있는지" 찾습니다 (이름? 카드번호? 이메일?)
- anonymizer — 찾은 자리를 치환합니다
둘 다 CPU만 쓰므로 GPU 자원이 필요 없습니다.
02-presidio.yaml:
apiVersion: apps/v1
kind: Deployment
metadata: {name: presidio-analyzer, namespace: llm-lab}
spec:
replicas: 1
selector: {matchLabels: {app: presidio-analyzer}}
template:
metadata: {labels: {app: presidio-analyzer}}
spec:
containers:
- name: analyzer
image: mcr.microsoft.com/presidio-analyzer:latest
ports: [{containerPort: 3000}]
resources:
requests: {cpu: 200m, memory: 1Gi}
limits: {cpu: "2", memory: 3Gi} # NLP 모델을 메모리에 올린다
readinessProbe:
httpGet: {path: /health, port: 3000}
initialDelaySeconds: 30
failureThreshold: 30
---
apiVersion: v1
kind: Service
metadata: {name: presidio-analyzer, namespace: llm-lab}
spec:
selector: {app: presidio-analyzer}
ports: [{port: 3000, targetPort: 3000}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: presidio-anonymizer, namespace: llm-lab}
spec:
replicas: 1
selector: {matchLabels: {app: presidio-anonymizer}}
template:
metadata: {labels: {app: presidio-anonymizer}}
spec:
containers:
- name: anonymizer
image: mcr.microsoft.com/presidio-anonymizer:latest
ports: [{containerPort: 3000}]
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "1", memory: 1Gi}
readinessProbe:
httpGet: {path: /health, port: 3000}
initialDelaySeconds: 15
failureThreshold: 30
---
apiVersion: v1
kind: Service
metadata: {name: presidio-anonymizer, namespace: llm-lab}
spec:
selector: {app: presidio-anonymizer}
ports: [{port: 3000, targetPort: 3000}]kubectl apply -f 02-presidio.yamlanalyzer는 NLP 모델을 로드하느라 1분쯤 걸립니다.
3. LiteLLM 게이트웨이 — 네 기능이 전부 여기 들어간다
이 글의 핵심 설정 파일입니다. 길지만 블록별로 나눠 설명하겠습니다.
03-litellm.yaml:
apiVersion: v1
kind: Secret
metadata: {name: litellm-secret, namespace: llm-lab}
stringData:
master-key: "sk-lab-master-key" # 실습용. 운영에서는 반드시 교체
---
apiVersion: v1
kind: ConfigMap
metadata: {name: litellm-config, namespace: llm-lab}
data:
config.yaml: |
model_list:
# ── 그룹 1: qwen-fast (백엔드 2대) ──
- model_name: qwen-fast
litellm_params:
model: hosted_vllm/qwen-small
api_base: http://vllm-a.llm-lab.svc.cluster.local:8000/v1
api_key: "dummy"
model_info: {id: vllm-a}
- model_name: qwen-fast
litellm_params:
model: hosted_vllm/qwen-small
api_base: http://vllm-b.llm-lab.svc.cluster.local:8000/v1
api_key: "dummy"
model_info: {id: vllm-b}
# ── 그룹 2: qwen-quality (백엔드 1대) ──
- model_name: qwen-quality
litellm_params:
model: hosted_vllm/qwen-large
api_base: http://vllm-c.llm-lab.svc.cluster.local:8000/v1
api_key: "dummy"
model_info: {id: vllm-c}
# ── Auto Routing 진입점 ──
- model_name: smart
litellm_params:
model: auto_router/complexity_router
drop_params: true
complexity_router_config:
tiers:
SIMPLE: qwen-fast
MEDIUM: qwen-fast
COMPLEX: qwen-quality
REASONING: qwen-quality
complexity_router_default_model: qwen-fast
router_settings:
routing_strategy: latency-based-routing
num_retries: 2
allowed_fails: 2
cooldown_time: 20
fallbacks:
- qwen-fast: ["qwen-quality"]
guardrails:
- guardrail_name: "pii-mask"
litellm_params:
guardrail: presidio
mode: "pre_call"
default_on: true
presidio_language: "en"
pii_entities_config:
EMAIL_ADDRESS: "MASK"
PHONE_NUMBER: "MASK"
CREDIT_CARD: "MASK"
PERSON: "MASK"
IP_ADDRESS: "MASK"
litellm_settings:
callbacks: ["prometheus"]
drop_params: true
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY블록별 해설
model_list — 모델 그룹이 만들어지는 곳
model_name이 같으면 같은 그룹입니다. 위에서 qwen-fast가 두 번 나오는데, 이게 오타가 아니라 핵심입니다. 클라이언트는 qwen-fast만 부르고, 두 백엔드 중 어디로 갈지는 라우터가 정합니다.
model: hosted_vllm/...— LiteLLM에게 "이건 vLLM 서버다"라고 알려주는 접두사api_key: "dummy"— vLLM은 키를 검사하지 않지만 OpenAI SDK가 값을 요구합니다model_info.id— 로그·지표에서 백엔드를 구분할 이름
router_settings — 라우팅과 Failover
| 설정 | 하는 일 |
|---|---|
routing_strategy |
그룹 안에서 어느 백엔드를 고를지 |
num_retries |
실패 시 같은 그룹의 다른 백엔드로 재시도 |
allowed_fails / cooldown_time |
연속 실패한 백엔드를 잠시 그룹에서 뺌 (회로 차단) |
fallbacks |
그룹 전체가 죽었을 때 다른 그룹으로 |
재시도와 폴백은 층이 다릅니다. 앞은 그룹 안, 뒤는 그룹 간입니다.
guardrails — 마스킹
mode: pre_call이 핵심입니다. 요청이 vLLM으로 나가기 전에 치환하므로 원문 PII가 백엔드에 도달하지 않습니다. post_call이면 이미 모델이 본 뒤라 늦습니다.
litellm_settings.callbacks: ["prometheus"]
이 한 줄이 /metrics 엔드포인트를 켭니다. 05장에서 씁니다.
프록시 배포
04-litellm-deploy.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: litellm
namespace: llm-lab
labels: {app: litellm}
spec:
replicas: 1
selector: {matchLabels: {app: litellm}}
template:
metadata: {labels: {app: litellm}}
spec:
containers:
- name: litellm
image: ghcr.io/berriai/litellm:main-stable
args: ["--config", "/config/config.yaml", "--port", "4000"]
env:
- name: LITELLM_MASTER_KEY
valueFrom: {secretKeyRef: {name: litellm-secret, key: master-key}}
# 마스킹 위임 대상 — 이 두 환경변수가 없으면 guardrail이 동작하지 않는다
- {name: PRESIDIO_ANALYZER_API_BASE, value: "http://presidio-analyzer.llm-lab.svc.cluster.local:3000"}
- {name: PRESIDIO_ANONYMIZER_API_BASE, value: "http://presidio-anonymizer.llm-lab.svc.cluster.local:3000"}
ports: [{name: http, containerPort: 4000}]
volumeMounts: [{name: config, mountPath: /config}]
resources:
requests: {cpu: 200m, memory: 512Mi}
limits: {cpu: "2", memory: 2Gi}
readinessProbe:
httpGet: {path: /health/liveliness, port: 4000}
initialDelaySeconds: 20
failureThreshold: 30
volumes:
- name: config
configMap: {name: litellm-config}
---
apiVersion: v1
kind: Service
metadata:
name: litellm
namespace: llm-lab
labels: {app: litellm}
spec:
selector: {app: litellm}
ports: [{name: http, port: 4000, targetPort: 4000}]kubectl apply -f 03-litellm.yaml -f 04-litellm-deploy.yaml전부 뜰 때까지
kubectl -n llm-lab get pod -w
# 6개가 모두 1/1 Running이 될 때까지 (5~10분)테스트용 클라이언트 파드
앞으로의 모든 테스트를 클러스터 안에서 실행합니다.
kubectl -n llm-lab run cli --image=curlimages/curl:8.10.1 --restart=Never -- sleep 7200첫 확인 — 모델 그룹이 보이는지.
kubectl -n llm-lab exec cli -- \
curl -s http://litellm:4000/v1/models \
-H "Authorization: Bearer sk-lab-master-key"{"data":[{"id":"qwen-fast",...},{"id":"qwen-quality",...},{"id":"smart",...}]}백엔드는 3대인데 모델은 3개로 보입니다. qwen-fast가 두 백엔드를 감춘 하나의 그룹이기 때문입니다.
4. 마스킹 검증 — PII가 정말 안 나가나
"마스킹이 켜졌다"와 "원문이 모델에 안 갔다"는 다른 말입니다. 후자를 증명하려면 모델에게 받은 걸 그대로 되풀이하게 시키면 됩니다.
kubectl -n llm-lab exec cli -- sh -c 'cat > /tmp/req.json <<JSON
{"model":"qwen-fast","max_tokens":80,"temperature":0,
"messages":[{"role":"user","content":"Echo the following back verbatim: My name is John Smith, email john.smith@acme.com, phone 555-123-4567, card 4111-1111-1111-1111, ip 192.168.1.42."}]}
JSON
curl -s http://litellm:4000/v1/chat/completions \
-H "Authorization: Bearer sk-lab-master-key" \
-H "Content-Type: application/json" --data @/tmp/req.json'모델이 되돌려준 문장
My name is [PERSON], email [EMAIL_ADDRESS], phone [PHONE_NUMBER],
card [CREDIT_CARD], and IP address [IP_ADDRESS]."그대로 되풀이하라"고 했는데 플레이스홀더가 돌아왔습니다. 모델이 본 것 자체가 이미 치환된 문장이었다는 뜻입니다.
응답만 검사하는 방식이었다면 이 증명이 안 됩니다. 응답에서 지우는 건 이미 모델이 본 뒤니까요 — pre_call과 post_call의 차이가 여기서 갈립니다.
잘 안 될 때
마스킹이 안 먹으면 LiteLLM 로그에 Presidio 연결 오류가 찍힙니다.kubectl -n llm-lab logs deploy/litellm | grep -i presidio
대개PRESIDIO_*_API_BASE환경변수 오타이거나 analyzer가 아직 안 떴을 때입니다.
5. 라우팅 — 전략마다 분배가 다르다
어디로 갔는지 아는 법
먼저 함정 하나. 응답으로는 어느 백엔드가 처리했는지 알 수 없습니다. LiteLLM은 클라이언트가 요청한 모델명(qwen-fast)을 그대로 돌려줍니다.
그래서 vLLM 각 파드의 카운터를 직접 세는 방식으로 측정합니다.
kubectl -n llm-lab exec cli -- sh -c '
count() {
curl -s "http://vllm-$1:8000/metrics" \
| awk "/^vllm:request_success_total/ {s+=\$2} END {printf \"%d\", s+0}"
}
A0=$(count a); B0=$(count b)
echo "시작: vllm-a=$A0 vllm-b=$B0"
i=1
while [ $i -le 12 ]; do
curl -s -o /dev/null http://litellm:4000/v1/chat/completions \
-H "Authorization: Bearer sk-lab-master-key" \
-H "Content-Type: application/json" \
-d "{\"model\":\"qwen-fast\",\"max_tokens\":8,\"temperature\":0,\"messages\":[{\"role\":\"user\",\"content\":\"say hi\"}]}"
i=$((i+1))
done
A1=$(count a); B1=$(count b)
echo "증분: vllm-a=$((A1-A0)) vllm-b=$((B1-B0))"
'latency-based-routing · 12건
증분: vllm-a = 0 vllm-b = 12
오해하기 쉬운 지점
"라우팅이 안 되는 것"처럼 보이지만 정상입니다.latency-based-routing은 최근 응답이 빠른 쪽으로 몰아주는 전략입니다. 순차 요청에서 한쪽이 계속 빠르면 계속 그쪽으로 갑니다. 균등 분배를 기대하고 이 전략을 쓰면 오해합니다.
전략을 바꿔 다시 재보기
# ConfigMap을 꺼내 전략만 바꾸고 다시 넣는다
kubectl -n llm-lab get cm litellm-config -o jsonpath='{.data.config\.yaml}' > /tmp/cfg.yaml
sed -i 's/routing_strategy: latency-based-routing/routing_strategy: simple-shuffle/' /tmp/cfg.yaml
kubectl -n llm-lab create cm litellm-config --from-file=config.yaml=/tmp/cfg.yaml \
--dry-run=client -o yaml | kubectl apply -f -
# ConfigMap을 바꿔도 프로세스는 다시 읽지 않는다 — 재시작 필요
kubectl -n llm-lab rollout restart deploy/litellm
kubectl -n llm-lab rollout status deploy/litellm같은 측정을 다시 돌리면 결과가 뚜렷이 갈립니다.
simple-shuffle · 12건
증분: vllm-a = 9 vllm-b = 3완벽한 반반은 아닙니다 — 가중 랜덤이라 12건 표본에서 9:3은 충분히 나올 수 있습니다. 중요한 건 양쪽에 퍼졌다는 것입니다.
| 전략 | 기준 | 쓸 자리 |
|---|---|---|
simple-shuffle |
가중 랜덤 | 백엔드 성능이 균질할 때 |
latency-based-routing |
최근 응답 지연 | 성능이 들쭉날쭉할 때 |
least-busy |
진행 중 요청 수 | 요청별 처리시간 편차가 클 때 |
usage-based-routing |
TPM/RPM 여유 | 쿼터가 걸린 상용 API와 섞어 쓸 때 |
6. Failover — 한 대, 그리고 그룹 전체
두 층으로 방어합니다. 앞서 본 num_retries(그룹 안)와 fallbacks(그룹 간)입니다. 하나씩 확인합니다.
관측용 스크립트
이번엔 응답의 model 필드를 봅니다. 폴백이 일어나면 요청한 것과 다른 이름이 돌아옵니다.
kubectl -n llm-lab exec cli -- sh -c '
i=1
while [ $i -le 6 ]; do
m=$(curl -s http://litellm:4000/v1/chat/completions \
-H "Authorization: Bearer sk-lab-master-key" \
-H "Content-Type: application/json" \
-d "{\"model\":\"qwen-fast\",\"max_tokens\":8,\"temperature\":0,\"messages\":[{\"role\":\"user\",\"content\":\"say hi\"}]}" \
| sed -n "s/.*\"model\":\"\([^\"]*\)\".*/\1/p" | head -1)
echo "요청 $i → ${m:-실패}"
i=$((i+1))
done'1단계 — 그룹 안에서 한 대 제거
kubectl -n llm-lab scale deploy/vllm-a --replicas=0
sleep 15
# 위 스크립트 다시 실행요청 1 → qwen-fast 요청 4 → qwen-fast
요청 2 → qwen-fast 요청 5 → qwen-fast
요청 3 → qwen-fast 요청 6 → qwen-fast남은 vllm-b가 전부 받았습니다. 클라이언트 입장에서는 아무 일도 없었습니다.
2단계 — 그룹 전체 제거
kubectl -n llm-lab scale deploy/vllm-b --replicas=0
sleep 15
# 이제 qwen-fast 그룹에 살아있는 백엔드가 없다. 그래도 qwen-fast를 부른다요청 1 → qwen-large 요청 4 → qwen-large
요청 2 → qwen-large 요청 5 → qwen-large
요청 3 → qwen-large 요청 6 → qwen-large클라이언트는 qwen-fast를 불렀는데 qwen-large가 응답했습니다. 에러 없이 그룹을 넘어간 것입니다. 이게 fallbacks가 하는 일입니다.
# 복구
kubectl -n llm-lab scale deploy/vllm-a deploy/vllm-b --replicas=1
설계상 유의
폴백은 조용히 다른 모델로 바꿔칩니다. 응답 품질·비용·토큰 한도가 달라지는데 클라이언트는 모릅니다. 상용에서는 폴백 대상을 아무거나 두지 말고, 뒤에 나오는litellm_deployment_state지표로 폴백이 발동한 사실 자체를 관측할 수 있게 해두는 편이 낫습니다.
7. Auto Routing — 프롬프트를 보고 티어를 고른다
같은 smart라는 이름으로 요청하되, 프롬프트의 복잡도를 채점해 티어를 고릅니다. 기본 채점기는 휴리스틱이라 외부 임베딩 서비스가 필요 없습니다 — 토큰 수, 코드 포함 여부, 추론 표지 같은 신호를 봅니다.
앞의 설정에서 이 부분입니다.
tiers:
SIMPLE: qwen-fast
MEDIUM: qwen-fast # ← 이 매핑이 정책이다
COMPLEX: qwen-quality
REASONING: qwen-quality티어를 어떻게 확인하나
여기도 함정이 있습니다. 응답의 model 필드는 요청한 이름(smart)을 그대로 돌려줍니다. 어느 티어로 갔는지 응답만으로는 알 수 없습니다.
라우팅 때처럼 백엔드 카운터를 세면 됩니다.
kubectl -n llm-lab exec cli -- sh -c '
mkdir -p /tmp/auto
cat > /tmp/auto/simple.json <<JSON
{"model":"smart","max_tokens":16,"temperature":0,"messages":[{"role":"user","content":"What is 2+2?"}]}
JSON
cat > /tmp/auto/complex.json <<JSON
{"model":"smart","max_tokens":16,"temperature":0,"messages":[{"role":"user","content":"Refactor this Python function to be thread-safe and explain your reasoning step by step: def add(self, k, v): self.cache[k] = v; if len(self.cache) > self.cap: self.cache.pop(next(iter(self.cache)))"}]}
JSON
cnt() { curl -s "http://vllm-$1:8000/metrics" \
| awk "/^vllm:request_success_total/ {s+=\$2} END {printf \"%d\", s+0}"; }
for f in simple complex; do
A0=$(cnt a); B0=$(cnt b); C0=$(cnt c)
curl -s -o /dev/null http://litellm:4000/v1/chat/completions \
-H "Authorization: Bearer sk-lab-master-key" \
-H "Content-Type: application/json" --data @/tmp/auto/$f.json
A1=$(cnt a); B1=$(cnt b); C1=$(cnt c)
fast=$(( (A1-A0)+(B1-B0) )); qual=$(( C1-C0 ))
[ $qual -gt 0 ] && t="qwen-quality (1.5B)" || t="qwen-fast (0.5B)"
echo "$f → $t [fast+$fast quality+$qual]"
done'네 가지 프롬프트로 돌려본 결과입니다.
| 프롬프트 | tier | score | 도착지 |
|---|---|---|---|
| "What is 2+2?" | SIMPLE |
−0.150 | qwen-fast |
| "hi" | SIMPLE |
−0.150 | qwen-fast |
| 분산 rate limiter 설계 + 실패 모드 추론 | MEDIUM |
0.325 | qwen-fast |
| 코드 스레드 안전 리팩터링 + 단계별 설명 | REASONING |
0.565 | qwen-quality |
예상과 달랐던 것
세 번째가 이상합니다. 꽤 복잡한 설계 질문인데 상위 티어로 가지 않았습니다.
DEBUG 로그를 켜면 왜인지 그대로 나옵니다.
kubectl -n llm-lab set env deploy/litellm LITELLM_LOG=DEBUG
kubectl -n llm-lab rollout status deploy/litellm
# 다시 요청한 뒤
kubectl -n llm-lab logs deploy/litellm | grep -i "routing decision"ComplexityRouter: routing decision cause=heuristic_scorer,
tier=MEDIUM, score=0.325, signals=('code (api)', 'reasoning (step by ste…')
ComplexityRouter: routing decision cause=reasoning_override,
tier=REASONING, score=0.565,
signals=('code (function, def, refactor)', 'reasoning (step by step,
explain your reasoning)', 'multi-step')
thresholds: simple_medium=0.15 medium_complex=0.35 …0.325는 medium_complex 임계값 0.35에 0.025 모자랐습니다. 그래서 MEDIUM으로 분류됐고, 제 설정에서 MEDIUM은 qwen-fast를 가리킵니다.
라우터가 틀린 게 아니라 제가 쓴 정책이 그렇게 동작한 것입니다. 바꾸고 싶다면 손댈 곳은 둘 중 하나입니다.
- 매핑 바꾸기:
MEDIUM: qwen-quality— 애매하면 좋은 모델로 - 임계값 낮추기:
medium_complex를 낮춰 COMPLEX 판정을 넓힌다
확인이 끝나면 DEBUG는 끕니다. 로그가 매우 많아집니다.
kubectl -n llm-lab set env deploy/litellm LITELLM_LOG-8. 모니터링 — 두 층을 다 본다
두 층의 지표는 겹치지 않습니다.
- vLLM — KV 캐시 사용률, 대기 큐 같은 엔진 상태
- LiteLLM — 어느 백엔드로 몇 건 갔는지, 가드레일이 몇 번 돌았는지
둘 다 /metrics를 내보내므로 ServiceMonitor 두 개면 됩니다.
05-monitoring.yaml:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: litellm
namespace: llm-lab
# Prometheus의 serviceMonitorSelector가 비어있지 않다면
# 여기에 그 라벨을 달아야 한다 (예: release: kube-prometheus-stack)
spec:
selector: {matchLabels: {app: litellm}}
endpoints:
- port: http
path: /metrics/ # ← 끝 슬래시 필수
interval: 15s
authorization: # ← LiteLLM은 인증 필요
type: Bearer
credentials: {name: litellm-secret, key: master-key}
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata: {name: vllm, namespace: llm-lab}
spec:
selector: {matchLabels: {app: vllm}} # a·b·c 공통 라벨로 3대를 한 번에
endpoints:
- port: http
path: /metrics
interval: 15skubectl apply -f 05-monitoring.yaml
여기서 두 번 막혔습니다
① 인증 — LiteLLM의
/metrics는master_key로 보호됩니다. 인증 없이 긁으면401입니다. vLLM 쪽은 열려 있어서 이 블록이 필요 없습니다.② 끝 슬래시 — 인증을 붙여도
307 → /metrics/리다이렉트가 나옵니다.curl은-L로 따라가지만 Prometheus 스크레이퍼는 리다이렉트를 따라가지 않습니다. 슬래시를 빼면 타깃은up인데 시계열만 비는, 진단하기 나쁜 상태가 됩니다.
직접 확인해보면 이렇습니다.
# 인증 없이 → 401
kubectl -n llm-lab exec cli -- curl -s -o /dev/null -w "%{http_code}\n" \
http://litellm:4000/metrics
# 인증만 → 307
kubectl -n llm-lab exec cli -- curl -s -o /dev/null -w "%{http_code}\n" \
http://litellm:4000/metrics -H "Authorization: Bearer sk-lab-master-key"
# 인증 + 슬래시 → 200
kubectl -n llm-lab exec cli -- curl -s -o /dev/null -w "%{http_code}\n" \
http://litellm:4000/metrics/ -H "Authorization: Bearer sk-lab-master-key"타깃이 붙었는지 확인
kubectl -n monitoring exec prometheus-kube-prometheus-stack-prometheus-0 -c prometheus -- \
sh -c 'wget -qO- "http://localhost:9090/api/v1/targets?state=active"' \
| python3 -c "
import json,sys
for t in json.load(sys.stdin)['data']['activeTargets']:
j = t['labels'].get('job','')
if 'litellm' in j or 'vllm' in j:
print(f\"{j:10s} {t['health']:8s} {t.get('lastError','')[:50]}\")
"litellm up
vllm-a up
vllm-b up
vllm-c up실제로 뽑아본 것
Prometheus UI(/graph)나 아래 방식으로 쿼리합니다.
kubectl -n monitoring exec prometheus-kube-prometheus-stack-prometheus-0 -c prometheus -- \
sh -c "wget -qO- 'http://localhost:9090/api/v1/query?query=<쿼리>'"라우팅이 분산됐나 — sum by(api_base)(litellm_deployment_success_responses_total)
http://vllm-a…:8000/v1/chat/completions 9
http://vllm-b…:8000/v1/chat/completions 6
http://vllm-c…:8000/v1/chat/completions 1마스킹이 돌았나 — sum(litellm_guardrail_requests_total)
32백엔드가 살아있나 — litellm_deployment_state (0 = 정상, Failover 관측용)
qwen-small vllm-a 0
qwen-small vllm-b 0
qwen-large vllm-c 0엔진 상태 — vllm:gpu_cache_usage_perc · vllm:num_requests_waiting
vLLM 쪽에서만 보이는 지표입니다.
qwen-large 0.0000892…
qwen-small 0.0000456…
qwen-small 0.0000456…이 실습은 부하가 거의 없어 KV 캐시가 사실상 0입니다. 실제 운영에서 gpu_cache_usage_perc가 1에 가까워지고 num_requests_waiting이 쌓이기 시작하면 그 백엔드는 이미 포화 상태입니다 — 라우팅 전략을 least-busy로 바꾸거나 슬라이스를 키울 시점입니다.
9. 정리(cleanup)
네임스페이스 하나만 지우면 전부 사라집니다. ServiceMonitor도 함께 지워지므로 Prometheus 쪽에 따로 손댈 게 없습니다.
kubectl delete ns llm-labGPU 슬라이스가 회수됐는지 확인합니다.
kubectl get pod -A -o json | python3 -c "
import json,sys
u=0
for p in json.load(sys.stdin)['items']:
for c in p['spec']['containers']:
v=c.get('resources',{}).get('limits',{}).get('nvidia.com/gpu')
if v: u+=int(v)
print('사용 중 슬라이스:', u)
"막혔을 때 보는 곳
| 증상 | 확인할 것 |
|---|---|
vLLM 파드가 Pending |
kubectl describe pod → GPU 슬라이스 부족 or HAMi 스케줄러 미동작 |
| vLLM이 OOM으로 죽음 | --gpu-memory-utilization 낮추기, --max-model-len 줄이기 |
vLLM이 /dev/shm 오류 |
shm emptyDir를 빠뜨렸는지 |
| 마스킹이 안 됨 | logs deploy/litellm | grep -i presidio, 환경변수 2개 확인 |
| 라우팅이 한쪽으로만 | 정상일 수 있음 — latency-based인지 확인 |
| 폴백이 안 됨 | fallbacks 대상 그룹이 살아있는지 |
Prometheus 타깃은 up인데 데이터 없음 |
/metrics/ 끝 슬래시 |
| 타깃이 아예 안 뜸 | serviceMonitorSelector 라벨 조건 |
| 설정을 바꿨는데 반영 안 됨 | ConfigMap 수정 후 rollout restart 했는지 |
정리
| 기능 | 확인된 것 | 주의할 것 |
|---|---|---|
| 마스킹 | 원문 PII가 백엔드에 도달하지 않음 | Presidio 2개 파드가 별도로 필요 |
| 라우팅 | 전략에 따라 12:0 ↔ 9:3으로 갈림 | latency-based는 균등 분배가 아님 |
| Failover | 그룹 내 재시도 + 그룹 간 폴백 모두 동작 | 폴백은 조용히 다른 모델로 바꿔침 |
| Auto Routing | 휴리스틱만으로 티어 분류, 외부 의존 없음 | 임계값 부근에서 갈리며 응답으로는 못 봄 |
| 모니터링 | 두 층 지표를 기존 Prometheus에 그대로 편입 | /metrics/ 슬래시와 인증 두 군데 |
관통하는 교훈이 하나 있습니다. 네 기능 전부 "동작한다"와 "의도대로 동작한다" 사이에 관측이 필요했습니다. 마스킹은 되풀이 프롬프트로, 라우팅과 Auto Routing은 백엔드 카운터로, Failover는 응답 모델명으로 확인했습니다. 응답 본문만 봤다면 라우팅도 Auto Routing도 전부 똑같아 보였을 것입니다.
게이트웨이를 세우는 일의 절반은 설정이고, 나머지 절반은 그 설정이 실제로 무엇을 하는지 볼 수 있게 만드는 일이었습니다.
실습 환경: Kubernetes 테넌트 클러스터 · NVIDIA L40S 1장(HAMi 분할) · vLLM v0.10.1.1 · LiteLLM main-stable · Presidio latest · kube-prometheus-stack 67.3.1
'LLM Serving and Optimization Study' 카테고리의 다른 글
| [LLMSO 4주차] 쿠버네티스 위 TensorRT-LLM 실습기 — 설치부터 FP8 양자화, 그리고 vLLM 대조 (0) | 2026.08.30 |
|---|---|
| [LLM 3.5주차] 양자화 실습 (0) | 2026.08.23 |
| [LLMSO 3주차] K8s 기반 vLLM 서빙 파라미터 실측 튜닝기 (0) | 2026.08.23 |
| [LLMSO 2주차] Ray Serve로 LLM(Qwen2.5) 서빙하기 (0) | 2026.08.15 |
| [LLMSO 1주차] 기본 개념과 PagedAttention 최적화 메커니즘 (0) | 2026.08.09 |
