NGW開発BLOG
← 記事一覧へ
·#074

52時間、止まっていたのは見張りのほうだったバックアップではなく、その周りを作り直した一日

朝、2台の画面が正反対のことを言っていた。片方は正常、片方は「52時間バックアップが止まっている」。追ってゆくと、バックアップは一度も止まっていなかった。止まっていたのは、それを見に行く先だった。その日、世代の預かりから監視アプリまで作って、最後にこう言われた。「十年後も正常に稼働しているためのバックアップシステムだと思う」。

朝である。

2台ぶんの画面を並べて開く。同じ仕組みを、同じ目盛りで、同じ時刻に見ているはずの2台が、まるきり正反対のことを言っていた。

CLONE   freshness  OK    最新 0h前
MAIN    freshness  CRIT  最新バックアップが52h前(閾値14h) — 停止の疑い

52時間。丸二日である。その二日のあいだ、この赤い文字は誰にも読まれずにそこにあった。読まれない警告は、鳴っていないのと同じだ。壁に向かって叫んでいる男を、通りすがりの誰も見ない。そういう二日間が、静かに過ぎていた。

まず決めねばならぬのは、どちらが嘘をついているかである。両方が正しいということはありえない。


どちらが嘘か

クローンに入って確かめる。当日12時30分のダンプが482メガバイトで残っており、gzip -t も通る。壊れていない。

つまり、バックアップは取れている。取れているのに、メインだけが「止まっている」と出している。

犯人はすぐに割れた。保管先であった。

MAIN の設定    WATCH_BACKUP_ROOT=/home/administrator/ngw-backup/data
CLONE の実際   BACKUP_ROOT=/mnt/ngw-backup/data

数日前、クローンのバックアップの保管先を外付けディスクへ移していた。移した当の機体の設定は、当然、正しく書き換わる。だが——「相手の保管先はここである」という写しを持っていたメインのほうは、誰にも知らされずに取り残された。

メインは、移設前のパスに残っていた古い状態ファイルを、二日間ずっと読み続けていたのである。そのファイルの更新は7月27日の朝で止まっている。だから52時間。数字は正確だった。指している先が間違っていただけだ。

バックアップは一度も止まっていない。止まっていたのは、それを見に行く先だった。

同じ穴から、もうひとつ出てきた。画面のディスク使用率が「22%」と出ている。これはクローンの / の値であって、本当に見るべき外付けディスクは40%だった。つまり、8テラバイトが満杯になっても、この監視は永久に気づかぬ構えでいた。番人が、蔵ではなく蔵の絵を見張っていたようなものである。


写しを持つな

直し方はふたつあった。

ひとつ、メインの設定を正しい値に書き換える。5秒で終わる。 ふたつ、メインが設定を持つのをやめる。

1番目を選べば、今日の午後は平穏に過ぎる。そして次に保管先を移した日に、また同じ赤い文字が二日間読まれずに立っていることになる。

採ったのは2番目だった。

# 監視のたびに、相手の ngw-backup.conf から BACKUP_ROOT を読み直す
rroot=$($CLONE_SSH "grep -m1 '^BACKUP_ROOT=' '$RKIT/ngw-backup.conf' | cut -d= -f2-")

相手のことは、相手に聞け。写しを持つな。

写しというのは、持った瞬間から古くなってゆく。持ち主はそれが古くなったことを知らない。知らないまま、その値を根拠に判断が下される。機械が誤った値を出すのは、たいていこの形をしている。壊れたのではない。更新されなかっただけだ。

実機で確かめた。設定をわざと古いパスに戻して走らせると、こう出る。

(監視先の保管先を対象機から取得: /home/administrator/ngw-backup/data → /mnt/ngw-backup/data)
[OK] freshness: 最新 0h前
[OK] disk: 使用率 40%      ← 22% ではなく、本当に見るべきほう

以後は自動で追従する。設定を直す人間を必要としない。それがこの直し方の値打ちである。


1.9キロバイトの壁

機能をひとつ足した。サーバへ配ろうとした。落ちた。

/bin/bash: Argument list too long

