技術ログ

バックアップは毎日成功していた。一度も復元したことがなかった

公開: 2026-08-17 · 著者: GRAMSHIFT

「取れた」と「戻せる」は別の状態 — 復元訓練を自動化して、緑の意味を作り直す

バックアップは毎日成功していた。一度も復元したことがなかった

個人開発を続けていると、バックアップは「毎日成功しています」という緑のチェックマークに変わります。私の環境でも、毎晩3時に自動でバックアップが走り、完了通知が届き、失敗した日は一度もありませんでした。ところがある日、自分に一つ質問してみて血の気が引きました。「このバックアップから、実際に復元したことは何回ある?」 答えはゼロ回でした。この記事は、その穴を埋めるために「復元訓練」を自動化した実装記録です。

「取れた」と「戻せる」は別の状態である

私のバックアップは2系統あります。1つはソース一式をプライベートリポジトリへ push するミラー。もう1つは、失うと作り直せないもの (メモ・設定・アプリの署名鍵) だけを暗号化して、非公開のチャットチャンネルへ毎日添付で送るバンドルです。どちらも毎日成功していました。

しかし、この2つが証明しているのは「送信に成功した」までで、「中身が正しい」でも「開ける」でもありません。実際、私はこの1か月で、緑のまま壊れていた事故を2件踏んでいます。

1件目。暗号化バンドルに入れる署名鍵の収集先が、コードにハードコードされたパス3つ分でした。アプリを追加するたびに鍵は増えるのに、収集対象は増えません。気づいたときには10本中7本の鍵がバックアップに入っていませんでした。うち5本はストアに公開済みで、PCが飛べば二度と更新できないアプリになるところでした。その間もバックアップは毎日「成功」しています。

2件目。ミラーの push が7日連続で失敗していました。原因は、あとから追加した100MB超のバイナリがホスティング側の上限に当たっていたこと。ところがスクリプトは片方が失敗しても「完了」と表示して終了コード0を返す作りで、失敗が失敗として見えませんでした。暗号化バンドルの方は成功し続けていたので、通知だけ見ていると正常そのものです。

復元訓練を自動化する — 「実際に開ける」ところまでやる

そこで、毎月1回、実際にバックアップを復号して展開し、中身を数えるジョブを書きました。バックアップの検査ではなく、復元の検査です。手順はこうです。

  1. 最新のバンドルを生成し直す (送信はしない)
  2. 保管してある鍵で実際に復号する
  3. 展開して中身を数える
  4. ミラー側が止まっていないかを、ホスティングのAPIで最新コミット日時から確認する
  5. 隔離フォルダと生成物を消す

暗号化は openssl 互換の AES-256-CBC + PBKDF2 (SHA-256・10万回) です。ここで意図的に外部コマンドへ依存させませんでした。復元が必要な場面は「新しいPCに何も入っていない」場面だからです。Node の標準モジュールだけで復号できるようにしておけば、openssl が無い環境でも開けます。

import crypto from 'node:crypto';

// openssl 互換フォーマット: "Salted__" + salt(8) + 本体
function opensslDecrypt(buf, password) {
  if (buf.slice(0, 8).toString('ascii') !== 'Salted__') {
    throw new Error('openssl形式ではない');
  }
  const salt = buf.slice(8, 16);
  const keyiv = crypto.pbkdf2Sync(Buffer.from(password, 'utf-8'), salt, 100000, 48, 'sha256');
  const d = crypto.createDecipheriv('aes-256-cbc', keyiv.slice(0, 32), keyiv.slice(32, 48));
  return Buffer.concat([d.update(buf.slice(16)), d.final()]);  // 鍵が違えばここで必ず例外
}

「何件あればOKか」を先に決める

復号できただけでは足りません。空のzipでも復号は成功するからです。そこで合格条件を数値で定義し、下回ったら赤にしました。判定はこういう純粋関数に切り出してあります。

export function judgeBundle(inv) {
  const ng = [];
  if (inv.memoryFiles < 50) ng.push('メモが少なすぎる (収集漏れの疑い)');
  if (!inv.hasIndex) ng.push('索引ファイルが無い');
  if (!inv.hasSettings) ng.push('設定ファイルが無い');
  if (inv.hasSettings && !inv.settingsValid) ng.push('設定ファイルがJSONとして壊れている');
  if (!inv.hasRecovery) ng.push('復元手順が入っていない');
  // 過去の事故の再発検知: 公開しているアプリの数だけ署名鍵が要る
  if (inv.keystores < inv.publishedApps) ng.push('署名鍵が公開アプリ数より少ない');
  return ng;
}

