TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

運用機能:スリープモード、起動時間、/metrics、そして --api-key が守らない領域

状態:ドラフト(2026-10-27)、実測値。 8GB級ノートPC用GPU(Ada世代)1枚、vLLM 0.31.0、Qwen3-Reranker-0.6B を使用して2026-10-27に測定しました。スリープモードのテスト中、コンテナの1つがメモリ制限により強制終了し、その経緯も含めて結果に記載しています。指標名がバージョンごとに変更された経緯については「オープンソースコントリビューション記録」の第3回、OpenTelemetryのトレーシングについては「トス ML/AI Infra 準備」の第25回にあります。 2026-10-27 追加。 同日、vLLM 0.32.0にて認証パス、起動時間、スリープモードの再測定結果を各セクションの「0.32.0 再測定」に追加しました。0.31.0の数値はそのまま残しています。 執筆ルール:実測データを基本とし、測定できなかったものは「未測定」、確認できなかったものは「未確認」と表示します。

目次

1. スリープモード:サーバーを落とさずにGPUを解放する

1枚のGPUを複数のモデルやトレーニングと共用する場合、使用していない間はGPUメモリを解放する必要があります。サーバーを終了させると、再起動に数十秒かかります(第2節)。スリープモードでは、プロセスやCUDAコンテキスト、コンパイル済みグラフを維持したまま、大容量のメモリのみを解放します。

段階 重み KVキャッシュ 復帰時 利用シーン
1段階目 CPUメモリに退避 破棄 CPUから復元 同じモデルに再度切り替えるとき
2段階目 破棄 破棄 重みを再ロードする必要がある(reload_weights) 別の重みに切り替えるとき(RLHFの重み更新)、CPUメモリが不足しているとき

有効化にはサーバーオプションと開発モード用環境変数の両方を使用します。スリープ状態への移行と復帰はHTTPパス経由で開放されるため、開発モードでのみ使用可能です。

VLLM_SERVER_DEV_MODE=1 vllm serve <모델> --enable-sleep-mode
curl -X POST 'localhost:8000/sleep?level=1'
curl -X POST 'localhost:8000/wake_up'
# 2단계: 가중치 자리만 깨우고, 가중치를 다시 읽고, KV 를 깨운다
curl -X POST 'localhost:8000/sleep?level=2'
curl -X POST 'localhost:8000/wake_up?tags=weights'
curl -X POST 'localhost:8000/collective_rpc' -H 'Content-Type: application/json' -d '{"method":"reload_weights"}'
curl -X POST 'localhost:8000/wake_up?tags=kv_cache'

--enable-sleep-mode はvLLMのCuMemアロケータを有効にし、重みとKVをタグ付き仮想メモリとして確保した後、実メモリのみを解放および再アタッチします。vLLMの公式ブログ(2025-10-26、vLLM 0.11.0)によると、A100環境でQwen3-0.6Bを1段階目で復帰させるのに0.26秒、2段階目で0.85秒かかり、新規起動と比較して18〜20倍高速であると報告されています。同記事では、A4000での復帰に0.1秒から0.8秒かかるとされています(ベンダー発表)。

実際に測定してみる

項目 2段階目(4回)
スリープ呼び出し 42ms(42、42、42、50)
スリープ中のGPUメモリ 6,168MiB → 606MiB
復帰(重みの位置 → reload_weights → KVの位置) 中央値 0.39秒(初回 0.85秒、以降 0.40・0.38・0.38秒)
復帰後の初リクエスト(100トークン) 569ms(スリープ前 579ms)
出力はスリープ前と同じか 4回すべて同じ
コンテナ匿名メモリ 2,750MiBのまま変化なし
項目 1段階目
1回目の試行(コンテナ制限 4GiB、スリープ前匿名メモリ 2,704MiB) スリープ呼び出し中に コンテナが OOMKilled
再試行(新規コンテナ、制限 4.5GiB、匿名メモリ 2,750MiB) 再度 OOMKilled