配布の仕組みはこうなっている。スクリプト9本と設定4本を base64 に潰して本文へ埋め込み、1本の長い長いシェルスクリプトを、コマンドの引数として相手へ差し出す。Linux には引数ひとつあたり 128キロバイト(MAX_ARG_STRLEN の上限がある。

測った。133,047 バイト。 上限は 131,072 バイト。

1.9キロバイトの超過であった。

その日足した機能のぶんが、ちょうど壁を越えさせたのである。あと一日早ければ通っていた。あと一本足していれば、もっと派手に落ちていた。境界というものはいつも、こういう間の悪さで訪れる。

=== 引数で渡す(従来) ===   /bin/bash: Argument list too long
=== 標準入力で渡す(新) ===  start / size=150000

渡すのは bash -s の8文字だけにして、本体は標準入力から流し込むことにした。標準入力に上限はない。増え続けるものを、上限のある道に載せてはならぬ。 当たり前である。当たり前だが、越える日まで誰も測らない。


戻せぬバックアップは、バックアップではない

添付ファイルが1個消えたとする。さて、どうやって戻すか。手順をたどって、青くなった。

  • 世代を展開する機能はある。画面にボタンもある
  • 本番へ書き戻す機能もある。ボタンがない
  • そもそも世代はクローンの外付けディスクにあり、戻したい相手はメインである

つまり、バックアップは確かに存在する。存在するのに、画面だけでは指一本触れられない。金庫はある。鍵もある。だが金庫は隣の建物にあり、鍵は郵便で送られてくる、という状態だった。

このプロジェクトには一行の掟がある。「手順書に『scpして実行』と書きそうになったら実装漏れのサイン」。まさにそれであった。

作ったのは全自動の復元ではない。取り出すだけの機能である。

世代を開き、フォルダを潜り、ファイル名の一部で全世代を横断して探す。「どの世代になら、まだ残っているか」を出す。目的の1個を、手元へ落とす。それだけ。

📄 shared_files/400/1000/files/15755_dummy1_54.pdf   43KB
   残っている世代(新しい順)  [⬇ daily/Tue] [⬇ daily/Mon] [⬇ weekly/20260724] …

本番への書き戻しは、意図してボタンにしなかった。上書きというやつは、慌てているときほど押される。 火事場で人は、消火器ではなく手近な布をつかむ。取り出すだけなら、どう間違えても何も壊れない。


複製は、事故もまた複製する

ここで根っこの問いに行き当たった。メインに世代バックアップは要るのか。

この構成が抱えているものを、性質で割ってみる。

実体役割
レプリカDBいまの複製メインが死んだとき代わりに動く
世代バックアップ過去の姿過去へ戻る

そして、ぞっとする事実に行き着いた。

誤削除は、レプリケーションが健全であればあるほど、確実にクローンへ届く。

複製は正確である。正確だから、事故も正確に複製する。消したという事実が、そのまま、瞬時に伝わる。だから複製は事故から救わない。救うのは世代だけだ。

その世代は、クローンに繋いだ外付けSSD1台のなかにしかなかった。

  • いまのデータの複製は2つある(本番DB・レプリカ)
  • 過去へ戻れる履歴は、1つしかない

その1台が壊れた日、時間軸の保険は消える。二重化されているという安心の裏で、片側だけが無防備なまま残っていた。


一本では足りぬ

そこで作ったのが「預かり」である。メインは mysqldump を一切走らせない。走らせず、クローンが取ったものを毎晩受け取るだけ。

書きながら、最初の案の穴に気づいた。最新の1本だけを預かって上書きしてゆくと、事故に気づくのが遅れた場合、手元に残るのは「事故のあとのデータ」になる。

丁寧に、正確に、毎晩、事故を運んでくることになる。

だから2本にした。

日次の最新1本   … 昨日へ戻る
週次の最新1本   … 一週間前へ戻る
添付の最新1世代 … 昨夜の添付

締めて1.4ギガバイト。実測47秒。二度目からは中身が同じなら運ばない。

罠がひとつ、目立たない形で残っていた。世代ファイルは曜日とスロットで名前を使い回す。ゆえに預かり品と、自分で取った世代が、同じ名前になりうる。名前だけを渡せば、復元スクリプトは自分の db/ を先に覗き、古いほうを黙って復元する。同姓同名の別人を、確かめずに取り出すのと同じことになる。コンソールからは絶対パスで指すことにした。


消してよいものより、消してはならぬもの

掃除の機能も作った。運用を続けると、不要なものが溜まる。設定の控え、古いスクリプト、ログ、作業の残骸、役目を終えた世代。現地で rm を打たせるのは危ない。1文字の打ち間違いが、取り返しのつかぬ形で効く。

メインを測った。22ギガバイトあった。クローンへバックアップを移す前の、自前の世代がそっくり残っていた。添付だけで14ギガ。

前: 54G 使用 / 186G 空き
後: 32G 使用 / 208G 空き

この手の機能で難しいのは、消してよいものを決めることではない。消してはならぬものを決めることである。

  • MySQL のデータと binlog(消せばレプリケーションが継がれない)
  • クローンから預かった世代(メインが持つ唯一の履歴)
  • 現に定時バックアップを取っている機の、現役の世代
  • 現地で書き換えられた設定の本体(消してよいのは .bak.new だけ)

3番目で、危うくやった。

クローンの画面に、こう出たのである。

⚠ この機が自分で取ったバックアップ世代   21.9GB・20件
   この機は定時バックアップを取っていません

取っている。cron に4行ある。現役の世代21.9ギガバイトが、削除候補として画面に並んでいた。

犯人は自分の書いた一行だった。

echo "backupcron\t1\t0\t"      # → backupcron\t1\t0\t (ただの文字)
printf 'backupcron\t1\t0\t\n'  # → backupcron⇥1⇥0⇥   (本物のタブ)

echo は多くのシェルで \t をタブにしない。区切りのない一列として届き、解析はその行を丸ごと捨て、判定は初期値の「取っていない」のまま残った。

わずか4文字の書式の違いが、21.9ギガバイトの生殺与奪を握っていた。機械のなかでは、こういうことが普通に起きる。

直したのは2箇所である。printf にしたこと。そしてもうひとつ——判定できなかったときに、危険なほうへ倒れないようにした。 行が欠けようと壊れようと、世代の削除は選択肢に出ない。

わからぬときは、手を出さぬ。人間なら誰でもやることを、機械にもさせる。


音の鳴らぬ事務所で

戻す手段は揃った。だが冒頭の問いが、まだ立ったままである。

52時間、なぜ誰も気づかなかったのか。

通知は実装されていない。LGWAN という閉じた網のなかでは、外の監視サービスへ出せず、雲の上へ逃がすこともできない。メールもサーバ次第。これは怠慢ではなく、環境そのものの形である。 言い訳としてではなく、設計条件として扱わねばならない。

相談の途中で、こういう言葉が出た。

「マシンが壊れたら通知もできないけどね。接続できません、が出て、結局後から気づくもんね」

これが核心そのものであった。

壊れた機械は、自分の死を報告できない。

死人に口なし。だから答えは「通知を足す」の前にある。死んだほうではない誰かが、それを知っていること。 そこから始めねばならなかった。

そして、もうひとつ。

「このコンソールは、基本的にサーバーにそれぞれ置きます。相互検知のような機能があっても全部をモニターするのは難儀です。いっそのこと、別の小さなアプリがあれば良くないですか」

そのとおりであった。運用の道具と、見張りの道具は、置き場所の要求がちがう。


見張りは、外に置く

監視アプリに、3つの条件を課した。

1. 3台目で動くこと。 見張られる2台のどちらかに置けば、その機体と一緒に死ぬ。番人を蔵のなかに寝かせてはならない。職員のWindowsかMacの端末で動かす。

2. 壊せぬ作りであること。 運用コンソールには復元も削除も切替もある。それを職員の机に置くのは剣呑である。別の実行ファイルにして、サーバを操作するコードを1行も取り込まない。壊す手立てが中に入っていなければ、構造として何もできない。テストで縛った。

forbidden := []string{"internal/ngwops", "internal/sshx", ...}
// import した瞬間にテストが落ちる

3. 常に見えていること。 メールの使えぬ閉域において、通知とは結局「誰かの視界に入っている画面」のことである。

機能を決める段で、こういう返事が来た。

「色+履歴。事務所内では、音は基本的にミュートです」

現場の事実で決まるのが、いちばん良い。 音が使えぬなら、色は遠目に効く面積で置く。そして「いつ落ちて、いつ戻ったか」を履歴に残す。

認証情報は持たせなかった。各機の見張りが状態を JSON で公開し、監視アプリはそれを HTTP で読むだけにした。鍵も合言葉も要らない。読む以外の道が、そもそも無い。


動かした直後に、二つ壊れた

両機に仕込んで確かめると、クローンだけが 404 を返した。

すぐ割れた。メインが5分ごとに webroot を --delete 付きで鏡写しにしている。クローンが自分で書いた状態ファイルを、メインの同期が消していた。

これは設計の段で予想して、手当てを入れてあった。ただし設定ファイルではなく、スクリプトのなかに書いた。設定は現地で温存されるから、更新が届かぬのである。新しい同期を入れて回すと、200 のまま生き残った。予想が当たったことより、当たると見て先に手を打ってあったことのほうが効いた。

もうひとつ、履歴に誤りが残った。

07/29 17:47  NGWクローン  応答なし → 正常

クローンはずっと動いていた。起動直後の1回目の取得が失敗し、2回目で成功した。それだけである。一度の取りこぼしを「落ちた」と記録していた。

直した。失敗したら2秒待って、必ずもう一度取りに行く。二度とも駄目なときだけ「応答なし」とする。

記録にゴミが混じると、本当に落ちた日の記録まで信用されなくなる。 狼が来たと3度叫べば、4度目の狼は誰にも見えない。その日ずっと話していたことと、同じ話であった。


正常を、警告の色で塗るな

似た指摘を、もうひとつ受けた。クローンの画面で、唯一の黄色がこれだった。

MySQL  [読取専用]  書き込みできません

クローンにおいて、読取専用は正常な姿である。 書けるほうが異常なのだ。二重本番という、いちばん厄介な事故の入口になる。

「クローンで唯一のイエロー=正常。クローンは読取専用が正常です、のように表示しませんか」

調べると、サーバ側の判定は正しかった。「メインなのに読取専用」と「クローンなのに書込可」の2つだけを警告に上げ、待機中の読取専用は何も言っていない。画面の色づけだけが、役割を見ていなかった。

値そのものに善悪はない。役割と組み合わさって、はじめて意味が生まれる。

無人再起動の時刻を決める段でも、同じ考えが効いた。2台をずらすのは同時に落とさぬためだが、決め手は間隔ではなかった。メインの見張りは15分刻み(3:00 / 3:15 / 3:30)で走る。クローンを 3:20 にすれば、次の点検まで10分しかない。復帰が遅れた晩には、毎回 CRIT が残る。

3:16 にした。 :15 の直後に始めれば、次まで約14分ある。

たった4分。だがこの4分が、10年ぶんの信用を分ける。毎晩鳴る警報は、ひと月で誰も見なくなる。


何が起きたら、何で戻すか

NGWバックアップの全体図。左にメイン(Mac mini と LinuxMain)、中央にクローン(Mac mini と LinuxClone)、右にクローンのMacへ繋いだ外付けSSD上のイメージ NGW_BACKUP が並ぶ。メインからクローンへは MySQL レプリケーション(常時)と添付ファイルの rsync(5分毎)、クローンから外付けSSDへは mysqldump とスナップショット(1日4回)、外付けSSDからメインへは最新分の預かり(毎日01:10)が向かう。メインからクローンへの監視と、クローン自身の監視がそれぞれ15分毎。2台の外側、上部には3台目の監視端末 NGW MONITOR が置かれ、両機の status.json を HTTP で読むだけの点線でつながっている

その日の終わりの姿。太い線はデータが増える経路、点線は監視(データは動かない)。左下の keep がメインの預かり、上の NGW MONITOR が3台目の見張りで、どちらもこの日に足したものだ。バックアップ本体(クローン → 外付けSSD)だけが、朝から変わっていない。

日暮れに、復旧の手立てを1枚の表にした。作った機能を並べるのではなく、事故の側から引ける形にしたかった。人は、機能の名で困らない。事故で困る。

起きたこと使うものどこまで戻るか
🔴 メインが停止したクローンを昇格(promote)停止の直前まで(レプリカが持っている)
🔴 DBを壊した・誤って削除した世代から復元最大6時間前(1日4回の直近)
🟠 添付ファイルを1個消した世代の中を検索して取り出す7日前まで/週次で3週前
🟠 クローンの機体が壊れた外付けSSDを別の機械へ繋ぐ履歴はすべて無事
🟠 外付けSSDが壊れたメインの預かり 1.4GB昨日 / 一週間前
🟢 止まっていないか知りたいwatchdog(15分毎)freshness は14時間で警報
🔴 火災・盗難で2台とも失われた戻せない

書いていて、大事なのは行の中身ではないと気づいた。事故の種類で、使うものが入れ替わる——この一点である。

メインが死んだならレプリカで戻る。世代は関係ない。データを壊したならレプリカは役に立たぬ。事故の複製を抱えているだけだからだ。添付が1個消えたなら、DBの世代は無縁である。添付は別の道を通って運ばれている。

「バックアップはありますか」と問われたとき、「何のバックアップですか」と問い返せる。この表が手に入れたのは、それだった。

そして表には、書きたくない行がひとつある。いちばん下だ。


完全ではない

一日の終わりに、こういう言葉があった。

「これで完全ではないが、想定しうるトラブルには対応可能になった」

そのとおりだと思う。戻せぬ場合は残っている。

火災、盗難、水損。2台とも失われたら復旧できない。2台は同じ建物にあり、外付けSSDもその場にある。履歴の実体は2箇所になったが、同じ場所の2箇所である。閉域網では雲へ逃がせぬから、残る手は物理的な持ち出ししかない。

それは「やらない」と決めた。未着手ではなく、承知のうえの割り切りである。

だから記録にはこう書いた。「未着手ではなく、承知のうえの割り切り。方針が変わったら、持ち出し運用の設計から始めること」。

十年後の誰かがこれを読んだとき、何も書かれていなければ、それは「抜け」と読まれる。 抜けだと思えば慌てて埋めようとするか、あるいは前任者を無能と結論するか、どちらかだ。どちらも正しくない。

決めたことと、決めなかったことの区別が残っていること。 引き継ぎの実体は、たぶんそれだけである。


十年後の、見も知らぬ誰かのために

すべて終えてから、こういう言葉をもらった。

「十年後も正常に稼働しているためのバックアップシステムだと思う。NGWを次世代に引き継ぐ準備とでもいうべきか」

言われて、その日の判断を並べ直してみた。ほとんどが、明日のためではなく、十年後の誰かのためのものだった。

行政の文書管理システムは長生きする。十年のあいだに Mac mini は2度か3度替わる。担当者も替わる。作った人間は、いなくなる。そのとき残っているのは、動いているコードと、書いてある理由だけだ。

手順書は劣化する。ボタンは劣化しない。 手順書は書いた瞬間から古くなってゆく。三年後にそれを開いた人は、書いてあるとおりに打ち、動かず、そこで立ち止まる。ボタンは、動かなくなればテストが落ちる。壊れたことに気づける形で壊れる。 これが決定的な違いである。

覚えているものは古くなる。聞きに行くものは古くならない。 52時間の一件が教えたのは、それだった。「あっちの設定も直さねば」と覚えている人間を必要とする設計は、その人がいなくなった日に壊れる。

理由をコードに埋める。 その日書いたコメントには、事故がそのまま彫ってある。

# ★ echo は \t をタブにしない(実機で判定行が解析できず、現役の世代を
#   「消してよい」と表示した)。区切りを出すときは必ず printf を使う。

十年後にこれを読む者は、なぜこんな回りくどい書き方をするのかと訝るだろう。理由が書いていなければ、たぶん「簡潔に」直される。そして同じ事故が起きる。簡潔さは、しばしば記憶の抹殺である。

テストにも同じ役を負わせた。監視アプリの forbidden は、正しさのテストではない。判断のテストである。将来の誰かが便利のために依存を足せば、テストが落ち、理由を読むことになる。落ちることが説明になる。

そして——機体は替わる。役割は入れ替わる。 だからスクリプトはメインにもクローンにも同じものを全部置き、差をつけるのは設定と cron だけにした。役割ごとに違うものを配れば、入れ替わるたびに配り直しになり、いつか誰かが必ず配り忘れる。


バックアップは、長期保存ではない

十年の話をするなら、書いておかねばならぬことがある。

この仕組みで戻れるのは、日次で七日、週次で九十日までだ。

行政文書には保存年限がある。永年、三十年、十年、五年。だがそれはデータベースのなかの話であって、バックアップ世代の話ではない。十年前の文書を見たいなら、それは十年間データベースに在り続けているべきもので、バックアップから掘り出すものではない。

混同すると、こういう誤解が生まれる。「バックアップがあるから、十年前のデータも戻せる」。

戻せない。 バックアップは事故から戻る仕掛けであって、時を遡る仕掛けではない。

長期保存に効くのは、むしろ別のところだ。データが壊れずに増え続けること。器が足りなくなったら広げられること(この日、60ギガを128ギガへ通した)。そして十年後も同じ画面で操作できること。

その日やったことの多くは、実はそこに効く。事故から戻る話をしていたつもりで、十年動き続ける話をしていた。


バックアップのコードは、一行も書かなかった

最後に、その日の差分を数えて、少し愕然とした。

バックアップを取るコード(ngw-backup.sh)は、1行も変わっていない。 最終更新は十日前のままだった。ダンプを取り、gzip で固め、検証し、世代を回す——朝から晩まで、そこには指一本触れていない。

ngw-watchdog.sh    +67   相手の保管先を聞きに行くようにした
ngw-keep.sh       +208   預かる仕組みを足した
ngw-restore.sh     +11   預かった世代からも戻せるようにした
ngw-sync.sh         +6   状態ファイルを消さないようにした
watchdog.conf       +5
ngw-backup.sh        0   ← バックアップ本体

そしてその日いちばん長く時間を使ったものは、この一覧にすら載っていない。画面と、監視アプリと、テストと、図と、用語である。

朝の時点で、バックアップは正常に動いていた。52時間、ずっと動いていた。止まっていたのは、それを見に行く先であり、戻るための道であり、気づくための仕掛けだった。

だから、その日やったことをひとことで言うなら、こうなる。

バックアップのコードを書いたのではない。仕組みを作り直したのだ。

バックアップが動いているかどうかと、バックアップシステムが機能しているかどうかは、まったく別のことであった。前者は十日前から動いており、後者はその日まで穴だらけだった。

十年後も回り続けるのは、たぶん mysqldump を呼ぶあの数行のほうだろう。先に朽ちるのは、その周りにある「気づく・辿り着く・決める・記録する」のほうだ。


AIとの協働作業での学び

その日いちばん面白かったのは、どちらか一方が正しい場面が、一度も無かったことである。

人間の側が見つけたもの。図に書いた「1.4GB / 23GB / 128GB」を見て、それは今日の実測値であって理論値ではないと自ら気づいたこと。配布先ごとにデータ量が違うのだから、器は式から見積もらねばならぬ、と。構成図の ngw-serverserver という妙な連結を見咎めたこと。クローンの唯一の黄色を見て「読取専用が正常です、と表示しませんか」と言ったこと。

AI の側が落としたもの。sort -t'\t' と書いて実機に拒まれたこと。echo のタブで現役の世代を削除候補に出したこと。tail -1 で出力を切り、71メガ減っているのに「freed 0B」とだけ表示したこと。そして——イメージ拡張で無人運用を止め忘れたこと。

最後のひとつは、こういう一言で露見した。

「その前に、無人再起動も解除する必要はあるか?」

必要だった。しかも自動で止めるべきだった。 2分ごとの見張りが停止中のVMを起こすので、書き込み中のイメージが壊れる作りだった。コピー機能ではきちんと止めているのに、拡張では抜けていた。この一言がなければ、そのまま配って壊していた。

同じ日、逆のことも起きた。クローンの掃除画面に21.9ギガバイトが削除候補として並び、「次はこれを消します」と言われて、AIの側が止めた。

待ってください。押さないでください。

現役の世代だった。消せば、過去へ戻る術が世界から消える。「取っていません」という表示のほうが嘘で、cron には四行あった。

そのあと、こういうやり取りがあった。「この環境は完全に開発環境なので、壊れても作り直せばいい。試験は全て実施します」。それでも、この試験だけは「成功させる試験」ではなく「拒否されることを確認する試験」だと答えた。

止める側と止められる側が、その都度、入れ替わる。危ないところは、だいたい相手が先に見つけた。 一日を通じて、そうだった。

道具は、使う者を疑うべきときがある。使う者は、道具を疑うべきときがある。その両方が働いた日は、たいてい何も壊れずに終わる。

その日は、何も壊れずに終わった。