テクノロジー · 実用ガイド

Resticでpack欠損が出たら:リポジトリ変更前に修復台帳を作る

証拠を保全し、resticが記録したcheck結果を分類してから、indexまたはsnapshotを直す前に最小影響の次手を決めます。

保護されたbackup repositoryから読み取り専用修復台帳へ流れ、変更gateが閉じているオリジナル概念イラスト。
オリジナル概念イラスト:修復変更を承認する前にrepository evidenceを保存しcheck結果を分類します。 Watch at Ease

resticのpack欠損や破損は、単なるcleanup警告ではありません。完全なcheckで否定されるまでrestoreは失敗し得て、新しいsnapshotも安全に戻せるとは限りません。定期backup、forget、pruneを止め、原出力を保存し、修復前にrepository全体、最低でもindexとsnapshotsを複製します。

このガイドは、公式の`restic check --json` summary、別保存したstderr、snapshot repairのdry-runから読み取り専用の修復台帳を作ります。付属parserはrepositoryを開かず、resticを実行せず、削除も変更承認もしません。

台帳はbroken pack、index修復提案、その他の未解決error、任意cleanup、clean summaryを区別します。それでも、使用中versionが実際に表示した指示に従い、backendやhardware原因を調べ、代表restoreで回復性を証明する必要があります。

1. 変更を止め、最初の証拠を保存する

対象repositoryの定期backup、forget、pruneを無効化します。分類が終わるまで、失敗したpruneの続行、packの手動削除、index再構築、使用中lockの解除、snapshot repair開始をしません。lock errorと確認済みstale lockは別問題です。

正確なrestic version、command、exit status、stdout、stderrを保存します。最初の失敗には後続commandで消えるpack IDやbackend errorが含まれ得ます。公開前にrepository URL、username、pathを伏せ、password、token、cloud credentialを出してはいけません。

可能ならrepository全体を複製します。公式の最低fallbackはindexとsnapshots folderの複製です。別repositoryへの新規backupは運用圧力を下げますが、破損repositoryの修復証明にはなりません。

2. stderrを混ぜずにmachine summaryを取る

既存の安全な方法でrepositoryとpasswordを設定し、費用と時間に合うcheckを選びます。default checkは構造を検証し、`--read-data`は全packを読むため時間とbandwidthを要します。subset checkは保守には有用でも、incident closeの完全coverageには代えられません。

公式JSON interfaceはfinal summaryをstdout、error objectをstderrに出すため、両方を別保存します。次は単純なPOSIX shell例です。exit code行はcheck直後でなければなりません。

```sh restic version > restic-version.txt restic check --json > check-summary.json 2> check-errors.jsonl printf '%s\n' "$?" > check-exit.txt ```

`check-summary.json`が完全なsummary objectを1つ含むことを確認します。summaryだけで足りそうでも`check-errors.jsonl`を残します。free-form messageは変わり得るので、automationはdocumented fieldで分岐し、人向けには原error evidenceを保持します。

3. 読み取り専用の判断台帳を作る

以下の全コードを `restic_check_ledger.py` として保存します。保存済み概要だけを読み、最終パス要素がシンボリックリンクや通常ファイルでない入力を拒否します。最大1 MiBと検出用1バイトを読み、超過を拒否。明示的にUTF-8でデコードし、重複JSONキーも拒否します。認証情報を読まず、バックエンドに接続せず、証拠やリポジトリを変更しません。

