科技 · 实用指南
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`,代表性恢复还提供另一层用户级恢复测试。