結果の読み方は以下の通りです。

  1. 2段階目はこのモデルにおいてほぼコストゼロでした。 重み1.12GiBをディスク(ページキャッシュ)から再読み込みしても0.4秒以内に復帰し、復帰後の初リクエストはスリープ前と同じ時間で同じ出力を生成しました。同設定での再起動(第2節、32秒)と比較して約80倍高速です。CUDAコンテキストとコンパイル済みグラフが維持されているためです。
  2. スリープ中も606MiBは残りました。 CUDAコンテキスト、CUDAグラフ、スリープアロケータが管理していないバッファです。他の用途に割り当て可能なGPUメモリは、全体からこの値を引いたものになります。
  3. 1段階目はコンテナのメモリ制限を超過しました。 1段階目は重みをCPU側の固定メモリに退避させるため、その分コンテナのメモリ消費が増加します。計算上は 2.75GiB + 1.12GiB で4.5GiBの範囲内に収まるはずですが、どちらの試行でも上限を超過しました。重み以外にも退避されるデータが存在するか、コピー中に一時的に2重の領域が生成されるものと推測され、正確な容量は未確認です。このOOMは単一のPod内で完結しノードへの影響はありませんでしたが、本番環境で同様の事象が発生した場合、サービス中のエンジン全体がクラッシュします。 1段階目を利用する場合は、コンテナの制限値に対して重みサイズの2倍以上の余裕を持たせてから事前にテストする必要があります。 0.32.0 再測定(2026-10-27 0.31.0での測定 → 0.32.0で再測定)。 0.32.0のリリースノートには、スリープ機能がワークスペースとKVの初期化状態までも解放するように修正されたと記載されています。同じモデル、同じ4.5GiBの制限で再測定しました。
項目 0.31.0 0.32.0
2段階目スリープ呼び出し(4回の中央値) 42ms 43ms
スリープ中のGPUメモリ 6,168 → 606MiB 6,170 → 606MiB
2段階目復帰(4回の中央値) 0.39秒 0.41秒
復帰後の初リクエスト、出力一致 569ms、4/4 569ms、4/4
1段階目 コンテナ OOMKilled(2回とも) 再度 OOMKilled(スリープ前匿名メモリ 2,693MiB)

2段階目はバージョンが変わってもほぼ同等であり、スリープ中に残存する606MiBも減少しませんでした。1段階目のメモリ問題もそのまま残っていました。発生した3回のOOMはすべて単一のPod内で収まり、ノードへの影響はありませんでした。

  1. ドキュメントでも同様の注意喚起がなされています。1段階目は重みをCPUにバックアップするため十分なCPUメモリが必要であり、CPUメモリが不足している場合は2段階目を使用し、 reload_weights で再度読み込むよう記載されています。今回の実測により、その「十分な容量」が重みサイズよりも大きいことが示されました。

2. 起動時間:コンパイルキャッシュで半分に短縮

同一Pod内でサーバーを複数回起動し、 /health が200を返すまでの時間を記録しました。

ケース 準備完了まで うち torch.compile の時間
新規Podでの初回起動(デフォルト設定) 65秒、72秒(2つのPod) 19.6秒
同設定での再起動 30〜34秒 0.15秒(ディスク上の結果を読み込み)
初めて使用する設定(オプション変更時) 46〜62秒 約19秒
推論先読みを有効化 78秒、96秒 19〜36秒

vLLMは torch.compile の結果を ~/.cache/vllm 配下に保存し、モデル、設定、関連環境変数、torchのバージョン、GPUが一致している場合に再利用します。ログには Directly load AOT compilation from path … が出力されます。コンテナが新規作成されるとこのキャッシュが失われるため、頻繁に再起動が発生するサーバーの場合、キャッシュフォルダをボリュームにマウントするかイメージに組み込んでおくことがドキュメントで推奨されています。0.31.0のドキュメントでは、このほかにメモリ測定をスキップする --kv-cache-memory、コンパイルを一切行わない --enforce-eager(代わりにデコードが低速化)、最適化段階の -O0 から -O3 まで(デフォルトは -O2)を起動時間を調整するパラメータとして挙げています。

起動時間とは別に、サーバーが「準備完了」を出力した後であっても、初見の入力パターンに対してはコンパイルが追加で発生します。第4回で確認した通り、最初のプロンプトサンプリングのバッチ処理でTritonカーネルをコンパイルするために数秒かかり、ログに jit_monitor の警告が残りました。準備完了の確認は /health ではなく、実際のクエリパターンで一度ウォームアップを行ってから判断する方が安全です。

0.32.0 再測定。 新規コンテナでの初回家起動時は60秒、同設定での再起動時は30秒となり、0.31.0と同様の傾向を示しました。0.32.0のログには「JIT kernel warmup」というステージが新たに見られたものの、最初のサンプリングバッチにおけるカーネルコンパイルの警告および遅延(チャットモデルで6.2秒、リランクモデルで同時実行数16の場合に5.3秒)はそのまま残っていました。

3. /metrics:何が出力されるか

/metrics はPrometheus形式です。デフォルトのサーバーでは vllm: で始まる指標が38種類あり、推論先読みサーバーでは41種類、KVオフロードサーバーでは53種類でした。本番ダッシュボードに優先的に配置すべきものを、実測サーバーの実値とともにまとめました。

