科技 · 實用指南
Restic 回報 pack 缺失:先建修復台帳,再變更儲存庫
先保存證據,依 restic 已記錄的檢查結果分類,並在修復索引或快照前決定破壞性最低的下一步。

Restic 的 pack 缺失或損壞不只是清理警告。在完整檢查證明相反情況以前,還原可能失敗,甚至新快照也未必可安全還原。先暫停定時備份、forget 和 prune,保留原始輸出,並在任何修復前複製儲存庫;最低也要複製 index 與 snapshots。
本指南把官方 `restic check --json` 摘要、單獨保存的 stderr 和快照修復 dry-run 彙成只讀修復台帳。附帶解析器不會打開儲存庫、執行 restic、刪除資料或核准任何變更。
台帳區分損壞 pack、索引修復建議、其他未解決錯誤、可選清理與無錯誤摘要。仍須執行當前 restic 版本實際輸出的指令、排查後端或硬體故障,並用代表性還原證明可還原性。
1. 停止改寫並保留第一份證據
關閉針對此儲存庫的定時 backup、forget 與 prune。分類完成前,不要繼續失敗的 prune、手動刪除 pack、重建索引、解開仍在使用的鎖,或啟動快照修復。鎖錯誤與已確認的陳舊鎖是兩個不同問題。
保存準確的 restic 版本、命令、結束碼、stdout 與 stderr。第一次失敗可能含有後續命令不再顯示的 pack ID 或後端錯誤。公開日誌前刪去儲存庫 URL、使用者名和路徑,絕不要包含密碼、令牌或雲端憑證。
可行時完整複製儲存庫。官方給出的最低限度的回復方案是複製 index 與 snapshots 目錄,以便失敗後回退。新建第二儲存庫繼續備份可以緩解營運壓力,但不能證明損壞儲存庫已修好。
2. 保存機器摘要,但不要丟掉 stderr
繼續使用現有安全方式提供儲存庫與密碼,然後按時間和成本預算選擇檢查。預設 check 檢查結構;`--read-data` 會讀取每個 pack,可能耗費大量時間和頻寬。抽樣適合維護,卻不能替代事故結案所需的完整覆蓋。
stdout 與 stderr 要分開保存,因為官方 JSON 介面把最終摘要寫到 stdout,把錯誤物件寫到 stderr。下面是簡單的 POSIX shell 擷取;結束碼一行必須緊跟 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` 只有一個完整摘要物件。即使摘要看似足夠,也保留 `check-errors.jsonl`;自由格式文字可能變化,自動化應只按文件記載的欄位分支,同時為人工診斷保留原錯誤證據。
3. 建立只讀決策台帳
把以下完整程式碼儲存為 `restic_check_ledger.py`。它只讀取已保存摘要,拒絕最終路徑元件為符號連結或非一般檔案的輸入;最多讀1 MiB加一個探測位元組後拒絕超限內容。它明確以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`。只有 `NO_ERRORS_REPORTED` 傳回 0;需要行動但不乾淨的類別傳回 1;格式錯誤或不支援的輸入以 `INCONCLUSIVE` 傳回 2。重定向會新建報告,請選新檔名並保留原檔案不變。
已核讀的restic v0.19.1檢查實作將 `broken_packs` 初始化為空Go切片,可編碼成JSON `null`。解析器把 `null` 和 `[]` 視為未列出損壞封裝,但拒絕缺失欄位或無效ID。19項合成測試涵蓋五種結果、空值/陣列、優先順序、重複鍵、UTF-16拒絕、受限讀取、符號連結、布林型別、無效ID,以及輸入位元組不變時的CLI結束0/1/2。另外保留原check結束狀態。它不是防路徑競態的沙箱,也不是真實儲存庫診斷。
4. 解釋五種台帳結果
`BROKEN_PACKS` 表示摘要列出了完整損壞 pack ID。保留列表,只執行同一檢查與版本明確給出的 `repair packs` 指令。官方說明此路徑優先於通用索引修復,因為 pack 修復也會更新索引。
`INDEX_REPAIR_SUGGESTED` 表示 check 明確設置了 `suggest_repair_index`,且摘要未列出損壞 pack。`OTHER_ERRORS` 表示這些文件記載的標誌無法解釋失敗;查看已保存 stderr,並向 restic 支援求助,不要猜修復命令。
`PRUNE_OPTIONAL_AFTER_HEALTH_REVIEW` 是清理,不是損壞修復。未引用 pack 可觸發 prune 建議,但與缺失所需 pack 不同。`NO_ERRORS_REPORTED` 只是本次摘要的結論,不是還原測試,也不證明歷史檢查覆蓋全部資料。
狀態 | 已證明內容 | 變更邊界 BROKEN_PACKS | check 列出損壞 pack ID | 備份並查明故障後,僅使用明確的 repair-packs 指令 INDEX_REPAIR_SUGGESTED | check 要求修復索引 | 只有通過保全副本門檻後才修復 OTHER_ERRORS | 文件記載的旗標無法解決失敗 | 停止並檢查 stderr/尋求支援 PRUNE_OPTIONAL_AFTER_HEALTH_REVIEW | 可能需要清理 | 不把 prune 當修復 NO_ERRORS_REPORTED | 本次摘要報告無錯誤 | 進行代表性還原演練
5. 改儲存庫元資料前先修根因
在排除前,反覆損壞應視為儲存、傳輸、記憶體、檔案系統或硬體問題。若後端繼續截斷或改變檔案,先修元資料只會再次損壞儲存庫。為支援人員記錄後端類型、restic 版本和最早可見的儲存故障。
不要看到異常大小的 pack 就手動刪除。現行官方指導要求先遵循 `check`,並在檢測到損壞 pack 時使用 `repair packs`。論壇中的歷史變通只適用於其版本與故障場景,不是通用方案。
若作業不能暫停備份,可按既有審批流程初始化另一個儲存庫,並把損壞儲存庫離線保留調查。不要在公開範例中重複使用其 URL 或憑證,也不要把替代儲存庫稱作修復。
6. 只走 check 實際要求的分支
若列出損壞 pack,就使用保存的 check 輸出指定的 `repair packs` 命令與 ID;若沒有損壞 pack、但明確建議索引修復,才走文件記載的索引路徑。不要條件反射地兩者都跑,也不要“以防萬一”修健康索引。
索引正確後,重跑原備份任務可能補回源資料仍存在的 blob,也可能修復引用相同內容的舊快照;但它無法重建已從來源端消失的位元組,新快照成功也不證明所有舊快照完整。
完成修復或備份補全後,再跑結構檢查。若仍有錯誤,把新結果保存為新的證據集,不要覆蓋最初記錄。錯誤集變化是進展證據,不是刪除事故歷史的許可。
7. 接受快照資料遺失前先映射受影響檔案
只有在索引正確、且已盡可能用備份補全後,才執行 `restic repair snapshots --dry-run`。逐條檢查 `would save new snapshot`,記錄原快照、受影響路徑、可用來源副本和決策負責人。
dry-run 是影響圖,不會修復資料,也不核准 `--forget`。真正的快照修復會從重寫快照中移除無法存取內容;再加 `--forget` 還會刪除原損壞快照。這是明確的資料遺失決策,不是日常清理。
若列出重要路徑、dry-run 不完整、尚未查找來源副本,或無人獲授權接受損失,就暫停。結果不確定時保留舊快照,並諮詢專案支援管道。
8. 用 check 與 restore 兩條證據證明修復路徑
官方收尾檢查是完整的 `restic check --read-data` 並無錯誤結束。大型或昂貴後端應規劃窗口與費用;一系列隨機百分比抽樣不能保證最終覆蓋全部資料。
還要把代表性快照還原到新的空目標。至少包含一個高價值目錄、一個大檔案和一個元資料密集的小目錄樹,並在可行時比較應用可讀性或獨立雜湊。不要覆蓋唯一來源副本。
只有最終 check、還原演練、快照/路徑清單與儲存庫副本都可明確定位時才關閉台帳,並記錄仍未測試的內容。“無錯誤”與“所需檔案可還原”有關聯,但不是同一結論。
常見問題
能用 prune 消除錯誤嗎? 所需 pack 缺失或仍有其他錯誤時不能。Prune 是清理,應先執行 check 明確給出的修復指令。
`repair index` 能找回缺失檔案位元組嗎? 不能,它只修正索引。重跑備份可能補回來源端仍存在的內容。
已有 JSON 摘要,為什麼還要保存 stderr? 官方錯誤物件寫在 stderr,可保留失敗細節。可按穩定摘要欄位分支,但診斷仍需原錯誤。
`repair snapshots --dry-run` 有破壞性嗎? dry-run 用於影響審查;之後的修復會寫替代快照,`--forget` 會刪除原始快照,必須分開決策。
結構 check 乾淨是否證明每個 pack 位元組完整? 不能。完整 pack 校驗需要 `--read-data`,代表性還原還提供另一層使用者層級還原測試。
