# 生产级备份与恢复(目标环境) 面向目标环境(云上 WeKnora v0.7.1 + Aliyun RDS + ES + OSS)。核心原则只有一条: **导入/发布之前,先把目标库现场留档,然后才允许写库** —— 这件事不是靠人记得做,而是靠发布前置硬门禁强制。 ## 现状(2026-10-09 实测) | 层 | 状态 | 证据 | |---|---|---| | 导入前留档(PG 行 + ES 文档) | ✅ 已建:`scripts/target_kb_snapshot.sh` 云内执行,8 张表 + 该库在 ES 的全部文档(只读 scroll,gzip JSONL,按 `_id` 可重建)+ `MANIFEST.json`(行数/字节/sha256/每库 ES 文档数)+ `export.sql` + `README.md`。实测 **2 分 52 秒**(两个库 13.1 万条 ES 文档 / 692 MB,字节留云主机);`PULL_ES=1` 拉回本机则 **19 分 40 秒** | `$KB_BACKUP_ROOT/target-kb-snapshots/-/`(云主机全量;本地镜像 PG 行 + 清单) | | 发布前硬门禁 | ✅ 已上线(`internal/releasesnapshot`):写目标库之前必须存在覆盖该库、24h 内、**PG 行与 ES 文档两层都齐备且哈希可校验**的留档,否则 `release_target_snapshot_missing`(不可重试),目标库一行不碰 | 单测 10(门禁)+ 6(发布器);`go run ./cmd/guardcheck $KB_BACKUP_ROOT/target-kb-snapshots ` 实测:两个绑定库 PASS、未绑定库 BLOCK | | 自管整库逻辑备份 | ✅ 已建并在云主机实跑:`scripts/logical-backup.sh`(`pg_dump -Fd -j4 -Z6` 目录格式,可并行/按表恢复)。实测 **33 秒 / 742.6 MiB / 214 文件 / 失败 0**,覆盖该实例全部 8 个库(`weknora_test`、`weknora_prod`、`weknora_drill74`、`dobotai_prod/test`、`knowledge_adapter_prod/test`、`postgres`);RDS 管理库 `rdsadmin` 默认跳过,单库失败只记账不中止;被中断的运行会把目录改名 `*.incomplete` | `$KB_BACKUP_ROOT/rds-logical//<库>.dir` + `MANIFEST.json`(含 `failed_databases`) | | 定时与保留(平台管理) | ✅ 已建:平台「系统设置 → 存储与备份」设置定时时间与储存天数,保存即下发到目标主机的 `/etc/cron.d/kb-logical-backup`,并读回主机上生效的计划与最近一次结果。当前:**每天 03:30、保留 7 天、每月另留 3 份**,cron 服务 active | 接口 `GET/PUT /api/v1/backup-schedule`、`GET …/host`、`POST …/apply`、`POST …/run`(写操作仅管理员) | | ES 快照 | ✅ 已建:仓库 `kb_backup`(容器内 `path.repo=/usr/share/elasticsearch/backup`,宿主 `$KB_BACKUP_ROOT/es-snapshots`)→ 首个快照 `initial-20261009t024209z` **36 秒 / 4.7 GB / 0 分片失败**;SLM `kb-hourly`(留 3 天)+ `kb-daily`(留 7 天)已各成功执行 | `GET _snapshot/kb_backup/_all`、`_slm/policy/*` | | OSS 对象 | ✅ 目标桶 `dobot-weknora-test` 已开启**版本控制**(覆盖/删除可按 VersionId 回滚);未配跨区域复制 | `GET /?versioning` = `Enabled` | | RDS 托管备份 | ⏳ **待控制台确认**:库里 `archive_mode=on` 但 `archive_command` 为空、`pg_stat_archiver.archived_count=0`;当前连接 `is_superuser=off`(看不到备节点)→ 系列与日志备份开关只能看控制台 | 见下节 | ## 备份目录契约(换主机只改一处) 任何一台扮演"目标库所在主机"的机器(现在的**测试云主机**、未来的**生产云主机**、以及本地演练机) 都跑同一份 `scripts/backup_layout.sh`,得到同一套落点: | 目录 | 内容 | 谁写 | |---|---|---| | `$KB_BACKUP_ROOT/target-kb-snapshots/-/` | 导入前留档(PG 8 表 + ES 文档 + 清单) | `scripts/target_kb_snapshot.sh` | | `$KB_BACKUP_ROOT/rds-logical//` | 整库逻辑备份(`pg_dump -Fd`) | `scripts/logical-backup.sh` | | `$KB_BACKUP_ROOT/es-snapshots/` | ES 共享仓库 `kb_backup` 的宿主目录 | ES 快照 / SLM | | `$KB_BACKUP_ROOT/logs/` | 上述脚本运行日志 | 各脚本 | - 默认 `$KB_BACKUP_ROOT=/srv/kb-backups`,由 `/etc/knowledge-governance/backup.env` 落盘(`backup_layout.sh` 生成); 脚本与 compose 都读它:`source /etc/knowledge-governance/backup.env`,compose 用 `${KB_BACKUP_ROOT:-…}` 插值。 - 已执行实例:测试云主机 `/srv/kb-backups/`(原 `/opt/weknora-test/{kb-snapshots,es-backup}` 已迁移, 容器内路径不变,ES 仓库无需重新注册、快照与 SLM 全部保留)。 - **切到生产云主机时**:① 在生产云主机跑同一份 `backup_layout.sh`;② 把生产 compose 的 ES 挂载源与发布前留档挂载源指向 `$KB_BACKUP_ROOT`;③ 门禁配置 `target_snapshot.root` 保持 **容器内路径** `/var/lib/knowledge-governance/target-kb-snapshots`(不用改);④ 在发布管理里授权前先跑一次留档。 ## RDS 控制台(约 5 分钟;先看"系列") 实例:`pgm-wz9ohr9gk919pr1q`(PostgreSQL 17.9,阿里云 RDS)。 1. 实例列表 → 点实例 ID → 左侧「备份恢复」→「备份策略」。 2. 「数据备份设置」编辑:备份周期 ≥ 每周 2 次(建议周一/周四)、时间窗 03:00-04:00、 **保留天数 14 天**(可选 7~730;不可关闭;云盘实例可开秒级备份/提高频率)。 3. 「日志备份设置」:**打开日志备份**(决定能否按时间点恢复 PITR)、保留 7 天。 注意:日志保留必须 ≤ 数据保留;关掉会清空已有日志备份并失去 PITR。 4. 抄下四个值到 `RDS_BACKUP_POLICY.md`:数据保留天数、日志备份开关+保留天数、最近一次备份完成时间、**最近可恢复时间点**。 5. 分支:**高可用系列** → 支持"按时间点恢复到新实例"(云盘实例不复制白名单/安全组,需重配); **基础系列** → 不支持日志备份/PITR,改用:数据保留期拉长 + 稀疏备份(月/年各留一份)+ 开云盘加密后启用跨地域备份;控制台有「实例备份下载」按钮时才可导出到 OSS。 只读旁证(最终以控制台为准): ```bash docker run --rm --network host postgres:17-alpine psql "$TARGET_DB_URL" \ -c "show archive_mode; show archive_command; select archived_count, last_archived_time from pg_stat_archiver" ``` ## 恢复路径(按事故类型选,越快越靠前) | 事故 | 首选 | 耗时 | |---|---|---| | 某个 KB 的 wiki / 元数据被覆盖 | **留档回填** `scripts/restore_drill.py`(psql 按原文 `\copy FROM` 回填;冲突行跳过并计数) | 秒~分钟 | | 检索/向量异常、索引误删 | **ES 快照 restore**(`kb_backup`;或按 `es_documents.jsonl.gz` 用 `_id` 重建) | 分钟级 | | OSS 对象被覆盖 / 删除 | **OSS 版本控制**回滚单对象或前缀 | 秒~分钟 | | 整库被改坏(要带走/异地一份) | **自管逻辑备份** `pg_restore -j4` 到临时库或新实例 | 分钟~十分钟 | | 整实例级错误、需要"回到某个时刻" | **RDS 快照 / PITR** → 新实例 → 按 KB 取行 | 10~30 分钟 | | 主机/磁盘整体丢失 | ES 仓 + 留档在**同一块盘**上 → 必须靠 RDS 托管备份 + OSS(见"边界") | 30 分钟+ | ## 回填演练("可恢复"的实测证明) 哈希校验只能证明文件没坏,不能证明能恢复。`scripts/restore_drill.py` 把留档**真的插回一个库**再核对: ```bash python3 scripts/restore_drill.py --snapshot-dir $KB_BACKUP_ROOT/target-kb-snapshots/- \ --target-url "$LOCAL_TEST_DB_URL" # 用本地测试库;--keep 可保留插入的行 ``` 实测(本地测试库 WKNFull `weknora_full`,留档 `20261009T041913Z-0b217e8a`): | 表 | 清单行数 | 回填 | 已插入 | 冲突跳过 | 清理后残留 | |---|---|---|---|---|---| | knowledge_bases / knowledges / wiki_folders / wiki_pages / resources / resource_bindings | 2 / 3830 / 65 / 1982 / 3830 / 3830 | 同左 | 同左 | 0 | 0 | 合计 **13,539 行**回填成功,抽样页面正文与标题逐字段相同;`knowledge_tags` / `knowledge_tag_relations` 为空表。 演练在目标库临时建一张 `_drill_restored` 跟踪表记录插入了哪些行,核对后**按行精确删除**并断言残留为 0(需要目标库有建表权限,故演练只用测试库)。 ## 定时与保留怎么下发(平台 → 目标主机) 备份必须在**目标库所在主机**上本地执行(内网直连 RDS、内网写盘),所以平台只负责"下发计划 + 读回结果": - 页面:系统设置 → 存储与备份(管理员可改;保存 = 写入库并立刻下发;另有"重新下发当前设置"与"立即备份一次")。 - 接口:`GET /api/v1/backup-schedule`(不探测主机,首屏快)、`GET …/host`(SSH 读回主机 cron 与最近一次结果)、 `PUT`(校验时间 `HH:MM` 与 1~365 天 / 0~24 月,非法值直接 400 且不落库不下发)、`POST …/apply`、`POST …/run`。 写操作按角色网关(`classifyRole`)限管理员——这份计划会落到数据面主机的 cron 上。 - 主机侧:`scripts/install_backup_schedule.sh`(幂等)写 `/etc/knowledge-governance/backup-schedule.env` 与 `/etc/cron.d/kb-logical-backup`;`logical-backup.sh` 从该文件读保留策略(显式传 `KEEP_DAILY/KEEP_MONTHS` 可覆盖)。 - 凭据:容器内只读挂载 `secrets/backup-host/{ssh_config,id_ed25519,known_hosts}`(0600), 环境变量 `BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET / BACKUP_HOST_SCRIPT_DIR / BACKUP_HOST_ROOT`; **三项缺任一即"未接线"**,页面降级提示而不是报错。 - 主机状态读取只认**有 `MANIFEST.json` 的完整目录**:被中断的运行会被脚本改名 `*.incomplete`, 所以"最近一次备份"永远不会显示一份半成品。 - 依赖两个运行前提:运行镜像里有 `openssh-client`,且容器用户的 uid 在 `/etc/passwd` 有名字 (否则 ssh 报 `No user exists for uid 1000`)。`Dockerfile` 已加 `openssh-client` 与 `adduser -u 1000`。 ## 每次导入/发布前的标准动作 1. `bash scripts/target_kb_snapshot.sh`(默认云内执行 + ES 字节留云主机,实测 **2 分 52 秒**; PG 8 张表 44 秒,其余是 ES 只读 scroll 导出;`INCLUDE_CHUNKS=1` 才带 chunks, `PULL_ES=1` 才把 692 MB ES 导出拉回本机——家用上行 ~0.6 MB/s,要 19 分 40 秒); 2. **不需要人工登记**:worker 门禁自动核验"覆盖目标库 + 24h 内 + PG 与 ES 两层哈希可校验"; 3. 门禁报 `release_target_snapshot_missing` → 先跑第 1 步,再在发布管理里授权发布; 4. 想复核门禁判断,可直接跑 `go run ./cmd/guardcheck $KB_BACKUP_ROOT/target-kb-snapshots `。 ## 边界(别把这些当"已经备份了") - 留档**不含**:OSS 对象本体(`resources.jsonl` 只记路径/大小/哈希)、ES 索引 settings/mappings、 表结构与索引、列出知识库之外的行。整库级要靠 RDS 托管备份 + 自管逻辑备份。 - **ES 快照仓与留档在同一台云主机、同一块盘**上:整机故障会一起丢。异地副本需把 `es-snapshots/` 与 `target-kb-snapshots/` 同步到 OSS(需一把可写备份桶的 AK,属授权项)。 - 自管逻辑备份**不能替代 PITR**:它只能回到"备份那一刻",且恢复要靠 `pg_restore` 手工执行。 它按库产出目录格式归档(`pg_restore -d <库> -j4 <库>.dir`,跨实例加 `--no-owner --no-acl`), 且 **同一实例上的生产库(`weknora_prod`)也在其中** —— 备份目录权限按 0700/0600 保管。 - 留档默认不含 `chunks` 行:向量在 ES 里、分块行可由源端重发;要"整库行级可离线重建"才需 `INCLUDE_CHUNKS=1`(实测本机隧道约 24 分钟、云内 44 秒内)。 - 留档 JSONL 是 psql `\copy` **text 格式**(反斜杠与控制字符被转义):回填必须用对称的 `\copy FROM`(由 psql 反转义,`restore_drill.py` 就是这么做的);按行 `json.loads` 会失败 (实测 `knowledges` 3830/3830、`wiki_pages` 218/1982 行报错),程序化读取需先反转义。 - `es_documents.jsonl.gz` 的 sha256 **每次导出都不同**(scroll 顺序非确定),判据是清单里的 每库文档数与逐条内容;要跨次比对请比 `es_document_counts` 与 `_id` 集合,不要比文件哈希。