```python #!/usr/bin/env python3 """Validate a saved `restic check --json` summary and print a read-only ledger.""" import argparse import json import re import sys from pathlib import Path PACK_ID = re.compile(r"^[0-9a-f]{64}$") def unique_object(pairs): result = {} for key, value in pairs: if key in result: raise ValueError("duplicate JSON property") result[key] = value return result def load_summary(path: Path) -> dict: if not path.is_file() or path.is_symlink(): raise ValueError("input must be a regular file, not a symlink") with path.open("rb") as stream: raw = stream.read(1024 * 1024 + 1) if len(raw) > 1024 * 1024: raise ValueError("input exceeds the 1 MiB audit limit") try: value = json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError("input is not one complete UTF-8 JSON document") from exc if not isinstance(value, dict) or value.get("message_type") != "summary": raise ValueError("requires the stdout summary object from restic check --json") errors = value.get("num_errors") if "broken_packs" not in value: raise ValueError("broken_packs is required, including when it is null") packs = value["broken_packs"] if packs is None: packs = [] # Restic v0.19.1 encodes an empty Go slice as JSON null. index = value.get("suggest_repair_index") prune = value.get("suggest_prune") if type(errors) is not int or errors < 0: raise ValueError("num_errors must be a nonnegative integer") if not isinstance(packs, list) or any( not isinstance(item, str) or not PACK_ID.fullmatch(item) for item in packs ): raise ValueError("broken_packs must contain complete lowercase pack IDs") if len(packs) != len(set(packs)): raise ValueError("broken_packs contains duplicates") if type(index) is not bool or type(prune) is not bool: raise ValueError("repair suggestions must be booleans") return { "numErrors": errors, "brokenPacks": packs, "suggestRepairIndex": index, "suggestPrune": prune, } def classify(summary: dict) -> dict: if summary["brokenPacks"]: status = "BROKEN_PACKS" next_step = "follow the exact restic check instruction for repair packs" elif summary["suggestRepairIndex"]: status = "INDEX_REPAIR_SUGGESTED" next_step = "follow the exact restic check instruction for repair index" elif summary["numErrors"]: status = "OTHER_ERRORS" next_step = "stop and review saved stderr or ask the restic support channel" elif summary["suggestPrune"]: status = "PRUNE_OPTIONAL_AFTER_HEALTH_REVIEW" next_step = "treat prune as cleanup, not damage repair" else: status = "NO_ERRORS_REPORTED" next_step = "retain evidence and perform a representative restore rehearsal" return { "status": status, **summary, "mutationApproved": False, "nextStep": next_step, "requiredBeforeMutation": [ "preserve full stdout, stderr, exit status and restic version", "freeze backup, forget and prune jobs", "copy the repository or at least index and snapshots when possible", "investigate storage or hardware faults before repair", ], } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("summary", type=Path, help="saved stdout summary JSON") args = parser.parse_args() try: ledger = classify(load_summary(args.summary)) except (OSError, ValueError) as exc: print(json.dumps({"status": "INCONCLUSIVE", "reason": str(exc)})) return 2 print(json.dumps(ledger, indent=2, sort_keys=True)) return 0 if ledger["status"] == "NO_ERRORS_REPORTED" else 1 if __name__ == "__main__": raise SystemExit(main()) ```

`python3 restic_check_ledger.py check-summary.json > repair-ledger.json`を実行します。0は`NO_ERRORS_REPORTED`だけ、actionableな非clean分類は1、malformed/unsupported inputは`INCONCLUSIVE`で2です。redirectionは新reportを作るので別名を使い、原fileを保ちます。

確認したrestic v0.19.1の実装は `broken_packs` を空のGoスライスで初期化し、JSON `null` として出力する場合があります。解析器は `null` と `[]` を損傷パック一覧なしと解釈し、キーの欠落や無効IDは拒否します。19件の合成テストは5状態、空値と配列、優先順位、重複キー、UTF-16拒否、制限した読み取り、リンク、真偽値、無効IDと、入力が不変のCLI終了0/1/2を検査しました。元checkの終了状態は別途保存します。パスの競合に対するサンドボックスでも、実リポジトリの診断でもありません。

4. 5つの台帳結果を読む

`BROKEN_PACKS`はsummaryが完全なdamaged pack IDを列挙した状態です。listを保存し、同じcheck/versionの`repair packs`指示だけを使います。公式説明ではpack repairがindexも更新するため、一般的index repairより先です。

`INDEX_REPAIR_SUGGESTED`はcheckが`suggest_repair_index`を明示し、summaryにbroken packがない状態です。`OTHER_ERRORS`はdocumented flagで解決できない失敗なので、stderrを読みsupportへ相談し、repair commandを推測しません。

`PRUNE_OPTIONAL_AFTER_HEALTH_REVIEW`はcleanupでありdamage repairではありません。unreferenced packのprune提案はmissing-needed-packと別です。`NO_ERRORS_REPORTED`はそのsummaryの報告で、restore testでも全履歴coverageの証明でもありません。

Status | 分かったこと | 変更境界 BROKEN_PACKS | checkがdamaged pack IDを列挙 | backupと原因確認後に明示されたrepair-packsだけ INDEX_REPAIR_SUGGESTED | checkがindex repairを要求 | preserved-copy gate通過後のみ OTHER_ERRORS | flagで未解決 | 停止してstderr/supportを確認 PRUNE_OPTIONAL_AFTER_HEALTH_REVIEW | cleanup候補 | pruneをrepair扱いしない NO_ERRORS_REPORTED | 今回のsummaryにerrorなし | 代表restoreを実施

5. metadata変更前に原因を直す

繰り返すcorruptionは、除外されるまでstorage、transport、memory、filesystem、hardware問題と考えます。backendがfileをtruncate/changeし続ける中でmetadataだけ直せば再破損します。backend type、restic version、最初のstorage faultをsupport用に記録します。

unexpected-size packを一律手動削除してはいけません。現行公式手順は`check`に従い、damaged pack検出時は`repair packs`を使います。古いforum workaroundは当時のversionとfailure context用で、万能recipeではありません。

backupを止められない場合は承認済み手順で別repositoryを初期化し、破損側は調査用にoffline保存します。公開例でURLやcredentialを再利用せず、代替repositoryをrepairと呼びません。

6. checkが実際に求めた枝だけを選ぶ

damaged packが列挙されたら保存済みcheckの`repair packs` commandとIDを使います。broken packがなく、index repairが明示された時だけdocumented index pathへ進みます。両方を反射的に実行せず、healthy indexを念のため修復しません。

indexが正しくなった後、元backup taskの再実行でsourceに残るblobを補充し、同じcontentを参照する古いsnapshotも直る場合があります。sourceから消えたbyteは戻せず、新snapshot成功も全旧snapshotの完全性を証明しません。

repairまたはbackup healing後にstructural checkを再実行します。errorが残れば原記録を上書きせず、新しいevidence setとして保存します。error setの変化は進展で、incident history削除の許可ではありません。

7. snapshotのdata lossを受け入れる前に影響fileを対応付ける

indexが正しく、可能なbackup healing後だけ`restic repair snapshots --dry-run`を使います。各`would save new snapshot`を確認し、original snapshot、affected path、利用可能なsource copy、decision ownerを記録します。

dry-runはimpact mapで、dataを修復せず`--forget`も承認しません。本repairは書き直すsnapshotからunreadable contentを除き、`--forget`はoriginal damaged snapshotも消します。明示的data-loss decisionであり日常cleanupではありません。

重要path、incomplete dry-run、未探索source copy、lossを承認できる人がいない場合は停止します。不確実ならold snapshotを残し、project supportへ相談します。

8. checkとrestoreの両方で修復経路を証明する

公式のclosing checkは完全な`restic check --read-data`がerrorなしで終わることです。大規模・高コストbackendではwindowとcostを計画します。random percentage subsetの連続では完全coverageを保証できません。

代表snapshotも新しい空targetへrestoreします。high-value directory、大file、metadata-heavyな小treeを含め、可能ならapplication readabilityまたはindependent hashを比べます。唯一のsource copyへ上書きしません。

final check、restore rehearsal、snapshot/path inventory、repository copyを全て特定できてから台帳を閉じ、未test範囲を記録します。「errorなし」と「必要fileをrestoreできる」は関連しますが別claimです。

よくある質問

pruneでerrorを消せますか? needed pack欠損や他errorが残る間は不可です。pruneはcleanupで、先にcheckの明示repairへ従います。

`repair index`で欠損file byteは戻りますか? いいえ。indexを直すだけです。sourceに残るcontentはbackup再実行で戻る場合があります。

JSON summaryがあるのにstderrを保存する理由は? 公式error objectがstderrへ出てfailure detailを残すためです。stable summary fieldで分岐しても診断には原errorが要ります。

`repair snapshots --dry-run`は破壊的ですか? dry-runはimpact review用です。後のrepairはreplacement snapshotを書き、`--forget`はoriginalを消すため別決定です。

clean structural checkで全pack byteの完全性が証明されますか? いいえ。全pack検証は`--read-data`、代表restoreは別のuser-level recovery testです。

5つのrepository check結果を独立sandbox restore rehearsalの前に配置したオリジナル概念イラスト。
オリジナル概念イラスト:check結果で次の判断を分類し、別のrestore rehearsalで実回復を試します。 Watch at Ease