0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【止まっちゃう事件の記録 #1】複数vLLMを同一GPUに常駐させたら原因不明のホストハングに悩まされた話 〜GPUメモリプロファイリング衝突からの脱出〜

0
Last updated at Posted at 2026-08-08

はじめに

オンプレミスの GPU サーバー(NVIDIA DGX Spark、統合メモリ 128GB)上で、Docker Compose により複数の vLLM インスタンスを常駐させて社内向け LLM 基盤を運用しています。チャット用の大型モデル、業務特化のファインチューニング済みモデル、Embedding、Reranker など役割の異なる vLLM を同時に立ち上げる構成です。

ある時から、このホストが数時間〜数週間おきに前触れなくハングし、電源の物理再投入でしか復旧できないという障害に悩まされるようになりました。本記事は、その原因調査から対策・実機検証までの記録です。GPU統合メモリ環境で複数の推論サーバーを同居させている方には特に刺さる内容だと思います。

とにかく先に結論だけという場合は第二回の後半をみてください。
→ 続編: GPUメモリ衝突を潰したのに、まだホストが「無痕跡」で凍りつく話 〜熱暴走という仮説にたどり着くまで〜

環境

  • NVIDIA DGX Spark(GB10、統合メモリ128GB共有)
  • Docker Compose で以下の vLLM インスタンスを常駐運用
    • チャット用大型モデル(35B級、量子化KVキャッシュ)
    • 業務特化ファインチューニング済みモデル(FP8量子化)
    • Embedding用モデル
    • Reranker用モデル
  • 各インスタンスは --gpu-memory-utilization で個別にGPUメモリ予約割合を指定
  • 全サービス restart: unless-stopped

発生した問題

  • ホストが前触れなく完全にフリーズし、SSHはおろかコンソールも一切反応しなくなる
  • 復旧はハードウェアの電源OFF/ONのみ
  • 発生周期はバラバラ(数時間で起きることもあれば、3週間安定稼働した後に起きることもある)
  • 一度は約10時間気づかずにサービスが完全停止していたこともあった

最初の仮説:スワップ枯渇

当初はスワップ領域の枯渇によるスラッシングを疑いました。総GPUメモリ予約率(util)が0.70(実メモリ換算で約85GiB)に達する構成で運用しており、これが「動くが安全域ではない」水準だったためです。util を0.54まで下げる対策を取ったところ、翌日たまたま別要因で0.70へ戻してしまい、その翌日に再ハングするという事象もあり、当初はこの説が有力でした。

しかし、詳細に調査したところ、これだけでは説明できないハングパターンが存在することが分かりました。

本当の原因:dockerdによる一斉自動復旧時のGPUメモリプロファイリング衝突

journalctl --list-boots で過去のブート履歴を洗い出し、異常終了直前のカーネルログを確認したところ、決定的な手がかりが見つかりました。

NVRM: nvCheckOkFailedNoLog: Check failed: Out of memory [NV_ERR_NO_MEMORY] ... _memdescAllocInternal

カーネルドライバレベルのGPUメモリ確保失敗が、ハング直前に繰り返し出現していたのです。GPU自体のハードウェアエラー(Xid)は皆無で、ハードウェア故障は否定できました。また、ホストメモリのearlyoom閾値も一度も踏んでおらず、スワップも枯渇していませんでした。

ここで重要な事実に気づきます。GPUの統合メモリはLinuxの /proc/meminfo に現れないため、ホストメモリ監視ツール(earlyoom等)ではGPU側の逼迫を検知できないのです。

根本原因はこうでした。

常駐させている複数の vLLM は restart: unless-stopped で運用している。ホスト再起動や dockerd 自体の再起動が起きると、dockerd が全コンテナを一斉に自動復旧させる。この時、各 vLLM インスタンスの起動時 GPU メモリプロファイリング(起動時に実際に確保できるKVキャッシュ量を実測する処理)が同時多発的に走り、互いに干渉する。軽症であればアプリケーションレベルの AssertionError: Error in memory profiling で済むが、重症化するとカーネルドライバレベルの NV_ERR_NO_MEMORY まで悪化し、ホストごとフリーズする。