最後の1行が、さきほどの「鍵が7本足りない」事故の再発検知です。事故は必ず条件式に翻訳して残す。そうしないと、同じ穴に人はもう一度落ちます。

検査自体が壊れていないかを検査する

ここが今回いちばん大事だった部分です。「常に緑を返す検査」は、無いより有害です。安心を配りながら何も見ていないからです。そこで判定関数に対して、わざと壊した版を作って赤くなることを確かめました (ミューテーションテスト)。

  • 合格条件の閾値を外す → 赤くなるか
  • 境界値 (鍵の数 = 公開アプリ数ちょうど) → 緑のままか
  • 判定を常に空配列にする → 赤くなるか

加えて、復号そのものにも対照実験を入れました。正しい鍵で開けること、そして違う鍵では必ず失敗することの両方を検査します。後者が無いと「開けた」という結果に意味がありません。何を入れても開くコードかもしれないからです。

let ok1 = false, ok2 = false;
try { ok1 = opensslDecrypt(enc, 'right-pass').equals(sample); } catch {}
try { opensslDecrypt(enc, 'wrong-pass'); } catch { ok2 = true; }  // 失敗するのが正常

初回の結果と、Windowsで踏んだ罠

初回の訓練はこう出ました。メモ495件、署名鍵18件、設定ファイルはJSONとして読める、復元手順あり、合計519ファイル・2.43MB。ミラー側も当日のコミットが確認できました。ここでようやく「バックアップがある」と言える状態になりました。

実装中、Windows特有の罠も1つ踏みました。判定後に process.exit() を呼ぶと、fetch (undici) のソケットが後片付け中に落ちて、libuv がアサーションで異常終了します。中身は正常に完了しているのに終了コードが127になり、スケジューラ上は失敗した扱いになる。監視ジョブとしては最悪の挙動です。ラベル付きブロックで抜けて process.exitCode を置くだけにしたら解決しました。

main: {
  if (nothingToDo) break main;          // process.exit() を使わない
  // ...
  process.exitCode = failed ? 1 : 0;    // 自然に終わらせる
}

まとめ: 監視は「成果物」で測る

今回の教訓は、バックアップに限らず自動化全般に効きます。

  • 終了コード0は「動いた」証明ではない。ログ本文と成果物まで見る
  • バックアップは復元して初めてバックアップ。復元していないものは「たぶん戻せるファイル」でしかない
  • 事故は条件式にして残す。文章の反省文は次に効かないが、赤くなる検査は効く
  • 検査は必ず壊して、赤くなるのを見る。一度も赤くならない検査は、緑の意味がない

ちなみに、復元の手順書そのものもバンドルの中に同梱してあります。新しいPCで最初に読むのは私ではなく、状況を知らない自分か、AIアシスタントだからです。手順書を安全な場所に置いても、その場所がPCの中なら意味がない — これも当たり前のようで、実際にやってみるまで気づけないことでした。

よくある質問

バックアップの復元テストはどのくらいの頻度でやるべき?

対象の変化速度に合わせるのが基本です。私は毎月1回にしました。中身 (メモ・設定・署名鍵) が日々増えるため、半年に1回だと「増えた分が入っていない」に気づくのが遅すぎるからです。逆に毎日回すと生成と復号のコストが無駄なので、月1で十分でした。重要なのは頻度そのものより、復元が自動で走り、失敗したときだけ通知が飛ぶ形にしておくことです。

復号に openssl コマンドを使わず自前実装したのはなぜ?

復元が必要になる場面は「新しいPCにまだ何も入っていない」場面だからです。openssl 前提にすると、その1コマンドのために環境構築が要ります。openssl 互換のフォーマット (Salted__ + salt + AES-256-CBC、鍵は PBKDF2/SHA-256) は Node の標準 crypto だけで再現できるので、ランタイムさえあれば開けるようにしておくと復元の初速が上がります。もちろん openssl が使える環境なら1行コマンドでも開けます。

「検査が常に緑を返す」問題はどう防いだ?

判定を純粋関数に切り出し、わざと壊した版を作って赤くなることを実測しました (ミューテーションテスト)。閾値を外す・境界値・判定を空にする、の3系統です。さらに復号については「正しい鍵で開けること」と「違う鍵では必ず失敗すること」を対で検査しました。後者が無いと、何を入れても開くコードでも緑になってしまい、成功したという結果に情報がありません。