- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに
研究環境でAIモデルを学習させることと、本番環境で安定してサービングすることはまったく異なる課題です。モデルの精度と並んで、低レイテンシ、高スループット、安定したスケーリングも同様に重要です。このガイドでは、プロダクションファーストの考え方でAIモデルデプロイのライフサイクル全体を解説します。
1. サービングアーキテクチャのパターン
1.1 オンラインサービング vs バッチサービング
オンラインサービングはユーザーリクエストをリアルタイムで処理し、厳格なレイテンシ目標を持ちます。
- レイテンシ目標:P99で200ms以下
- ユースケース:レコメンドシステム、チャットボット、リアルタイム画像分類
- インフラ:REST API / gRPCエンドポイント、オートスケーリングレプリカ
バッチサービングはスケジューリングされたジョブで大量データを処理します。
- レイテンシ目標:数分から数時間
- ユースケース:夜間スコアリングパイプライン、オフラインレコメンド生成
- インフラ:Sparkジョブ、Airflow DAG、大規模GPUバッチジョブ
1.2 同期 vs 非同期サービング
| モード | 特徴 | 最適なシナリオ |
|---|---|---|
| 同期 | リクエストがレスポンスを待つ | レイテンシ重視のAPI |
| 非同期 | ワークキュー+結果ポーリング | 長時間推論ジョブ、LLM |
| ストリーミング | トークンごとの段階的レスポンス | LLMチャット、コード生成 |
1.3 Server-Sent Eventsによるストリーミングレスポンス
ストリーミングはLLMサービングの体感レスポンス性を大幅に向上させます。**TTFT(Time To First Token)**がユーザーに見える重要指標です。
import httpx
import asyncio
async def stream_llm_response(prompt: str):
async with httpx.AsyncClient() as client:
async with client.stream(
"POST",
"http://localhost:8000/v1/completions",
json={
"model": "llama-3-8b",
"prompt": prompt,
"max_tokens": 512,
"stream": True
},
timeout=60.0
) as response:
async for line in response.aiter_lines():
if line.startswith("data: "):
data = line[6:]
if data != "[DONE]":
import json
chunk = json.loads(data)
token = chunk["choices"][0]["text"]
print(token, end="", flush=True)
2. コンテナ化:GPUサービング用Docker
2.1 最適化イメージのためのマルチステージビルド
本番イメージはビルド時の依存関係を除外し、最小限のランタイムフットプリントのみを含めるべきです。
# ---- ステージ1: ビルダー ----
FROM nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04 AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y \
python3.11 \
python3.11-dev \
python3-pip \
git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---- ステージ2: ランタイム ----
FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04
ENV PYTHONUNBUFFERED=1
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
WORKDIR /app
RUN apt-get update && apt-get install -y \
python3.11 \
python3-pip \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# ビルダーステージからインストール済みパッケージのみコピー
COPY /install /usr/local
COPY . .
# セキュリティのため非rootユーザーで実行
RUN useradd -m -u 1000 mluser
USER mluser
EXPOSE 8080
CMD ["python3.11", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
2.2 NVIDIA Container Toolkitのセットアップ
GPUコンテナにはホストマシンへのNVIDIA Container Toolkitのインストールが必要です。
# UbuntuへのNVIDIA Container Toolkitのインストール
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor \
-o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# GPUコンテナアクセスの確認
docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi
3. Kubernetes MLデプロイ
3.1 GPUデプロイメント + HPA
apiVersion: apps/v1
kind: Deployment
metadata:
name: model-serving
namespace: ml-serving
spec:
replicas: 2
selector:
matchLabels:
app: model-serving
template:
metadata:
labels:
app: model-serving
annotations:
prometheus.io/scrape: 'true'
prometheus.io/port: '8080'
prometheus.io/path: '/metrics'
spec:
nodeSelector:
accelerator: nvidia-tesla-a10g
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
containers:
- name: model-server
image: myregistry/model-server:v1.2.0
ports:
- containerPort: 8080
resources:
requests:
cpu: '2'
memory: '8Gi'
nvidia.com/gpu: '1'
limits:
cpu: '4'
memory: '16Gi'
nvidia.com/gpu: '1'
env:
- name: MODEL_PATH
value: '/models/llama-3-8b'
- name: MAX_BATCH_SIZE
value: '32'
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 60
periodSeconds: 15
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: model-serving-hpa
namespace: ml-serving
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: model-serving
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Pods
pods:
metric:
name: model_requests_per_second
target:
type: AverageValue
averageValue: '50'
3.2 Karpenterノードオートスケーリング
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: gpu-nodepool
spec:
template:
metadata:
labels:
accelerator: nvidia-tesla-a10g
spec:
nodeClassRef:
name: gpu-nodeclass
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ['on-demand', 'spot']
- key: node.kubernetes.io/instance-type
operator: In
values: ['g5.xlarge', 'g5.2xlarge', 'g5.4xlarge']
- key: kubernetes.io/arch
operator: In
values: ['amd64']
taints:
- key: nvidia.com/gpu
effect: NoSchedule
limits:
nvidia.com/gpu: '20'
disruption:
consolidationPolicy: WhenUnderutilized
consolidateAfter: 30s
4. AIサービングフレームワークの比較
4.1 NVIDIA Triton Inference Server
Tritonは単一サーバーで複数のモデルフォーマット(TensorRT、ONNX、PyTorch、TensorFlow)をサポートします。Dynamic Batching機能により、受信リクエストを自動的にグルーピングしてGPU使用率を最大化します。
# config.pbtxt
name: "text_classifier"
platform: "onnxruntime_onnx"
max_batch_size: 64
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ -1 ]
},
{
name: "attention_mask"
data_type: TYPE_INT64
dims: [ -1 ]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [ -1, 2 ]
}
]
dynamic_batching {
preferred_batch_size: [ 8, 16, 32 ]
max_queue_delay_microseconds: 5000
}
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0 ]
}
]
4.2 BentoMLサービス定義
BentoMLはPythonネイティブのサービングフレームワークで、ラピッドプロトタイピングと本番デプロイの両方をサポートします。
import bentoml
from bentoml.io import JSON
from pydantic import BaseModel
import numpy as np
from typing import List
class InferenceRequest(BaseModel):
texts: List[str]
top_k: int = 5
class InferenceResponse(BaseModel):
labels: List[str]
scores: List[float]
# モデルランナーの作成
classifier_runner = bentoml.pytorch.get("text_classifier:latest").to_runner()
svc = bentoml.Service("text_classification_svc", runners=[classifier_runner])
@svc.api(input=JSON(pydantic_model=InferenceRequest),
output=JSON(pydantic_model=InferenceResponse))
async def classify(request: InferenceRequest) -> InferenceResponse:
batch_results = await classifier_runner.async_run(request.texts)
labels = []
scores = []
for result in batch_results:
top_idx = np.argsort(result)[-request.top_k:][::-1]
labels.extend([f"label_{i}" for i in top_idx])
scores.extend(result[top_idx].tolist())
return InferenceResponse(labels=labels, scores=scores)
4.3 フレームワーク比較まとめ
| フレームワーク | 強み | 弱み | 最適なシナリオ |
|---|---|---|---|
| Triton | 最高性能、マルチフォーマット | 複雑な設定 | 高スループットGPUサービング |
| BentoML | 使いやすい、パッケージング | Tritonより低性能 | 迅速なMVP、小規模チーム |
| Ray Serve | 分散、パイプラインサポート | 学習曲線が急峻 | 複雑なMLパイプライン |
| TorchServe | ネイティブPyTorch統合 | 単一フレームワークのみ | PyTorchのみのデプロイ |
5. LLMサービング:vLLMとTGI
5.1 vLLM — PagedAttentionによる高性能LLMサービング
vLLMはPagedAttentionアルゴリズムを使用して、OSの仮想メモリのようにKVキャッシュメモリを管理し、GPUメモリの無駄を最小化します。
from vllm import LLM, SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.engine.async_llm_engine import AsyncLLMEngine
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import uvicorn
import json
import uuid
app = FastAPI(title="vLLM OpenAI互換API")
engine_args = AsyncEngineArgs(
model="meta-llama/Llama-3-8B-Instruct",
tensor_parallel_size=2, # 2-GPUテンソル並列処理
gpu_memory_utilization=0.90,
max_model_len=8192,
enable_chunked_prefill=True,
)
engine = AsyncLLMEngine.from_engine_args(engine_args)
@app.post("/v1/chat/completions")
async def chat_completions(request: dict):
messages = request.get("messages", [])
stream = request.get("stream", False)
prompt = format_messages(messages)
sampling_params = SamplingParams(
temperature=request.get("temperature", 0.7),
max_tokens=request.get("max_tokens", 512),
top_p=request.get("top_p", 0.95),
)
request_id = str(uuid.uuid4())
if stream:
async def generate_stream():
async for output in engine.generate(prompt, sampling_params, request_id):
if output.outputs:
token = output.outputs[0].text
chunk = {
"id": request_id,
"object": "chat.completion.chunk",
"choices": [{"delta": {"content": token}, "index": 0}]
}
yield f"data: {json.dumps(chunk)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate_stream(), media_type="text/event-stream")
final_output = None
async for output in engine.generate(prompt, sampling_params, request_id):
final_output = output
return {
"choices": [{"message": {"content": final_output.outputs[0].text}}]
}
def format_messages(messages):
result = ""
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
result += f"<|{role}|>\n{content}\n"
return result + "<|assistant|>\n"
5.2 TGI(Text Generation Inference)のデプロイ
Hugging Face TGIはDockerで素早くデプロイできます。
# TGIでLLaMA-3サービングを開始
docker run --gpus all \
-p 8080:80 \
-v /data/models:/data \
-e HUGGING_FACE_HUB_TOKEN=$HF_TOKEN \
ghcr.io/huggingface/text-generation-inference:2.0.4 \
--model-id meta-llama/Llama-3-8B-Instruct \
--num-shard 2 \
--max-input-length 4096 \
--max-total-tokens 8192 \
--max-batch-prefill-tokens 16384 \
--dtype bfloat16
# ストリーミングレスポンスのテスト
curl http://localhost:8080/generate_stream \
-H 'Content-Type: application/json' \
-d '{"inputs": "What is the capital of France?", "parameters": {"max_new_tokens": 100, "stream": true}}'
6. パフォーマンス最適化戦略
6.1 モデルウォームアップ
サーバー起動時にダミーリクエストを送ることでコールドスタートレイテンシを防ぎます。
import asyncio
import httpx
import logging
logger = logging.getLogger(__name__)
async def warmup_model(base_url: str, num_warmup_requests: int = 5):
"""サーバー起動時にモデルウォームアップを実行します。"""
dummy_request = {
"inputs": "warmup",
"parameters": {"max_new_tokens": 10}
}
async with httpx.AsyncClient(timeout=120.0) as client:
logger.info("モデルウォームアップを開始します...")
# ヘルスチェックの待機
for _ in range(30):
try:
resp = await client.get(f"{base_url}/health")
if resp.status_code == 200:
break
except Exception:
await asyncio.sleep(2)
# ウォームアップリクエストの送信
tasks = [
client.post(f"{base_url}/generate", json=dummy_request)
for _ in range(num_warmup_requests)
]
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"ウォームアップ完了({num_warmup_requests}件のリクエストを送信)")
6.2 動的リクエストバッチング
import asyncio
from collections import deque
from dataclasses import dataclass, field
from typing import Any
@dataclass
class BatchRequest:
request_id: str
payload: dict
future: asyncio.Future = field(default_factory=asyncio.Future)
class DynamicBatcher:
def __init__(self, max_batch_size: int = 32, max_wait_ms: float = 10.0):
self.max_batch_size = max_batch_size
self.max_wait_ms = max_wait_ms
self.queue: deque = deque()
self._lock = asyncio.Lock()
async def add_request(self, request_id: str, payload: dict) -> Any:
req = BatchRequest(request_id=request_id, payload=payload)
async with self._lock:
self.queue.append(req)
return await req.future
async def process_batches(self, model_fn):
while True:
await asyncio.sleep(self.max_wait_ms / 1000)
async with self._lock:
if not self.queue:
continue
batch = []
while self.queue and len(batch) < self.max_batch_size:
batch.append(self.queue.popleft())
if batch:
try:
inputs = [r.payload for r in batch]
results = await model_fn(inputs)
for req, result in zip(batch, results):
req.future.set_result(result)
except Exception as e:
for req in batch:
req.future.set_exception(e)
7. モニタリング:Prometheusメトリクス
from prometheus_client import Histogram, Counter, Gauge, generate_latest
from fastapi import FastAPI, Request, Response
import time
REQUEST_LATENCY = Histogram(
"model_request_latency_seconds",
"モデル推論のレイテンシ(秒)",
["model_name", "endpoint"],
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
)
REQUEST_COUNT = Counter(
"model_requests_total",
"推論リクエストの総数",
["model_name", "endpoint", "status"]
)
ACTIVE_REQUESTS = Gauge(
"model_active_requests",
"現在処理中のリクエスト数",
["model_name"]
)
TOKEN_THROUGHPUT = Counter(
"model_tokens_generated_total",
"生成されたトークンの総数",
["model_name"]
)
GPU_MEMORY_USED = Gauge(
"gpu_memory_used_bytes",
"GPU使用メモリ(バイト)",
["gpu_index"]
)
app = FastAPI()
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
model_name = "llama-3-8b"
endpoint = request.url.path
ACTIVE_REQUESTS.labels(model_name=model_name).inc()
start = time.perf_counter()
try:
response = await call_next(request)
status = str(response.status_code)
REQUEST_COUNT.labels(
model_name=model_name, endpoint=endpoint, status=status
).inc()
return response
except Exception as e:
REQUEST_COUNT.labels(
model_name=model_name, endpoint=endpoint, status="500"
).inc()
raise
finally:
latency = time.perf_counter() - start
REQUEST_LATENCY.labels(
model_name=model_name, endpoint=endpoint
).observe(latency)
ACTIVE_REQUESTS.labels(model_name=model_name).dec()
@app.get("/metrics")
async def metrics():
return Response(generate_latest(), media_type="text/plain")
クイズ:AIモデルサービングの深掘り
Q1. NVIDIA TritonのDynamic BatchingはシンプルなリクエストバッチングよりもGPU使用率をどのように向上させますか?
答え:Dynamic Batchingはサーバー側でキューに入ったリクエストを自動的にグルーピングし、preferred_batch_sizeとmax_queue_delay設定を使用して、GPUが常に最適なバッチサイズで実行されるようにします。
解説:シンプルなリクエストバッチングでは、クライアントが送信前に手動でバッチを構築する必要があります。Tritonのdynamic batchingはサーバー側で短いキューイングウィンドウ(例:最大5ms)を設けて到着リクエストを自動的にグルーピングします。これによりGPU Streaming Multiprocessor(SM)の使用率が向上し、リクエスト単位の処理と比べてスループットを数倍に高めることができます。instance_groupで複数のモデルインスタンスを組み合わせることでさらにバッチング機会が増加します。
Q2. vLLMのPagedAttentionはLLMサービングにおけるメモリフラグメンテーションをどのように解決しますか?
答え:PagedAttentionはKVキャッシュを固定サイズの「ページ」に分割し、OSの仮想メモリと同様にブロックテーブルを使用して非連続の物理メモリにマッピングします。これにより可変長シーケンスによる外部・内部フラグメンテーションのほとんどが排除されます。
解説:従来のLLMサービングでは、各リクエストに対して最大シーケンス長に等しい連続メモリを事前に確保するため、実際の出力が短い場合にメモリが無駄になります。vLLMのPagedAttentionはKVキャッシュをブロック(例:1ブロック=16トークン)に分割し、論理から物理のブロックテーブルを使用します。メモリの無駄は4%未満に低下し、同じGPUでナイーブな実装と比べて最大24倍の同時リクエスト処理が可能になります。
Q3. BentoMLとRay Serveのアーキテクチャの違いは何で、それぞれどのデプロイシナリオに適していますか?
答え:BentoMLはシンプルなデプロイのためのシングルサービスパッケージングとコンテナビルドに特化し、Ray Serveは複雑なMLパイプラインとアンサンブル推論に適した分散アクターモデルを使用します。
解説:BentoMLはモデル、依存関係、APIを単一のBentoアーティファクトにバンドルし、Dockerイメージのビルドやクラウドデプロイを簡素化します。小規模チームや単一モデルのAPIによく適しています。Ray ServeはRayクラスター上で動作し、複数のモデルをパイプラインにチェーンしたり、A/Bテストや複雑なルーティングロジックを実現するのに優れています。細かなリソース制御が必要なエンタープライズスケールの分散推論やアンサンブルワークロードにはRay Serveが適しています。
Q4. KubernetesのGPUメトリクスベースのHPAスケーリングがCPUベースのスケーリングより難しい理由は何ですか?
答え:GPUメトリクスはKubernetesのデフォルトのmetrics-serverでサポートされておらず、別途スタック(DCGM Exporter + Prometheus Adapter)が必要で、GPUは整数リソースであるため細かな使用率制御が困難です。
解説:CPUとメモリはkubeletがネイティブに収集しますが、GPU使用率(DCGM_FI_DEV_GPU_UTIL)はNVIDIA DCGM Exporterで収集し、Prometheusに格納し、Prometheus AdapterでカスタムメトリクスAPIとして公開してからHPAが使用できます。プロセスが保持しているGPUメモリの解放が遅く、スケールダウン時にOOMのリスクがあります。GPUノードのプロビジョニングには5〜10分かかり、CPUノードよりはるかに長いため、先行的なスケールアウトが重要です。
Q5. P99レイテンシが平均レイテンシよりサービス品質の重要指標である理由は何ですか?
答え:P99はユーザーの99%が経験する最悪のレスポンスタイムを表し、平均値は一部ユーザーの劣悪な体験を隠してしまいます。P99は平均が覆い隠す実際のユーザー不満を直接反映します。
解説:平均レイテンシが50msであっても、1%のリクエストが2,000msかかっている場合、スケール時には毎分数千人のユーザーが遅いレスポンスを受け取ります。P99 SLO(例:200ms以下)を設定することで、チームはテールレイテンシの問題を早期に検出できます。サービスが直列に呼び合うマイクロサービスアーキテクチャでは、個々のP99値が累積し「テールレイテンシ増幅」を引き起こすため、すべての層でP99を監視することが不可欠です。
まとめ
AIモデルのサービングはモデルをAPIエンドポイントでラップする以上のものです。GPUリソース管理、リクエストバッチング、ストリーミングレスポンス、Kubernetesオートスケーリング、包括的なモニタリングがすべて連携して初めて信頼性の高い本番サービングが実現します。このガイドのパターンを段階的に適用し、実際のトラフィックで各改善の効果を測定してください。