バックアップが「成功」したあと、復元できるとどう確かめるか
バックアップの画面は毎日緑色なのに、パソコンが壊れてから、ディレクトリーが一段抜けていたことに気づく。あるいは、復元先に前回の演習で使った古いファイルが残っていた。これは問題を説明するための仮定で、私が経験した障害ではありません。ただし、問うべきことは具体的です。処理が正常終了したあと、資料を取り戻せると信じるには、どんな証拠が必要でしょうか。
対象を、最後まで検証できる小さな資料集に絞りました。変更を止めた、通常のファイルとディレクトリーだけを含む資料です。Node.js で内容一覧を作り、一つのバックアップへコピーしたあと、実験用の元ディレクトリーを削除しました。そのバックアップから新しい場所に復元し、さらに意図的な差異を作って、検証が拒否するか確認します。
実験は 2026-09-28 に macOS、Node.js v22.23.1 で行いました。復元演習スクリプトの全文をダウンロードできます。スクリプトが作成・削除するのは、自分で生成した一時ディレクトリーだけです。架空の内容を使い、実際の資料のディレクトリーを引数として受け取りません。クラウドストレージ、ディスク障害、データベースの復元、ほかの OS は検証していません。
保存したディレクトリーで次のコマンドを実行します。各ケースが出力され、最後に All restore-drill assertions passed. と表示されます。破損させたケースの ok: false は想定した結果です。アサーションが想定と異なるか、操作そのものが失敗した場合にスクリプトが失敗します。
node backup-restore-drill.mjs
1. 「成功」を三つの検証可能な判断に分ける
コピー関数がエラーを投げなかったことは有用な証拠です。実行した操作が完了したとわかります。しかし、対象範囲を正しく選んだか、復元先に必要なファイルがすべて存在するかまではわかりません。最初から隠しファイルを対象に含めなければ、以後の操作は問題なく終了することがあります。
第二の証拠は、内容と構造の一致です。事前に保存した一覧と、バックアップや復元先から作り直した一覧を比較すれば、欠落、追加、バイトの変化を調べられます。デジタル保存では、内容が変わっていないかの確認を fixity チェックと呼び、チェックサムがよく使われます。Digital Preservation Coalition の説明を参照してください。
第三の証拠は、資料を利用できることです。バイトが完全に一致しても、元から壊れていたファイルかもしれません。開くためのソフトウェア、フォント、添付資料が不足していることもあります。米国議会図書館の個人デジタル資料の保存案内は、異なる場所にコピーを保つことと、ファイルを定期的に読めるか確認することの両方を挙げています。それぞれ別の役割を持つ確認です。
復元の報告には、コピーが完了したか、基準と一致したか、何を読めると確認したかを分けて記載したいと思います。範囲を示さず一つの「復元成功」にまとめないためです。この記事の自動検証は最初の二つを対象にし、自作のテキスト、JSON、バイナリーの一部を読み取るアサーションも行います。あらゆる文書形式を開けると証明したものではありません。
2. 対象範囲を決めてからダイジェストを計算する
実験の資料集には六つのファイルがあります。隠し設定ファイル、Markdown の下書き、名前に日本語と空白を含むテキスト、名前に改行を含むテキスト、四バイトのバイナリー、ゼロバイトのファイルです。ディレクトリーは assets、notes、空の empty の三つです。
この組み合わせは、抜けやすい約束を確認するために選びました。ゼロバイトでもファイルなので、内容がないという理由で省けません。空ディレクトリーも、今回は復元する構造に含めます。画面上で見える一覧だけでは隠しファイルの有無を判断できません。名前の空白や改行も、簡単な文字列分割でレコードの区切りにしてはいけません。
一覧には相対パスと型を記録し、ファイルにはバイト数と SHA-256 を追加します。ディレクトリーについては存在を記録します。所有者、権限、更新時刻、拡張属性、ハードリンクの関係は記録せず、復元も保証しません。それらに依存する資料なら、一覧と復元の検証を拡張する必要があります。検証した範囲も結果の一部です。 範囲が曖昧なままでは、「一致」の対象が定まりません。
import { createHash } from 'node:crypto';
import { lstat, readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
async function inventory(root) {
if (!(await lstat(root)).isDirectory()) throw new Error('Root must be a directory');
const entries = [];
async function walk(directory, relative = '') {
const names = (await readdir(directory)).sort();
for (const name of names) {
const full = path.join(directory, name);
const relativePath = relative ? `${relative}/${name}` : name;
const stat = await lstat(full);
if (stat.isDirectory()) {
entries.push({ path: relativePath, type: 'directory' });
await walk(full, relativePath);
} else if (stat.isFile()) {
const bytes = await readFile(full);
entries.push({ path: relativePath, type: 'file', bytes: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex') });
} else {
throw new Error(`Unsupported entry: ${JSON.stringify(relativePath)}`);
}
}
}
await walk(root);
return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
}
lstat でディレクトリー項目自体を調べ、シンボリックリンクや未対応の型ならエラーにします。黙って飛ばしたり、リンク先を自動的に対象へ加えたりしません。このルールはシンボリックリンクのケースで確認しました。ただし、型を調べてから読むコードであり、その間にディレクトリーを別の処理が置き換える競合を解決してはいません。前提は静止した実験用の資料集です。
ファイルは UTF-8 テキストへ変換せず、元のバイト列として読みます。そのためバイナリーにも同じ計算を使えます。update と digest の使い方は Node の Hash APIに説明があります。この実装は一度に一つのファイルをメモリーへ読み込むため、今回の小さな対象に向いています。大きなファイルの速度は測定しておらず、ストリーム読み取りや失敗処理は別途設計する必要があります。
3. 基準を先に残し、バックアップを検証する
最初に元ディレクトリーの一覧を生成し、バックアップの manifest.json に書きます。内容は同じ階層にある payload/ に置きます。一覧と内容を分けることで、一覧が自分自身を含めようとする循環を避けます。
外側には形式名 gumi-content-snapshot-v1、アルゴリズム名 sha256、作成時刻も記録します。形式名はフィールドの約束を示し、アルゴリズム名は後でダイジェストの意味を推測せずに済むようにします。作成時刻は一覧を作った記録であり、全ファイルがその瞬間に同時に存在した証明ではありません。ここでのバックアップは書き込みを止めたファイルの集合です。再帰的なコピーを、ファイルシステムの原子的なスナップショットとは扱っていません。
コピーには次のオプションを使います。スクリプトの全文では、まだ存在しないコピー先のパスを用意します。
await cp(source, payload, {
recursive: true,
force: false,
errorOnExist: true,
});
recursive はディレクトリーツリーのコピーを指定します。force: false と errorOnExist: true を組み合わせると、既存のコピー先項目に対してエラーになります。詳細は Node のファイルシステム APIにあります。ここでは一回の演習に新しいディレクトリーを使い、別の復元結果と混ぜない方針を表します。ただし、ツリー全体のトランザクションを保証する設定ではありません。一部をコピーした後で失敗しても、その回は失敗として扱います。
コピーが終わったら payload/ を改めて調べ、元の一覧と比較します。一致を確認してから復元へ進みます。検証対象の復元先から基準を作り直すと、壊れた状態が正しい基準になってしまいます。差異を見つけたあとで単にダイジェストを更新し、解決したと判断することもできません。更新が正当かどうかは、確認済みの意図的な変更だったかに依存します。
4. 比較するのはファイル集合と内容の両方
一覧にあるファイルだけを順番に調べれば、欠落や内容の変化はわかります。しかし、一覧にない古いファイルは見落とします。この実験では、一覧の項目がすべて存在し、実際のディレクトリーにも余分な項目がないという、厳密な一致を採用します。比較関数の全文です。
function compare(expectedEntries, actualEntries) {
const expected = new Map(expectedEntries.map(entry => [entry.path, entry]));
const actual = new Map(actualEntries.map(entry => [entry.path, entry]));
const issues = [];
for (const [name, before] of expected) {
const after = actual.get(name);
if (!after) issues.push({ kind: 'missing', path: name });
else if (before.type !== after.type) issues.push({ kind: 'type-changed', path: name });
else if (before.type === 'file' &&
(before.bytes !== after.bytes || before.sha256 !== after.sha256)) {
issues.push({ kind: 'content-changed', path: name });
}
}
for (const name of actual.keys()) {
if (!expected.has(name)) issues.push({ kind: 'unexpected', path: name });
}
return { ok: issues.length === 0, issues };
}
パスは文字列全体をキーとして扱い、一覧は JSON で保存します。改行はシリアライザーがエスケープします。「一行に一つのファイル名を書き、空白で分割する」といった方法でパスを再構築しません。名前を別の表記へ自動変換する処理もなく、比較のルールを対象範囲と揃えています。
この比較器が受け取るのは、スクリプトが自分で生成した、パスの重複がない一覧です。外部の一覧を完全に検証する実装ではありません。形式とアルゴリズムの確認、読み取りエラー、コピー失敗はスクリプトを停止させます。未対応のディレクトリー項目も走査を失敗させます。読み取りエラーを空配列に置き換えず、「確認できない」を「差異がない」と扱わないようにしています。
5. 元ディレクトリーが消えたあとの結果
バックアップを検証したあと、スクリプトは自分で作った元ディレクトリーを削除し、存在しないことを確認します。その後は、バックアップの内容と保存済みの一覧だけを使って、新しい一時ディレクトリーに復元します。小さな手順ですが、復元中に元の資料から不足を補ってしまう近道を排除できます。
今回の結果は次のとおりです。差異を作る各ケースは、変更していないバックアップから別のコピーを作ります。前のケースの破損が次のケースに影響しないようにするためです。
| ケース | 結果 | 理由 |
|---|---|---|
| バックアップと元の一覧を比較 | 一致 | 差異なし |
| 復元先ディレクトリーが既に存在 | コピーを拒否 | ERR_FS_CP_EEXIST |
| 元ディレクトリー削除後に正常復元 | 一致 | 差異なし |
# Draft を等長の # draft に変更 | 不一致 | content-changed |
ゼロバイトの zero.txt を削除 | 不一致 | missing |
old.txt を追加 | 不一致 | unexpected |
空ディレクトリー empty を削除 | 不一致 | missing |
| 下書きファイルを空ディレクトリーに置換 | 不一致 | type-changed |
シンボリックリンク shortcut を追加 | 走査を拒否 | 未対応の項目 |
同じ長さへの変更は大事なケースです。サイズだけなら変化を見落とします。保存済みのダイジェストと再計算した値が違うため、内容の変化を報告できました。空ディレクトリーの欠落も見落としやすい結果です。今回はディレクトリーも一覧に含めるので、「ファイルはすべて残っている」という確認の陰に隠れません。
正常復元後は Markdown の下書きを読み戻し、バイナリーのバイトを比較し、隠し設定ファイルの JSON を解析しました。これらのアサーションはすべて通りました。ただし、証明したのはこの自作資料集の期待した内容です。すべての写真、スプレッドシート、暗号化ファイルを利用できるとは示していません。実際の資料では、代表的な形式を選び、将来使う予定のソフトウェアで開く確認も必要です。
6. ダイジェストで差異はわかっても、復元は完結しない
検証器は問題のあるパスを報告しますが、なくなったバイトを作り出せません。復元には、別に確認済みの使えるコピーが必要です。この実験のバックアップ、元資料、復元先は、同じパソコンの一時領域にあります。手順の検証であって、機器の破損に備えた独立コピーは作っていません。三つのディレクトリーを、障害から隔離された三つの保存先とは数えられません。
ダイジェストの信頼性も、基準をどう保つかに依存します。内容と一覧が一緒に書き換われば、比較が一致する可能性があります。ここでは署名、独立した信頼記録、能動的な改ざんへの対策は提供していません。この限界は JSON の一覧だけの話ではありません。RFC 8493 の BagIt の紹介も、データ破損の検出と能動的な攻撃への耐性を区別しています。参照したのはこの限界であり、本例の独自形式は BagIt を実装していません。
既知のダイジェストは、既知の版とバイトが一致することを示します。正しい版を選んだとは示しません。コピー前からファイルが誤っていれば、基準はその誤りを正確に記録します。バックアップの対象と版、それを残す理由も理解する必要があります。その説明は一覧の外にある情報で、ハッシュ値から推論はできません。
7. 一度の演習を繰り返せる手順にする
自分のノート、写真の説明、プロジェクト資料に適用するなら、まず対象と除外項目を列挙し、どの内容の書き込みを止めるかを決め、版の説明を添えた一覧を保存します。変化を続けるデータには、一貫した状態を得られるエクスポートやスナップショットの仕組みが必要です。静止したディレクトリーでの今回の実験は、その代わりの証拠にはなりません。
復元のたびに独立した新しい場所を使い、バックアップの版、照合結果、差異のパス、読み取り確認、残った問題を記録します。復元に必要な説明、ソフトウェア、アクセス条件も探せるようにします。解凍方法を本人しか覚えていないコピーは、パソコンを替えたあとで使えなくなる可能性があります。手順書も資料と一緒に保ち、演習では実際にその手順を実行するべきです。
頻度は資料の更新速度や復元の必要性に応じて決めます。一つのブログ実験から、全員に共通する周期を導く必要はありません。むしろ、変更を契機に確認することが大切です。バックアップ範囲、保存先、暗号化方法、復元ツールを変えたときは、以前の復元の約束がまだ成り立つか調べる理由があります。ログは処理が動いた証拠であり、新しい演習結果が、変更後も資料を取り戻せるかを示します。
バックアップの価値は、完了できる復元にあります。何を保存するかを決め、コピーが一覧と一致するか確認し、元ディレクトリーに頼らず必要な内容を読み戻す。それぞれに再確認できる証拠を残すことで、「成功」に具体的な意味が生まれます。