確認したい項目 指標 今回の実測値
現在いくつ実行中でいくつ待機しているか vllm:num_requests_running, vllm:num_requests_waiting, vllm:num_requests_waiting_by_reason 待機理由を capacity(空きスロットなし)と deferred(一時的制約)に分類
KVキャッシュの使用率 vllm:kv_cache_usage_perc 0〜1の割合
プレフィックスキャッシュが機能しているか vllm:prefix_cache_hits_total / vllm:prefix_cache_queries_total ベースサーバーのテスト全体で 94,592 / 330,084 トークン
プロンプトトークンはどこから来たか vllm:prompt_tokens_by_source_total{source=...} local_compute, local_cache_hit, external_kv_transfer(第7回のCPUオフロード)
レイテンシ vllm:time_to_first_token_seconds, vllm:inter_token_latency_seconds, vllm:e2e_request_latency_seconds, vllm:request_queue_time_seconds ヒストグラム。0.32.0以降は --custom-histogram-buckets でバケットを変更可能
プリエンプション(割り込み)が発生しているか vllm:num_preemptions_total 今回のテストでは 0
終了理由 vllm:request_success_total{finished_reason=...} stop 372, length 807
推論先読みが的中しているか vllm:spec_decode_num_accepted_tokens_total / ..._draft_tokens_total、位置ごとの ..._per_pos_total 第3回
ツール呼び出しパーサーの出力結果 vllm:tool_call_parser_invocations_total{outcome=...} 第5回
スリープ状態か vllm:engine_sleep_state{sleep_state=...} awake, weights_offloaded(1段階目)、 discard_all(2段階目)
モデルFLOPs活用率 vllm:estimated_flops_per_gpu_total など デフォルトは0、 --enable-mfu-metrics で有効化

指標名はバージョンごとに変更されてきました。例えばV0エンジンの vllm:gpu_prefix_cache_hit_rate は0.10で削除され、その代わりに上記のhits・queriesの2つのカウンターが使われます。ダッシュボードやアラート規則は、バージョンを上げるたびに現在のサーバーが出力する名前と突き合わせる必要があります。今回使用した方法はシンプルで、 curl -s localhost:8000/metrics | grep '^# HELP vllm:' で名前の一覧を取得し、ルールファイルと比較しました。

4. --api-key は /v1 のみを保護する

vLLMのセキュリティドキュメントには、「--api-key は /v1, /v2, /inference, /cohere で始まるパスのみを保護する」と明記されており、認証なしでオープンになるパスが列挙されています。実際にその通りか確認しました。サーバーを VLLM_API_KEY(このテストでのみ使用して破棄したランダムな値)で起動し、キーなしで各パスにリクエストを送信しました。

パス キーなしの場合 意味
GET /v1/models 401 保護されている(キーの指定で200を返す)
POST /v1/completions 401 保護されている(キーの指定で200を返す)
POST /invocations 200 SageMaker互換パス。 /v1/completions と同様の生成機能が キーなしで実行できた
POST /tokenize 200 トークナイゼーション。プロンプト処理コストをキーなしで消費可能
GET /metrics 200 モデル名、スループット、キュー、キャッシュ状態
GET /version, GET /load 200 バージョン情報、負荷
POST /pause, POST /sleep 404 このバージョン、この設定ではパスが存在しない(スリープは開発モードのみ)

0.32.0再測定(2026-10-27 0.31.0測定 → 0.32.0再測定)。 同じテストを0.32.0の公式コンテナで再度実行したところ、14個のパスのステータスコードが1つも変わりませんでした。 /invocations は0.32.0でもキーなしで200を返しました。

/invocations 1つの結論に至ります。 このサーバーをインターネットや他のチームのネットワークにそのまま公開すると、キーを渡していても誰でもモデルを実行できます。 ドキュメントも --api-key だけに頼らず、許可されたパスのみを通過させるリバースプロキシやゲートウェイを手前に置くよう推奨しています。同じドキュメントによると、プーリングモデルの /score, /rerank, /pooling, /classify も /v1 がないバージョンは認証を受けません。

開発モード(VLLM_SERVER_DEV_MODE=1)はさらに危険です。スリープ(サービス拒否)、キャッシュのクリア、そしてエンジン内部の任意のメソッドを呼び出す /collective_rpc が有効になります。ドキュメントには本番環境では絶対に有効化しないよう記載されています。第1節のスリープモードを本番環境で使用するには、そのパスが外部からアクセスできないようにネットワークポリシーでブロックし、オーケストレータのみが呼び出せるようにする必要があります。

0.32.0ではデフォルトのCORSが「すべてのオリジンを許可」から「許可なし」に変更されました。ブラウザから直接呼び出していたサービスであれば、バージョンを上げる際に --allowed-origins を明示する必要があります(リリースノート基準、未実測)。

5. 本番環境での運用事項

6. 未確認事項

参考

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます