Skip to content

AI Infrastructure

Ingress から推論ゲートウェイへ: Gateway API と Inference Extension

Kubernetes のネットワーキングはこうしてモデルを理解するようになった: Gateway API のリソースモデル、Inference Extension の InferencePool、そしてラウンドロビンの代わりに KV キャッシュとキューのメトリクスでルーティングするエンドポイントピッカーを解説します。

Ivan Porta

Founder & Principal Engineer

15 分で読了
#gateway-api#inference#kubernetes#vllm#envoy#platform-engineering
Ingress から推論ゲートウェイへ: Gateway API と Inference Extension

従来のロードバランシングは、どのバックエンドも同じであり、どれがリクエストを処理しても構わないという前提に立っています。LLM のトラフィックはこの前提を崩します。たとえば、ある vLLM レプリカは進行中の会話の KV キャッシュを温めた状態で持っているのに、別のレプリカはゼロから計算し直さなければならない、ということが起こります。短い質問のリクエストもあれば、巨大なコードベースを丸ごと含むリクエストもあります。ラウンドロビンのバランサーはこの違いを見分けられないため、キャッシュ済みの作業を持つレプリカが遊んでいるのに、リクエストが混み合ったレプリカに送られることがあります。その結果、ユーザーの体感するレイテンシーは悪化し、GPU リソースは無駄になります。

Kubernetes はこの問題に 2 つの層で取り組みます。Gateway API は、トラフィックがクラスターに入る主要な経路として Ingress を置き換えつつあります。その上に、同じコミュニティ(SIG-Network と WG-Serving)が開発した Gateway API Inference Extension が、一連の API と 1 つのプロトコルを追加します。これにより、ゲートウェイはモデルサーバーを汎用の 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 はそのうちの 1 つをインスタンス化します。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 はきわめて構成の自由度が高く、いまではスケジューリング、リクエスト制御、フローコントロール、リクエスト処理、データレイヤーという 5 つのカテゴリーにわたって 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-policystatic-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 に対して InferencePoolPodInferenceObjectiveInferenceModelRewrite リソースのウォッチを張ります。プール内の各ポッドからメトリクスをスクレイピングし、自身のメトリクスも出力し、精密なプレフィックスキャッシュ認識が有効な場合は各ポッドの 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 パスのサフィックスにもとづいてパーサーを選択します。デフォルトでは 3 つのパーサーが用意され、それぞれ担当のサフィックスを処理します。どれにもマッチしなければリクエストは拒否されます:

  • openai-parsercompletionschat/completionsembeddingsresponses とその系統を担当します。
  • anthropic-parsermessagesmessages/count_tokens を担当します。
  • vllmhttp-parserinference/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 の根底にある考え方はシンプルです。大きなモデルのすべての重みを更新する代わりに、ベースモデルを凍結し、特定のユースケースのために選ばれたレイヤーへ取り付けられる「アダプター」、すなわち低ランク行列の小さなペアだけを学習します。これで生まれる成果物はたいてい数ギガバイトではなく、数十から数百メガバイトにとどまります。対象のレイヤーごとに、入力は凍結された元の重みとアダプターの低ランク経路の両方を通り、2 つの出力が足し合わされて特化した結果になります。

この方法なら、特化のたびに数ギガバイトのモデルコピーを別に作る必要がありません。推論時には、アダプターをベースの重みへマージすることも、分離したままにすることもできます。これにより、vLLM のような LoRA 対応のサービングエンジンは、共有された 1 つのベースモデルの上へ、異なるアダプターを動的に適用できます。

サービングエンジンの起動時、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-bf16

EPP と 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 アフィニティは、要求されたアダプターを提供するうえで各レプリカがどれだけ適しているかを示すスコアを 1 つ足すだけです。

スコアラーが走り終えたあとに、新しいことは何も起こりません。値は設定された重みと掛け合わされて合算され、ピッカーが勝者のエンドポイントを選び、EPP はそのポッドのアドレスを x-gateway-destination-endpoint に載せて Envoy へ返します。

1 つのゲートウェイ、複数のモデル

State of AI Engineering 2026 によると、複数モデルの利用はもはや標準です。AI を本番環境で運用する数千の組織のうち、70% を超える組織が 3 つ以上のモデルを使っており、6 つを超える数を運用する組織の割合はこの 1 年でほぼ倍増しました。チームはモデルを置き換えるのではなく、追加しています。同じクラスターの中で、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 バイトの擬似トークンで指紋化し、その指紋をポッドがどれだけ持っていそうかにもとづいて採点します。このランキングは有用ですが、保証ではありません。ヒット率はスコアラーではなくサービングエンジン側で確認し、両者が食い違うなら精密な追跡へ切り替えてください。

実践的な推奨

すでに 1 つのモデルの複数レプリカを通常の Service の後ろで運用しているなら、今週のうちにそのプールを InferencePool の後ろへ移し、あとは自分たちのトラフィックに判断させましょう。パイロットは半日で足ります。queue-scorerkv-cache-utilization-scorer だけで EPP をデプロイし、HTTPRoute を 1 本プールへ向け、すぐ戻せるように Service のルートは残しておきます。本番のプロンプト長の分布を両方の構成にリプレイし、p95 TTFT、秒あたりの出力トークン数、エンジンのプリフィルキャッシュヒット率を比較してください。プレフィックススコアラーは 2 回目の実行で追加します。そうしないと、どの変更が差を生んだのか分からなくなります。プレフィックスヒットは、キャッシュ済み区間のプリフィルを速くするのではなく、その計算自体をスキップします。プリフィルが走るのはキャッシュされていない末尾だけです。年に 10 万ドル単位の費用がかかる H100 ノードでは、その差は無視できません。自分たちのトラフィックで改善が見えないなら、採用しないでください。

FAQ

推論ゲートウェイについて、よくいただく 3 つのご質問。

サービングエンジンのレプリカが 1 つでも推論ゲートウェイは必要ですか?

いいえ。EPP の仕事は、キューの深さ、KV キャッシュ使用率、プレフィックスの局所性をもとにレプリカの中から選ぶことです。ポッドが 1 つなら選びようがありません。同じモデルのレプリカを複数運用するまでは、通常の Service のままで構いません。

どのサービングエンジンが対応していますか?

モデルサーバープロトコルに従うエンジンならどれでも使えます。OpenAI 互換の Completions と Chat の両 API と、キューの深さ、実行中リクエスト数、KV キャッシュ使用率などの Prometheus メトリクスがあれば十分です。現時点では vLLM、SGLang、Triton の TensorRT-LLM バックエンドがいずれも条件を満たしています。

EPP がダウンしたらどうなりますか?

InferencePool の failureMode が決めます。FailClose はピッカーが復帰するまでリクエストを拒否し、FailOpen は Envoy をプール全体への自前のロードバランシングへフォールバックさせ、賢い配置は失われますがトラフィックは流れ続けます。

このサイトは分析のためにCookieを使用しています。