家庭内の分散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つのモデル追加で表面化した。トラブルは、設計の弱いところを正確に教えてくれる。直したあとのクラスタは、前より少しだけ賢く、壊れにくくなった。