さらに git log と実ログを突き合わせて調べたところ、GPUメモリ確保エラーによる完全フリーズ自体は、以前使っていた別の推論エンジン(Ollama)の時代から既に発生していたことも判明しました。つまりこれは特定エンジン固有の不具合ではなく、プラットフォーム側(GPUドライバ/統合メモリ管理)に起因する既知の脆弱性の可能性が高いという結論です。ただし、常駐エンジンが複数あり、かつ再起動のたびに毎回プロファイリングが走る現在の運用パターンが、発生頻度を大きく押し上げていたことも実測で確認できました(旧構成:31日間で1件 → 新構成:16日間で6件)。

なぜ「1台ずつ手動起動」だけでは防げなかったのか

複数エンジンを同時に起動しない、という運用ルール自体は早い段階で導入していました(起動スクリプトで1台ずつ healthy を確認しながら順番に上げる)。しかし、これは docker compose up人間が実行した時にしか効きません。dockerd自体が異常終了から自動復旧する際の一斉再起動には無力です。

docker-compose.ymldepends_on: condition: service_healthy も検討しましたが、これは compose のオーケストレーション機能であり、dockerd 単体でのコンテナ復旧(compose起動を介さない)では評価されないため不採用としました。

対策

1. entrypointロックラッパーによる相互排他

各 vLLM コンテナの entrypoint を、自作のシェルスクリプトに差し替えました。既存の起動コマンドはそのまま vllm serve への引数として透過させ、スクリプト内で共有ボリューム上のロックファイルを flock で取り合う形にします。

#!/usr/bin/env bash
# 概念を簡略化した抜粋
exec 200>/startup-lock/vllm-init.lock
echo "ロック取得待機中..."
flock 200

echo "ロック取得。vLLMを起動します"
vllm serve "$@" &
VLLM_PID=$!

# SIGTERM/SIGINTはvLLM本体へ転送(グレースフルシャットダウン維持)
trap 'kill -TERM $VLLM_PID' TERM INT

# ヘルスチェック通過を待つ
until curl -sf http://localhost:8000/health; do sleep 2; done
echo "vLLM healthy。ロックを解放します"
flock -u 200

wait $VLLM_PID

これにより「GPUメモリプロファイリング中(起動〜ヘルスチェック通過まで)は他インスタンスの起動処理をブロックする」ことを、docker compose up 経由だけでなく dockerd による自動一斉復旧時にも強制できるようになりました。

実機で docker compose restart により常駐4台を同時に再起動させて検証したところ、ログ上でロックが実際に機能していること(後発インスタンスが ロック取得待機中 で待たされ、先発インスタンスのプロファイリングと重ならないこと)を確認できました。

2. Settle Delay(ヘルスチェック通過後の猶予)

上記の対策には残課題がありました。「ヘルスチェック通過 → 即ロック解放 → 次のインスタンスが即座にプロファイリング開始」という設計だったため、ヘルスチェックは通ったがGPUメモリ確保がまだ裏で変動中の状態に、次のインスタンスの起動が重なるケースが残っていたのです。

対策として、ヘルスチェック通過後もすぐにロックを解放せず、一定時間(既定30秒、環境変数で調整可)待機してからロックを解放するようにしました。

[vllm-startup-wrapper] vLLM healthy(またはタイムアウトで打ち切り)。GPUメモリ安定化のため30秒待機してからロックを解放します。

3. 最終防衛ライン:earlyoom + oom_score_adj

上記2つはあくまで「衝突を減らす」対策であり、100%防げるわけではありません。万一メモリが逼迫した場合の安全網として earlyoom を導入しました。

ここでも一つ罠がありました。すべての vLLM プロセスは comm"vllm" に切り詰められるため、earlyoom の --avoid オプションではモデル名で区別ができません。そこで、各コンテナに Linux の oom_score_adj を個別設定し、優先度で保護するモデルを制御しました。

services:
  vllm-main-chat:
    oom_score_adj: -800   # 最優先で保護
  vllm-finetuned:
    oom_score_adj: 0
  vllm-embed:
    oom_score_adj: 0
  vllm-rerank:
    oom_score_adj: 0

/proc/<pid>/oom_score_adj を実測し、設定通りに反映されていることを確認しています。

4. sysctl調整

# swapへの書き出しを遅らせ、可能な限りRAM側で粘る
vm.swappiness=10
# 突発的な大量アロケーションに備え、kswapdの早期始動閾値を引き上げ
vm.watermark_scale_factor=200
# 逼迫時でも復旧操作ができるよう最低限のメモリを予約
vm.admin_reserve_kbytes=262144

