この記事では、Worker死亡・ゾンビタスクを検出して別Workerへ自動再投入するDead Worker Retryと、
ライブロックを根本解消するper-workerキュー設計の実装を解説します。
家庭内クラスタのような不安定な環境での自己修復設計に興味がある方向けです。
自宅サーバーで育てている分散AI推論基盤に、今回ようやく「自己修復(Self-Healing)」を入れた。Workerが落ちても、生き残っている別のマシンへタスクを自動で投げ直す仕組み――いわゆる Dead Worker Retry だ。あわせて、長らく抱えていたキュー設計の地雷も根っこから片付けた。実装そのものより、本番で機能が「勝手に仕事を始めた」瞬間が面白かったので、その顛末も含めて記録しておく。
そもそも何が困っていたのか
うちのクラスタは、役割の違う数台のマシンにローカルLLMを分散させて、Coordinator(司令塔)が「タスクの内容 × 過去の実績 × 現在の負荷」を見て最適なモデルとマシンを選ぶ、という構成になっている。フロンティアモデルと性能で張り合うのが目的ではなく、手元の機材でどこまで実用的にできるかを楽しむプロジェクトだ。
ただ、家庭内環境は本番データセンターと違って、WSLが止まる・電源が落ちる・Wi-Fiが切れる、が日常的に起きる。そして致命的だったのが 単一障害点 の存在だ。一番大きい gemma3:12b はGPUを積んだ1台(rtx3070ti)にしか載っていない。このマシンが落ちると、12bを要求したタスクは誰にも拾われず「永久にpending」のまま宙ぶらりんになる。フロント側(OpenWebUI)から見ると、ただ5分間ポーリングし続けてタイムアウトするだけ。原因も分からない。
タスクが詰まる失敗モードは、整理すると2種類あった。
| モード | 状況 |
|---|---|
| A: 誰も取らない | キューに積まれたが、それを見ているWorkerが落ちている。DBはpendingのまま放置。 |
| B: 取った後に死ぬ | Workerが受け取った直後に落ちた。キューからは消え、status=runningのまま完了しない。 |
もうひとつの地雷:キュー共有によるライブロック
これまではCPUタスク用のキューを2台のマシンで共有監視していた。すると「moon宛てのタスクを、たまたまrtx3070tiが先に取ってしまう」ことが起きる。取った側は「これは自分宛じゃない」と判断してキューの末尾に押し戻す(RPUSH)。だが押し戻した先でまた別の誰かが取って押し戻して……というライブロックのリスクを、外部レビュー(Gemini)に指摘されていた。
今回はここを対症療法でごまかさず、根本から作り直すことにした。
設計:指名制のキューに変える
キューの命名規則を、共有から「マシン指名制」に変更した。
旧: tasks:cpu / tasks:gpu ← 複数マシンで共有 → 押し戻し合戦
新: tasks:<tier>:<worker_id> ← 指名(例 tasks:cpu:moon, tasks:gpu:rtx3070ti)
tasks:<tier>:shared ← 共有(将来の負荷分散用に配線だけ)
各Workerは「自分宛のキュー → 共有キュー」の優先順でしか覗かない。Redisの brpop が複数キーを左から順に見る特性をそのまま使う。これで他マシン宛のタスクを掴むこと自体が物理的に起きなくなり、押し戻し処理がまるごと不要になった。ライブロックは「対策」ではなく「消滅」した。監視するキューはマシンの素性(worker_id+対応capability)から自動導出させたので、設定ファイルにキュー名をベタ書きする必要もない。
| マシン | 監視キュー(優先順) |
|---|---|
| moon(CPU専用) | tasks:cpu:moon, tasks:cpu:shared |
| rtx3070ti(CPU+GPU) | tasks:gpu:rtx3070ti, tasks:cpu:rtx3070ti, tasks:gpu:shared, tasks:cpu:shared |
実装:監視役(reaper)を常駐させる
Coordinatorに、定期的にタスクを見回るバックグラウンドスレッド(reaper=刈り取り役)を追加した。15秒ごとに「pending/runningのまま長く動いていないタスク」を拾い、こう判断する。
- 担当マシンが消えている(Redisの登録キーがTTL切れ)→ 別マシンへ投げ直す
- マシンは生きているのにrunningのまま固まっている(420秒超)→ ゾンビとみなして投げ直す
- 代替マシンがいない(gemma3:12bのような単一障害点)/ リトライ3回到達 → 即エラー確定(fail-fast)
ゾンビ判定の閾値を420秒と大きめに取っているのには理由がある。gemma3:12b は重いタスクだと実測で4分近くかかることがあり、ここを短くすると「正当に長考しているだけ」のタスクを誤って二重実行してしまう。推論のタイムアウト(300秒)に余裕を足した値にしてある。
そして単一障害点は「再投入」ではなく「即エラー」にしたのがポイントだ。代替がいないモデルを何度投げ直しても無駄なので、潔く失敗を返してフロントの5分ハングを防ぐ。原因はエラーメッセージに残す。
二重実行をどう防ぐか
投げ直しを入れると、「再投入したのに、その後で死んだはずのマシンが復活して元のタスクも実行してしまう」という競合が怖い。ここはWorker側に奪取(CAS)の関門を1枚かませた。タスクを受け取った直後に、DB上で「pendingかつ自分宛なら running に書き換える」更新を打ち、1行も更新できなければ(=誰かが既に処理済み/再割当済み)そのまま黙ってスキップする。
UPDATE tasks SET status='running', worker=<自分>
WHERE task_id=? AND status='pending'
AND (worker=<自分> OR worker='default')
RETURNING task_id -- 0行ならスキップ
これで、復活したマシンが古いキューに残った「幽霊エントリ」を拾っても、安全に握りつぶされる。
本番投入と、reaperが勝手に始めた仕事
マシンの種類が違うので、Workerを先に全台更新してからCoordinatorを入れ替える、という順序でカットオーバーした。Coordinatorを再起動してログを見ると、reaperの起動メッセージのすぐ下に、こんな行が出ていた。
[reaper] started interval=15s dead_grace=15s running_timeout=420s max_retries=3
[reaper] REQUEUE task=daff1b0c... reason=dead_worker moon->rtx3070ti model=llama3.2:3b attempt=1/3
status=done / worker=rtx3070ti / retry_count=1。狙いどおり、しかも仕込みなしで動作実証できてしまった。わざと壊して確かめる
もう片方の系統――単一障害点の即エラー(fail-fast)も確認したい。gemma3:12b を投げて、rtx3070tiのマシン登録キーを消し、「死亡」を演出した。ところが結果は status=done。
理由はすぐ分かった。登録キーを消しただけではプロセスは生きていて、推論は普通に完走する。reaperが「死んだ」と見て失敗を書き込もうとしたまさにその瞬間に、rtx3070tiが推論を終えて成功を書き込み、成功が辛勝したのだ。前述のCAS(状態が変わっていたら書き換えない)が効いて、データとしては正しい done が残った。競合時の安全性も、はからずも確認できた形になる。
そこで今度はマシン上でサービスごと停止し、推論を完走できない状態を作ってから再投入した。30秒後――
{
"status": "error",
"model": "gemma3:12b",
"retry_count": 0,
"error_message": "no alternative worker can serve model 'gemma3:12b'
(reason=dead_worker); single point of failure"
}
これで、自己修復の両系統――「代替がいれば投げ直す」と「いなければ潔く諦める」――を実機で確認できた。
まとめと、次にやること
今回入れたのは派手な機能ではない。むしろ「落ちても大丈夫にする」という地味な土台づくりだ。だが家庭内クラスタのように壊れることが前提の環境では、こういう自己修復こそが実用性を左右する。性能でフロンティアと張り合わない代わりに、自分で全部理解して制御できる――その方針に、また一歩近づいた気がする。
面白かったのは、検証のために用意した2つのシナリオが、ひとつは仕込みなしで本番発火し、もうひとつは「キーを消すだけでは死なない」という現実に教えられたことだ。分散システムは、机上で考えた失敗モードと実際の壊れ方が微妙にずれる。そのズレを実機で確かめられたのが、今回いちばんの収穫だった。
次は、ルーティングの賢さそのものに手を入れる。今の選定基準は「速さ」と「実績の多さ」だけで、回答の品質を見ていない。大きいモデルに小さいモデルの回答を採点させる(LLM-as-judge)仕組みを、夜間バッチで回して品質スコアを蓄積する――そこに進む予定だ。