AI Infrastructure
Ingress에서 추론 게이트웨이로: Gateway API와 Inference Extension
Kubernetes 네트워킹은 어떻게 모델을 이해하게 되었나: Gateway API 리소스 모델, Inference Extension의 InferencePool, 그리고 라운드 로빈 대신 KV 캐시와 큐 메트릭으로 라우팅하는 엔드포인트 피커를 다룹니다.
Ivan Porta
Founder & Principal Engineer

전통적인 로드 밸런싱은 모든 백엔드가 동일하다고 가정하므로, 어떤 백엔드가 요청을 처리하든 상관이 없습니다. LLM 트래픽은 이 전제를 무너뜨립니다. 예를 들어 어떤 vLLM 레플리카는 진행 중인 대화의 KV 캐시를 데워 둔 상태인 반면, 다른 레플리카는 처음부터 다시 계산해야 할 수 있습니다. 어떤 요청은 짧은 질문이고, 어떤 요청은 거대한 코드베이스를 담고 있습니다. 라운드 로빈 밸런서는 이 차이를 구분하지 못하므로, 캐시된 작업을 가진 레플리카가 놀고 있는데도 요청이 바쁜 레플리카로 갈 수 있습니다. 그 결과 사용자가 겪는 지연은 커지고 GPU 자원은 낭비됩니다.
Kubernetes는 이 문제를 두 개의 층으로 해결합니다. Gateway API는 트래픽이 클러스터로 들어오는 주된 진입 방식으로서 Ingress를 대체해 가고 있습니다. 그 위에 같은 커뮤니티(SIG-Network와 WG-Serving)가 개발한 Gateway API Inference Extension이 일련의 API와 프로토콜 하나를 더합니다. 덕분에 게이트웨이는 모델 서버를 일반 HTTP 백엔드로 취급하는 대신, 큐 깊이, 캐시 상태, 로드된 어댑터 같은 요소를 기준으로 트래픽을 라우팅할 수 있습니다.
Ingress에서 Gateway API로
추론 이야기로 들어가기 전에, Gateway API가 무엇이고 오늘날 Kubernetes 네트워킹에서 왜 중요한지 짧게 짚고 넘어가겠습니다. Ingress 컨트롤러와의 가장 큰 차이는 협업을 지원하는 방식입니다. Ingress에서는 여러 팀이 같은 호스트네임을 쓰는 것을 API 차원에서 막을 방법이 없습니다. Kubernetes는 이렇게 충돌하는 오브젝트를 그대로 받아들이고, 그다음에 벌어지는 일은 컨트롤러에 달려 있습니다. 예를 들어 ingress-nginx는 같은 호스트를 쓰는 모든 Ingress 리소스를 병합하고, 경로가 중복되면 가장 오래된 규칙이 우선합니다. 그래서 한 팀이 다른 팀이 자기 것이라 여기는 호스트네임에 라우트나 설정을 추가할 수 있습니다. 네임스페이스 바인딩 문제도 있습니다. Ingress는 자기 네임스페이스에 있는 TLS Secret만 사용할 수 있습니다. 그래서 플랫폼 엔지니어가 개인 키 노출 위험을 감수하며 인증서를 모든 애플리케이션 네임스페이스에 복사하거나, 각 팀이 인증서 수명 주기를 직접 관리해야 합니다. 마지막으로 Ingress 스펙은 타임아웃, 인증, 요청 제한, 재작성, 카나리 같은 것을 다루지 못합니다. 이런 기능은 대개 애플리케이션, 인프라, 운영처럼 서로 다른 층위에서 벤더별 애너테이션으로 처리됩니다.
SIG-Network 팀은 이 혼란스러운 애너테이션 조합을 타입이 있고, 역할을 구분하며, 확장 가능한 API로 대체하기 위해 KubeCon San Diego 2019에서 Gateway API를 제안했습니다. Gateway API는 2023년 10월 정식 버전(GA)에 도달했습니다. 하지만 진짜 전환점은 널리 쓰이던 NGINX Ingress Community 컨트롤러의 개발 중단이었습니다. 릴리스도, 수정도, 보안 패치도 더는 없다는 사실 앞에서, 전 세계의 팀들은 Gateway API로의 이동이 더 이상 선택이 아니라 계획을 세워 완수해야 할 시급한 마이그레이션임을 깨달았습니다.
Gateway API는 과부하 상태였던 Ingress 오브젝트를, 각 결정을 누가 소유하는지에 따라 책임을 나누는 방식으로 분해하도록 설계되었습니다:
GatewayClass는 플랫폼이 제공하는 로드 밸런서의 종류(내부 L7, 외부 L7, 특정 벤더의 구현)를 선언합니다.Gateway는 그중 하나를 인스턴스화합니다. Gateway를 생성하는 행위가 리스너, 호스트네임, TLS 등을 프로비저닝합니다.- 애플리케이션 팀이 자기 네임스페이스에서 소유하는
HTTPRoute오브젝트는 그 Gateway에 연결되어 "이 조건에 맞는 요청은 내 백엔드로 보낸다"를 선언합니다.
게이트웨이는 어떻게 추론 게이트웨이가 되는가
Gateway API Inference Extension의 핵심은 Envoy의 외부 처리 필터(ext-proc) 위에 구축된 새로운 CRD와 컨트롤러의 모음입니다.
들어오는 요청은 Envoy의 HTTP 필터 체인을 차례로 통과합니다. 요청이 ext-proc 필터에 도달하면 Envoy는 외부 서버, 즉 이 확장의 컨트롤러로 양방향 gRPC 스트림을 엽니다. Envoy는 요청의 헤더를 보내고, 필터의 처리 모드가 요구하는 경우에는 본문도 도착하는 대로 보냅니다. 외부 서버는 지시로 응답하고, Envoy는 헤더 변경, 본문 수정, 메타데이터 추가, 즉시 응답 전송 같은 지시를 그대로 적용합니다. 처리 로직이 별도의 서버에서 실행되므로 독립적으로 배포·확장·업데이트할 수 있고, 어떤 언어로도 작성할 수 있습니다. ext-proc 계약은 Envoy가 정의한 것이지만 Envoy에만 국한되지는 않습니다. 같은 외부 처리 프로토콜을 따르는 게이트웨이로는 Envoy 기반의 Istio를 비롯해 Agentgateway와 NGINX Gateway Fabric이 있습니다.
요청이 Envoy에 도달하면 먼저 일반적인 라우트 매칭이 일어납니다. 다만 매칭된 HTTPRoute가 이제 일반 Service가 아니라 InferencePool을 가리킵니다. InferencePool은 모델 서버 파드들과 이를 관리하는 ext-proc 서버를 나열하는 새 CRD입니다. Envoy의 역할은 여기까지입니다. 라우팅 로직은 엔드포인트 피커(Endpoint Picker, EPP)라 불리는 ext-proc 서버가 담당합니다. EPP는 메트릭을 수집하고, 서빙 엔진을 실행하는 후보 파드들에 점수를 매겨, 어느 파드가 요청을 처리할지 결정합니다.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: llama3-route
namespace: llm
spec:
parentRefs:
- name: inference-gateway
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- group: inference.networking.k8s.io
kind: InferencePool
name: vllm-llama3-8b
---
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
name: vllm-llama3-8b
namespace: llm
spec:
selector:
matchLabels:
app: vllm-llama3-8b
targetPorts:
- number: 8000
endpointPickerRef:
name: vllm-llama3-8b-epp
port:
number: 9002
failureMode: FailClose풀에 참여하는 파드는 프로젝트의 모델 서버 프로토콜을 따라야 합니다. 프로토콜 자체는 단순합니다. OpenAI 호환 Completions와 Chat API를 서빙하고, 큐 깊이·실행 중 요청 수·KV 캐시 사용률 같은 Prometheus 메트릭을 노출하면 됩니다. EPP는 이 메트릭으로 라우팅을 결정합니다. 현재 vLLM, SGLang, 그리고 Triton의 TensorRT-LLM 백엔드가 각자 고유한 메트릭 이름으로 이를 지원합니다.
엔드포인트 피커(llm-d-router)는 어떻게 동작하는가
2026년 현재 EPP 코드베이스는 llm-d 프로젝트의 llm-d/llm-d-router로 이관되었습니다(이전 이름은 llm-d-inference-scheduler). Gateway API Inference Extension 안의 버전은 곧 아카이브될 예정입니다. 이 글에서는 이관 이후 스케줄링 로직이 크게 개선된 llm-d-router의 EPP를 기준으로 설명합니다.
EPP와 플러그인
EPP는 구성의 폭이 매우 넓어서, 이제 스케줄링, 요청 제어, 플로우 컨트롤, 요청 처리, 데이터 레이어라는 다섯 범주에 걸쳐 80개가 넘는 플러그인을 지원합니다. 플러그인은 설정에서 활성화하며 시작 시점에 로드됩니다. 예를 들면:
kind: ConfigMap
apiVersion: v1
metadata:
name: vllm-llama32-1b-epp
data:
default-plugins.yaml: |
apiVersion: llm-d.ai/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: queue-scorer
- type: kv-cache-utilization-scorer
- type: prefix-cache-scorer
- type: metrics-data-source
parameters:
scheme: "http"
path: "/metrics"
insecureSkipVerify: true
- type: core-metrics-extractor
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: kv-cache-utilization-scorer
weight: 2
- pluginRef: prefix-cache-scorer
weight: 3설정에 정의한 플러그인 외에도 EPP는 여러 기본 플러그인을 함께 로드합니다. 예를 들어 프로파일에 피커가 지정되어 있지 않으면 max-score-picker를 추가하고, 파서가 설정되어 있지 않으면 OpenAI, Anthropic, vLLM-HTTP 파서를 포함합니다. global-strict-fairness-policy, static-usage-limit-policy 등도 로드하는데, 여기에는 여러분이 구성한 플러그인에 데이터를 공급하는 데 필요한 플러그인도 들어갑니다. 예컨대 prefix-cache 플러그인은 토큰화된 프롬프트가 필요한데, ConfigMap에 정의되어 있지 않아도 EPP가 시작 시점에 필요한 토큰화 플러그인을 준비해 둡니다.
{"level":"info","ts":1785950733.484222,"caller":"loader/configloader.go:164","msg":"Instantiated all plugins and applied system defaults. Effective raw configuration","config":"{Plugins: [{Name: queue-scorer, Type: queue-scorer} {Name: kv-cache-utilization-scorer, Type: kv-cache-utilization-scorer} {Name: prefix-cache-scorer, Type: prefix-cache-scorer} {Name: metrics-data-source, Type: metrics-data-source, Parameters: {\"insecureSkipVerify\":true,\"path\":\"/metrics\",\"scheme\":\"http\"}} {Name: core-metrics-extractor, Type: core-metrics-extractor} {Name: single-profile-handler, Type: single-profile-handler} {Name: max-score-picker, Type: max-score-picker} {Name: fcfs-ordering-policy, Type: fcfs-ordering-policy} {Name: global-strict-fairness-policy, Type: global-strict-fairness-policy} {Name: static-usage-limit-policy, Type: static-usage-limit-policy} {Name: openai-parser, Type: openai-parser} {Name: anthropic-parser, Type: anthropic-parser} {Name: vllmhttp-parser, Type: vllmhttp-parser} {Name: utilization-detector, Type: utilization-detector}], SchedulingProfiles: [{Name: default, Plugins: [{PluginRef: queue-scorer, Weight: 2.00} {PluginRef: kv-cache-utilization-scorer, Weight: 2.00} {PluginRef: prefix-cache-scorer, Weight: 3.00} {PluginRef: max-score-picker}]}], DataLayer: {Sources: [{PluginRef: metrics-data-source, Extractors: [{PluginRef: core-metrics-extractor}]}], Discovery: <nil>}, FlowControl: {MaxBytes: unlimited, MaxRequests: unlimited, SaturationDetector: {PluginRef: utilization-detector}}, RequestHandler: {Parsers: [{PluginRef: openai-parser}, {PluginRef: anthropic-parser}, {PluginRef: vllmhttp-parser}]}}"}EPP는 각 플러그인이 생산하고 사용하는 데이터 키를 바탕으로 의존성 그래프를 구성합니다. 그리고 요청마다 이 그래프에 따라 데이터를 생산하는 플러그인들을 올바른 순서로 실행합니다.
EPP가 대역 외에서 하는 일
주 요청 흐름 밖에서 EPP는 Kubernetes API에 InferencePool, Pod, InferenceObjective, InferenceModelRewrite 리소스에 대한 워치를 걸어 둡니다. 풀의 각 파드에서 메트릭을 스크레이핑하고, 자신의 메트릭을 내보내며, 정밀한 프리픽스 캐시 인지가 활성화된 경우에는 각 파드의 KV 캐시 이벤트 스트림을 구독합니다.
정밀한 프리픽스 캐시 인지가 특히 유용한 이유는, Prometheus 메트릭은 KV 캐시가 얼마나 찼는지만 보여줄 뿐 어떤 블록이 어느 파드에 있는지는 알려주지 않기 때문입니다. ZeroMQ의 PUB 소켓으로 서빙 엔진의 알림을 구독하면, 블록(vLLM의 KV 캐시 단위로, 기본값은 토큰 16개 고정이며 블록의 내용과 그 앞의 모든 내용을 연쇄 해시한 값으로 식별됩니다)이 추가되거나 축출되는 업데이트를 받을 수 있습니다. 이 업데이트는 엔진 어댑터가 디코딩해 EPP 메모리에 보관합니다. 단, 서빙 엔진에서 KV 이벤트 발행을 켜 두어야 합니다. vLLM은 기본적으로 이 이벤트를 내보내지 않으므로, 각 파드에 --kv-events-config '{"enable_kv_cache_events": true, "publisher": "zmq", "endpoint": "tcp://*:5557"}' 같은 플래그가 필요합니다.
요청이 도착하면
새 요청이 ext-proc 스트림을 타고 EPP에 도착하면, EPP는 엔드 오브 스트림 플래그가 올 때까지 청크를 버퍼링하며 본문 전체가 도착하기를 기다립니다. 그래야 완전한 JSON을 손에 들고 처리할 수 있기 때문입니다. 다음으로 URL 경로의 접미사를 보고 파서를 선택합니다. 기본으로 세 개의 파서가 준비되어 각자 맡은 접미사를 처리하며, 아무것도 매칭되지 않으면 요청은 거부됩니다:
openai-parser는completions,chat/completions,embeddings,responses및 그 계열을 맡습니다.anthropic-parser는messages와messages/count_tokens를 맡습니다.vllmhttp-parser는inference/v1/generate를 맡습니다.
선택된 파서는 원시 바이트를 모델 이름, 메시지 또는 프롬프트, 스트림 플래그를 포함한 타입 있는 요청으로 변환하고, 이를 요청별 상태 오브젝트에 저장합니다.
{"caller":"handlers/server.go:435","msg":"EPP received request","x-request-id":"a8a70838-..."}
{"caller":"handlers/server.go:448","msg":"Incoming body chunk","EoS":false}
{"caller":"handlers/server.go:448","msg":"Incoming body chunk","EoS":true}
{"caller":"handlers/server.go:454","msg":"decoding"}이 설계의 대가는 메모리입니다. EPP는 처리하는 동안 요청 본문 전체를 메모리에 들고 있고, 스트리밍이 아닌 응답 본문도 같은 방식으로 버퍼링하며, 자체 크기 제한이 없기 때문에 아주 긴 프롬프트는 게이트웨이 메모리를 소모합니다. 스트리밍 응답은 영향을 받지 않습니다. EPP는 SSE 청크를 도착하는 대로 클라이언트에 전달합니다.
다음으로 EPP는 풀과 요청된 모델에 매칭되는 InferenceModelRewrite 리소스를 찾아 그 규칙을 적용합니다. 이 리소스로는 가중치를 가진 여러 타깃을 정의할 수 있습니다. HTTPRoute 방식의 트래픽 분할을 모델에 적용한 것이라고 생각하면 됩니다. 예를 들면:
apiVersion: llm-d.ai/v1alpha2
kind: InferenceModelRewrite
metadata:
name: canary-model-split
spec:
poolRef:
name: production-llm-pool
rules:
- matches:
- model:
value: "llama3"
targets:
- modelRewrite: "llama3-stable"
weight: 90
- modelRewrite: "llama3-canary"
weight: 10매칭되면 EPP는 요청 본문의 model 필드를 선택된 타깃으로 바꾸고 본문을 다시 직렬화합니다. 돌아오는 길에는 응답 본문의 모델 이름을 원래 이름으로 되돌려 놓으므로, 클라이언트는 재작성을 전혀 눈치채지 못합니다. 로그에서 incomingModelName은 클라이언트가 요청한 이름이고, targetModelName은 규칙 적용 후 실제로 사용되는 이름입니다:
{"caller":"requestcontrol/director.go:220","msg":"No associated InferenceObjective found, using default","objectiveKey":""}
{"caller":"requestcontrol/director.go:281","msg":"LLM request assembled","incomingModelName":"meta-llama/Llama-3.2-1B-Instruct","targetModelName":"meta-llama/Llama-3.2-1B-Instruct","priority":0}다음으로, EPP가 토큰화·해싱·스코어링 같은 작업을 수행하기 전에 기본 어드미션 컨트롤러가 풀이 포화 상태인지 확인하고, InferenceObjective로 우선순위가 0 미만으로 지정된 요청을 429 상태 코드로 거부합니다. 우선순위가 0 이상인 요청은(매칭되는 InferenceObjective가 없을 때의 기본값입니다) 이 검사를 항상 통과합니다.
참고: 이 수락 아니면 거부 동작은 기본값일 뿐입니다. 실험적인 플로우 컨트롤 기능을 켜면 요청이 우선순위 밴드별로 큐에 쌓이고 각 밴드 안에서 공정하게 처리됩니다. 용량이 확보되는 대로 우선순위 순서에 따라 디스패치됩니다. 포화 신호 역시 플러그인이며 기본 이름은 utilization-detector이고, 임계값을 설정할 수 있습니다.
# PASS
{"level":"trace","caller":"requestcontrol/admission.go:116","msg":"Executing LegacyAdmissionController","objectiveKey":"sheddable-batch","priority":-1,"fairnessID":"default-flow"}
{"level":"trace","caller":"requestcontrol/admission.go:127","msg":"Request admitted","requestID":"4eb39a51-..."}
Rejection, mid-burst. Probes 1–5 got HTTP 429, and each produced this pair:
# DROPPED
{"level":"trace","caller":"requestcontrol/admission.go:76","msg":"Request rejected: system saturated and request is sheddable","x-request-id":"98bbfafe-...","objectiveKey":"sheddable-batch","priority":-1}
{"level":"error","caller":"handlers/server.go:478","msg":"Error handling request","error":"inference error: ResourceExhausted - system saturated, sheddable request dropped"}요청이 수락되면 EPP는 풀의 파드들을 평가합니다. 먼저 준비된 모든 파드의 스냅샷을 뜨고, 설정과 의존성 그래프에 따라 각 플러그인을 순서대로 실행합니다. 앞의 ConfigMap 기준이라면 과정은 토큰화로 시작합니다. EPP는 프롬프트를 4바이트 의사 토큰으로 쪼개고, 이를 블록으로 묶은 뒤, 각 블록을 이전 블록들과 함께 해싱합니다. 이렇게 하면 vLLM과 같은 연쇄 방식으로, 각 블록의 해시가 그 지점까지의 전체 프리픽스를 대표하게 됩니다.
참고: 설정이 다르면 이 단계는 바뀌거나 생략될 수 있습니다. 정밀 prefix-cache 플러그인을 쓰면 이 추정은 대역 외 섹션에서 설명한 KV 이벤트 인덱스 조회로 바뀝니다. 프리픽스 스코어러를 아예 빼면 이 단계에서는 아무 일도 일어나지 않습니다.
그다음 EPP는 이 블록 해시들을 순서대로 인덱스와 대조합니다. 어느 파드에도 기록되어 있지 않은 첫 블록에서 멈추고, 인덱스가 파드별로 몇 개의 블록을 귀속시키는지 세어 후보 파드마다 그 수를 기록합니다.
이제 스케줄러가 이어받습니다. 프로파일 핸들러가 어떤 스케줄링 프로파일을 적용할지 고르고(single-profile-handler는 항상 default를 답합니다), 프로파일의 필터가 스냅샷을 걸러냅니다:
{"caller":"scheduling/scheduler.go:69","msg":"Running profile handler, Pick profiles","plugin":"single-profile-handler/..."}
{"caller":"scheduling/scheduler_profile.go:162","msg":"Completed running filter plugins","remainingEndpoints":2}이어서 각 스코어러가 남은 모든 파드에 0과 1 사이의 점수를 매깁니다. 예를 들어:
queue-scorer: 대기 큐 길이를 파드끼리 상대 평가합니다. 스냅샷에서 큐가 가장 짧은 파드가 1, 가장 긴 파드가 0을 받고, 모두 같으면 전원이 1을 받습니다.kv-cache-utilization-scorer: 남은 캐시 공간에 보상을 줍니다. KV 캐시 사용률을 83%로 보고한 파드는 0.17을, 놀고 있는 파드는 1을 받습니다.prefix-cache-scorer: 이 프롬프트의 블록 중 파드가 이미 가진 비율만큼 캐시된 작업에 보상을 줍니다. 50개 중 40개면 0.8, 하나도 없으면 0입니다.
{"caller":"scheduling/scheduler_profile.go:210","msg":"Calculated score","plugin":"queue-scorer/...","endpoint":"...8p55s...","score":1}
{"caller":"scheduling/scheduler_profile.go:210","msg":"Calculated score","plugin":"queue-scorer/...","endpoint":"...4r286...","score":1}
{"caller":"scheduling/scheduler_profile.go:210","msg":"Calculated score","plugin":"kv-cache-utilization-scorer/...","endpoint":"...8p55s...","score":1}
{"caller":"scheduling/scheduler_profile.go:210","msg":"Calculated score","plugin":"kv-cache-utilization-scorer/...","endpoint":"...4r286...","score":1}
{"caller":"scheduling/scheduler_profile.go:210","msg":"Calculated score","plugin":"prefix-cache-scorer/...","endpoint":"...8p55s...","score":0}
{"caller":"scheduling/scheduler_profile.go:210","msg":"Calculated score","plugin":"prefix-cache-scorer/...","endpoint":"...4r286...","score":0}각 점수는 ConfigMap의 가중치와 곱해진 뒤 합산됩니다. 이 예시의 피커인 max-score-picker는 동점을 무작위로 깨기 위해 후보를 섞은 다음, 총점이 가장 높은 파드를 선택합니다.
{"caller":"scheduling/scheduler_profile.go:303","msg":"Candidate pods for picking","endpoints-weighted-score":[{"...8p55s...","Score":4},{"...4r286...","Score":4}]}
{"caller":"maxscore/picker.go:88","msg":"Selecting endpoints from candidates sorted by max score","max-num-of-endpoints":1,"num-of-candidates":2}
{"caller":"requestcontrol/director.go:488","msg":"Request handled","endpoint":"10.20.0.2:8000"}EPP는 선택된 파드의 주소를 x-gateway-destination-endpoint 헤더와 같은 키의 동적 메타데이터에 담아 Envoy에 보냅니다. 그러면 Envoy가 요청 본문을 그 파드로 전달합니다.
파인튜닝, LoRA 어댑터, 그리고 Inference Extension
이제 더 많은 회사가 Claude나 ChatGPT 같은 서비스에만 의존하는 대신, 오픈 웨이트 모델로 자체 인프라에서 추론을 실행합니다. 이 접근에는 실질적인 이점이 있습니다. 규모가 커질수록 낮아지는 비용, 데이터에 대한 더 강한 통제력, 그리고 유스케이스마다 별도의 모델을 완전히 파인튜닝해 배포하지 않고도 범용 모델을 회사의 특정 요구에 맞출 수 있는 능력입니다. Meta의 llama-cookbook, Microsoft의 LoRA, Hugging Face의 PEFT 같은 프로젝트 덕분에 이런 특화 작업의 문턱이 크게 낮아져, 한때 많은 컴퓨팅과 데이터, ML 전문성이 필요했던 기법을 이제 일반 엔지니어링 팀도 쓸 수 있게 되었습니다.
LoRA란 무엇인가
LoRA의 핵심 아이디어는 단순합니다. 큰 모델의 모든 가중치를 업데이트하는 대신, 베이스 모델을 동결하고 특정 유스케이스를 위해 선택된 레이어에 붙는 '어댑터', 즉 저랭크(low-rank) 행렬의 작은 쌍만 학습합니다. 그 결과물은 보통 기가바이트가 아니라 수십에서 수백 메가바이트에 그칩니다. 대상 레이어마다 입력이 동결된 원래 가중치와 어댑터의 저랭크 경로를 모두 통과하고, 두 출력을 더해 특화된 결과를 만듭니다.
이 방식이면 특화마다 수 기가바이트짜리 모델 사본을 따로 만들 필요가 없습니다. 추론 시 어댑터는 베이스 가중치에 병합할 수도, 분리된 채로 둘 수도 있습니다. 덕분에 vLLM처럼 LoRA를 인지하는 서빙 엔진은 공유된 베이스 모델 하나 위에 서로 다른 어댑터를 동적으로 적용할 수 있습니다.
서빙 엔진이 시작될 때 vLLM은 여러 설정 옵션으로 LoRA 어댑터용 메모리를 준비합니다. 예를 들어 --max-loras는 같은 GPU 배치에서 동시에 쓸 수 있는 어댑터 수를 정하고, --max-cpu-loras는 호스트 메모리에 캐시해 두었다가 필요할 때 GPU로 옮길 수 있는 어댑터 가중치 수를 정합니다. 어댑터는 --lora-modules로 등록되므로, 요청은 같은 로드된 베이스 모델을 공유하면서 model 필드로 어댑터 하나를 선택할 수 있습니다.
spec:
template:
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args:
- --model
- Qwen/Qwen2.5-3B-Instruct
- --port
- "8000"
- --max-model-len
- "2048"
- --gpu-memory-utilization
- "0.85"
- --max-num-seqs
- "16"
- --enable-lora
- --max-loras
- "2"
- --max-cpu-loras
- "3"
- --max-lora-rank
- "32"
- --lora-modules
- medical=calebking/qwen2.5-3b-instruct-medical-lora
- korean-law=kim0924/qwen2.5-3b-korean-law-lora
- reasoning=namanadep/qwen2.5-3b-instruct-reasoning-lora-bf16EPP와 LoRA
LoRA 어댑터를 지정한 요청을 보내도 EPP 흐름의 시작은 같습니다. 요청은 Envoy로 가고, ext-proc 스트림을 타고 EPP에 전달되며, OpenAI 파서가 model 필드에서 모델을 추출하고 매칭되는 InferenceModelRewrite를 이전과 똑같이 적용합니다.
curl -s http://35.216.6.64/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"korean-law","messages":[{"role":"user","content":"전세 계약이 무엇인가요?"}],"max_tokens":64}'차이는 EPP가 InferencePool의 엔드포인트들을 평가할 때 생깁니다. 모든 레플리카가 LoRA 어댑터 요청을 똑같이 잘 서빙할 수 있는 것은 아닙니다. 어떤 레플리카는 이미 korean-law 어댑터가 활성 상태이고, 다른 레플리카는 로드할 여유가 있고, 또 다른 레플리카는 LoRA 용량이 꽉 찼을 수 있습니다. EPP는 서빙 엔진의 lora_requests_info 메트릭으로 각 레플리카에서 어떤 어댑터가 활성 또는 대기 상태인지, 그리고 레플리카가 담을 수 있는 최대 개수를 파악합니다.
{"msg":"Refreshed metrics", "ts":1787719680.300, "endpoint":{"name":"vllm-qwen25-3b-lora-6888d54787-wjcb4-rank-0"}, "metrics":["…", "vllm:lora_requests_info", "…"], "updated":"{ActiveModels:map[korean-law:0] WaitingModels:map[korean-law:0] MaxActiveModels:2 …}"}이를 위해서는 lora-affinity-scorer 플러그인을 EndpointPickerConfig에 추가해야 합니다. 설정을 마치면 이 플러그인이 후보 엔드포인트마다 점수를 매깁니다:
- 1.0 이 엔드포인트에서 어댑터가 이미 활성 상태
- 0.8 빈 어댑터 슬롯 있음: active + waiting < max_lora
- 0.6 이 엔드포인트에 어댑터 로드가 이미 큐에 올라 있음
- 0.0 엔드포인트가 가득 참: 요청은 슬롯이 빌 때까지 기다려야 함
참고: 이것이 다른 스케줄링 신호를 대체하는 것은 아닙니다. 스케줄러는 여전히 큐 깊이, KV 캐시 사용률, prefix-cache 지역성, 그 외 설정된 스코어러들을 함께 고려합니다. LoRA 어피니티는 각 레플리카가 요청된 어댑터를 서빙하기에 얼마나 적합한지를 보여 주는 점수를 하나 더 얹을 뿐입니다.
스코어러들이 실행된 뒤에는 새로울 것이 없습니다. 점수는 설정된 가중치와 곱해져 합산되고, 피커가 최고 점수를 받은 엔드포인트를 고르며, EPP는 그 파드의 주소를 x-gateway-destination-endpoint에 담아 Envoy에 돌려줍니다.
게이트웨이 하나, 모델 여럿
State of AI Engineering 2026에 따르면 여러 모델을 쓰는 것이 이제 표준입니다. AI를 프로덕션에서 운영하는 수천 개 조직 가운데 70% 이상이 세 개 이상의 모델을 사용하고, 여섯 개를 넘게 운영하는 조직의 비중은 1년 새 거의 두 배가 되었습니다. 팀들은 모델을 교체하는 대신 추가하고 있습니다. 같은 클러스터 안에서 게이트웨이 하나가 각각 다른 작업에 집중하는 Llama 풀, Qwen 풀, Mistral 풀 앞에 설 수 있습니다.
다만 라우팅은 더 까다로워집니다. OpenAI API 스키마는 모델 이름을 요청 본문에 넣는데, 본문 필드는 게이트웨이가 일반적으로 라우팅에 쓸 수 없는 값이기 때문입니다. 게이트웨이는 보통 경로와 헤더만 매칭합니다. 본문 기반 라우팅(body-based routing)이 이 문제를 풉니다. 이 확장은 JSON 본문에서 모델 이름을 추출해 X-Gateway-Model-Name 헤더로 설정하고, HTTPRoute는 이를 여느 헤더처럼 매칭할 수 있습니다. 그다음은 일반 HTTPRoute로 올바른 InferencePool에 라우팅하면 됩니다. 예를 들면:
rules:
- matches:
- path:
type: PathPrefix
value: /
headers:
- type: Exact
name: X-Gateway-Model-Name
value: meta-llama/Llama-3.1-8B-Instruct
backendRefs:
- group: inference.networking.k8s.io
kind: InferencePool
name: vllm-llama3-8b운영 현실
-
확장에 아직 자체 EPP가 들어 있지만, llm-d 라우터를 쓰는 것이 좋습니다. Gateway API Inference Extension의 엔드포인트 피커도 여전히 동작하고, 계속 쓴다고 당장 무언가 깨지지는 않습니다. 하지만 코드는 새 스케줄링 작업이 진행되는 곳인
llm-d/llm-d-router로 옮겨 갔고, 이전 버전은 아카이브될 예정입니다. 새 풀은 처음부터 llm-d EPP를 바라보게 하고, 기존 풀의 전환은 플러그인 설정이 그대로 이전되지 않으므로 단순한 이미지 업데이트가 아니라 계획된 마이그레이션으로 다루세요. -
선언하지 않은 플러그인이 돌고 있을 수 있습니다. ConfigMap에 무엇을 나열했든 로더는 그 위에 자기 플러그인을 얹습니다. 피커가 없는 프로파일에는 피커를, 파서와 정책들, 그리고 여러분이 요청한 플러그인이 필요로 하는 플러그인까지 추가합니다. 실제 설정은 런타임에야 확정됩니다. EPP는 시작할 때 실효 설정을 한 번 출력하고, 그 직후에 자동 생성한 데이터 프로듀서를 "auto-created default producer"라는 별도의 로그 라인으로 남깁니다. 둘 다 캡처해 보관하고, 업그레이드 때마다 비교하세요.
-
기본 프리픽스 스코어링은 정확한 조회가 아니라 추정일 뿐입니다. KV 이벤트 추적이 없으면 EPP는 파드의 캐시 내부를 볼 수 없습니다. 프롬프트를 4바이트 의사 토큰으로 지문화하고, 그 지문 중 파드가 갖고 있으리라 추정되는 양에 따라 각 파드에 점수를 매깁니다. 이 순위는 유용하지만 보장은 아닙니다. 적중률은 스코어러가 아니라 서빙 엔진에서 확인하고, 둘이 어긋나면 정밀 추적으로 전환하세요.
실전 권장 사항
이미 한 모델의 레플리카 여러 개를 일반 Service 뒤에서 운영하고 있다면, 이번 주에 그 풀을 InferencePool 뒤로 옮기고 나머지는 여러분 자신의 트래픽이 알려 주게 하세요. 파일럿은 오후 한나절이면 됩니다. queue-scorer와 kv-cache-utilization-scorer만으로 EPP를 배포하고, HTTPRoute 하나를 풀로 향하게 하되, 쉽게 되돌릴 수 있도록 Service 라우트는 남겨 두세요. 프로덕션의 프롬프트 길이 분포를 두 구성에 재생해 p95 TTFT, 초당 출력 토큰 수, 엔진의 프리필 캐시 적중률을 비교하세요. 프리픽스 스코어러는 두 번째 실행에서 추가하세요. 그러지 않으면 어느 변화가 차이를 만들었는지 알 수 없습니다. 프리픽스 적중은 캐시된 구간의 프리필을 빠르게 만드는 것이 아니라 그 계산 자체를 건너뜁니다. 프리필이 여전히 도는 것은 캐시되지 않은 꼬리 부분뿐입니다. 1년에 10만 달러가 넘게 드는 H100 노드에서 그 차이는 결코 작지 않습니다. 여러분의 트래픽에서 개선이 보이지 않는다면, 도입하지 마세요.
FAQ
추론 게이트웨이에 대해 가장 자주 받는 질문 세 가지.
서빙 엔진 레플리카가 하나여도 추론 게이트웨이가 필요한가요?
아니요. EPP의 역할은 큐 깊이, KV 캐시 사용률, 프리픽스 지역성을 근거로 레플리카들 사이에서 고르는 것인데, 파드가 하나면 고를 대상이 없습니다. 같은 모델의 레플리카를 여러 개 운영하기 전까지는 일반 Service를 유지하세요.
어떤 서빙 엔진이 함께 동작하나요?
모델 서버 프로토콜을 따르는 엔진이라면 무엇이든 됩니다. OpenAI 호환 Completions와 Chat API에 큐 깊이, 실행 중 요청 수, KV 캐시 사용률에 대한 Prometheus 메트릭을 더하면 됩니다. 현재 vLLM, SGLang, 그리고 Triton의 TensorRT-LLM 백엔드가 모두 이 조건을 충족합니다.
EPP가 다운되면 어떻게 되나요?
InferencePool의 failureMode가 결정합니다. FailClose는 피커가 돌아올 때까지 요청을 거부하고, FailOpen은 Envoy가 풀 전체에 대해 자체 로드 밸런싱으로 대체하게 해서, 지능적인 배치 없이도 트래픽은 계속 흐르게 합니다.
이 롤아웃을 직접 진행하고 싶지 않으시다면, Todea가 매니지드 플랫폼 서비스의 일부로 추론 게이트웨이를 처음부터 끝까지 구축하고 운영해 드립니다.