ops(备份): 加 backup-db.sh,按容器名导出、校验完整性、带保留策略
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
原来手敲的那条
docker compose exec -T oj-postgres pg_dumpall -c -U onlinejudge > db_backup_xxx.sql
在服务器上报 `service "oj-postgres" is not running`。容器明明在跑 —— 线上是外接
形态,postgres 由 /root/OJDeploy/docker-compose.yml 起,不归 OJ2 这套 compose 管;
compose.debian.yml 里那个 oj-postgres 挂着 `profiles: ["local-data"]`,只在自带
数据形态下启动。所以脚本直接按容器名 `docker exec`,跟谁起的无关,两种形态都能用。
比原来那条命令多做的事:
- **先写 .partial,验完才改名**。`> file.sql` 中途失败(容器挂了、盘满了、
pg_dumpall 报错)会留个半截文件,看着像备份,等到要恢复那天才发现不是。
中断也清掉。
- **验完整性**。gzip -t,再检查结尾有没有「PostgreSQL database cluster dump
complete」,缺了就当失败。
- **umask 077 + chmod 600**。备份里有 user.raw_password(明文密码,本来就是留给
老师查的)和角色口令散列,不该落成 644。
- **自检真连一次库**(psql -c 'select 1' 而不是 pg_isready)。后者用户名写错照样
说 OK,要到 pg_dumpall 才炸出一句 role does not exist。
- **默认存到仓库外面**。deploy.sh 头部那条 rsync 带 --delete,备份放仓库里下次
部署就没了;真放进去了会警告。
- **保留策略带兜底**。删超期的,但最新 3 份永远保留 —— 时钟错乱或者 --keep-days
手滑填 0,都不该把手头唯一的备份删掉。
- **磁盘余量检查**。剩余空间比库还小就拒绝,--force 才继续。备份把生产盘写满比
没备份更糟。
默认 gzip,--plain 关掉。头部写了 cron 的写法,以及恢复时那几条
`does not exist` / `already exists` 是 pg_dumpall -c 的正常噪音。
本机 dev 库(245 MiB)实跑:正常路径 38.9 MiB gz / 2 秒;容器名写错、用户名写错都在
自检拦住;导出中途失败后 .partial 被清掉;保留策略造 5 份 30 天前的 → 删 5 留 3,
把全部文件做旧 → 仍然保住最新 3 份。**恢复也真跑了**:起一个干净的 postgres:16-alpine
灌进去,submission=12 / user=11 / problem=20 / 28 张表,和原库一致。
只管数据库。判题测试点在 data/backend/test_case,不在库里,那份还没有备份手段。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
This commit is contained in:
214
docker/backup-db.sh
Executable file
214
docker/backup-db.sh
Executable file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 备份线上数据库:pg_dumpall 全量(所有库 + 所有角色),带校验和保留策略。
|
||||
#
|
||||
# ## 为什么不走 docker compose exec
|
||||
#
|
||||
# 线上是**外接形态**:postgres 容器由 /root/OJDeploy/docker-compose.yml 起,不归
|
||||
# OJ2 这套 compose 管。compose.debian.yml 里虽然也定义了 oj-postgres,但它挂着
|
||||
# `profiles: ["local-data"]`,只在「自带数据」形态下才启动。所以在 OJ2 目录里跑
|
||||
#
|
||||
# docker compose exec -T oj-postgres pg_dumpall ...
|
||||
#
|
||||
# 会报 `service "oj-postgres" is not running` —— 容器明明在跑,只是属于另一个
|
||||
# compose 项目。这里直接按容器名 `docker exec`,跟谁起的无关,两种形态都能用。
|
||||
#
|
||||
# ## 用法
|
||||
#
|
||||
# docker/backup-db.sh # 备份到 <仓库上级>/backups
|
||||
# docker/backup-db.sh --out /mnt/backup # 指定目录
|
||||
# docker/backup-db.sh --plain # 不压缩(默认 gzip)
|
||||
# docker/backup-db.sh --keep-days 30 # 保留 30 天(默认 14)
|
||||
# docker/backup-db.sh --force # 磁盘余量不足也照做
|
||||
# CONTAINER=oj2-postgres docker/backup-db.sh # 本机 dev
|
||||
#
|
||||
# ## 定时跑
|
||||
#
|
||||
# crontab -e
|
||||
# 30 3 * * * /root/OJDeploy/OJ2/docker/backup-db.sh >> /var/log/oj-backup.log 2>&1
|
||||
#
|
||||
# cron 的 PATH 很短,docker 一般在 /usr/bin 里,通常够用;真找不到就写绝对路径。
|
||||
#
|
||||
# ## 恢复时这几条报错是正常的
|
||||
#
|
||||
# ERROR: database "xxx" does not exist DROP 一个目标机上没有的库
|
||||
# ERROR: current user cannot be dropped 正连着的角色删不掉
|
||||
# ERROR: role "onlinejudge" already exists 角色已经在了
|
||||
#
|
||||
# pg_dumpall -c 生成的是「先删后建」,往一个干净实例灌的时候这几条必然出现,
|
||||
# 不影响结果。恢复完对一下行数才是准的:
|
||||
#
|
||||
# select count(*) from submission;
|
||||
#
|
||||
# ## 几个刻意的做法
|
||||
#
|
||||
# - **不给 docker exec 加 -t**。分配了 TTY,导出的 SQL 行尾会变成 CRLF,恢复时炸。
|
||||
# (`-T` 更是压根不存在于 `docker exec`,那是 `docker compose exec` 的参数,
|
||||
# 照抄过来会直接报错。)
|
||||
# - **先写 .partial,验完才改名**。`> db_backup_xxx.sql` 这种写法一旦中途失败
|
||||
# —— 容器挂了、磁盘满了、pg_dumpall 报错 —— 留下的是个半截文件,看着像备份,
|
||||
# 等到要恢复的那天才发现不是。
|
||||
# - **验完整性**:pg_dumpall 正常结束的最后一行是「PostgreSQL database cluster
|
||||
# dump complete」。没有这行就当失败,删掉重来。
|
||||
# - **umask 077**。备份里有 user.raw_password(明文密码,本来就是留给老师查的)
|
||||
# 和所有角色的口令散列,不该是 644。
|
||||
# - **默认存到仓库外面**。deploy 那条 rsync 带 `--delete`,备份放在仓库目录里,
|
||||
# 下次部署就被删了。
|
||||
#
|
||||
# 只管数据库。判题测试点在 data/backend/test_case,不在库里,那份要另外备。
|
||||
|
||||
# `sh docker/backup-db.sh` 会用 dash 跑(Debian 的 /bin/sh 就是 dash),而下面那行
|
||||
# 的 pipefail 是 bash 专有的,一上来就报 `Illegal option -o pipefail`。
|
||||
# 这行必须在 set 之前,且只能用 dash 也认的语法。deploy.sh 里是同一道垫片。
|
||||
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
REPO_DIR="$PWD"
|
||||
|
||||
CONTAINER="${CONTAINER:-oj-postgres}"
|
||||
DB_USER="${DB_USER:-onlinejudge}"
|
||||
OUT_DIR="${BACKUP_DIR:-$(dirname "$REPO_DIR")/backups}"
|
||||
KEEP_DAYS="${KEEP_DAYS:-14}"
|
||||
# 保留策略再狠也不动最新的这几份 —— 服务器时钟错乱、或者 --keep-days 手滑填了 0,
|
||||
# 都不该把手头唯一的备份删掉
|
||||
KEEP_MIN=3
|
||||
COMPRESS=1
|
||||
FORCE=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--out) OUT_DIR="${2:?--out 后面要跟目录}"; shift 2 ;;
|
||||
--keep-days) KEEP_DAYS="${2:?--keep-days 后面要跟天数}"; shift 2 ;;
|
||||
--plain) COMPRESS=0; shift ;;
|
||||
--force) FORCE=1; shift ;;
|
||||
*) echo "未知参数:$1(可用:--out、--keep-days、--plain、--force)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
|
||||
die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
|
||||
|
||||
human() {
|
||||
awk -v b="$1" 'BEGIN {
|
||||
split("B KiB MiB GiB TiB", u, " "); i = 1
|
||||
while (b >= 1024 && i < 5) { b /= 1024; i++ }
|
||||
printf "%.1f %s\n", b, u[i]
|
||||
}'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- 自检
|
||||
say "自检"
|
||||
|
||||
docker ps --filter "name=^/${CONTAINER}\$" --filter status=running -q | grep -q . \
|
||||
|| die "容器 $CONTAINER 没在跑。用 CONTAINER=... 指定容器名;外接形态下它由
|
||||
/root/OJDeploy/docker-compose.yml 起:
|
||||
docker compose -f /root/OJDeploy/docker-compose.yml up -d $CONTAINER"
|
||||
|
||||
# 用 psql 真连一次,不用 pg_isready:后者只探「服务在不在」,DB_USER 写错照样说 OK,
|
||||
# 要等到 pg_dumpall 那步才炸出一句 role does not exist
|
||||
docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -tAc 'select 1' >/dev/null 2>&1 \
|
||||
|| die "连不上 $CONTAINER 里的 postgres(用户 $DB_USER)—— 服务没起,或者用 DB_USER=... 指定用户"
|
||||
ok "容器 $CONTAINER 在跑,$DB_USER 能连上"
|
||||
|
||||
mkdir -p "$OUT_DIR" || die "建不了备份目录 $OUT_DIR"
|
||||
case "$OUT_DIR/" in
|
||||
"$REPO_DIR"/*) warn "备份目录在仓库里 —— deploy 的 rsync --delete 会把它删掉" ;;
|
||||
esac
|
||||
ok "备份目录 $OUT_DIR"
|
||||
|
||||
# datallowconn 是为了跳过 template0:它不让连,pg_database_size 也算不了
|
||||
db_bytes=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -tAc \
|
||||
"select coalesce(sum(pg_database_size(datname)), 0)::bigint from pg_database where datallowconn" \
|
||||
2>/dev/null | tr -d '[:space:]' || true)
|
||||
[ -n "${db_bytes:-}" ] || db_bytes=0
|
||||
|
||||
free_bytes=$(df -Pk "$OUT_DIR" | awk 'NR == 2 { print $4 * 1024 }')
|
||||
ok "库 $(human "$db_bytes"),磁盘剩 $(human "$free_bytes")"
|
||||
|
||||
if [ "$db_bytes" -gt 0 ] && [ "$free_bytes" -lt "$db_bytes" ]; then
|
||||
[ "$FORCE" -eq 1 ] \
|
||||
|| die "磁盘余量比库还小。压缩后通常小一个数量级,确认够用就加 --force
|
||||
—— 备份把生产磁盘写满,比没有备份更糟"
|
||||
warn "磁盘余量不足,--force 已指定,继续"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 导出
|
||||
say "导出"
|
||||
|
||||
stamp=$(date +%Y_%m_%d_%H_%M_%S)
|
||||
target="$OUT_DIR/db_backup_${stamp}.sql"
|
||||
[ "$COMPRESS" -eq 1 ] && target="${target}.gz"
|
||||
partial="${target}.partial"
|
||||
|
||||
# 明文密码在里面,别落成 644
|
||||
umask 077
|
||||
# 中途失败(含 Ctrl-C)不留半截文件冒充备份
|
||||
trap 'rm -f "$partial"' EXIT INT TERM
|
||||
|
||||
started=$(date +%s)
|
||||
if [ "$COMPRESS" -eq 1 ]; then
|
||||
# pipefail 已开:pg_dumpall 挂了整条管道就算失败,不会只看 gzip 的返回码
|
||||
docker exec "$CONTAINER" pg_dumpall -c -U "$DB_USER" | gzip -c > "$partial"
|
||||
else
|
||||
docker exec "$CONTAINER" pg_dumpall -c -U "$DB_USER" > "$partial"
|
||||
fi
|
||||
elapsed=$(( $(date +%s) - started ))
|
||||
|
||||
# ---------------------------------------------------------------- 校验
|
||||
say "校验"
|
||||
|
||||
if [ "$COMPRESS" -eq 1 ]; then
|
||||
gzip -t "$partial" 2>/dev/null || die "gzip 自检没过,文件是坏的(已删)"
|
||||
ok "gzip 完整"
|
||||
ending=$(gzip -cd "$partial" | tail -5)
|
||||
else
|
||||
ending=$(tail -5 "$partial")
|
||||
fi
|
||||
|
||||
printf '%s\n' "$ending" | grep -q 'database cluster dump complete' \
|
||||
|| die "结尾没有「PostgreSQL database cluster dump complete」,导出不完整(已删)
|
||||
最后几行:
|
||||
$ending"
|
||||
ok "结尾正常,导出完整"
|
||||
|
||||
mv -- "$partial" "$target"
|
||||
trap - EXIT INT TERM
|
||||
chmod 600 "$target"
|
||||
|
||||
size_bytes=$(wc -c < "$target")
|
||||
ok "$(basename "$target") — $(human "$size_bytes"),用时 ${elapsed}s"
|
||||
|
||||
# ---------------------------------------------------------------- 保留
|
||||
say "保留 $KEEP_DAYS 天"
|
||||
|
||||
# 按 mtime 倒序取最新的几份,它们无论多旧都不删
|
||||
protected=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'db_backup_*.sql*' -printf '%T@ %p\n' \
|
||||
| sort -rn | head -n "$KEEP_MIN" | cut -d' ' -f2-)
|
||||
|
||||
removed=0
|
||||
while IFS= read -r old; do
|
||||
[ -n "$old" ] || continue
|
||||
case $'\n'"$protected"$'\n' in
|
||||
*$'\n'"$old"$'\n'*) continue ;;
|
||||
esac
|
||||
rm -f -- "$old" && removed=$((removed + 1))
|
||||
done < <(find "$OUT_DIR" -maxdepth 1 -type f -name 'db_backup_*.sql*' -mtime "+$KEEP_DAYS")
|
||||
|
||||
kept=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'db_backup_*.sql*' | wc -l)
|
||||
ok "删掉 $removed 份过期的,现存 $kept 份(最新 $KEEP_MIN 份永远保留)"
|
||||
|
||||
say "完成"
|
||||
printf ' %s\n\n' "$target"
|
||||
printf ' 恢复(会先 DROP 再建,确认连的是对的实例;开头几条 does not exist\n'
|
||||
printf ' / already exists 是正常的,见本脚本头部):\n'
|
||||
if [ "$COMPRESS" -eq 1 ]; then
|
||||
printf ' gzip -cd %s | docker exec -i %s psql -U %s -d postgres\n\n' \
|
||||
"$target" "$CONTAINER" "$DB_USER"
|
||||
else
|
||||
printf ' docker exec -i %s psql -U %s -d postgres < %s\n\n' \
|
||||
"$CONTAINER" "$DB_USER" "$target"
|
||||
fi
|
||||
Reference in New Issue
Block a user