Files
knowledge-governance/scripts/logical-backup.sh
T
姚章浩 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

222 lines
9.8 KiB
Bash
Raw 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.
#!/usr/bin/env bash
# 目标 RDS PostgreSQL 的**自管逻辑备份**:手动触发,文件落在你自己这台云主机上,
# 不经过 RDS 托管备份服务。
#
# 为什么要它:RDS 托管的备份只存在阿里云内部存储,你既看不到也拿不走,只能"恢复成
# 新实例";这份是你能随手取用、能在任何地方 pg_restore 的自有副本。两者互补:
# 托管备份 + 日志备份负责"按时间点恢复",这份负责"带走 / 异地 / 独立校验"。
#
# 用法(在云主机上):
# bash logical-backup.sh # 全库逻辑备份 + 轮转
# BACKUP_ROOT=/data/rds-backups bash logical-backup.sh
# DBS="weknora_test" bash logical-backup.sh # 只备指定库
# KEEP_DAILY=14 KEEP_MONTHS=6 bash logical-backup.sh
# DRY_RUN=1 bash logical-backup.sh # 只看会做什么
#
# 环境变量:
# BACKUP_ROOT 备份根目录(默认 /opt/weknora-test/rds-backups)
# APP_CONTAINER 提供 DB 凭据的容器(默认 weknora-test-v071-app-1)
# PG_IMAGE pg_dump 容器镜像(默认 postgres:17.9-alpine,必须 ≥ 服务端 17.9)
# DBS 逗号分隔的库名;默认自动列出非模板库
# JOBS 并行度(默认 4)
# KEEP_DAILY 保留最近几份每日备份(默认 7)
# KEEP_MONTHS 额外保留最近几个自然月各一份(默认 3)
# EXCLUDE_DBS 逗号分隔的跳过列表(默认 rdsadmin:RDS 管理库,普通账号导不出来)
set -euo pipefail
# 备份目录契约:由 scripts/backup_layout.sh 写到 /etc/knowledge-governance/backup.env。
# 换主机(测试云主机 → 生产云主机)时只改这一处,脚本本身不动。
if [ -z "${KB_BACKUP_ROOT:-}" ] && [ -f /etc/knowledge-governance/backup.env ]; then
# shellcheck disable=SC1091
. /etc/knowledge-governance/backup.env
fi
KB_BACKUP_ROOT="${KB_BACKUP_ROOT:-/srv/kb-backups}"
BACKUP_ROOT="${BACKUP_ROOT:-$KB_BACKUP_ROOT/rds-logical}"
APP_CONTAINER="${APP_CONTAINER:-weknora-test-v071-app-1}"
PG_IMAGE="${PG_IMAGE:-postgres:17.9-alpine}"
JOBS="${JOBS:-4}"
# 定时计划(平台「存储与备份」下发到 /etc/knowledge-governance/backup-schedule.env)优先,
# 显式传入的 KEEP_DAILY / KEEP_MONTHS 仍可覆盖(便于手工跑一次性保留策略)。
if [ -z "${KEEP_DAILY:-}" ] || [ -z "${KEEP_MONTHS:-}" ]; then
if [ -f /etc/knowledge-governance/backup-schedule.env ]; then
# shellcheck disable=SC1091
. /etc/knowledge-governance/backup-schedule.env
fi
fi
KEEP_DAILY="${KEEP_DAILY:-${KB_BACKUP_KEEP_DAYS:-7}}"
KEEP_MONTHS="${KEEP_MONTHS:-${KB_BACKUP_KEEP_MONTHS:-3}}"
DRY_RUN="${DRY_RUN:-0}"
DBS="${DBS:-}"
EXCLUDE_DBS="${EXCLUDE_DBS:-rdsadmin}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
env_value() {
docker inspect "$APP_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E "^$1=" | head -1 | cut -d= -f2-
}
DB_HOST_VALUE="$(env_value DB_HOST)"; DB_PORT_VALUE="$(env_value DB_PORT)"
DB_USER_VALUE="$(env_value DB_USER)"; DB_PASS_VALUE="$(env_value DB_PASSWORD)"
if [ -z "${DB_HOST_VALUE}${DB_PORT_VALUE}${DB_USER_VALUE}${DB_PASS_VALUE}" ]; then
echo "缺少数据库凭据:容器 $APP_CONTAINER 里没有 DB_* 环境变量" >&2; exit 1
fi
PGURL="$(python3 - "$DB_USER_VALUE" "$DB_PASS_VALUE" "$DB_HOST_VALUE" "$DB_PORT_VALUE" "$(env_value DB_NAME)" <<'PY'
import sys, urllib.parse as u
user, password, host, port, name = sys.argv[1:6]
print(f"postgres://{u.quote(user)}:{u.quote(password)}@{host}:{port}/{name}")
PY
)"
# 库列表:默认所有非模板库
if [ -z "$DBS" ]; then
DBS="$(docker run --rm -e PGURL="$PGURL" "$PG_IMAGE" sh -c \
"psql \"\$PGURL\" -tAc \"select datname from pg_database where datistemplate = false and datallowconn order by datname\"" \
| tr -d '\r' | paste -sd, -)"
fi
if [ -n "$EXCLUDE_DBS" ]; then
DBS="$(python3 -c "
import sys
exclude = set(filter(None, sys.argv[2].split(',')))
print(','.join(d for d in sys.argv[1].split(',') if d and d not in exclude))
" "$DBS" "$EXCLUDE_DBS")"
fi
if [ -z "$DBS" ]; then echo "没有可备份的库" >&2; exit 1; fi
TARGET="$BACKUP_ROOT/$STAMP"
# 失败或被中断(含被 head/管道 SIGPIPE 杀掉):把目录显式标记为半成品,
# 这样"最近一次备份"的选择器与人都不会把它误当成功结果。
mark_incomplete() {
if [ -d "$TARGET" ] && [ ! -f "$TARGET/MANIFEST.json" ]; then
rm -rf "${TARGET}.incomplete"
mv "$TARGET" "${TARGET}.incomplete" 2>/dev/null || true
echo "本次备份未完成,已标记为 ${TARGET}.incomplete" >&2
fi
}
trap mark_incomplete EXIT
echo "备份目标: $TARGET"
echo "库列表 : $DBS"
echo "镜像 : $PG_IMAGE (并行 $JOBS)"
echo "保留策略: 每日 $KEEP_DAILY 份 / 每月 $KEEP_MONTHS 份"
if [ "$DRY_RUN" = "1" ]; then echo "DRY_RUN=1,不执行"; exit 0; fi
mkdir -p "$TARGET"
STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
START_SECONDS="$(date +%s)"
# 全局对象(角色/表空间):RDS 上通常受限,尽力而为,失败不阻塞。
docker run --rm -e PGURL="$PGURL" "$PG_IMAGE" sh -c \
"pg_dumpall --globals-only --no-role-passwords --dbname=\"\$PGURL\"" >"$TARGET/globals.sql" 2>"$TARGET/globals.err" || true
[ -s "$TARGET/globals.err" ] || rm -f "$TARGET/globals.err"
IFS=',' read -r -a DB_ARRAY <<<"$DBS"
FAILED_DBS=""
for db in "${DB_ARRAY[@]}"; do
db="$(printf '%s' "$db" | tr -d ' ')"
[ -n "$db" ] || continue
DB_URL="$(python3 - "$DB_USER_VALUE" "$DB_PASS_VALUE" "$DB_HOST_VALUE" "$DB_PORT_VALUE" "$db" <<'PY'
import sys, urllib.parse as u
user, password, host, port, name = sys.argv[1:6]
print(f"postgres://{u.quote(user)}:{u.quote(password)}@{host}:{port}/{name}")
PY
)"
echo " - $db ..."
if docker run --rm -e DB_URL="$DB_URL" -v "$TARGET:/backup" "$PG_IMAGE" sh -c \
"pg_dump -Fd -j $JOBS -Z 6 --no-sync -d \"\$DB_URL\" -f /backup/${db}.dir"; then
:
else
# 一个库失败(权限/编码/管理库)不能把整次备份作废:清掉半个目录并记账
echo " 失败,跳过并记账: $db" >&2
rm -rf "$TARGET/${db}.dir"
FAILED_DBS="${FAILED_DBS}${db} "
fi
done
# dump 之后写清单与说明(先写清单,再算哈希,避免把哈希写进自己的输入)
DURATION="$(( $(date +%s) - START_SECONDS ))"
python3 - "$TARGET" "$STAMP" "$STARTED_AT" "$DURATION" "$DBS" "$PG_IMAGE" "$JOBS" "$FAILED_DBS" <<'PY'
import hashlib, json, os, pathlib, sys
target = pathlib.Path(sys.argv[1]); stamp, started, duration = sys.argv[2], sys.argv[3], int(sys.argv[4])
databases, image, jobs = sys.argv[5], sys.argv[6], int(sys.argv[7]); failed = sys.argv[8].split()
files = []
for path in sorted(target.rglob("*")):
if path.is_dir() or path.name in ("MANIFEST.json",):
continue
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1 << 20), b""):
digest.update(block)
files.append({"file": str(path.relative_to(target)), "bytes": path.stat().st_size,
"sha256": digest.hexdigest()})
manifest = {
"kind": "rds-logical-backup",
"created_at": stamp,
"started_at": started,
"duration_seconds": duration,
"databases": [name for name in databases.split(",") if name],
"failed_databases": failed,
"format": "pg_dump -Fd (directory, parallel, zlib)",
"pg_dump_image": image,
"parallel_jobs": jobs,
"restore_hint": "pg_restore -d <目标库> -j4 <库名>.dir(先建库;跨实例恢复可能需要 --no-owner/--no-acl)",
"total_bytes": sum(item["bytes"] for item in files),
"files": files,
}
(target / "MANIFEST.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
print(f"duration={duration}s total={manifest['total_bytes']/1048576:.1f}MiB files={len(files)}")
PY
cat >"$TARGET/README.md" <<'MD'
# 自管逻辑备份(pg_dump 目录格式)
- 谁做的:`logical-backup.sh`,手动触发,文件在本机磁盘(不经 RDS 托管备份服务)。
- 每个库一个 `.dir` 目录(`pg_dump -Fd -jN -Z6`):**目录格式支持并行恢复与按表选择性恢复**。
- `MANIFEST.json`:库列表、耗时、每个文件的 sha256/size。
- `globals.sql`:角色/表空间(RDS 上通常受限,尽力而为,可能为空)。
## 怎么恢复
```bash
# 1) 建目标库(已在别处建好则跳过)
psql "$TARGET_URL" -c 'CREATE DATABASE restore_check'
# 2) 并行恢复(-j 至少 4;目录格式才能并行)
docker run --rm -e TARGET_URL="$TARGET_URL" -v "$PWD:/backup" postgres:17.9-alpine \
pg_restore -d "$TARGET_URL" -j4 --clean --if-exists /backup/<库名>.dir
# 3) 只验某张表(不动生产)
docker run --rm -v "$PWD:/backup" postgres:17.9-alpine \
pg_restore -l /backup/<库名>.dir | grep -i wiki_pages
```
注意:
- **pg_restore 版本必须 ≥ dump 时的 pg_dump**(本次是 17.9)→ 用 `postgres:17.9-alpine`。
- 跨实例恢复若报角色/权限错误,加 `--no-owner --no-acl`。
- 这份备份**不能替代按时间点恢复(PITR)**:它只能回到"备份那一刻"。
MD
# 轮转:保留最近 KEEP_DAILY 份,另在最近 KEEP_MONTHS 个自然月各留一份
python3 - "$BACKUP_ROOT" "$KEEP_DAILY" "$KEEP_MONTHS" <<'PY'
import pathlib, re, shutil, sys
root = pathlib.Path(sys.argv[1]); keep_daily = int(sys.argv[2]); keep_months = int(sys.argv[3])
stamps = sorted([p for p in root.iterdir() if p.is_dir() and re.fullmatch(r"\d{8}T\d{6}Z", p.name)], reverse=True)
keep = {p.name for p in stamps[:keep_daily]}
months = []
for path in stamps:
month = path.name[:6]
if month not in months:
months.append(month)
keep.add(path.name)
if len(months) >= keep_months:
break
for path in stamps:
if path.name not in keep:
shutil.rmtree(path)
print("rotated out:", path.name)
print("kept:", len([p for p in stamps if p.name in keep]))
PY
if [ -n "$FAILED_DBS" ]; then
echo "done(有失败的库): $TARGET | failed: $FAILED_DBS" >&2
exit 1
fi
echo "done: $TARGET"