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

12 KiB
Raw Permalink Blame History

生产级备份与恢复(目标环境)

面向目标环境(云上 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。

只读旁证(最终以控制台为准):

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 把留档真的插回一个库再核对:

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 与 1365 天 / 024 月,非法值直接 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 集合,不要比文件哈希。