#!/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"