|SSDが壊れたので換装したら、ルーティングの設計ミスが見つかった ― ハード故障対応から動的ティアフォールバックへ

家庭内の分散AI推論クラスタ「Coordinator」を運用している。複数のローカルマシンにLLM推論を振り分ける自作の基盤だ。今回は、ワーカーノードの1台「moon」のSSDが物理的に壊れたところから話が始まる。換装して復旧させるだけの作業……のはずが、その過程で自分のルーティング設計に潜んでいた硬直性が炙り出された。「ハードの故障対応をしていたら、いつの間にかソフトの設計を直していた」という記録である。

発端:moonのSSDが死んだ

moonはThinkCentre(Ryzen 5 PRO 2400GE)で、クラスタの中では軽量・中型モデル担当のCPUワーカーだ。ある日、このマシンのSSDが故障した。換装してOSを入れ直し、ワーカーとして復帰させる必要がある。

ついでに構成も少し見直した。メモリを32GBから16GBに減らし、余った分はesxiホストへ回した。これまでmoonはWake-on-LANで「使うときだけ起こして、終わったら寝る」運用だったが、今回を機に常時起動に切り替えることにした。

復旧でいきなり転ぶ:requestsが無い

OSを入れてサービスを起動したら、いきなりこうなった。

ModuleNotFoundError: No module named 'requests'

systemdのRestart=alwaysが効いていて、5秒おきに同じエラーで再起動を繰り返している。原因は単純で、SSD換装=venvの作り直しなので、Pythonの依存パッケージが入っていなかっただけだ。

source ~/venv/bin/activate
pip install requests redis==7.4.0

ここでredisを7.4.0に固定しているのには理由がある。以前、redis-py 8.0系がRedis Server 8.x環境でBRPOPの応答を返さなくなる問題に何日もハマったことがあり、それ以来このバージョンをピン留めしている。SSDを換装するたびに同じ依存漏れを踏むのは馬鹿らしいので、今回は各ワーカーにrequirements.txtを置いて、venv再構築時は最初にこれを通す運用にした。

pip freeze | grep -E "redis|requests|psycopg2|fastapi|httpx" > ~/requirements.txt

常時起動への変更は3行消すだけ

WoL運用をやめて常時起動にするのは、設定ファイルから3行を消すだけで済んだ。ワーカーの共通ロジックは、アイドル停止の設定キーが存在しなければidle監視スレッド自体を起動しない設計にしてある。

# この3行を削除(または将来戻せるようコメントアウト)
"idle_shutdown_sec":       int(os.getenv("IDLE_SHUTDOWN_SEC", 900)),
"min_uptime_sec":          int(os.getenv("MIN_UPTIME_SEC", 300)),
"idle_check_interval_sec": int(os.getenv("IDLE_CHECK_INTERVAL_SEC", 30)),

WoL用のMACアドレス登録やshutdown用のsudoers設定はそのまま残した。また寝かせたくなったら3行戻すだけで元に戻る。こういう「やめるのも復活も簡単」な状態を保てると、運用の心理的なハードルが下がる。

16GBに合わせてqwen2.5:7bを追加

メモリが16GBになったので、それに見合う中型モデルとしてqwen2.5:7b(Q4_K_M、おおよそ5GB/実行時RAM 8GB程度)を追加した。32Bや14Bは16GBには重すぎるので、7Bが現実的な上限という判断だ。

ここまでは順調だった。モデルを追加して、ワーカー指名でルーティングして動作確認しよう……というところで、思わぬエラーに遭遇する。

炙り出された設計の硬直性

moonにqwen2.5:7bを指名で投げたら、こう返ってきた。

"status": "error",
"response": "no alternative worker can serve model 'qwen2.5:7b'
            (reason=ghost_pending); single point of failure"

タスクが積まれたキューを見るとtasks:gpu:moonになっていた。これが問題だ。moonはCPUワーカーで、監視しているのはtasks:cpu:moonだけ。GPUキューなど見ていないので、タスクは誰にも拾われずゴースト化し、最終的にReaperが「代替ワーカーもいない単一障害点だ」とエラーにした。

なぜGPUキューに積まれたのか。原因はルーティングの根っこにあった。モデルごとの処理ティア(GPU/CPU)を、こんなふうにモデル名だけで決め打ちしていたのだ。

