前回までで、我が家の分散推論基盤「Coordinator」には OpenWebUI から日本語で指示すると、公開サーバ mars の状態を読んだり(Phase A)、限られたサービスを再起動したり(Phase B write)できる「サーバ管理」の口ができていた。自然文を planner(gemma3:12b)がカタログ準拠のプランに変換し、Coordinator が承認判定して、mars 上の executor(coordinator-maintenance.service)が定型コマンドを実行し、結果を OpenWebUI に返す——という一本道だ。
今回はその executor 自身が止まったとき、誰がそれに気づくのか、という話をする。結論から言うと、死んだ executor は自分の死を報告できない。当たり前のようで、実際に踏むまで気づかなかった。そしてその冗長化を作る過程で、LLM が「たった一語の助詞」に引かれて経路を間違える様子を、本番で何度も観測した。
発端:誰も見ていない見張り番
Phase B のデプロイ作業中、mars の coordinator-maintenance.service が静かに停止し、そのまま放置された時間帯があった。その間に OpenWebUI から「メモリを見て」「ディスクを見て」と投げると、90秒のポーリング窓の中で完了確認が取れず、⏳のタイムアウト表示になる。
後で PostgreSQL の maintenance_tasks を見ると、実はタスクは done になっていた。executor を再起動した直後、キューに溜まっていた分の処理に6〜8分かかり、90秒の窓を大きく超えてから Talk 通知だけが遅れて届いていた。実害はない。だが問題の本質はそこではない。見張り番(executor)が倒れているのに、それを教えてくれる仕組みが無かったことだ。
第一の手:AIに自分の死活を見せる
そこで、障害対応で人間が叩いたコマンド——systemctl status/journalctl/キュー長の確認——を、そのままカタログの read アクションとして追加した。mars のカタログ(v4)に3本:
service.logs journalctl -u <unit> -n <lines> --no-pager # unit は enum・lines は 10-200 で暴走防止
queue.length redis-cli -h 192.168.0.40 LLEN tasks:maintenance:mars
maintenance.self_status systemctl status coordinator-maintenance.service --no-pager
これで「executor が止まっていて反応がない」という状況そのものを、OpenWebUI から日本語で聞けるようになった……はずだった。ここに落とし穴がある。maintenance.self_status は「executor の死活を診断する」アクションだが、この診断コマンドを実行するのは、当の executor 自身だ。executor が生きていれば「生きています」と答え、死んでいれば——診断コマンドも動かない。
自分の脈を自分で測る医者のようなもので、心臓が止まった瞬間には測定もまた止まる。死んだ executor は自分の死を報告できない。
冗長化:もう一人の見張り番を、別の箱に
ならば、mars を 外から診る第二の目が要る。幸い、Coordinator の中枢 ai-core(Redis と PostgreSQL のホスト)には、結果を集約する reaper が常駐している。ここに診断能力を持たせればいい——と考えて、まず却下した案がある。
reaper は「結果を拾って PG を更新し Talk に流す」だけの、subprocess を一切持たない設計だ。ここにコマンド実行を足すと、executor が持っている allowlist の二重強制・shell=False・argv 固定といった防壁を、reaper 側にも丸ごと複製することになる。せっかく「実行しない」という綺麗な安全境界を、自分で崩すことになる。
正解は拍子抜けするほど単純だった。executor 本体 worker_maintenance.py は、環境変数 NODE_NAME だけでキュー(tasks:maintenance:<node>)もカタログ(catalog_<node>.yaml)も決まるよう、最初から完全にパラメータ化されていた。つまり同じ実行体を NODE_NAME=ai-core でもう一度立てるだけ。Python のコードは一行も書き換えない。ai-core 用のカタログ(read 専用の自己診断アクション)と systemd unit を一枚ずつ足せば済む。
worker_maintenance v0.2.0 up node=ai-core env=production actions=7 queue=tasks:maintenance:ai-core
ここに嬉しい副作用があった。mars の executor は Phase B で sudo によるサービス再起動を解禁したため、堅牢化フラグ NoNewPrivileges を yes から no に緩めざるを得なかった。だが ai-core 側は全アクションが read で sudo を一切使わないので、NoNewPrivileges=yes の厳格な堅牢化をそのまま維持できる。write を許した片割れと、読むだけの片割れ。守りの強度が非対称なのは、役割が非対称だからだ。
寄り道:Redis のキー名を、LLM は選べるか
ai-core から mars のキュー滞留を読むにあたり、queue.length を「固定コマンド」から「どのキューを見るか enum で選ぶ」形にパラメータ化した(mars 側も v5 で揃えた)。enum の値は tasks:maintenance:mars のような Redis のキー文字列そのものだ。
正直、不安だった。「apache の状態を見て」から unit=apache2 を選ぶのは易しい(指示文に “apache” がある)。だが「mars のメンテナンスキュー」から tasks:maintenance:mars という機械的な文字列を選べるだろうか。杞憂だった。gemma3:12b は「marsのメンテナンスキューの長さを見て」に対し、node=mars・{"queue": "tasks:maintenance:mars"} を正しく組み立ててきた。enum に選択肢が並んでいれば、機械的な文字列でも転記できる。
核心:冗長経路が、発火しない
さて本番。「ai-coreから mars のメンテナンスキューの滞留を見て」と投げる。狙いはai-core の executor に mars のキューを読ませることだ。返ってきたプランは——node=mars。
exit 0、値は 0。数字は取れた。だがこれは mars の executor が実行したのであって、私が欲しかった「mars を経由しない経路」を通っていない。mars が生きていたから読めただけで、mars が死んでいれば、このプランは tasks:maintenance:mars に積まれて詰まる——診断したいまさにその状況で、診断コマンドまで一緒に詰まる。最初の自己参照パラドックスが、そっくり戻ってきた。
原因は助詞だった。指示文には “ai-core” も “mars” も両方入っていたのに、planner は「marsの…キュー」という所有格に引かれて node=mars を選んだ。人間なら「ai-coreから(読む)」の方を主語だと解釈するところを、LLM は「marsの(もの)」に吸い寄せられた。
ついでにもう一つ、面白い拒否を踏んだ。テスト中に queue.length を何度か投げていたら、こう返ってきた:
理由: rate_limit: queue.length は直近 3600s で 3+1 回 > 上限 3 回
これは以前に仕込んだ「アクション別レート制限」(1時間3回まで)が、本番で初めて発火した瞬間だ。挙動としては完璧に正しい。だが示唆的でもある。冗長診断とは、非常時にこそ打ちたいものだ。その非常時に限って、通常運用で消費された共有の queue.length 枠が埋まっていて打てない——というのは、冗長化の趣旨と噛み合わない。
格下げ:ノード選択を「推論」から「転記」へ
この基盤には以前から一つの思想がある。LLM のノード選択を「推論」ではなく「転記」に落とす——planner がプランに書いたノード名が指示文中に文字どおり出てこなければ、プラン全体を却下する(node_presence_guard)。LLM に「賢く選ばせる」のではなく、「書いてある通りに写させる」。
今回の対処も同じ発想に落ち着いた。ai-core のカタログにだけ存在する専用アクション queue.length_mars_peer(固定コマンド・パラメータなし)を一本足す。mars のカタログには載せない。すると、planner がこのアクションを選んだ瞬間、それが存在するノードは ai-core しかない——node の選択が消え、転記に化ける。加えて別のアクション名なのでレート枠も独立する。共有の queue.length が満杯でも、この非常用の窓口は別枠で開いている。
これを発火させるまでの試行が、そのまま学びになった。まず「ai-coreから mars のキュー滞留を冗長診断して」。planner は今度こそ queue.length_mars_peer を選んだ(説明文を「ai-core 側から読む冗長診断」と寄せた効果だ)。だが node には——また “marsの” に引かれて mars を付けた。結果:
理由: step[0]: unknown_action 'queue.length_mars_peer' on mars
これは失敗ではなく、設計どおりの安全だ。peer は ai-core にしか無いので、mars に対しては「そんなアクションは無い」と unknown_action で実行前に拒否される。#2 で怖れた「誤ったノードにプランが積まれて詰まる」が、構造的に起きない。誤った経路は、実行ではなく拒否で止まる。
そして主語を寄せた。「ai-coreのexecutorで mars キューの滞留を冗長診断して」。あるいは最短に「ai-coreで queue.length_mars_peer を実行して」。どちらも:
ai-core / queue.length_mars_peer {} → exit 0 · 0.009s
0
node=ai-core に着地し、mars を経由せず mars のキュー滞留(=0)を読んだ。しかも共有の queue.length 枠が先のテストで埋まっていたにもかかわらず、peer は別枠で発火した。冗長経路も、独立したレート枠も、同時に本番で実証された。
収束した運用規約
三つの観測は、一つの規約に収束した。
- 所有格は経路を誤らせる。「marsの…」は node=mars に引く。冗長診断を ai-core に落としたいなら、主語を「ai-coreの…で」に寄せる。
- ノードを一意にすれば、選択は転記になる。そのノードにしか無い専用アクションを選ばせれば、node の推論は消える。
- 誤ったノードは、拒否で止まる。peer が ai-core 限定であることが、間違った経路を「実行」ではなく「fail-closed の拒否」に変える。
冗長化というと「同じものをもう一台」で終わりがちだが、本当に難しいのは冗長な経路を、必要なときに確実に通させることだった。人間なら文脈から自然に選ぶ経路を、LLM は「文法が示唆する自然な方」に流れる。だから経路そのものを構造で一意にしておく必要がある——プロンプトで「こう選べ」と頼むのではなく。
見張り番が二人になった。片方が倒れれば、もう片方が別の箱から様子を見に行く。ただしその「見に行かせ方」までを AI の判断に委ねず、行き先が一つに定まるアクションとして先に敷いておく。柵は、賢い planner を安全に走らせるための土台だ——という以前からの方針が、今回はノードの選び方にまで及んだ。死んだ者の死を、生きている隣人に、間違いなく看取らせるために。