DockerでLinuxにvLLMをデプロイする:PagedAttentionとOpenAI互換APIによる高性能LLM推論

AI tutorial - IT technology blog
AI tutorial - IT technology blog

午前2時に叩き起こされた問題

午前2時17分、監視ダッシュボードが一斉にアラートを上げた。顧客向けチャットボットに使っていた自前のLLM推論サーバーが完全に止まっていた。レスポンスのレイテンシが800msから45秒にまで跳ね上がり、メモリ使用率は100%で張り付いたまま。サービスはクラッシュしているわけではなかった―ただそこで動き続けながら、200件のリクエストをキューに積んで、完全に飽和した状態で何もできなくなっていた。

当時はHuggingFace Transformersをシンプルなセットアップで使い、標準的なFlask APIでラップしていた。同時接続5ユーザーのステージング環境では問題なく動いていた。本番環境は300ユーザーだった。典型的なミスを犯していた:モデルをロードしてループ内でmodel.generate()を呼ぶだけで本番対応のアーキテクチャになると思い込んでいたのだ。

そうではない。あの夜、vLLMが存在する理由が身に染みてわかった。本番でLLMを動かすなら、今理解しておく方が、午前2時にデバッグするよりずっと安上がりだ。

根本原因:なぜ単純なLLM推論は負荷に耐えられないのか

標準的なTransformer推論には根本的なメモリ問題がある。各推論リクエストは、全生成ステップにわたってAttentionの状態を保持するKV(キーバリュー)キャッシュを必要とする。単純な実装では、このキャッシュが静的に確保される。最悪のケースでは、実際の出力が5トークンであれ500トークンであれ、全リクエストに対して最大シーケンス長分のメモリを事前確保してしまう。

同時負荷がかかると、これは以下を意味する:

  • GPUメモリがほぼ空のKVキャッシュ確保で埋め尽くされる
  • 利用可能なメモリがないため、新しいリクエストがキューに積まれるか拒否される
  • メモリが解放されるのを待つ間、GPUがアイドル状態になりスループットが崩壊する

Flaskのラッパーはリクエストを1つずつ同期的に処理していた。各リクエストが処理中ずっとGPUメモリを占有し、他の全てをブロックしていた。300ユーザー × 逐次処理 = 惨事だ。

検討した解決策

選択肢1:TGI(HuggingFaceのText Generation Inference)

HuggingFace自身が提供する推論サービスソリューション。堅牢で、ドキュメントも充実しており、HTTPレイヤーはRustで実装されている。ただし制限もある:コンティニュアスバッチングは実装されているが、メモリ管理がvLLMほど積極的ではない。コンシューマー向けGPUで大規模モデルを動かすと、バースト負荷時にOOMエラーが発生しやすかった。

選択肢2:Triton Inference Server(NVIDIA)

非常に強力で、エンタープライズ環境で広く使われている。ただし設定の複雑さは本物で、モデル固有のバックエンド、設定ファイル、そしてNVIDIAツールの深い知識が必要だ。1〜2つのモデルを安定して提供したいだけのチームには過剰なツールだ。

選択肢3:vLLM

Pythonネイティブで、OpenAI API互換、そしてPagedAttentionと呼ばれる技術を中心に設計されている。最終的に私が選んだのはこれで、自前のハードウェアでLLMを動かすチームの90%に勧めたいソリューションだ。

vLLMの違いとは:PagedAttentionを解説

PagedAttentionが核となる革新技術だ。OSの仮想メモリの概念を取り入れ、KVキャッシュを固定サイズのブロック(「ページ」)に分割して、リクエストごとに事前確保するのではなく、必要に応じて動的に確保する。

実際には、これは以下を意味する:

  • 短いレスポンスが長いレスポンス用に確保されたメモリを無駄にしない
  • 完了したリクエストのメモリが即座に回収される
  • 同じVRAMで、GPUがより多くの同時リクエストを処理できる

さらに、vLLMはコンティニュアスバッチングを実装している。フルバッチが揃うまで待つのではなく、空きが生まれた瞬間に新しいリクエストを実行中のバッチに組み込む。バースト的で不規則なトラフィックの中でも、GPU使用率を高い状態に保てる。

結果として、私のベンチマークでは、同じハードウェアでvLLMは単純なHuggingFaceセットアップと比べて8〜12倍高いスループットを達成した。

LinuxでDockerを使ったvLLMのデプロイ

前提条件

  • Linuxホスト(Ubuntu 20.04以上を推奨)
  • 16GB以上のVRAMを持つNVIDIA GPU(フル精度で7Bモデルを動かすには24GB以上)
  • NVIDIAドライバー525以上がインストール済み
  • nvidia-container-toolkit付きのDocker Engine
  • モデルへのアクセス権を持つHuggingFaceアカウント(制限付きモデルを使用する場合)

ステップ1:NVIDIA Container Toolkitのインストール

DockerでGPUアクセスをまだ設定していない場合:

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/stable/deb/nvidia-container-toolkit.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

ステップ2:vLLMのプルと起動

vLLMは公式のDockerイメージを提供している。meta-llama/Llama-3.2-3B-Instructで素早くテストする場合(HuggingFaceトークンが必要):

