Deploy / deploy (push) Canceled after 0s
compose.debian.yml 去掉外接形态,postgres / redis 固定由本栈起(PostgreSQL 18、Redis 8)。 docker/move-db-into-oj2.sh 把服务器上旧栈的 16 / 7 倒进来:先编镜像、确认机房已断开、 备份、恢复时剥掉 dump 里的 md5 口令(18 只认 SCRAM)、核对行数和网络登录,最后交给 deploy.sh。 生产库口令原来是默认的 onlinejudge(超级用户、5445 对公网开着),脚本要求换成强口令才执行。 本机按线上「之前」的状态完整演练过:停机到恢复 36 秒,行数一致,登录态保住,提交照常判题。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
9.5 KiB
Bash
Executable File
226 lines
9.5 KiB
Bash
Executable File
#!/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 了,但按名字找
|
||
# 照样能用,move-db-into-oj2.sh 搬家时也靠这一点去备份旧容器(CONTAINER=旧容器名)。
|
||
#
|
||
# ## 用法
|
||
#
|
||
# 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
|