3f603aa293
需求:整库逻辑备份做定时,但要在「系统设置 → 存储与备份」里设置定时时间与储存天数。 后端 - 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)。
12 KiB
12 KiB
生产级备份与恢复(目标环境)
面向目标环境(云上 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)。
- 实例列表 → 点实例 ID → 左侧「备份恢复」→「备份策略」。
- 「数据备份设置」编辑:备份周期 ≥ 每周 2 次(建议周一/周四)、时间窗 03:00-04:00、 保留天数 14 天(可选 7~730;不可关闭;云盘实例可开秒级备份/提高频率)。
- 「日志备份设置」:打开日志备份(决定能否按时间点恢复 PITR)、保留 7 天。 注意:日志保留必须 ≤ 数据保留;关掉会清空已有日志备份并失去 PITR。
- 抄下四个值到
RDS_BACKUP_POLICY.md:数据保留天数、日志备份开关+保留天数、最近一次备份完成时间、最近可恢复时间点。 - 分支:高可用系列 → 支持"按时间点恢复到新实例"(云盘实例不复制白名单/安全组,需重配); 基础系列 → 不支持日志备份/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。
每次导入/发布前的标准动作
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 秒);- 不需要人工登记:worker 门禁自动核验"覆盖目标库 + 24h 内 + PG 与 ES 两层哈希可校验";
- 门禁报
release_target_snapshot_missing→ 先跑第 1 步,再在发布管理里授权发布; - 想复核门禁判断,可直接跑
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
\copytext 格式(反斜杠与控制字符被转义):回填必须用对称的\copy FROM(由 psql 反转义,restore_drill.py就是这么做的);按行json.loads会失败 (实测knowledges3830/3830、wiki_pages218/1982 行报错),程序化读取需先反转义。 es_documents.jsonl.gz的 sha256 每次导出都不同(scroll 顺序非确定),判据是清单里的 每库文档数与逐条内容;要跨次比对请比es_document_counts与_id集合,不要比文件哈希。