Files
OJ2/docker/backup-db.sh
yuetsh 2bf0e7bfc1
Some checks failed
Deploy / deploy (push) Has been cancelled
ops(备份): 加 backup-db.sh,按容器名导出、校验完整性、带保留策略
原来手敲的那条

    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
2026-09-08 05:21:13 -06:00

215 lines
8.8 KiB
Bash
Executable File
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
#
# 备份线上数据库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