Files
knowledge-governance/docs/PRODUCTION_BACKUP_RECOVERY.md
姚章浩 3f603aa293 feat(backup): 系统设置里的存储与备份——定时时间与储存天数可配、下发到目标主机 cron
需求:整库逻辑备份做定时,但要在「系统设置 → 存储与备份」里设置定时时间与储存天数。

后端
- migration 000045 backup_schedule(单行:enabled/schedule_time/retention_days/retention_months/
  last_applied_*),store + 领域校验(HH:MM、1~365 天、0~24 月,非法值 400 且不落库不下发)
- internal/backuphost:通过 ssh 在目标主机上读回/下发计划并立即备份(未接线时 Available=false,
  页面降级提示;主机侧错误带输出回传)。最近一次只认有 MANIFEST.json 的完整目录,跳 *.incomplete
- 接口 GET/PUT /api/v1/backup-schedule、GET …/host、POST …/apply、POST …/run;
  写操作经 classifyRole 限管理员;run 为异步 202 + 在途单飞(http 写超时 30s,备份约 35s)
- openapi.yaml 补齐 5 条路由(契约测试要求每条都写 400/500)

主机侧
- scripts/install_backup_schedule.sh(幂等):写 /etc/knowledge-governance/backup-schedule.env 与
  /etc/cron.d/kb-logical-backup,参数校验,--show/--disable,最后一行 SCHEDULE_APPLIED 供平台核对
- logical-backup.sh 从该文件读保留策略;失败或被中断把目录改名 *.incomplete,不留'像成功'的半成品
- install --show 只回显计划行(原先把 SHELL=/PATH= 当计划显示)

前端
- web/src/backupSchedule.ts(严格解析:缺计划字段即报错,不把默认值伪装成设置)
- BackupScheduleSection:容器 + 纯视图(管理员表单/只读、已一致/待下发、在途轮询)

运行前提(已落实):运行镜像加 openssh-client,且容器 uid 1000 在 /etc/passwd 有名字
(否则 ssh 报 No user exists for uid 1000);凭据只读挂载 secrets/backup-host(git 忽略)

实测:主机 cron 每天 03:30 / 保留 7 天 / 每月 3 份;立即备份一次 34 秒 / 742.7 MiB / 失败 0;
Go 与前端测试全绿(170 vitest)。
2026-10-09 13:50:23 +08:00

130 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 生产级备份与恢复(目标环境)
面向目标环境(云上 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/<UTC>-<kb8>/`(云主机全量;本地镜像 PG 行 + 清单) |
| 发布前硬门禁 | ✅ 已上线(`internal/releasesnapshot`):写目标库之前必须存在覆盖该库、24h 内、**PG 行与 ES 文档两层都齐备且哈希可校验**的留档,否则 `release_target_snapshot_missing`(不可重试),目标库一行不碰 | 单测 10(门禁)+ 6(发布器);`go run ./cmd/guardcheck $KB_BACKUP_ROOT/target-kb-snapshots <kb_id>` 实测:两个绑定库 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/<UTC>/<库>.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/<UTC>-<kb8>/` | 导入前留档(PG 8 表 + ES 文档 + 清单) | `scripts/target_kb_snapshot.sh` |
| `$KB_BACKUP_ROOT/rds-logical/<UTC>/` | 整库逻辑备份(`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/<UTC>-<kb8> \
--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 <kb_id>`。
## 边界(别把这些当"已经备份了")
- 留档**不含**: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` 集合,不要比文件哈希。