K8s Sidecar AI推論プロキシ:トラフィックインターセプトからモデルルーティングまでの7つのプロダクションパターン
AI推論サービスをそのまま裸で実行していませんか?モデルのアップグレードのたびにビジネスコードを変更していますか?複数のモデルバージョンが共存する際のトラフィック管理が混乱していませんか?GPU利用率が30%未満なのにスケールアウトを続けていますか?2026年、Kubernetes SidecarパターンはAI推論プロキシの標準アーキテクチャとなりました。Sidecarコンテナで推論ロジックとビジネスロジックを分離する時が来ました。
主要なポイント
- K8s Sidecar AI推論プロキシのアーキテクチャ原理と適用シナリオの理解
- 7つのプロダクショングレードSidecarプロキシパターンの完全な実装を習得
- トラフィックインターセプト、モデルルーティング、A/BテストのYAML設定を学習
- GPUリソースプーリング、バッチマージなどの高度な最適化テクニックを理解
- 5つのよくある落とし穴と10の高頻度エラーを回避
目次
- Sidecar AIプロキシアーキテクチャ概要
- パターン1:Envoyトラフィックインターセプトとリライト
- パターン2:スマートモデルルーティング
- パターン3:A/Bテストとカナリアデプロイ
- パターン4:マルチモデルサービングとバージョン管理
- パターン5:GPUリソースプーリング
- パターン6:バッチ処理とリクエストマージ
- パターン7:オブザーバビリティと分散トレーシング
- 5つのよくある落とし穴と解決策
- 10のよくあるエラートラブルシューティング
- 高度な最適化テクニック
- 比較分析:Sidecarプロキシ vs Service Mesh vs Gateway
- オンラインツール推奨
Sidecar AIプロキシアーキテクチャ概要
AI推論にSidecarプロキシが必要な理由
従来のAI推論デプロイメントでは、モデル読み込み、推論実行、トラフィック管理がすべてビジネスコンテナ内に結合されています。モデルバージョンの反復、ルーティングポリシーの変更、リソース制限の調整時に、サービス全体を再構築・再デプロイする必要があります。Sidecarプロキシパターンは関心の分離を実現します:
┌─────────────────────────────────────────────────┐
│ Pod │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Business │ │ Sidecar Proxy │ │
│ │ Container │─────▶│ (AI Inference) │ │
│ │ │ │ │ │
│ │ - API論理 │ │ - トラフィック傍受 │ │
│ │ - ビジネス │ │ - モデルルーティング│ │
│ │ - 結果集約 │ │ - ロードバランシング│ │
│ │ │ │ - バッチマージ │ │
│ │ :8080 │ │ - メトリクス収集 │ │
│ └──────────────┘ │ │ │
│ │ │ :15001(inbound) │ │
│ │ │ :15006(outbound) │ │
│ ▼ └──────────────────────┘ │
│ ┌──────────────┐ │ │
│ │ Model │◀──────────────┘ │
│ │ Server │ │
│ │ (vLLM/Triton│ │
│ │ /Ollama) │ │
│ │ :8000 │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────┘
Sidecarプロキシのコア責務
| 責務 | 説明 | メリット |
|---|---|---|
| トラフィックインターセプト | ビジネスコンテナの推論リクエストを傍受 | ビジネスコードの変更ゼロ |
| モデルルーティング | リクエスト特性に基づき異なるモデルにルーティング | マルチモデルバージョン共存 |
| ロードバランシング | 複数レプリカ間で推論リクエストを分配 | スループット向上 |
| バッチ処理 | 複数リクエストをマージしてバッチ推論 | GPU利用率3-5倍向上 |
| サーキットブレーカー | モデルサービス異常時の高速デグラデーション | システム安定性保護 |
| オブザーバビリティ | 推論レイテンシ、スループット等のメトリクス収集 | フルチェーン可観測性 |
パターン1:Envoyトラフィックインターセプトとリライト
アーキテクチャ原理
EnvoyがSidecarプロキシとして機能し、iptablesを介してビジネスコンテナのアウトバウンドトラフィックを傍受し、推論リクエストをターゲットモデルサービスにリライトします。これはK8s Sidecarパターンで最も古典的なトラフィックインターセプト方式です。
Client Request
│
▼
┌─────────┐ iptables ┌──────────────┐
│ Business │───redirect───▶│ Envoy │
│ Container│ :8080 │ Sidecar │
│ │ │ :15001 │
└─────────┘ └──────┬───────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Model A │ │ Model B │ │ Model C │
│ :8000 │ │ :8001 │ │ :8002 │
└─────────┘ └─────────┘ └─────────┘
完全設定
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-inference-app
namespace: ai-serving
spec:
replicas: 3
selector:
matchLabels:
app: ai-inference
template:
metadata:
labels:
app: ai-inference
annotations:
sidecar.istio.io/inject: "true"
traffic.sidecar.istio.io/includeOutboundIPRanges: "10.0.0.0/8"
traffic.sidecar.istio.io/excludeInboundPorts: "9090"
spec:
containers:
- name: business-app
image: myregistry/ai-business-app:v2.1.0
ports:
- containerPort: 8080
env:
- name: INFERENCE_ENDPOINT
value: "http://localhost:15001/v1/completions"
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2000m"
memory: "2Gi"
- name: model-server
image: vllm/vllm-openai:v0.6.0
ports:
- containerPort: 8000
env:
- name: MODEL_NAME
value: "Qwen/Qwen2.5-72B-Instruct"
- name: GPU_MEMORY_UTILIZATION
value: "0.9"
resources:
requests:
nvidia.com/gpu: "1"
limits:
nvidia.com/gpu: "1"
Envoyリライトルール
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: inference-rewrite
namespace: ai-serving
spec:
hosts:
- inference-internal
http:
- match:
- uri:
prefix: "/v1/completions"
- headers:
x-model-type:
exact: "chat"
rewrite:
uri: "/v1/chat/completions"
route:
- destination:
host: model-server
port:
number: 8000
- match:
- uri:
prefix: "/v1/embeddings"
route:
- destination:
host: embedding-server
port:
number: 8001
パターン2:スマートモデルルーティング
リクエスト特性に基づく動的ルーティング
AI推論シナリオでは、異なるリクエストが異なる仕様のモデルにルーティングされる必要があります。Sidecarプロキシはリクエストヘッダー、ペイロード内容、トークン数などの特徴に基づいてスマートルーティングを行います。
┌──────────────────────────────────────────────────────┐
│ Smart Model Router │
│ │
│ Request ──▶ [Token Counter] ──▶ [Model Selector] │
│ │ │ │
│ │ ┌──────────────┼──────────┐ │
│ │ ▼ ▼ ▼ │
│ │ ┌───────┐ ┌─────────┐ ┌──────┐ │
│ │ │Small │ │Medium │ │Large │ │
│ │ │Model │ │Model │ │Model │ │
│ │ │<1K tok│ │1K-8K tok│ │>8K │ │
│ │ │Qwen2.5│ │Qwen2.5 │ │Qwen2.│ │
│ │ │-7B │ │-32B │ │5-72B │ │
│ │ └───────┘ └─────────┘ └──────┘ │
└──────────────────────────────────────────────────────┘
ルーティング設定
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: smart-model-route
namespace: ai-serving
spec:
hosts:
- ai-router
http:
- match:
- headers:
x-token-range:
exact: "small"
uri:
prefix: "/v1"
route:
- destination:
host: qwen-7b-service
port:
number: 8000
weight: 100
- match:
- headers:
x-token-range:
exact: "medium"
uri:
prefix: "/v1"
route:
- destination:
host: qwen-32b-service
port:
number: 8000
weight: 100
- match:
- headers:
x-token-range:
exact: "large"
uri:
prefix: "/v1"
route:
- destination:
host: qwen-72b-service
port:
number: 8000
weight: 100
- route:
- destination:
host: qwen-32b-service
port:
number: 8000
Goルーティングプロキシ実装
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
)
type ModelRouteConfig struct {
SmallModelEndpoint string `json:"smallModelEndpoint"`
MediumModelEndpoint string `json:"mediumModelEndpoint"`
LargeModelEndpoint string `json:"largeModelEndpoint"`
SmallTokenThreshold int `json:"smallTokenThreshold"`
LargeTokenThreshold int `json:"largeTokenThreshold"`
}
type InferenceRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages,omitempty"`
Prompt string `json:"prompt,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type SmartRouter struct {
config *ModelRouteConfig
logger *zap.Logger
metrics *RouterMetrics
}
type RouterMetrics struct {
routeDecision *prometheus.CounterVec
requestLatency *prometheus.HistogramVec
}
func NewSmartRouter(cfg *ModelRouteConfig, logger *zap.Logger) *SmartRouter {
metrics := &RouterMetrics{
routeDecision: prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "ai_router_route_decision_total", Help: "Model route decision counter"},
[]string{"model_size", "model_name"},
),
requestLatency: prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "ai_router_request_latency_seconds", Help: "Request routing latency", Buckets: prometheus.DefBuckets},
[]string{"model_size"},
),
}
prometheus.MustRegister(metrics.routeDecision, metrics.requestLatency)
return &SmartRouter{config: cfg, logger: logger, metrics: metrics}
}
func (r *SmartRouter) estimateTokenCount(req *InferenceRequest) int {
totalChars := 0
if req.Prompt != "" {
totalChars += len(req.Prompt)
}
for _, msg := range req.Messages {
totalChars += len(msg.Content)
}
return totalChars / 4
}
func (r *SmartRouter) selectModel(tokenCount int) (string, string) {
if tokenCount <= r.config.SmallTokenThreshold {
return "small", r.config.SmallModelEndpoint
}
if tokenCount <= r.config.LargeTokenThreshold {
return "medium", r.config.MediumModelEndpoint
}
return "large", r.config.LargeModelEndpoint
}
func (r *SmartRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
start := time.Now()
body, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
defer req.Body.Close()
var inferReq InferenceRequest
if err := json.Unmarshal(body, &inferReq); err != nil {
http.Error(w, "invalid request format", http.StatusBadRequest)
return
}
tokenCount := r.estimateTokenCount(&inferReq)
modelSize, endpoint := r.selectModel(tokenCount)
r.logger.Info("routing decision", zap.Int("token_count", tokenCount), zap.String("model_size", modelSize))
r.metrics.routeDecision.WithLabelValues(modelSize, inferReq.Model).Inc()
target, _ := url.Parse(endpoint)
proxy := httputil.NewSingleHostReverseProxy(target)
req.Body = io.NopCloser(strings.NewReader(string(body)))
req.ContentLength = int64(len(body))
proxy.ServeHTTP(w, req)
r.metrics.requestLatency.WithLabelValues(modelSize).Observe(time.Since(start).Seconds())
}
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync()
cfg := &ModelRouteConfig{
SmallModelEndpoint: "http://qwen-7b-service:8000",
MediumModelEndpoint: "http://qwen-32b-service:8000",
LargeModelEndpoint: "http://qwen-72b-service:8000",
SmallTokenThreshold: 1000,
LargeTokenThreshold: 8000,
}
router := NewSmartRouter(cfg, logger)
mux := http.NewServeMux()
mux.Handle("/v1/", router)
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "ok") })
server := &http.Server{Addr: ":15001", Handler: mux, WriteTimeout: 120 * time.Second}
logger.Info("smart router starting", zap.String("addr", server.Addr))
server.ListenAndServe()
}
パターン3:A/Bテストとカナリアデプロイ
重みベースのカナリアデプロイ
AIモデルのリリース時、カナリアデプロイは不可欠なプロセスです。Sidecarプロキシは重みベースのトラフィック分配を実現し、旧モデルから新モデルへ段階的にトラフィックを移行します。
┌────────────────────────────────────────────┐
│ Canary Deployment Flow │
│ │
│ Traffic ──▶ [Sidecar Proxy] ──┬── 90% ──▶│──▶ Model v1 (Stable)
│ │ │
│ └── 10% ──▶│──▶ Model v2 (Canary)
│ │
│ Metrics: │
│ ┌──────────────────────────────────────┐ │
│ │ v1: latency_p99=120ms error=0.1% │ │
│ │ v2: latency_p99=95ms error=0.05% │ │
│ └──────────────────────────────────────┘ │
│ │
│ Decision: Promote v2 ──▶ Shift to 50/50 │
└────────────────────────────────────────────┘
カナリアデプロイ設定
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: model-canary
namespace: ai-serving
spec:
hosts:
- model-service
http:
- route:
- destination:
host: model-v1
port:
number: 8000
weight: 90
- destination:
host: model-v2
port:
number: 8000
weight: 10
retries:
attempts: 3
perTryTimeout: 30s
retryOn: 5xx,reset
ヘッダーベースのA/Bテスト
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: model-ab-test
namespace: ai-serving
spec:
hosts:
- model-service
http:
- match:
- headers:
x-experiment:
exact: "model-v2-creative"
route:
- destination:
host: model-v2-creative
port:
number: 8000
- match:
- headers:
x-experiment:
exact: "model-v2-precise"
route:
- destination:
host: model-v2-precise
port:
number: 8000
- route:
- destination:
host: model-v1
port:
number: 8000
Argo Rollouts自動カナリア
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: model-rollout
namespace: ai-serving
spec:
replicas: 4
strategy:
canary:
canaryService: model-v2
stableService: model-v1
trafficRouting:
istio:
virtualServices:
- name: model-canary
routes:
- primary
steps:
- setWeight: 10
- pause: { duration: 5m }
- setWeight: 25
- pause: { duration: 10m }
- setWeight: 50
- pause: { duration: 15m }
- setWeight: 75
- pause: { duration: 10m }
- setWeight: 100
analysis:
templates:
- templateName: model-quality-check
startingStep: 2
args:
- name: canary-service
value: model-v2
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: model-quality-check
namespace: ai-serving
spec:
args:
- name: canary-service
metrics:
- name: error-rate
interval: 30s
count: 10
successCondition: result[0] <= 0.01
provider:
prometheus:
query: |
sum(rate(http_requests_total{service="{{args.canary-service}}",code=~"5xx"}[1m]))
/
sum(rate(http_requests_total{service="{{args.canary-service}}"}[1m]))
- name: latency-p99
interval: 30s
count: 10
successCondition: result[0] <= 500
provider:
prometheus:
query: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{service="{{args.canary-service}}"}[1m]))
by (le)
) * 1000
パターン4:マルチモデルサービングとバージョン管理
マルチバージョンモデル共存アーキテクチャ
本番環境では、異なるビジネスラインが異なるモデルバージョンに依存する場合があります。Sidecarプロキシは複数のモデルバージョンを同時に管理し、バージョン共存とスムーズな移行を実現します。
┌─────────────────────────────────────────────────────┐
│ Multi-Model Serving Pod │
│ │
│ ┌──────────┐ ┌────────────────────────────┐ │
│ │ Business │ │ Model Router Sidecar │ │
│ │ App │────▶│ │ │
│ │ │ │ /v1/chat ──▶ v2.5 model │ │
│ │ │ │ /v1/embed ──▶ embed model │ │
│ │ │ │ /v1/rerank─▶ rerank model │ │
│ └──────────┘ └────────────┬───────────────┘ │
│ │ │
│ ┌───────────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐│
│ │ vLLM │ │ TEI │ │ TEI ││
│ │ Chat │ │ Embed │ │ Rerank ││
│ │ :8000 │ │ :8080 │ │ :8081 ││
│ └──────────┘ └──────────┘ └────────┘│
└─────────────────────────────────────────────────────┘
マルチモデルDeployment設定
apiVersion: apps/v1
kind: Deployment
metadata:
name: multi-model-serving
namespace: ai-serving
spec:
replicas: 2
selector:
matchLabels:
app: multi-model
template:
metadata:
labels:
app: multi-model
spec:
containers:
- name: chat-model
image: vllm/vllm-openai:v0.6.0
ports:
- containerPort: 8000
env:
- name: MODEL_NAME
value: "Qwen/Qwen2.5-32B-Instruct"
- name: PORT
value: "8000"
resources:
requests:
nvidia.com/gpu: "1"
limits:
nvidia.com/gpu: "1"
volumeMounts:
- name: model-cache
mountPath: /root/.cache/huggingface
- name: embedding-model
image: ghcr.io/huggingface/text-embeddings-inference:latest
ports:
- containerPort: 8080
env:
- name: MODEL_ID
value: "BAAI/bge-large-zh-v1.5"
- name: PORT
value: "8080"
resources:
requests:
cpu: "2"
memory: "4Gi"
- name: rerank-model
image: ghcr.io/huggingface/text-embeddings-inference:latest
ports:
- containerPort: 8081
env:
- name: MODEL_ID
value: "BAAI/bge-reranker-v2-m3"
- name: PORT
value: "8081"
- name: RERANK
value: "true"
resources:
requests:
cpu: "2"
memory: "4Gi"
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: model-cache-pvc
モデルバージョン管理CRD
apiVersion: ai.toolsku.dev/v1alpha1
kind: ModelVersion
metadata:
name: qwen-chat-v2-5
namespace: ai-serving
spec:
modelName: qwen-chat
version: "2.5"
framework: vllm
source:
huggingFace:
modelId: Qwen/Qwen2.5-32B-Instruct
revision: main
serving:
port: 8000
maxBatchSize: 32
gpuMemoryUtilization: 0.9
routing:
weight: 80
canary: false
healthCheck:
endpoint: /health
interval: 10s
timeout: 5s
unhealthyThreshold: 3
---
apiVersion: ai.toolsku.dev/v1alpha1
kind: ModelVersion
metadata:
name: qwen-chat-v2-6-canary
namespace: ai-serving
spec:
modelName: qwen-chat
version: "2.6"
framework: vllm
source:
huggingFace:
modelId: Qwen/Qwen2.6-32B-Instruct
revision: main
serving:
port: 8000
maxBatchSize: 32
gpuMemoryUtilization: 0.9
routing:
weight: 20
canary: true
healthCheck:
endpoint: /health
interval: 10s
timeout: 5s
unhealthyThreshold: 3
パターン5:GPUリソースプーリング
GPU共有とタイムスライシング
GPUはAI推論で最も高価なリソースです。SidecarプロキシはGPUリソースプーリングを実現し、複数の推論サービスが同じGPUをタイムスライシングで共有し、利用率を向上させます。
┌─────────────────────────────────────────────────────┐
│ GPU Resource Pooling │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Pod A │ │ Pod B │ │ Pod C │ │
│ │ Sidecar │ │ Sidecar │ │ Sidecar │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ GPU Scheduler Sidecar │ │
│ │ │ │
│ │ Time-Slicing: │ │
│ │ GPU 0: [A][A][B][B][C][A][A][B][B][C] │ │
│ │ GPU 1: [C][C][A][B][C][C][A][B][C][C] │ │
│ │ │ │
│ │ Memory Partitioning: │ │
│ │ GPU 0: 40% A | 35% B | 25% C │ │
│ │ GPU 1: 30% A | 40% B | 30% C │ │
│ └──────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ GPU 0 │ │ GPU 1 │ │
│ │ A100 80G │ │ A100 80G │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────┘
GPUタイムスライシング設定
apiVersion: v1
kind: ConfigMap
metadata:
name: gpu-scheduler-config
namespace: ai-serving
data:
scheduler.yaml: |
scheduling:
strategy: time-slicing
gpuGroups:
- name: inference-pool
gpuIds: [0, 1, 2, 3]
timeSliceInterval: 100ms
maxSharesPerGpu: 4
memoryLimitPerShare: 20Gi
- name: embedding-pool
gpuIds: [4, 5]
timeSliceInterval: 50ms
maxSharesPerGpu: 8
memoryLimitPerShare: 10Gi
policies:
- modelType: chat
gpuGroup: inference-pool
minShares: 1
maxShares: 2
priority: high
- modelType: embedding
gpuGroup: embedding-pool
minShares: 1
maxShares: 4
priority: medium
GPUリソースクォータ管理
apiVersion: v1
kind: ResourceQuota
metadata:
name: gpu-quota
namespace: ai-serving
spec:
hard:
requests.nvidia.com/gpu: "8"
limits.nvidia.com/gpu: "8"
requests.nvidia.com/gpu-share: "32"
limits.nvidia.com/gpu-share: "32"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: gpu-high-priority
value: 1000000
globalDefault: false
description: "High priority for latency-sensitive AI inference"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: gpu-low-priority
value: 100000
globalDefault: false
description: "Low priority for batch inference jobs"
Python GPUスケジューラ
import asyncio
import time
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from enum import Enum
logger = logging.getLogger(__name__)
class TaskPriority(Enum):
HIGH = 1
MEDIUM = 2
LOW = 3
@dataclass
class InferenceTask:
taskId: str
modelId: str
gpuMemoryRequired: int
priority: TaskPriority
maxLatencyMs: int
submittedAt: float = field(default_factory=time.time)
assignedGpu: Optional[int] = None
@dataclass
class GpuSlot:
gpuId: int
totalMemory: int
usedMemory: int = 0
currentModel: Optional[str] = None
lastUsed: float = field(default_factory=time.time)
@property
def availableMemory(self) -> int:
return self.totalMemory - self.usedMemory
@property
def utilization(self) -> float:
return self.usedMemory / self.totalMemory if self.totalMemory > 0 else 0.0
class GpuScheduler:
def __init__(self, gpuSlots: List[GpuSlot], maxQueueSize: int = 1000):
self.gpuSlots = {slot.gpuId: slot for slot in gpuSlots}
self.taskQueue: List[InferenceTask] = []
self.maxQueueSize = maxQueueSize
self._lock = asyncio.Lock()
self._stats = {"totalScheduled": 0, "totalRejected": 0, "totalEvicted": 0}
async def submitTask(self, task: InferenceTask) -> Optional[int]:
async with self._lock:
if len(self.taskQueue) >= self.maxQueueSize:
self._stats["totalRejected"] += 1
return None
gpuId = self._findBestGpu(task)
if gpuId is not None:
slot = self.gpuSlots[gpuId]
slot.usedMemory += task.gpuMemoryRequired
slot.currentModel = task.modelId
slot.lastUsed = time.time()
task.assignedGpu = gpuId
self._stats["totalScheduled"] += 1
return gpuId
self.taskQueue.append(task)
self.taskQueue.sort(key=lambda t: t.priority.value)
return None
def _findBestGpu(self, task: InferenceTask) -> Optional[int]:
candidates = [(gid, s) for gid, s in self.gpuSlots.items() if s.availableMemory >= task.gpuMemoryRequired]
if not candidates:
return self._tryEvict(task)
candidates.sort(key=lambda x: x[1].utilization)
return candidates[0][0]
def _tryEvict(self, task: InferenceTask) -> Optional[int]:
if task.priority != TaskPriority.HIGH:
return None
for gpuId, slot in self.gpuSlots.items():
if slot.currentModel and slot.lastUsed < time.time() - 300:
slot.usedMemory = 0
slot.currentModel = None
self._stats["totalEvicted"] += 1
return gpuId
return None
async def releaseGpu(self, gpuId: int, memoryFreed: int):
async with self._lock:
slot = self.gpuSlots.get(gpuId)
if slot:
slot.usedMemory = max(0, slot.usedMemory - memoryFreed)
if slot.usedMemory == 0:
slot.currentModel = None
if self.taskQueue:
nextTask = self.taskQueue[0]
if slot.availableMemory >= nextTask.gpuMemoryRequired:
self.taskQueue.pop(0)
slot.usedMemory += nextTask.gpuMemoryRequired
slot.currentModel = nextTask.modelId
nextTask.assignedGpu = gpuId
self._stats["totalScheduled"] += 1
def getStats(self) -> Dict:
return {
**self._stats,
"queueSize": len(self.taskQueue),
"gpuUtilization": {
gid: {"utilization": f"{s.utilization:.1%}", "availableMemory": f"{s.availableMemory}MB", "currentModel": s.currentModel}
for gid, s in self.gpuSlots.items()
},
}
パターン6:バッチ処理とリクエストマージ
動的バッチングアーキテクチャ
LLM推論のGPU利用率は通常低く(10-30%)、各リクエストが個別に処理されるためです。Sidecarプロキシは短時間ウィンドウ内の複数リクエストを収集し、単一のバッチ推論にマージすることでスループットを大幅に向上させます。
┌─────────────────────────────────────────────────────┐
│ Dynamic Batching Sidecar │
│ │
│ Request 1 ──▶ ┐ │
│ Request 2 ──▶ │ ┌──────────────────────────┐ │
│ Request 3 ──▶ ├─▶│ Batch Window (50ms) │ │
│ Request 4 ──▶ │ │ │ │
│ Request 5 ──▶ ┘ │ Collect → Merge → Send │ │
│ │ │ │
│ │ Batch Size: 4-32 │ │
│ │ Max Wait: 50ms │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ Model Server │ │
│ │ (vLLM with │ │
│ │ continuous │ │
│ │ batching) │ │
│ └──────────────────────┘ │
│ │
│ Throughput: 1x → 5-8x │
│ Latency overhead: +5-15ms │
└─────────────────────────────────────────────────────┘
バッチプロキシ設定
apiVersion: v1
kind: ConfigMap
metadata:
name: batch-proxy-config
namespace: ai-serving
data:
proxy.yaml: |
server:
port: 15001
readTimeout: 30s
writeTimeout: 120s
batching:
enabled: true
maxBatchSize: 32
maxWaitTimeMs: 50
maxRequestTokens: 8192
strategy: dynamic
routing:
defaultEndpoint: http://localhost:8000
endpoints:
- path: /v1/chat/completions
model: chat
batchEnabled: true
- path: /v1/embeddings
model: embedding
batchEnabled: true
maxBatchSize: 64
circuitBreaker:
enabled: true
failureThreshold: 5
recoveryTimeout: 30s
halfOpenRequests: 3
Pythonバッチプロキシ
import asyncio
import time
import uuid
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional
from collections import defaultdict
logger = logging.getLogger(__name__)
@dataclass
class BatchRequest:
requestId: str
payload: Dict[str, Any]
future: asyncio.Future
submittedAt: float = field(default_factory=time.time)
@dataclass
class BatchConfig:
maxBatchSize: int = 32
maxWaitTimeMs: int = 50
maxRequestTokens: int = 8192
class DynamicBatcher:
def __init__(self, config: BatchConfig, inferenceFn):
self.config = config
self.inferenceFn = inferenceFn
self.pendingRequests: Dict[str, List[BatchRequest]] = defaultdict(list)
self._running = False
self._stats = {"totalBatches": 0, "totalRequests": 0, "avgBatchSize": 0.0, "avgWaitTimeMs": 0.0}
async def start(self):
self._running = True
asyncio.create_task(self._batchLoop())
async def stop(self):
self._running = False
async def submit(self, model: str, payload: Dict[str, Any]) -> Any:
future = asyncio.get_event_loop().create_future()
request = BatchRequest(requestId=str(uuid.uuid4()), payload=payload, future=future)
self.pendingRequests[model].append(request)
self._stats["totalRequests"] += 1
if len(self.pendingRequests[model]) >= self.config.maxBatchSize:
asyncio.create_task(self._processBatch(model))
return await future
async def _batchLoop(self):
while self._running:
for model in list(self.pendingRequests.keys()):
if self.pendingRequests[model]:
oldest = self.pendingRequests[model][0]
waitTime = (time.time() - oldest.submittedAt) * 1000
if waitTime >= self.config.maxWaitTimeMs:
await self._processBatch(model)
await asyncio.sleep(0.005)
async def _processBatch(self, model: str):
requests = self.pendingRequests[model][:self.config.maxBatchSize]
self.pendingRequests[model] = self.pendingRequests[model][len(requests):]
if not requests:
return
batchSize = len(requests)
self._stats["totalBatches"] += 1
waitTimes = [(time.time() - r.submittedAt) * 1000 for r in requests]
avgWait = sum(waitTimes) / len(waitTimes)
self._stats["avgWaitTimeMs"] = self._stats["avgWaitTimeMs"] * 0.95 + avgWait * 0.05
self._stats["avgBatchSize"] = self._stats["avgBatchSize"] * 0.95 + batchSize * 0.05
try:
batchPayload = self._mergePayloads([r.payload for r in requests])
results = await self.inferenceFn(model, batchPayload)
for i, request in enumerate(requests):
if not request.future.done():
request.future.set_result(results[i])
except Exception as e:
for request in requests:
if not request.future.done():
request.future.set_exception(e)
def _mergePayloads(self, payloads: List[Dict]) -> Dict:
messages = []
for payload in payloads:
if "messages" in payload:
messages.append(payload["messages"])
elif "prompt" in payload:
messages.append([{"role": "user", "content": payload["prompt"]}])
return {"model": payloads[0].get("model", "default"), "messages": messages, "stream": False, "batch_size": len(payloads)}
パターン7:オブザーバビリティと分散トレーシング
フルチェーントレーシングアーキテクチャ
AI推論チェーンは通常、複数のコンポーネント(APIゲートウェイ → Sidecarプロキシ → モデルサーバー → GPUスケジューラ)で構成されます。OpenTelemetryはフルチェーントレーシングを実現し、パフォーマンスボトルネックの特定を支援します。
┌─────────────────────────────────────────────────────────────┐
│ Observability Stack │
│ │
│ Request ──▶ [Gateway] ──▶ [Sidecar] ──▶ [Model Server] │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ OpenTelemetry Collector │ │
│ │ │ │
│ │ Traces ──▶ Jaeger/Tempo │ │
│ │ Metrics ──▶ Prometheus │ │
│ │ Logs ──▶ Loki/Elasticsearch │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Key Metrics: │
│ - inference_latency_ms (P50/P95/P99) │
│ - model_tokens_per_second │
│ - gpu_utilization_percent │
│ - batch_size_avg │
│ - request_queue_depth │
│ - model_load_time_seconds │
└─────────────────────────────────────────────────────────────┘
OpenTelemetry Sidecar設定
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-inference-obs
namespace: ai-serving
spec:
replicas: 2
selector:
matchLabels:
app: ai-inference-obs
template:
metadata:
labels:
app: ai-inference-obs
annotations:
sidecar.opentelemetry.io/inject: "true"
instrumentation.opentelemetry.io/inject-go: "true"
spec:
containers:
- name: business-app
image: myregistry/ai-business-app:v2.1.0
ports:
- containerPort: 8080
env:
- name: OTEL_SERVICE_NAME
value: "ai-inference-app"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector:4317"
- name: OTEL_TRACES_SAMPLER
value: "parentbased_traceidratio"
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.1"
- name: otel-sidecar
image: otel/opentelemetry-collector-contrib:latest
ports:
- containerPort: 4317
- containerPort: 4318
volumeMounts:
- name: otel-config
mountPath: /etc/otelcol-contrib/config.yaml
subPath: config.yaml
volumes:
- name: otel-config
configMap:
name: otel-sidecar-config
---
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-sidecar-config
namespace: ai-serving
data:
config.yaml: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
transform:
error_mode: ignore
trace_statements:
- context: span
statements:
- set(attributes["ai.model.name"], attributes["model"]) where attributes["model"] != nil
- set(attributes["ai.inference.latency_ms"], attributes["duration"]/1000000) where attributes["duration"] != nil
exporters:
otlp/jaeger:
endpoint: jaeger-collector:4317
tls:
insecure: true
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
loki:
endpoint: http://loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, transform, batch]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, transform, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loki]
カスタム推論メトリクス
apiVersion: v1
kind: ConfigMap
metadata:
name: inference-metrics-rules
namespace: ai-serving
data:
rules.yaml: |
groups:
- name: ai_inference_metrics
interval: 15s
rules:
- record: ai:inference:latency_p99
expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job="ai-inference"}[5m])) by (le, model))
- record: ai:inference:tokens_per_second
expr: sum(rate(ai_inference_tokens_total{job="ai-inference"}[5m])) by (model)
- record: ai:inference:gpu_utilization
expr: avg(DCGM_FI_DEV_GPU_UTIL{gpu="*"}) by (instance, gpu)
- alert: InferenceHighLatency
expr: ai:inference:latency_p99 > 5
for: 5m
labels:
severity: warning
annotations:
summary: "AI推論P99レイテンシが5秒を超過"
- alert: GPUUtilizationLow
expr: ai:inference:gpu_utilization < 30
for: 10m
labels:
severity: info
annotations:
summary: "GPU利用率が30%未満"
5つのよくある落とし穴と解決策
落とし穴1:Sidecar起動順序によるリクエスト損失
現象:ビジネスコンテナがSidecarプロキシより先に起動し、初期推論リクエストがlocalhostに送信されるがSidecarが準備できておらず、リクエストが失敗する。
解決策:postStartフックを使用してSidecarの準備完了後にビジネスコンテナを起動するか、ビジネスコンテナのreadinessProbeをSidecarのヘルスチェックに依存させる。
spec:
containers:
- name: business-app
readinessProbe:
httpGet:
path: /health
port: 15001
initialDelaySeconds: 5
periodSeconds: 5
- name: sidecar-proxy
lifecycle:
postStart:
exec:
command: ["/bin/sh", "-c", "until curl -s http://localhost:15001/health; do sleep 1; done"]
落とし穴2:iptablesルールとGPUドライバの競合
現象:Sidecarのiptablesトラフィック傍受ルールがNVIDIA GPUドライバ通信エラーを引き起こし、モデル読み込みが失敗する。
解決策:GPU通信ポートとIPレンジをiptables傍受から除外する。
metadata:
annotations:
traffic.sidecar.istio.io/excludeOutboundIPRanges: "10.96.0.0/12"
traffic.sidecar.istio.io/excludeOutboundPorts: "50051,50052"
落とし穴3:大規模モデルリクエストボディがEnvoyバッファを超過
現象:LLM推論リクエストのpromptが非常に長く(数十KB)、Envoyのデフォルトバッファサイズを超過し、リクエストが切り詰められたり413エラーが発生する。
解決策:Envoyのリクエストバッファサイズを増やすか、ストリーミング転送を設定する。
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: model-dest
namespace: ai-serving
spec:
host: model-service
trafficPolicy:
connectionPool:
http:
h2UpgradePolicy: UPGRADE
maxRequestsPerConnection: 100
落とし穴4:Sidecarリソース競合による推論レイテンシスパイク
現象:SidecarプロキシとモデルサービスがPodを共有し、CPU/メモリリソースの競合により推論レイテンシにスパイクが発生する。
解決策:Sidecarに独立したリソース制限を設定し、cpumanagerのstaticポリシーでCPUピニングを使用する。
spec:
containers:
- name: sidecar-proxy
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
runtimeClassName: nvidia
overhead:
podFixed:
cpu: "200m"
memory: "256Mi"
落とし穴5:モデルホットロード時のSidecar接続プール枯渇
現象:モデルバージョン切り替え時に古い接続が閉じられず、新しい接続の作成に失敗し、接続プールが枯渇する。
解決策:適切な接続プールタイムアウトとアイドル接続リサイクルポリシーを設定する。
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: model-conn-pool
namespace: ai-serving
spec:
host: model-service
trafficPolicy:
connectionPool:
tcp:
maxConnections: 1000
connectTimeout: 10s
idleTimeout: 60s
http:
maxRequestsPerConnection: 50
10のよくあるエラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決策 |
|---|---|---|---|
| 1 | Sidecar proxy not ready |
iptablesルールが注入されていないかSidecarコンテナが起動していない | namespaceラベルistio-injection=enabledを確認、Sidecarイメージのプル成功を確認 |
| 2 | upstream connect error or disconnect/reset before headers |
モデルサービスが準備できていないかポートが不一致 | モデルコンテナの健全性を確認、ポートがVirtualServiceと一致するか確認 |
| 3 | GPU out of memory |
モデル読み込みがGPUメモリを超過 | gpu_memory_utilizationを下げる、量子化モデルを使用、またはGPUを追加 |
| 4 | connection refused to 127.0.0.1:15001 |
Sidecarが期待されるポートでリッスンしていない | Sidecar設定を確認、inboundポート設定が正しいか確認 |
| 5 | request body too large |
リクエストボディがEnvoyバッファ制限を超過 | max_request_sizeを増やすかストリーミングを有効化 |
| 6 | model not found in registry |
モデル名がルーティング設定と一致しない | モデル登録名とルーティングルールのmodelフィールドを確認 |
| 7 | circuit breaker open |
バックエンドモデルサービスの連続失敗がサーキットブレーカーをトリガー | モデルサービスの健全性を確認、サーキットブレーカー閾値を調整 |
| 8 | timeout waiting for batch completion |
バッチ待機タイムアウト | maxWaitTimeMsを増やすかmaxBatchSizeを減らす |
| 9 | CUDA error: no kernel image is available |
GPUドライバとCUDAバージョンの非互換 | NVIDIAドライババージョンとコンテナCUDAバージョンの一致を確認 |
| 10 | OOMKilled for sidecar container |
Sidecarのメモリ不足でK8sにキルされる | Sidecarのmemory limitを増やす、メモリリークを確認 |
高度な最適化テクニック
1. アダプティブバッチングウィンドウ
リアルタイム負荷に基づいてバッチングウィンドウサイズを動的に調整:
class AdaptiveBatcher:
def __init__(self, minWaitMs=10, maxWaitMs=100, targetBatchSize=16):
self.minWaitMs = minWaitMs
self.maxWaitMs = maxWaitMs
self.targetBatchSize = targetBatchSize
self.currentWaitMs = minWaitMs
self._emaArrivalRate = 0.0
def updateWaitTime(self, queueSize: int, intervalMs: float):
if intervalMs > 0:
arrivalRate = queueSize / (intervalMs / 1000.0)
self._emaArrivalRate = 0.7 * self._emaArrivalRate + 0.3 * arrivalRate
if self._emaArrivalRate > 0:
optimalWait = (self.targetBatchSize / self._emaArrivalRate) * 1000
self.currentWaitMs = max(self.minWaitMs, min(self.maxWaitMs, optimalWait))
else:
self.currentWaitMs = self.maxWaitMs
2. モデルウォームアップとコールドスタート最適化
apiVersion: v1
kind: ConfigMap
metadata:
name: model-warmup-config
namespace: ai-serving
data:
warmup.yaml: |
models:
- name: qwen-chat-v2.5
warmupRequests:
- prompt: "Hello, how are you?"
maxTokens: 32
- prompt: "Explain quantum computing in one sentence."
maxTokens: 64
warmupInterval: 300s
maxWarmupRetries: 3
3. 推論結果キャッシング
同じpromptの推論結果をキャッシュし、重複計算を回避:
apiVersion: v1
kind: ConfigMap
metadata:
name: inference-cache-config
namespace: ai-serving
data:
cache.yaml: |
enabled: true
backend: redis
redis:
endpoint: redis://redis-cluster:6379
ttl: 3600
maxMemory: 2gb
keyStrategy: prompt_hash
cacheableModels:
- qwen-chat-v2.5
- bge-embedding-v1.5
hitRateThreshold: 0.3
4. リクエスト優先度とプリエンプション
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: realtime-inference
value: 1000000
globalDefault: false
description: "厳格なレイテンシSLAを持つリアルタイム推論リクエスト"
preemptionPolicy: PreemptLowerPriority
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: batch-inference
value: 10000
globalDefault: false
description: "レイテンシSLAのないバッチ推論"
preemptionPolicy: Never
比較分析:Sidecarプロキシ vs Service Mesh vs Gateway
| 次元 | Sidecarプロキシ | Service Mesh (Istio) | API Gateway |
|---|---|---|---|
| デプロイ位置 | Pod内、ビジネスコンテナと共存 | Pod内、メッシュ全体をカバー | クラスタイングレス、独立デプロイ |
| トラフィック傍受 | iptables/ebpf | iptables/ztunnel | DNS/仮想IP |
| モデルルーティング | カスタムロジック、柔軟 | VirtualService、宣言的 | Routeルール、限定的 |
| バッチ処理 | ネイティブサポート、深いカスタマイズ可能 | サポートなし | サポートなし |
| GPU認識 | GPUリソースを認識可能 | 認識なし | 認識なし |
| パフォーマンスオーバーヘッド | 低(5-15ms) | 中(10-30ms) | 低(2-5ms) |
| オブザーバビリティ | カスタムメトリクス | フルメッシュメトリクス | イングレスメトリクス |
| 運用複雑度 | 中 | 高 | 低 |
| 適用シナリオ | AI推論専用プロキシ | クラスタ全体のサービス通信 | 外部トラフィックイングレス |
| 学習曲線 | 中 | 高 | 低 |
推奨戦略:AI推論シナリオでは専用Sidecarプロキシでモデルルーティングとバッチ処理を扱い、Service Meshでサービス間通信を、API Gatewayで外部イングレスを処理する。3層がそれぞれの責務を担い、相互に干渉しない。
オンラインツール推奨
- YAML/JSONフォーマッター:/ja/json/format — K8s YAML設定のフォーマット
- Base64エンコード/デコード:/ja/encode/base64 — Secretの証明書とキーの処理
- curl to Code:/ja/dev/curl-to-code — APIテストコードの迅速生成
関連記事
- K8s Gateway APIサービスメッシュトラフィック管理移行ガイド — サービスメッシュにおけるGateway APIの詳細
- Python AIモデルプロダクションデプロイ — 開発からプロダクションまでの完全デプロイ
- K8s HPAオートスケーリングプロダクションプラクティス — AI推論サービスのオートスケーリング戦略
まとめ
K8s Sidecar AI推論プロキシは2026年にAI推論デプロイメントの標準アーキテクチャパターンとなりました。7つのプロダクションパターンは、トラフィックインターセプトからオブザーバビリティまでの完全なチェーンをカバーしています:Envoyトラフィックインターセプトでビジネスコードの変更ゼロを実現、スマートモデルルーティングでリクエスト特性に基づき動的にモデルを選択、A/Bテストとカナリアデプロイでモデルリリースの安全性を確保、マルチモデルサービングでバージョン共存を実現、GPUリソースプーリングで利用率を30%から80%以上に向上、バッチマージでスループットを5-8倍に向上、OpenTelemetryフルチェーントレーシングでパフォーマンスボトルネックを可視化。コア原則:Sidecarプロキシは推論ロジックに集中、ビジネスコンテナはビジネスロジックに集中、localhostで通信、ゼロ結合ゼロ侵入。
外部参考:
ブラウザローカルツールを無料で試す →