見張り番を二人にしたら、片方の急報が長話の後ろで待たされた ― 相互監視の逆向きを閉じ、診断の実行レーンを書き込みから分離するまで

前回、監視役の executor が自分の死を報告できない問題に、二人目の見張り番で答えました。ai-core に read 専用の executor をもう一台立て、ai-core のカタログにだけ置いた専用アクション queue.length_mars_peer で、mars のキュー滞留を mars を経由せずに読む ―― そういう冗長化です。そのとき私は「見張り番が二人になった」と書きました。

でも、正直に言えば、あれは片肺でした。ai-coremars を診られるようになった。では逆は? ai-core 側の見張り番が倒れたら、mars はそれに気づけるのか? 答えは「まだ無理」でした。今回はその逆向きを足して相互監視(mutual-watch)を閉じ、さらにその過程で見つかった「非常用の急報が、長話の後ろで待たされる」という別の詰まりを、実行レーンの分離で解いた話です。

逆向きは、鏡に映すだけ ―― ただし一つ、正直な非対称がある

逆向きの設計は、前回の完全な鏡像です。mars のカタログにだけ、固定コマンドの queue.length_aicore_peerredis-cli -h 192.168.1.40 LLEN tasks:maintenance:ai-core・パラメータなし)を一本足す。mars にしか無いので、planner がこれを選べば実行ノードは mars で確定する ―― ノード選択が「転記」に化ける、前回の理屈そのままです。別アクション名なのでレート枠も独立します。

ただ、鏡像と言い切れない非対称が一つあって、これは隠さず書いておきます。Redis は ai-core に集中しているのです。

  • 前回の順方向(ai-coremars を診る)では、監視する側が Redis を持っていました。だから mars がどんな状態でも、ai-core は必ずキューを読めた。きれいでした。
  • 今回の逆方向(marsai-core を診る)では、監視される側が Redis を持っています。だからこの経路がまっすぐカバーするのは「ai-core の executor プロセスだけが死に、ホストと Redis は生きている」場合。滞留値でそれを検知できます。もし ai-core がホストごと落ちれば、marsredis-cli は接続エラー(exit 非0)を返す ―― それはそれで「ai-core に到達できない」という別のシグナルとして役に立ちますが、キューの滞留値としては読めません。

これはトポロジ上の制約で、設計で消せるものではありません。片肺性を承知のうえで、それでも「executor プロセスの死」を独立に捕まえられる価値のために入れる ―― という判断です。冗長化の話でいちばん誠実でいるべきなのは、こういう「守れる範囲」の境界を曖昧にしないことだと思っています。

今度は、planner が初弾で正解した

配備して、OpenWebUI から投げました。「mars から ai-core のメンテナンスキューの滞留を冗長診断して」。結果、node=mars/queue.length_aicore_peer/exit 0・0.008秒・値0。逆向きの冗長経路が、端から端まで通りました。

面白かったのは次です。前回、私を散々てこずらせた「所有格トラップ」を、逆向きでも試しました。「ai-core のキューの滞留を mars から見て」―― 文頭に「ai-core の」という所有格を置いた、意地の悪い言い方です。前回の順方向では、これで planner が誤ったノードに引っ張られました。だから今回も、うまくいって拒否、悪くて誤ノードだろうと身構えていました。

ところが planner は、初弾から正しく node=mars/peer を選びました。予想が外れて、それも嬉しい方に。理由はたぶん、指示文に「mars から」と実行主体が明示されていたことと、peer が mars のカタログにしか無いことが噛み合って、転記先が一意に収束したからです。前回「所有格は経路を誤らせる」と書いたばかりでしたが、「〜から」で主語を先に置くと、その誤りは起きにくい。同じ現象の、逆側の顔を見た気がしました。

誤ノードが拒否で止まること自体は今回発火しませんでしたが、その構造保証は、集約したカタログ全体を舐めるテストで「このアクションは mars にしか無い」ことを機械的に押さえてあります。挙動は実測で確かめ、保証は構造で持つ。役割を分けてあるので、片方が今回鳴らなくても不安はありません。

気づいてしまった、もう一つの片肺

相互監視は閉じました。が、この作業をしている最中に、別の詰まりが視界に入ってきました。

前回、専用アクションを別の名前にしたことで、診断はレート枠が独立しました。共有の queue.length がいくら埋まっていても、非常用の窓口は別枠で開いている。よし、と思っていた。でも ―― 実行レーンは、共有のままだったのです。

このクラスタの executor は、自分宛のキューを一本の BRPOP で順に取り出して、一つずつ直列に実行します。だから、長い書き込みジョブ ―― たとえば apt.upgrade は実測で100秒走ります ―― が動いている最中に診断 read を投げると、その read は長話が終わるまで後ろで待たされる。レート枠は独立させたのに、実行の順番待ちは共有のまま。非常時にこそ即答してほしい診断が、いちばん混んでいるときに限って待たされる。片手落ちでした。

