diff --git a/docker/backup-db.sh b/docker/backup-db.sh new file mode 100755 index 0000000..2c7b329 --- /dev/null +++ b/docker/backup-db.sh @@ -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