5. util(GPUメモリ予約率)上限ガード

将来的な構成変更で気づかぬうちに危険水域まで予約率を積み増してしまわないよう、docker-compose.yml から各インスタンスの --gpu-memory-utilization を機械的に集計し、閾値超過を検出する簡易チェックスクリプトも用意しました。

$ ./check-util.sh
=== 検出したvLLMサービス ===
  vllm-main-chat   util=0.38   (resident)
  vllm-finetuned   util=0.16   (resident)
  vllm-vision      util=0.08   (ondemand)
  vllm-embed       util=0.03   (resident)
  vllm-rerank      util=0.03   (resident)

常駐合計util:            0.60(上限 0.60)
常駐+オンデマンド最大併用時: 0.68(上限 0.70、過去にハングを起こした水準に近づく目安)
OK: 安全域内です。

副産物:低頻度モデルのオンデマンド化

調査と並行して、利用頻度の低い Vision/OCR 用モデルは常駐させず、必要な時だけ起動・アイドル時は自動停止する構成に切り替えました。docker composeprofiles 機能を使うことで、既定の起動では上がらないようにしています。

services:
  vllm-vision:
    profiles: ["vision"]   # 明示的に指定しない限り起動しない

さらに、アイドル状態が一定時間(既定30分)続いたら自動停止するcronベースの仕組みも追加しました。実際にリクエストを処理中かどうかを示す「処理中マーカー」をアプリケーション側で作成・削除し、監視スクリプトはそのマーカーがある間は絶対に停止しない、という設計です。これによって「長時間かかる処理の最中に誤って止めてしまう」「監視スクリプトの判定直後に新規リクエストが来て巻き込まれる」といった competing race を潰し込みました。

これにより常駐合計のGPUメモリ予約率を抑えつつ、必要な時だけ機能を使える構成になりました。

検証で得られた教訓

  • docker ps の "Up" 表示やヘルスチェックの "healthy" は信用してはいけないRestartCount を必ず見ること。クラッシュループしていても再起動直後は "Up"/"healthy" に見えてしまう
  • GPU統合メモリはホストメモリ監視の死角になり得る/proc/meminfo ベースの監視ツールだけでは足りない
  • 複数の推論サーバーを同一GPUに同居させる場合、「手動での順次起動」だけでは不十分。dockerdレベルの自動復旧など、人間の運用ルールが介在しない経路も必ず考慮する
  • ヘルスチェック通過 = 内部状態が完全に安定、ではない。特にGPUメモリのような非同期に変動するリソースでは、猶予時間を設けることに意味がある
  • 対策後、ハング自体は大幅に減少しましたが、数日〜3週間隔で発生する、まったく別種の「無痕跡」フリーズは依然として残っていました。カーネルログに一切の手がかりを残さず、電源の物理OFF/ONでしか復旧しないという、今回の原因(起動時プロファイリング衝突)とは診断の手がかりの量が桁違いの厄介な問題です

まとめ

複数のGPU推論サーバーを1台のマシンに同居させる構成は、単体では発生しない「複数プロセスの初期化タイミングの衝突」という新しいクラスの障害を生み出し得ます。特に GPU の統合メモリ環境では、通常のホストメモリ監視の死角に落ちるため発見が難しくなります。

  • docker ps/healthcheckを鵜呑みにせずRestartCountを見る
  • 複数プロセスの初期化(特にGPUメモリのような非同期リソース確保を伴うもの)は、人間の運用ルールだけでなく、システムレベルで相互排他する
  • ヘルスチェック通過後も内部状態の安定に猶予を持たせる
  • 低頻度リソースはオンデマンド化し、常駐メモリ予約の総量を機械的にガードする

ただし、これで一件落着とはなりませんでした。この対策の後も、カーネルログに一切の痕跡を残さず、単一の軽量プロセスだけが動いている状態でも起きる、まったく別種のフリーズが残っていたのです。その調査の顛末は続編にまとめました。

→ 続編: GPUメモリ衝突を潰したのに、まだホストが「無痕跡」で凍りつく話 〜熱暴走という仮説にたどり着くまで〜

同じような構成でGPUサーバーを運用されている方の参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?