Phase C:読むだけの executor を、もう一台

対処は、また「もう一台」です。ただし今度は役割で分けるmars に、read 専用の第二 executor を立てました。読み取り系のタスクはこの新しいレーン(tasks:maintenance:mars:read)へ流し、書き込みは従来のキュー・従来の executor のまま直列に残す。

書き込みを並列化しないのは意図的です。apt はロックを取るので、二本同時に走らせれば衝突する。書き込みの直列保証は、崩してはいけない安全property です。だから分離するのは read だけ。

実装で触ったのは、Python の二ファイルに数行と、systemd の unit 一枚だけでした。

  • executor 本体には環境変数を二つ足しました。消費キューを差し替える MAINT_QUEUE(未設定なら従来どおり=完全な後方互換)と、MAINT_MODE_ONLY=read。後者はレーンの二重強制で、もし書き込みアクションが read レーンに間違って流れてきても、executor が実行せず mode_mismatch で拒否します。allowlist を二重に強制するのと同じ思想を、レーンにも効かせた形です。
  • Coordinator 側は、プランを投入するとき、そのアクションが read なら :read レーンへ振り分けるようにしました。read か write かはカタログに書いてある ―― つまりここでも「推論」ではなく「転記」です。planner に判断させるのではなく、登録済みの属性を写すだけ。

ここにも、前回と同じ嬉しい副作用がありました。mars の書き込み executor は Phase B で sudo を解禁したため堅牢化フラグ NoNewPrivilegesno に緩めてありますが、read 専用の新しい executor は sudo を一切使わないので NoNewPrivileges=yes の厳格な堅牢化を回復できます。渋滞対策のつもりが、読むだけのレーンの守りを一段強くするおまけまで付いてきました。

切り替えは、事故が起きない順序で

この手の「経路を足す」変更でいちばん怖いのは、移行の途中で :read レーンにタスクが積まれ始めたのに、まだ誰も拾っていない、という宙ぶらりんです。なので順序を固定しました。振り分けは既定 OFF で配備する ―― この状態では挙動は一文字も変わりません。次に read 専用 executor を起動する(この時点では誰も :read に積まないので、ただ静かに待っているだけ)。最後に、Coordinator 側のフラグを一枚の drop-in で ON にする。積む前に、必ず拾う側が先に居る。ロールバックは drop-in を消すだけで、一瞬で元通りです。

「通った」を、UIの成功で信じない

フラグを ON にして、例の逆向き診断をもう一度投げました。exit 0、値0。画面上は成功です。でも、これだけでは「read レーンを通った」証拠になりません。フラグが効いていなくても(read が旧レーンに流れても)、書き込み executor はその read を問題なく捌いて exit 0 を返すからです。UI の成功は、どちらのレーンで実行されたかを区別しない。

決め手は journal でした。二つの executor のログを突き合わせます。read 専用 executor 側には executed action=queue.length_aicore_peer status=ok が出ている。同じ時間帯の書き込み executor 側には ―― 何も無い。読み取りは read レーンで実行され、書き込み executor はそれを一切拾っていない。これで初めて、レーンが分離されたと言えます。「成功した」ではなく「どの経路で成功したか」まで見る。ここでも 計測してから信じるを、自分の安堵に対して適用しました。

まとめ

相互監視が閉じました。marsai-core は、互いを、相手を経由せずに診られる。そして診断 read は、レート枠だけでなく実行レーンまで書き込みから独立したので、長話の最中でも並走で即答します。前回「見張り番が二人になった」と書いた冗長化は、今回ようやく両方向になり、かつ非常時に本当に効く形になりました。

作業を通して腹落ちしたのは、冗長化は「もう一台」では終わらないということです。もう一台立てても、その一台への経路が所有格ひとつで誤るなら意味がないし、実行レーンが長い書き込みと共有なら非常時に詰まる。経路もレーンも独立させて、初めて「片方が倒れたら、もう片方が確実に様子を見に行く」が成立する。しかもその独立を、planner の賢さに頼るのではなく、アクションを置く場所やカタログの属性 ―― 構造で決める。賢さを足すより、選択肢を削る。この基盤を作りながら何度も出会ってきた方針が、今回はノードの選び方とレーンの分け方の両方に効きました。

残っているのは、この分離レーンをさらに活かすための地ならしです。長い書き込みそのものをどう扱うか(承認付き write の拡張)、そして本番でまた顔を出した apt リポジトリの Label 変更問題。ひとつずつ、計測してから進めます。