GPU_MODELS = {"gemma3:12b", "qwen2.5:7b", "phi4-mini:3.8b", "gemma3:4b"}

def task_tier(model):
    return "gpu" if model in GPU_MODELS else "cpu"

qwen2.5:7bがこのGPU_MODELSに入っていた。だから「これはGPUモデルだ→GPUキューへ」と機械的に振られ、CPUワーカーのmoonに渡せなくなっていた。

これは単なる設定ミスではない。「モデル名を見れば処理ティアが一意に決まる」という前提そのものが硬直していたのだ。現実には、同じqwen2.5:7bでもGPUのrtx3070tiで回すこともあれば、CPUのmoonで回すこともある。モデルとティアは1対1ではない。

設計改善:ティアを動的に判定する

対処は2段構えにした。まずqwen2.5:7bはmoonのCPU常用モデルなので、GPU_MODELSから外す。そのうえで、task_tier()を「モデル名だけで決める」のではなく「いま登録されているワーカーの顔ぶれを見て決める」動的判定に書き換えた。

def task_tier(model, workers=None):
    # GPU_MODELS でなければ常に cpu
    if model not in GPU_MODELS:
        return "cpu"
    # GPU_MODELS でも、そのモデルを持つ GPU ワーカーが
    # 登録に居なければ cpu にフォールバックする
    if workers is None:
        workers = get_all_workers()
    for w in workers:
        if "gpu_inference" in w.get("supports", []) and model in w.get("models", []):
            return "gpu"
    return "cpu"

これでGPU_MODELSの意味が「GPU必須」から「GPUがあれば優先したい」に変わった。GPUワーカーが生きていればGPUキューへ、落ちていればCPUキューへ自然に落ちる。

両方向で動作確認

ちょうどこのとき、GPUワーカーのrtx3070ti(WSL2)はUbuntu側を起動しておらず、クラスタから外れていた。これは検証には好都合だった。

GPUワーカー不在のとき、gemma3:12bを投げるとtasks:cpu:…へフォールバックする(CPU側にそのモデルを持つワーカーがいれば処理される)。qwen2.5:7bはtasks:cpu:moonに乗り、無事status: doneになった。

その後rtx3070tiのUbuntuを起動して復帰させ、GPUワーカーが居るときに同じgemma3:12bを投げると、今度はちゃんとtasks:gpu:rtx3070tiに乗り、GPU推論で完走した。GPUワーカーの有無に応じて、同じモデルが行き先を自動で変える。両方向で意図どおりに動くことを確認できた。

# GPUワーカー復帰後
{"worker_id":"rtx3070ti","model":"gemma3:12b","queue":"tasks:gpu:rtx3070ti"}
# → status: done

設計上の意味と限界

この変更で、特定のモデルが1台のGPUワーカーにしか無いときの単一障害点(SPOF)への耐性が一段上がった。GPUが落ちても、CPU側で同じモデルを持っていれば処理を続けられる。

ただし誤解してはいけないのは、「CPUに落ちれば必ず処理できる」わけではない点だ。gemma3:12bはCPUワーカーには載せていないので、GPUが落ちれば結局「候補なし」になる。動的ティアフォールバックが効くのは、あくまでCPU側にもそのモデルが配置されている場合に限られる。真の冗長化はティア判定のロジックではなく、モデルをどこに配置するかという話に帰着する。今回直したのは「配置されているのに辿り着けない」という到達性の問題で、それはそれで価値があるが、冗長化そのものとは別物だ。

おわりに

SSDが物理的に壊れて換装する、という地味なハード対応のつもりだった。それが「依存パッケージの再現性(requirements.txtの整備)」と「ルーティングの硬直性(動的ティア判定)」という、2つのソフト側の改善につながった。

故障対応は面倒だが、普段は触らない部分に手を入れる機会でもある。「モデル名でティアを決め打つ」という、動いている間は誰も疑問に思わなかった前提が、qwen2.5:7bという1つのモデル追加で表面化した。トラブルは、設計の弱いところを正確に教えてくれる。直したあとのクラスタは、前より少しだけ賢く、壊れにくくなった。