docker run --gpus all \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  -e HUGGING_FACE_HUB_TOKEN=your_token_here \
  -p 8000:8000 \
  --ipc=host \
  vllm/vllm-openai:latest \
  --model meta-llama/Llama-3.2-3B-Instruct \
  --dtype bfloat16

主要なフラグの説明:

  • --ipc=host:プロセス間の共有メモリに必要(パフォーマンスに重要)
  • -v ~/.cache/huggingface:再起動のたびにモデルが再ダウンロードされないようキャッシュをマウント
  • --dtype bfloat16:半精度推論で、品質をほぼ損なわずにVRAM使用量を約50%削減

ステップ3:本番向けDocker Compose

本番環境では、適切なCompose fileを使用する:

version: '3.8'
services:
  vllm:
    image: vllm/vllm-openai:latest
    restart: unless-stopped
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    volumes:
      - ~/.cache/huggingface:/root/.cache/huggingface
    environment:
      - HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
    ports:
      - "8000:8000"
    ipc: host
    command: >
      --model meta-llama/Llama-3.2-3B-Instruct
      --dtype bfloat16
      --max-model-len 4096
      --gpu-memory-utilization 0.90
      --tensor-parallel-size 1

--gpu-memory-utilization 0.90フラグはvLLMにVRAMの90%をKVキャッシュプールに使用するよう指示する。OOMエラーを避けるため、10%のヘッドルームを残している。OOMクラッシュが発生している場合は、0.85に下げてみよう。

ステップ4:OpenAI互換APIのテスト

このAPIはOpenAIのドロップイン代替として機能する。サーバーが起動したら、Uvicorn running on http://0.0.0.0:8000のログ行を確認してから、curlで叩いてみよう:

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Llama-3.2-3B-Instruct",
    "messages": [{"role": "user", "content": "KVキャッシュを一文で説明してください。"}],
    "max_tokens": 100
  }'

または、OpenAI Python SDKをローカルサーバーに向けて使用する:

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="not-needed",  # vLLMはデフォルトで認証不要
)

response = client.chat.completions.create(
    model="meta-llama/Llama-3.2-3B-Instruct",
    messages=[{"role": "user", "content": "PagedAttentionとは何ですか?"}],
    max_tokens=200,
    temperature=0.7,
)
print(response.choices[0].message.content)

OpenAI SDKで動くコードはvLLMでもそのまま動く。アプリケーションの変更は一切不要だ。

実負荷に向けたチューニング

並行処理の向上

ハードウェアに応じて以下のフラグを追加する:

# マルチGPU環境の場合(例:2× A100)
--tensor-parallel-size 2

# メモリスパイクを防ぐため最大同時シーケンス数を制限する
--max-num-seqs 128

# 繰り返されるシステムプロンプトのプレフィックスキャッシュを有効化(チャットボットに大きな効果)
--enable-prefix-caching

最後のフラグがチャットボットのユースケースで最も大きな違いをもたらした。全リクエストが長いシステムプロンプトを共有している場合、vLLMは最初のリクエスト後にそのKV状態をキャッシュし、以降の全リクエストで再利用する。プロンプト部分はほぼゼロのコンピューティングコストになる。1,000トークンのシステムプロンプトが1日10,000リクエストをさばく場合、GPU時間の削減は意味のある規模になる。

VRAMを削減するQuantization

16GB GPUで13Bモデルを動かしたい?AWQまたはGPTQ量子化を使おう:

docker run --gpus all -p 8000:8000 --ipc=host \
  vllm/vllm-openai:latest \
  --model TheBloke/Llama-2-13B-chat-AWQ \
  --quantization awq \
  --dtype float16

AWQ 4ビット量子化でVRAM使用量が約半分になる。スループットは若干低下するが、チャットボットのワークロードではほとんど気にならないレベルだ。全てのケースに最適なわけではない。正確または決定論的な出力が必要な場合は、採用前に慎重にベンチマークを取ること。

あの夜、違うやり方をするなら

もし時間を戻せるなら、HuggingFace Transformersのつぎはぎ実装ではなく、最初からvLLMをデプロイしていただろう。APIの互換性により移行の手間はゼロだ。アプリケーションのconfigのベースURLを更新するだけでいい。DockerイメージがCUDAのセットアップを全て処理してくれる。そしてPagedAttentionによりメモリの挙動が予測可能になるため、推測に頼らずVRAMベースでキャパシティプランニングができる。

意外だったのは、最初の起動でvLLMがモデルをロードして最適化する間に10〜20分かかること。Docker Composeまたはロードバランサーにきちんとヘルスチェックを追加しよう。/healthエンドポイントが200を返すまでトラフィックを流さないようにする。

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 300s

start_period: 300sによって、DockerがヘルスチェックのFailカウントを始める前に、vLLMが初期化するための5分間を確保できる。これがないと、通常の起動フェーズでDockerがコンテナを異常と判断して再起動してしまう。

午前2時のインシデントで、40分のサービス劣化が発生した。vLLMに移行した後、同じサーバーで300同時ユーザーをレイテンシ1秒未満でさばけるようになった。根本原因はハードウェアではなかった。メモリ管理の問題だった。そしてそれこそが、フレームワークを変えることで本当に解決できる部分だ。

Share: