AIにサーバ更新を任せたら、失敗が”静かに”なった話 ── 粒度フラグ・版ズレ・無音停止

前回(第44回)は、mars と ai-core がお互いのキューを見張り合う相互監視と、読み取り専用レーンの分離を扱いました。今回は、その基盤に「apt でパッケージ索引を更新する」というごく地味な運用を足したときの話です。ところが地味なはずのこの一手で、失敗が”静かになる”という、分散システムでいちばん怖い性質を三連続で踏みました。良性の失敗を粒度で受理する設計判断と、その適用中に executor が無言で止まった話、そして直したはずが別の無音失敗を生んだ話です。

この記事で分かること

  • apt が「リポジトリの素性が変わった」と言って止まるのは安全機能であること、そしてそれを「全部受理」ではなく変わったフィールドだけ受理で解く理由
  • 「投入は成功したのに結果が返らない」を、Redis のキュー長だけで一発で切り分ける方法
  • 権限(sudoers)と設定(カタログ)の版がズレると、本当の原因が exit=1 の下に埋もれるという落とし穴

1. apt update が時々こける。でも実害はゼロ

うちの公開サーバ mars には、PHP を新しく保つために ondrej の PPA という外部リポジトリが入っています。この PPA が時々、自分のメタデータ(InRelease)の Label という表示名フィールドを書き換えます。すると apt-get update はこう言って途中で止まります。

E: Repository '.../ondrej/php/ubuntu ... InRelease' changed its 'Label' value from '...' to '...'
N: This must be accepted explicitly before updates for this repository can be applied.

これはバグではなく安全機能です。apt は「前回とリポジトリの素性(origin/label/codename など)が変わった=乗っ取りや差し替えの可能性」を疑って、人が明示的に承諾するまで先へ進みません。exit code は 100。ただし今回は索引を読んで止まっただけで、パッケージのインストールも削除も一切していない=システムは無傷です。

手で直すなら、mars に入って一回こう受理すれば済みます。

sudo apt-get update --allow-releaseinfo-change

問題は、これを OpenWebUI 経由で AI に「mars の索引を更新して」と頼む運用に載せたとき。無人で走るこのアクションが、PPA が Label を変えるたびに failed になる。低頻度で実害ゼロですが、毎回手で入りに行くのも本末転倒です。恒久的にどう扱うかを決める必要がありました。

2. 「全部受理」ではなく「Label だけ受理」を選んだ理由

安直な解は、--allow-releaseinfo-change をコマンドに常設して、以後の素性変更を全部自動で受理させることです。動きはします。でもこれは、origin も codename も version も、どのフィールドが変わっても無言で受理するということ。つまりリポジトリ乗っ取りや差し替えを検知するいちばん重要なシグナルを、恒久的に一段潰すことになります。無人で走るアクションでこれをやるのは筋が悪い。

apt には、もっと細かいフラグ群があります。実際に変わっているのは Label だけなので、そこだけを受理する粒度フラグを使いました。

sudo apt-get update --allow-releaseinfo-change-label

これなら、Label の変更だけ自動で受理し、origin / codename / suite / version の変更は従来どおり exit 100 で止める。良性で再発しているフィールドだけをピンポイントで緩め、なりすまし検知の本体は残す。実害の出ている一点だけを開ける、いわば最小 blast radius の緩め方です。この設計判断が、後半で効いてきます(失敗のフィールドを見れば危険度が切り分けられる、という同じ考え方が、後述の失敗トリアージの土台になります)。

3. その適用中に、executor が”無言で”止まった

ここから転びます。カタログにこのフラグを反映して配布する作業の途中、mars に「apache の状態を見て」「パッケージ索引を更新して」と投げたら、どちらも「投入済み・結果待ち」のまま返ってきません。90秒後に UI は「結果は Talk に届きます」という縮退表示に変わるのに、その Talk 通知すら来ない

ここで焦らず、詰まっている場所を一発で特定します。この基盤はキューが Redis 上にあるので、キューの長さを見るだけで犯人が分かります。

redis-cli -h 192.168.1.40 LLEN tasks:maintenance:mars        # 書き込みレーン → 1
redis-cli -h 192.168.1.40 LLEN tasks:maintenance:mars:read   # 読み取りレーン → 1
redis-cli -h 192.168.1.40 LLEN maintenance:results           # 結果 → 0

読み方はこうです。mars 宛のキューに 1 件ずつ溜まっているのに、結果キューは空。つまりタスクは投入されたが、誰も拾って実行していない。結果を集約する reaper は元気なのに、そもそも仕事が届いていない。犯人は mars 側の実行役です。確認すると――

Active: inactive (dead) since Mon 2026-07-20 09:46:01 JST
Main PID: ... (code=killed, signal=TERM)

mars の executor が、書き込みレーンも読み取りレーンも両方とも停止していました。しかも signal=TERM=クラッシュではなく正常停止。配布作業の流れのどこかで止まって、起動が戻り切っていなかったのです。起こしてやれば、溜まっていた 1 件ずつを即座に拾って実行し、結果が Talk に着弾しました。

復旧そのものは systemctl start 一発です。怖いのは復旧の手間ではなく、「動いていない」ことに誰も気づけなかったという事実の方。投入は成功する。UI は結果待ちになる。縮退もする。なのに、実行役が死んでいることはどこにも能動的に通知されない。「投入が成功した」と「実行が成功した」は、まったく別のことでした。

4. 直したはずが、今度は「sudo: パスワードが必要です」

executor を起こしたら、read 側(apache の状態)はきれいに done。ところが apt.update の方は、また failed で返ってきました。今度のエラーはこうです。

[maint] mars / apt.update -> failed (status=failed exit=1 reason=nonzero_exit)
sudo: パスワードが必要です

これはさっきの Label 問題(exit 100)ではありません。原因は版のズレでした。この基盤では、AI が root 権限で叩けるコマンドを sudoers に引数まで完全一致で列挙しています(カタログ・承認ポリシーと並ぶ「三冊目の台帳」)。私は先に sudoers を新形(--allow-releaseinfo-change-label 入り)へ置き換えてしまい、一方でカタログはまだ旧形のまま。executor が投げる旧コマンドが sudoers のどの行にも一致せず、sudo -n(パスワード無しで実行)が弾かれた――それが「パスワードが必要です」の正体です。

厄介なのは、この本当の原因(版ズレ)が、結果の分類上は exit=1・reason=nonzero_exit というのっぺりした顔でしか出てこないこと。sudo: という決定的な一行は stderr の中に埋もれていて、ぱっと見では Label 問題と区別がつきません。ここでも失敗が”静かに”なっていました。

直し方は簡単で、カタログを新形に揃えて版を一致させるだけ。揃えた瞬間、apt.update は exit 0・23.7 秒で完走し、ondrej PPA を含む全リポジトリの索引が取得され、Label 拒否も出なくなりました。

5. 三者を版で揃える、という地味だが効く規律

今回の失敗②は、要するに権限(sudoers)・設定(カタログ)・実行役(executor)の三者が、同じ版で揃っていなかったことに尽きます。引数一致で守っている以上、片方だけ先に変えると必ず窓(一時的な不整合)が空く。

正しい順番は、消費する側より先に権限を用意するconsumer-firstです。具体的には、sudoers に旧行と新行を一時的に併存させて(旧コマンドも新コマンドも両方通る状態にして)から、カタログを切り替え、動作確認できてから旧行を消す。こうすれば失敗の窓がゼロになります。今回はこの併存を省いて「置換」したせいで、一時的に失敗しました。手順書に書いておいた通りのことを、自分で踏んだわけです。

6. 次にやること: 失敗を”無音”にしない

この一日で、同じ性質の穴を三回踏みました。apt が黙って止まる、executor が黙って死ぬ、版ズレが原因を黙って隠す。共通しているのは「失敗が静かで、能動的に知らされない」という点です。分散システムでいちばん危ないのは、派手に落ちることではなく、静かに止まって誰も気づかないことです。

そこで次の投資先は二つ決めました。ひとつは能動監視――相互監視のキュー滞留チェックを定期実行し、mars 宛が捌けていなければ Talk に鳴らす。executor の沈黙を無音にしない。もうひとつは失敗の一次トリアージ――exit コードと stderr のパターンから、「これは良性の Label 変更(受理コマンドはこれ)」「これは権限・版ズレ」といった一次判断まで自動で出す。ここで効いてくるのが、第2節で選んだ「フィールドで危険度を切り分ける」という発想です。良性のパターンは決定論的な規則で捌き、規則で分からないものだけ、人が外部AIに問い合わせるための情報一式を吐き出す。実行出力を基盤の LLM に自動で食わせない(=プロンプトインジェクションの口を開けない)という安全設計は崩さないまま、”静かな失敗”に声を持たせる、という方針です。

次回はこのトリアージ機構の実装を書く予定です。


この記事は自作の家庭内分散AI基盤シリーズの第45回です。これまでの全記事はシリーズ索引からたどれます。