Files
OJ2/docker/backup-db.sh
T
xuyueandClaude Opus 5.5 8427475337
Deploy / deploy (push) Canceled after 0s
chore(清理): 删掉并行试跑、搬库、Python3 别名、旧链接这些过渡期的兼容写法
- 部署:JUDGE_STATE_DIR 不再强制设;删掉搬库脚本和只为搬库存在的自检、提醒
- 语言:Python3 / Python2 别名表删掉,normalizeLanguage 改名 parseLanguage
- 旧链接:后台题单详情的跳转、用户列表 -last_login 的转换
- 旧页面:布置作业必须带 language(null = 没选);不再清旧版出题页草稿

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:15:04 +08:00

226 lines
9.4 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
#
# 按容器名 `docker exec`,跟容器是谁起的无关:以前线上的 postgres 由旧栈那份
# /root/OJDeploy/docker-compose.yml 起,在 OJ2 目录里 `docker compose exec` 会报
# `service "oj-postgres" is not running`。现在它归 compose.debian.yml 了,按名字找
# 照样能用,不跟着 compose 走。
#
# ## 用法
#
# 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=... 指定容器名;服务器上它由本栈起:
docker compose -f docker/compose.debian.yml --env-file docker/.env 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"
# 拿回来的数要进 [ ] 比大小,不是数字就当「读不出来」,不能让它把整个备份带崩
digits_or_zero() {
case "$1" in
'' | *[!0-9]*) printf '0\n' ;;
*) printf '%s\n' "$1" ;;
esac
}
# 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)
db_bytes=$(digits_or_zero "${db_bytes:-}")
# df 的第 4 列是 KB。**原样取字段,不让 awk 参与算术** —— Debian 的 /usr/bin/awk 是
# mawk,超过 2^31 的数会按 OFMT(%.6g)打成 `4.29685e+10`,丢回 [ ] 就是
# 「需要整数表达式」,脚本当场退出。换算放到 bash 的 64 位算术里做,谁的 awk 都一样。
free_kb=$(digits_or_zero "$(df -Pk "$OUT_DIR" | awk 'NR == 2 { print $4 }')")
free_bytes=$(( free_kb * 1024 ))
# 余量检查是**提醒**,不是前置条件:读不出来就跳过,不能因为读不到 df 就不备份
if [ "$free_bytes" -eq 0 ]; then
warn "库 $(human "$db_bytes"),磁盘余量读不出来 —— 跳过余量检查"
else
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
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