LLM推論APIを本番運用すると、アクセス数の増加だけでなく、入力・出力トークン数の偏りによって負荷が大きく変わります。CPU中心のWeb APIと同じ感覚でPod数だけ増やしても、GPUメモリ不足やキュー詰まり、スケール判定の遅延に悩まされがちです。
この記事では、Kubernetes上にvLLMベースの推論APIを配置し、複数のGPU Podへリクエストを分散する構成を実際に検証した内容をまとめます。結論としては、推論Podをステートレスに保ち、Serviceで分散し、GPU使用率だけでなく「待ちリクエスト数」や「処理時間」を見てスケールさせるのが重要です。
基本構成
検証では、次のような構成にしました。
Client
|
v
Kubernetes Service
|
+-- LLM inference Pod (GPU)
+-- LLM inference Pod (GPU)
+-- LLM inference Pod (GPU)
各Podは同じモデルをロードします。Pod間でKVキャッシュを共有する構成ではないため、リクエストは基本的にどのPodに送られても処理できるようにします。
チャット履歴などの状態をPod内に保持すると、Podの再起動やスケールインでセッションが失われます。履歴はリクエストに含めるか、外部のデータストアに保存し、推論Podはステートレスにしました。
まず、vLLMのDeploymentとServiceを用意します。
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-api
spec:
replicas: 2
selector:
matchLabels:
app: llm-api
template:
metadata:
labels:
app: llm-api
spec:
terminationGracePeriodSeconds: 120
containers:
- name: vllm
image: vllm/vllm-openai:latest
args:
- --model
- /models/my-model
- --served-model-name
- my-model
- --max-model-len
- "4096"
ports:
- containerPort: 8000
resources:
requests:
cpu: "2"
memory: "16Gi"
nvidia.com/gpu: "1"
limits:
cpu: "4"
memory: "32Gi"
nvidia.com/gpu: "1"
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 60
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: llm-api
spec:
selector:
app: llm-api
ports:
- port: 8000
targetPort: 8000
モデルの配置方法は、コンテナイメージに含める方法、PersistentVolumeから読む方法、起動時にダウンロードする方法があります。今回の検証では、モデルをあらかじめノード上のボリュームに配置しました。
起動時にモデルをダウンロードすると、Pod追加のたびに起動時間とネットワーク負荷が発生します。特にHPAで急にPodを増やす場合、モデルロードがボトルネックになりやすいため注意が必要です。
ServiceとProbeの設計
Serviceは、Readiness Probeが成功したPodにだけトラフィックを送ります。LLMではコンテナプロセスが起動していても、モデルロード中は推論を受け付けられません。そのため、単純なTCPチェックでは不十分です。
検証中にReadiness Probeを設定しなかったところ、Pod起動直後のリクエストが失敗しました。プロセスは起動済みでも、モデルのロードが終わっていなかったためです。
また、モデルロード中のPodを「準備完了」と判定しないよう、Startup Probeを併用しました。モデルサイズやストレージ速度によって起動時間は大きく異なるので、failureThreshold と periodSeconds は実環境で調整します。
Podの削除時にも注意が必要です。推論中のリクエストを即座に切断すると、クライアント側ではタイムアウトや途中応答になります。terminationGracePeriodSeconds を確保し、アプリケーション側でもSIGTERMを受けたら新規受付を停止する設計にします。
本番では、次のようなPodDisruptionBudgetも設定しました。
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: llm-api-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: llm-api
これにより、ノードメンテナンスなどで全Podが同時に停止することを避けられます。
HPAで水平スケールする
リクエスト処理とスケール判定の流れを図にすると次の通りです。
まずはCPU使用率を使ったHPAを設定できます。
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-api
minReplicas: 2
maxReplicas: 6
behavior:
scaleUp:
stabilizationWindowSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
適用後は次のコマンドで状態を確認します。
kubectl apply -f llm-api.yaml
kubectl apply -f llm-api-hpa.yaml
kubectl get pods -l app=llm-api
kubectl get svc llm-api
kubectl get hpa llm-api
ただし、LLM推論の負荷はCPU使用率だけでは判断しにくいです。GPUがほぼ100%でも、リクエストが効率よく処理されている場合があります。一方で、入力トークンが長いリクエストが集中すると、GPU使用率が一時的に低くても待ち時間が増えることがあります。
そのため、実運用では次のような指標をカスタムメトリクスとして利用するのが現実的です。
- 待機中リクエスト数
- リクエストの平均待ち時間
- p95、p99レイテンシ
- 1秒あたりの生成トークン数
- GPUメモリ使用量
- KVキャッシュ使用率
Prometheus Adapterなどを利用してHPAから参照できるようにします。例えば、待機リクエスト数が一定値を超えたらPodを増やす設計です。
ただし、GPUノードが空いていなければPod数だけ増えてもスケールしません。KubernetesのCluster Autoscalerなど、GPUノードを追加できる仕組みも合わせて検討します。
負荷試験で確認したこと
APIはOpenAI互換エンドポイントとして呼び出しました。
kubectl port-forward svc/llm-api 8000:8000
別ターミナルからリクエストを送ります。
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "my-model",
"messages": [
{"role": "user", "content": "KubernetesのReadiness Probeを説明してください"}
],
"max_tokens": 256,
"temperature": 0.2
}'
簡単な負荷試験では、同時実行数を少しずつ増やし、レスポンスタイムとエラー率を確認しました。
hey -n 100 -c 5 \
-m POST \
-H "Content-Type: application/json" \
-D request.json \
http://localhost:8000/v1/chat/completions
request.json の例です。
{
"model": "my-model",
"messages": [
{
"role": "user",
"content": "水平スケールの注意点を3つ説明してください"
}
],
"max_tokens": 128
}
検証では、Pod数を増やすことで同時リクエスト数は処理しやすくなりましたが、各Podがモデルをロードするため、GPUメモリはPod数に比例して必要になりました。
また、リクエストを増やしすぎると、Podを追加する前に既存Podのキューが膨らみます。スケールアウトにはPod起動とモデルロードの時間があるため、急激なスパイク対策には、キューイング、レート制限、クライアント側リトライの設計も必要です。
運用時の注意点
最も重要だったのは、リクエストの上限を明確にすることです。max_tokens や入力トークン数に上限を設定しないと、一部の長大なリクエストが他のリクエストを圧迫します。
API Gatewayやアプリケーション層で、次のような制御を入れました。
- 入力トークン数の上限
-
max_tokensの上限 - リクエストタイムアウト
- 同時実行数の制限
- 429レスポンスによるレート制限
- 指数バックオフ付きのリトライ
リトライは便利ですが、タイムアウトした推論がサーバー側でまだ実行中だと、同じリクエストが二重に処理される可能性があります。リクエストIDを付与し、必要に応じて冪等性を管理します。
GPUを使う場合は、Deploymentのローリングアップデートも慎重に設定します。更新中に新Podを作れないと、古いPodを先に削除して一時的に容量不足になることがあります。GPUノードの空き状況を確認し、maxUnavailable: 0 などの設定を環境に合わせて検討します。
まとめ
Kubernetes上でLLM推論APIを水平スケールする場合、Deploymentのreplicasを増やすだけでは不十分です。
重要なポイントは次の通りです。
- 推論Podをステートレスにする
- Readiness Probeでモデルロード完了を判定する
- Serviceで複数Podへ分散する
- CPU使用率だけでなく待ち時間やキュー長を見る
- GPUノードの追加能力を確保する
- トークン数、同時実行数、タイムアウトを制限する
- スケールイン時に実行中リクエストを保護する
特にLLMでは、リクエストごとのトークン数が性能を大きく左右します。まず固定条件で負荷試験を行い、その後に入力長や同時実行数を変えながら、レイテンシ・エラー率・GPUメモリ使用量を測るのがおすすめです。