Files
xuyueandClaude Opus 5.5 cc1361e596 chore(数据): 一条命令把线上最新的库和测试点、图片拉到本机
docker/pull-prod.sh 经 ssh debian 只导 onlinejudge 一个库,灌进新库对过行数再换名,
旧库留作 onlinejudge_prev;--files 顺带同步测试点、题面图片、头像。对服务器只读。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:59:25 +08:00

230 lines
11 KiB
Bash
Executable File
Raw Permalink 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
#
# 把服务器上的最新数据拉到本机 dev:数据库整库换掉,可选把测试点、题面图片、头像一起同步。
# 统计、画设计稿、实跑验证都要真实数据,原来靠手工备份 → 拷回来 → 恢复,每次都要重踩
# docs/deploy.md「备份与灾难恢复」里那两个坑。
#
# ## 用法(在本机跑)
#
# docker/pull-prod.sh # 只换库
# docker/pull-prod.sh --files # 换库 + 同步测试点、题面图片、头像
# docker/pull-prod.sh --files-only # 只同步文件,库不动
# docker/pull-prod.sh --force # 本机 dev 还连着库也照换(断开它们,dev 会自己重连)
# PROD_HOST=debian docker/pull-prod.sh # ssh 的主机名,默认就是 debian(~/.ssh/config 里的别名)
#
# 换下来的旧库改名成 onlinejudge_prev 留着,后悔了:
#
# docker exec oj2-postgres psql -U onlinejudge -d postgres \
# -c 'drop database onlinejudge' -c 'alter database onlinejudge_prev rename to onlinejudge'
#
# 拉完如果本机代码比线上新(有还没上线的迁移),再跑一次 `bun run db:migrate`。
#
# ## 对服务器只读
#
# 服务器上只跑 pg_dump(单个快照事务,不锁表,学生照常提交)和 rsync 的读端。
# 服务器只有 2 核,pg_dump 会占一个核十几秒 —— 上课时间(工作日 8–17 点)会提醒一句。
#
# ## 几个刻意的做法
#
# - **只导 onlinejudge 这一个库,不用 pg_dumpall**。pg_dumpall 带角色和口令,灌回来会把
# 本机 onlinejudge 的口令改成线上的,.env 里的 DATABASE_URL 当场连不上(deploy.md 的坑 1)。
# pg_dump -Fc + pg_restore --no-owner --no-privileges 只搬数据和结构。
# - **先落盘、验完再灌**。直接 `ssh ... | pg_restore` 网络一断就是半个库。落盘的 dump 先
# pg_restore -l 读一遍目录,读不出来就不往下走。
# - **灌进新库再换名,不在原库上 --clean**。原库连着 dev 的 api / worker 时,--clean 的
# DROP TABLE 会卡在锁上(deploy.md 的坑 2);灌新库不用停 dev,只有最后换名那一下要求
# 没人连着。灌到一半失败,原库原封不动。
# - **dump 存仓库外面**(默认 <仓库上级>/backups,和 backup-db.sh 一处),umask 077:
# 里面有 user.raw_password 明文。只留最近 3 份 prod_pull_*.dump。
# - **文件同步不带 --delete**。本机后台出题时生成的测试点只在本机,镜像过来会把它们删掉。
# 不带 owner / group / 权限:线上是 root 的,本机要你自己和判题沙箱都读得了。
#
# 本机库换成线上的之后,账号口令也都是线上的了 —— 自己在本机改过的测试口令要重设。
# 同 backup-db.sh:被 sh 调起时换成 bash,pipefail 是 bash 专有的
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
REPO_DIR="$PWD"
PROD_HOST="${PROD_HOST:-debian}"
REMOTE_CONTAINER="${REMOTE_CONTAINER:-oj-postgres}"
# 服务器 docker/.env 里的 DATA_DIR
REMOTE_DATA="${REMOTE_DATA:-/root/OJDeploy/data}"
LOCAL_CONTAINER="${LOCAL_CONTAINER:-oj2-postgres}"
DB_USER=onlinejudge
DB_NAME=onlinejudge
INCOMING="${DB_NAME}_incoming"
PREV="${DB_NAME}_prev"
OUT_DIR="${BACKUP_DIR:-$(dirname "$REPO_DIR")/backups}"
KEEP=3
DO_DB=1
DO_FILES=0
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--files) DO_FILES=1; shift ;;
--files-only) DO_FILES=1; DO_DB=0; shift ;;
--force) FORCE=1; shift ;;
*) echo "未知参数:$1(可用:--files、--files-only、--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; }
# drop database if exists 在库不存在时会打一条 NOTICE,正常情况不该有噪音
local_psql() { docker exec -e PGOPTIONS="-c client_min_messages=warning" "$LOCAL_CONTAINER" psql -U "$DB_USER" -v ON_ERROR_STOP=1 "$@"; }
# ssh 把参数拼成一行交给远端的 shell 重新拆,'select 1' 这种带空格的参数会被拆成两个。
# 逐个 %q 转义之后再拼,远端拆出来的才和这边写的一样
remote() { ssh -o BatchMode=yes "$PROD_HOST" "$(printf '%q ' "$@")"; }
# 本机除了这个脚本自己以外,还有谁连着 onlinejudge(dev 的 api、worker、开着的 psql)
others_connected() {
local_psql -d postgres -tAc \
"select count(*) from pg_stat_activity where datname = '$DB_NAME' and pid <> pg_backend_pid()" \
| tr -d '[:space:]'
}
# ---------------------------------------------------------------- 自检
say "自检"
remote true 2>/dev/null \
|| die "ssh $PROD_HOST 连不上(BatchMode,不会问口令)。先确认 ssh $PROD_HOST 能直接登进去"
ok "ssh $PROD_HOST 通"
if [ "$DO_DB" -eq 1 ]; then
remote docker exec "$REMOTE_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc 'select 1' >/dev/null 2>&1 \
|| die "服务器上连不上 $REMOTE_CONTAINER 里的 $DB_NAME。容器名不对就用 REMOTE_CONTAINER=... 指定"
ok "服务器的 $REMOTE_CONTAINER 能连上"
docker ps --filter "name=^/${LOCAL_CONTAINER}\$" --filter status=running -q | grep -q . \
|| die "本机容器 $LOCAL_CONTAINER 没在跑,先 bun run db:up"
ok "本机的 $LOCAL_CONTAINER 在跑"
# 换名那一步才真正要求没人连着,但在下载之前就先说,省得白等
n=$(others_connected)
if [ "${n:-0}" != 0 ]; then
[ "$FORCE" -eq 1 ] \
|| die "本机还有 $n 个连接连着 $DB_NAME(多半是 bun run dev 的 api / worker)。
停掉 dev 再跑,或者加 --force:换名前断开它们,dev 会自己重连到新库"
warn "本机有 $n 个连接,--force 已指定,换名前会断开"
fi
# 服务器的时钟是 UTC 还是东八区不确定,按 TZ 显式取东八区(大陆没有夏令时)
read -r dow hour < <(TZ=Asia/Shanghai date '+%u %-H')
if [ "$dow" -le 5 ] && [ "$hour" -ge 8 ] && [ "$hour" -lt 17 ]; then
warn "现在是上课时间,服务器只有 2 核,pg_dump 会占一个核十几秒"
fi
fi
# ---------------------------------------------------------------- 导出
if [ "$DO_DB" -eq 1 ]; then
say "从 $PROD_HOST 导出 $DB_NAME"
mkdir -p "$OUT_DIR" || die "建不了目录 $OUT_DIR"
umask 077
stamp=$(date +%Y_%m_%d_%H_%M_%S)
target="$OUT_DIR/prod_pull_${stamp}.dump"
partial="${target}.partial"
trap 'rm -f "$partial"' EXIT INT TERM
started=$(date +%s)
# 不给 docker exec 加 -t、ssh 也不分配 TTY:-Fc 是二进制,经过 TTY 会被改坏
remote docker exec "$REMOTE_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc > "$partial" \
|| die "pg_dump 失败(半截文件已删)"
elapsed=$(( $(date +%s) - started ))
# 读得出目录才算完整的 dump。用本机容器里的 pg_restore 读,宿主机不一定装了
toc=$(docker exec -i "$LOCAL_CONTAINER" pg_restore -l < "$partial" 2>/dev/null | grep -c ' TABLE DATA ' || true)
[ "${toc:-0}" -gt 0 ] || die "dump 读不出表数据目录,文件是坏的(已删)"
mv -- "$partial" "$target"
trap - EXIT INT TERM
ok "$(basename "$target") — $(du -h "$target" | cut -f1),$toc 张表的数据,用时 ${elapsed}s"
# 线上此刻的提交数,灌完拿来对。dump 是之前那一刻的快照,所以本机只会 ≤ 它
remote_count=$(remote docker exec "$REMOTE_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
'select count(*) from submission' | tr -d '[:space:]')
# ---------------------------------------------------------------- 灌进新库
say "灌进本机的 $INCOMING"
local_psql -d postgres -qc "drop database if exists $INCOMING" \
-c "create database $INCOMING owner $DB_USER"
# --exit-on-error:任何一条失败就停,别留一个缺表的库被换上去
docker exec -i "$LOCAL_CONTAINER" pg_restore -U "$DB_USER" -d "$INCOMING" \
--no-owner --no-privileges --exit-on-error < "$target" \
|| die "pg_restore 失败,原来的 $DB_NAME 没动。$INCOMING 留着方便看哪里坏了"
local_count=$(local_psql -d "$INCOMING" -tAc 'select count(*) from submission' | tr -d '[:space:]')
[ "${local_count:-0}" -gt 0 ] || die "灌完 submission 是空的,不换。原来的 $DB_NAME 没动"
if [ -n "$remote_count" ] && [ "$local_count" -gt "$remote_count" ]; then
die "灌完 submission $local_count 条,比线上此刻的 $remote_count 还多 —— 不对劲,不换"
fi
if [ "$local_count" = "${remote_count:-}" ]; then
ok "submission $local_count 条,和线上此刻一样"
else
ok "submission $local_count 条(线上此刻 ${remote_count:-?} 条,差的是导出之后新交的)"
fi
# ---------------------------------------------------------------- 换名
say "换上新库"
n=$(others_connected)
if [ "${n:-0}" != 0 ]; then
[ "$FORCE" -eq 1 ] \
|| die "灌的这会儿又有 $n 个连接连上了 $DB_NAME。停掉 dev 再跑一次换名,或者加 --force。
新库已经在 $INCOMING 里,手工换:
docker exec $LOCAL_CONTAINER psql -U $DB_USER -d postgres \\
-c 'drop database if exists $PREV' \\
-c 'alter database $DB_NAME rename to $PREV' \\
-c 'alter database $INCOMING rename to $DB_NAME'"
fi
# 断开连接和换名放在同一个 psql 里,中间不给 dev 的连接池留重连的空当
local_psql -d postgres -q \
-c "select pg_terminate_backend(pid) from pg_stat_activity where datname = '$DB_NAME' and pid <> pg_backend_pid()" \
-c "drop database if exists $PREV" \
-c "alter database $DB_NAME rename to $PREV" \
-c "alter database $INCOMING rename to $DB_NAME" >/dev/null \
|| die "换名失败。看一下 pg_database 里现在是什么:
docker exec $LOCAL_CONTAINER psql -U $DB_USER -d postgres -c '\\l'"
ok "$DB_NAME 换成了线上的,原来的改名成 $PREV"
# ---------------------------------------------------------------- 只留最近几份
old=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'prod_pull_*.dump' -printf '%T@ %p\n' \
| sort -rn | tail -n +"$((KEEP + 1))" | cut -d' ' -f2-)
if [ -n "$old" ]; then
printf '%s\n' "$old" | while IFS= read -r f; do rm -f -- "$f"; done
ok "删掉 $(printf '%s\n' "$old" | wc -l) 份旧的 dump,只留最近 $KEEP 份"
fi
fi
# ---------------------------------------------------------------- 文件
if [ "$DO_FILES" -eq 1 ]; then
say "同步文件"
command -v rsync >/dev/null || die "本机没装 rsync"
# 线上路径(DATA_DIR 下)→ 本机路径(apps/api/src/config.ts 的默认值)
sync_dir() {
local from="$1" to="$2"
mkdir -p "$to"
rsync -rltz --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r \
"$PROD_HOST:$REMOTE_DATA/$from/" "$to/" \
|| die "rsync $from 失败"
ok "$from → ${to#"$REPO_DIR"/}($(du -sh "$to" | cut -f1))"
}
sync_dir backend/test_case "$REPO_DIR/data/test_case"
sync_dir backend/public/upload "$REPO_DIR/data/upload"
sync_dir backend/public/avatar "$REPO_DIR/data/avatar"
fi
say "完成"
if [ "$DO_DB" -eq 1 ]; then
printf ' 本机代码比线上新的话,再跑一次:bun run db:migrate\n'
printf ' 账号口令现在都是线上的了,本机改过的测试口令要重设\n\n'
fi