# 隔离备份恢复演练设计 ## 设计结论 采用“ARM 最新正式日归档 + 本轮私有临时副本 + 无宿主端口的一次性 PostgreSQL 16 + 空附件目录”的数据恢复方案。腾讯云生产只做演练前后公网 `/ready` 读取,不执行任何远端生产写操作;开发机不接收真实备份。 ## 环境选择 | 方案 | 数据移动与风险 | 对生产影响 | 结论 | | --- | --- | --- | --- | | ARM 一次性影子恢复 | 备份已在 ARM,只在同机复制到私有临时区;明文恢复范围最小 | 无生产容器操作 | 采用 | | 开发机本地 Docker | 需要把真实 DB/附件下载到个人开发机,扩大敏感数据副本 | 无运行影响,但数据治理风险高 | 不采用 | | 腾讯云同机影子 Compose | 不需跨机下载,但会在真实生产宿主创建容器、目录和资源负载 | 生产宿主存在误操作和资源竞争风险 | 不采用 | ARM 在本设计中仍是备份与恢复演练节点,不是生产部署目标。一次性 PostgreSQL 不发布端口;本轮不启动 OA server。 ## 数据流与信任边界 ```text ARM 正式日归档(只读) │ 选择最新 complete 批次并校验 ▼ 本轮 0700 临时副本 ├── database.dump ──> 一次性 PostgreSQL 16 影子库 ──> migration/表计数检查 └── storage.tar.gz ─> 本轮空附件目录 ───────────────> 文件数/引用/哈希检查 │ ▼ 仅数量与状态的脱敏证据 ``` 正式归档不被解包、改名、删除或直接用作写入目标。临时副本、影子数据库和恢复附件目录都由本轮唯一 run id 标识,清理只允许命中该标识。 ## 恢复点合同 1. 从正式日归档中按 manifest `createdAt` 选择最新批次,而不是按目录 mtime 猜测。 2. 候选目录只能包含三个普通文件;manifest 必须为 schema v1、`complete`,并通过 run id、文件名、字节数和 SHA-256 自校验。 3. `buildSha` 必须唯一解析为本仓库已知提交,并且是演练开始时公网 `/ready` 实测当前构建的祖先或相同;同时保留 source image digest 是否存在的布尔证据,不输出完整 digest。相等状态仅用于度量发布后何时形成同源恢复点,不再决定原始备份是否可恢复。 4. 演练开始时批次年龄不得超过 24 小时。候选不合格直接 `HOLD`,不自动降级到旧批次。 5. 临时副本复制完成后再次验证三文件和 manifest,证明恢复输入与正式归档一致。 目录身份校验只适用于 ARM 正式归档源目录:其 basename 必须与 manifest `runId` 配对。临时副本允许使用本轮固定私有名称;对临时副本必须继续验证 manifest 合同、run id 格式、三文件集合、字节数、SHA-256、构建和时间,并与源目录首次校验得到的脱敏元数据完全一致,但不得要求临时目录 basename 冒充正式归档名称。 ## 隔离恢复合同 ### 数据库 - 使用随机一次性容器名和含 `drill` 的数据库名;容器无 `ports`,不加入现有业务网络,不挂载生产或 ARM 归档目录。 - 使用 PostgreSQL 16,与现有恢复基线和脚本一致。 - 调用 `scripts/restore-db.sh`,同时满足 `RESTORE_SCOPE=shadow`、精确数据库确认和显式 Docker 容器身份。 - 恢复后只运行 `SELECT`:migration 成功/失败状态、固定关键表行数和附件引用聚合。不执行 Prisma migrate、seed、DDL 或修复语句。 - 从 manifest 来源提交动态取得 migration 目录名称集合,与恢复库成功且未回滚的 `_prisma_migrations.migration_name` 做精确集合核对;不硬编码数量。再静态比较来源提交与当前构建的 migration 集合,记录“相同”或“当前有待升级 migration”。 固定 12 类表为:部门、员工、员工角色、采购申请、报销单、发票池、付款批次、合同、项目、采购附件、报销附件、付款回单附件。本次已知来源和当前构建现场均为 65 个 migration,但执行器必须使用动态名称集合,不能把 65 写成长期断言。 ### 附件 - 调用 `scripts/restore-storage.sh` 恢复到不存在或为空的本轮目录。 - 恢复前验证 tar 可读、无绝对路径、无 `..`、无软/硬链接;恢复后比较普通文件数。 - 对使用本地存储的发票、采购、报销、付款回单和请假附件记录,只在影子库内读取 `storage_path`、大小和哈希用于机器校验,输出仅保留总数和不一致数。 - `s3:` 引用不属于当前 tar 恢复范围;若最新备份中出现则 `FAIL` 并登记类别,不尝试访问外部对象存储。 ## RTO/RPO 口径 - `RPO = drill_started_at - manifest.createdAt`。它表示使用该最新批次恢复时最多回退的数据时间窗口。 - `data_restore_RTO = all_data_checks_passed_at - restore_copy_started_at`。起点位于全部只读前置门禁通过后、复制正式批次到临时工作区之前;终点为 DB、附件及关联检查全部通过。 - `same_build_recovery_point_lag` 的正式口径需要可审计的发布完成时刻与 ARM 原子归档完成时刻;当前证据不具备后者,因此不得输出精确正式值。内部诊断可记录 `manifest_creation_after_contract_dropin = manifest.createdAt - automatic-backup service 构建 drop-in 最大 mtime`,但必须明确它既不是应用发布时刻,也不是 ARM 到达时刻。首次严格构建门禁失败仍证明发布后存在一段没有当前构建同源恢复点的窗口;该画像独立于数据 RPO 和数据恢复 RTO,即使恢复没有开始也保留。 - 前置访问准备、等待人工输入和任务文档整理不计入 RTO;镜像拉取、批次复制、实际恢复和验证命令计入。 - 本轮不启动应用,因此不得称为“完整 OA 恢复 RTO”;生产 `/ready` 只证明演练未影响在线服务。 ## 状态判定 - `GO`:AC1–AC7 全部满足,可证明最新成对数据备份按其来源 schema 可恢复;若来源与当前 migration 集合不同,必须同时标注当前 schema 升级未验证,不能宣称完整灾难恢复 GO。 - `PAUSE`:访问权限、磁盘空间、Docker/PostgreSQL 16 运行条件或清理验证不足,但未发现备份内容损坏。 - `NO_GO`:最新批次超龄/损坏、来源提交未知或不是当前构建祖先/相同、DB restore 失败、来源 migration 集合或关键表异常、附件缺失/哈希不一致、DB 与附件不配对,或生产受到影响。 重试不得覆盖首次失败证据。若修正环境性问题后重试,必须使用新 run id 并保留首次脱敏 finding。 ## Attempt 1 后的只读身份探针 首次恢复入口因最新 manifest `build_mismatch` 失败关闭后,不直接重装恢复入口。该失败证据保留,但严格相等门禁已被证明会在发布后自然备份到达前产生必然假阻断。后续先安装一个固定无参数、运行后自删除的 ARM root-owned 只读探针,权限仅用于读取 receiver 的路径配置和最新正式日归档: - 按 manifest `createdAt` 选择最新正式日批次,不自动改选旧批次。 - 只输出 manifest 构建短标识、与当前生产短标识的相等状态、批次年龄、run id/目录配对状态、source image digest 是否存在,以及两份产物的文件类型、大小和 SHA-256 聚合校验状态;祖先关系在本地仓库只读验证,不要求 ARM 持有 Git 历史。 - 不输出完整 run id、完整 digest、完整路径、字节数、文件名之外的业务信息或原始 manifest。 - 不复制、不解包、不启动 Docker、不读取数据库内容或附件内容,不修改正式归档。 - 成功或失败均删除探针自身和对应 sudoers;普通用户暂存文件在 SHA-256 精确匹配后另行清理。 探针结果不能替代恢复验收。最新 manifest 与当前生产不相等时,先判断其是否为当前构建祖先:是祖先且批次完整、新鲜时可进入原始恢复重试审批,同时记录同源恢复点滞后;未知或非祖先才维持 `NO_GO` 并转入备份链调查。手工触发生产备份始终是可撤回备选项,不是完成项。 ## Attempt 3 后的数据库恢复诊断合同 Attempt 3 只留下 `database_restore_failed` 聚合类别,失败清理同时删除了权限 `0600` 的私有原始日志。删除潜在敏感日志是正确安全边界,但下一轮 runner 必须在清理前生成只含 allowlist 状态的阶段诊断,不能再次盲重试。 - 数据库恢复拆成 `dump_list`、`target_confirmation`、`restore_apply` 三个独立阶段;每个阶段只输出 `PASS`、`FAIL` 或 `NOT_RUN`,并保留稳定错误类别,不输出 pg_restore 原始 stderr、对象名、路径、凭据或 SQL。诊断必须在失败路径进入清理前写到标准输出,清理仍由 trap/finally 等价路径保证执行;输出先于清理不表示保留私有原始日志。 - `dump_list` 使用与实际恢复相同的 PostgreSQL 16 容器和临时副本,只解析 custom dump 目录,不执行数据库写入。失败时分类为 `dump_list_failed` 并停止。 - `target_confirmation` 只确认当前数据库名等于含 `drill` 标记的本轮目标,并确认容器连接可用;失败时分类为 `restore_target_confirmation_failed`。 - 只有前两项通过才允许调用现有 `restore-db.sh`。调用必须记录执行路径枚举 `DOCKER_STDIN` 或 `HOST_FILE`、脚本退出码和真实 pg_restore apply 退出码;ARM runner 固定使用 `DOCKER_STDIN`,两条路径都由本地集成回归直接驱动仓库中的 `scripts/restore-db.sh`。此后非零分类为 `restore_apply_failed_after_list_pass`,证明 dump 可解析但实际应用失败;它仍不能自动等同于备份内容损坏。 - 对 pg_restore 非零不设置“可忽略对象错误”白名单:即使私有日志末尾可脱敏提取到对象错误计数,任何非零仍为 `FAIL/NO_GO`。计数只输出非负整数或 `UNKNOWN`,不得据此把部分恢复升级为 `PASS`;未来若要允许部分成功,必须另行列出错误类别、证明对象集合完整并重新批准设计。 - 私有原始日志继续保持 `0600` 且不得复制出 ARM;成功或失败都随本轮 runtime 删除。清理前将三项阶段状态写入最终标准输出,保证失败原因至少可定位到合同边界。 - 合成回归必须分别制造 dump 列表失败、目标确认失败和列表通过但 apply 失败,并断言后续阶段保持 `NOT_RUN`、输出不含 fixture Secret/路径/SQL、容器与 runtime 清理为零;至少一条注入失败用例必须同时证明诊断在清理前已输出、清理完成后该诊断仍可从调用方捕获。成功回归直接以 PostgreSQL 16 custom dump 驱动真实 `scripts/restore-db.sh` 的 Docker stdin 与宿主文件两条路径,不重写等价恢复逻辑。 - 该修复只改变演练器诊断能力,不放宽正式归档、源/副本身份、24 小时新鲜度、shadow 数据库、无端口、附件或清理门禁。任何 Attempt 4 实际恢复仍须使用新 run id、重新探测最新批次并另行批准。 ## Attempt 4 前的 runner 持久化门禁提案 个人 `.trellis/workspace/` 中的 runner 和回归不会提交,不能作为 Attempt 4 的可重复证据。该限制不接受;实际恢复前应在另行批准的本地代码阶段完成以下持久化,不与 ARM 探针或恢复操作混在一起: - 将当前一次性恢复 runner 收敛为受版本控制、只含占位符和校验逻辑的 `scripts/qa/offhost-shadow-restore-runner.template.sh`,将附件关联校验器作为 `scripts/qa/validate-offhost-shadow-restore-attachments.py`。Attempt 4 候选必须由合同脚本使用私有 JSON 参数机械渲染,并与现有 `scripts/restore-db.sh`、`scripts/restore-storage.sh` 一起打包;不得从聊天或个人 workspace 重新生成等价实现。 - 新增 `scripts/qa/run-offhost-shadow-restore-runner-contract.sh`,直接校验候选 runner 的 Bash 语法、依赖 SHA-256、固定无参数入口、源/副本身份重绑、动态 migration 集合、三阶段脱敏诊断、退出码、失败先诊断后清理及敏感输出边界。 - 合同脚本直接以 PostgreSQL 16 custom dump 驱动真实 `scripts/restore-db.sh` 的 Docker stdin 和 host-file 两条路径,并覆盖 dump-list 失败、目标确认失败、apply 非零、后续阶段 `NOT_RUN` 和零残留。测试失败不得上传候选。 - 远端地址、现场路径、远端账号、一次性容器/数据库命名、timer、构建前缀和 migration 集合指纹均不进入模板;由私有 JSON 提供并按字段类型、字符集和安全命名规则验证后替换。渲染器拒绝额外/缺失字段、换行、shell 语法、非绝对或逃逸路径和错误摘要格式,不从不受信环境变量覆盖。 - 私有 JSON 和渲染后的现场候选只能位于仓库外,或位于仓库内专用的 `/.restore-drill-private/` ignored 目录。渲染器必须对输入和输出分别执行 Git ignore 判定;任一路径在仓库内但未被忽略时,在创建候选前失败关闭,不能依赖人工避免 `git add -A`。 - 权限 `0600` 的私有原始日志只允许初始化时截断一次;容器启动、dump list、目标确认、restore apply 和附件恢复各写固定阶段标记并使用追加模式。这样后续阶段失败时仍能在本轮 runtime 内交叉核对之前阶段,最终清理和脱敏输出边界不变。 - 上传前由合同脚本对实际候选文件生成/校验 SHA-256;ARM 安装前再次校验同一摘要。证据记录合同脚本提交、候选摘要和 PASS,不记录地址、路径或业务数据。 ## 清理与回滚 - 生产无变更,无生产回滚步骤。 - 成功或失败都只删除本轮 run id 对应的一次性容器、临时附件目录和临时批次副本;归档目录、receiver 管理目录和现有监控容器不在清理匹配范围。 - 清理前再次解析容器名和临时根目录;任何对象不满足本轮标识、类型或父目录约束时停止自动清理。 - 清理后确认无本轮容器、宿主端口、临时网络和临时目录残留;失败则结论至少为 `PAUSE`,交由管理员精确处置。 ## 证据边界 执行证据写入当前 task 的 `research/restore-drill-evidence.md`,随后只在现有 OPS7 Runbook 或人工试运行报告中补一条脱敏状态摘要;不新建 `docs/` 平行文档。不得保存主机地址、SSH 别名、完整远端路径、Secret、连接串、真实账号、表内容、附件名或原始日志。