Files
OJ2/docker/deploy.sh
yuetsh 586c88f629
Some checks failed
Deploy / deploy (push) Has been cancelled
build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
一条线上的三件事:让 drizzle 的迁移机制真正可用 → 用它加索引 → 用它清掉
不再需要的 Django 表,最后接进部署和 CI。

## 1. 让 drizzle-kit generate 可用

原本以为不能用:只加一个索引,generate 却吐出一堆噪音,其中 5 条
`DROP SEQUENCE auth_*/django_*` 打到生产库上会直接搞坏旧后端。
逐个查下来全是 `pull` 出的基线自己不能 round-trip,都是可修的:

- **快照里的 Django 序列**:tablesFilter 只过滤表、不过滤它们的序列。
  已从 0000_snapshot.json 清掉。
- **bigint 上限精度**:pull 生成的 `maxValue: 9223372036854775807` 是 JS
  number 字面量,round-trip 成 ...776000,每次 generate 都多出 10 条
  ALTER COLUMN。改成字符串。
- **表达式索引的 opclass**:problem_tag_name_ci_unique 在快照里带
  opclass,drizzle 自己序列化不出来,导致每次 drop + recreate。已去掉。

改完 `generate` 是干净的 no-op。

两个改不掉、只能绕的写进了 CLAUDE.md:索引 `.desc()` 生成 SQL 时会被丢
(单列索引不写方向即可,Postgres 用 Index Scan Backward 服务 ORDER BY
DESC,实测同样 0.08ms);migrator 把所有语句包一个事务,
CREATE INDEX CONCURRENTLY 跑不了。

最容易吃亏的是 drizzle 没有 --fake-initial:对已有数据的库直接 migrate
会从 0000 跑起、撞表回滚,**而且 exit 1 但一个错误都不打印**。

## 2. 0001 提交列表索引

`WHERE contest_id IS NULL ORDER BY create_time DESC LIMIT n` 用不上现有的
contest_create_time_idx (contest_id, create_time DESC) —— Postgres 不把
`IS NULL` 当成能吃掉首列、从而继承第二列有序性的等值条件。把
enable_seqscan / enable_bitmapscan 全关掉逼它用也不肯,宁可走单列
contest_id 索引再全量排序。于是每翻一页都 Parallel Seq Scan 扫完整张表。

换成部分索引后谓词由索引自己保证,索引序就是查询要的排序序。生产快照
(12.3 万条提交)实测首页取 10 行:61.8ms / 读 18936 blocks →
0.22ms / 读 34 blocks。端到端 94ms → 6ms。

真正要命的不是单次 61ms,是每个请求都要把 169MB 的表刷一遍
shared_buffers —— 一节课几十个学生同时开提交列表,磁盘和缓存直接被打穿。

## 3. 0002 删掉 Django 残留

确认旧 Django 后端不再使用、也不再作为回滚路径。删前核实过:没有任何
OJ2 保留的表引用这 7 张,3 条外键全在它们内部(所以不用 CASCADE,真有
漏网的会报错而不是被悄悄级联掉);5 个序列都由各自的表 owned,随
DROP TABLE 一并消失;数据全是 Django 自身元数据。tablesFilter 随之移除。

**回滚路径就此作废** —— CLAUDE.md 开头和 runbook 的「回滚保证」「七、回滚」
都改了。这条迁移已在本机 dev 库和生产快照副本上跑通,**生产库尚未执行**。

## 4. migrate 接进部署与 CI

deploy.sh 在构建之后、起栈之前跑 `oj2-api migrate`,失败就中止部署(旧
容器原样还在跑)。CI 走的也是 deploy.sh,所以不用给 GitHub 配数据库凭据,
也不用把生产库对外开放。

迁移文件**不内嵌进二进制**,随镜像装在 /usr/local/share/oj2/migrations。
这样 drizzle 的 migrate() 能原样用 —— 靠 _journal.json 自动发现,新增迁移
不用改任何代码,和 Django 扫 migrations/ 是一回事。内嵌就得为每条迁移
手写一行 import,那是迟早会漏的账。(CLAUDE.md 里「单二进制不能读文件」
那条讲的是 node_modules 和 import.meta.dir 推路径,按显式绝对路径读一个
数据目录不在此列。)

三道闸门,都是写完测出来才补上的:

- **破坏性迁移拦截**:DROP TABLE / DROP COLUMN / DROP SCHEMA /
  ALTER COLUMN ... TYPE / TRUNCATE 命中就退出 4,需要
  `OJ2_ALLOW_DESTRUCTIVE=1` 显式放行。DROP INDEX / DROP CONSTRAINT 不算,
  拦了只会让人习惯性带上放行开关。扫描前先剥注释,避免误报。
- **基线缺失**:退出 3 并直接打印该敲的 SQL。注意判的是
  `max(created_at) < 0` 而不是「表不存在」—— 表存在而为空(上次迁移失败
  留下的)同样是没基线。
- **迁移目录读不到**:这是最可能犯的错(Dockerfile 漏拷),原本是 drizzle
  的堆栈,现在直接说该检查哪一行。

另外发现 0000_crazy_gateway.sql 是 pull 的产物,**整份被 /* */ 包着,
可执行语句 0 条**,所以这个库根本不能靠迁移自举建表。原先写的「空库就从
0000 建」跑起来会炸在一个和真实原因毫不相干的 unterminated /* comment 上。
现在如实说明:结构只能来自 docs/specs/schema.sql 或生产 dump。

验证:镜像内编译(ARTIFACTS=build)出的真实镜像跑完五种场景 —— 拦截、
放行、幂等、无基线、漏拷目录,全部符合预期;dev 形态同样五种场景全过。
tsc / 路由遮蔽 / deploy.sh 语法 / generate no-op 都通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:56:30 -06:00

182 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
#
# 在**服务器上**起 OJ2 新栈(并行试跑形态:旧栈的 postgres/redis 继续跑,
# 这里只起 api / worker / web / judge旧站一个容器都不用停
#
# cd /root/OJDeploy/OJ2
# docker/deploy.sh # 自检 → 构建 → 起栈 → 冒烟
# docker/deploy.sh --check # 只自检,不动任何容器
# docker/deploy.sh --no-build # 跳过构建(只改了 env / compose 时用)
# docker/deploy.sh --prebuilt # 用现成产物不在这台机器上编CI 走这条)
#
# 默认(不带 --prebuilt是**服务器自己编**:只要有 docker 就能跑,不依赖 CI
# 也不依赖别处传产物过来。代价是这台机器性能差builder 阶段首次约 5 分钟。
#
# --prebuilt 则要求 dist/oj2-api 和 apps/web/dist 已经在这个目录里GitHub
# Actions 编好后 rsync 过来的),镜像构建只剩几条 COPY。产物缺失会在自检就拦下。
#
# 代码怎么上到服务器不归它管。没有 git remote 的话,在本机:
#
# rsync -az --delete --exclude node_modules --exclude .git --exclude data \
# --exclude 'docker/.env*' ~/Projects/OJ/OJ2/ root@服务器:/root/OJDeploy/OJ2/
#
# 前提docker/.env 已经填好(内容见 docs/specs/phase5-cutover-runbook.md 第三节)。
# `sh docker/deploy.sh` 会用 dash 跑Debian 的 /bin/sh 就是 dash而下面那行
# 的 pipefail 是 bash 专有的,一上来就报 `Illegal option -o pipefail`。
# 这行必须在 set 之前,且只能用 dash 也认的语法。
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
COMPOSE=(docker compose -f docker/compose.debian.yml --env-file docker/.env)
CHECK_ONLY=0
BUILD=1
for arg in "$@"; do
case "$arg" in
--check) CHECK_ONLY=1 ;;
--no-build) BUILD=0 ;;
# compose 里写的是 args: ARTIFACTS: ${ARTIFACTS:-build},导出即可生效
--prebuilt) export ARTIFACTS=prebuilt ;;
*) echo "未知参数:$arg(可用:--check、--no-build、--prebuilt" >&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; }
# ---------------------------------------------------------------- 自检
say "自检"
[ -f docker/.env ] || die "docker/.env 不存在。内容见手册第三节,别用 .env.example 直接改名(里面的值全是空的)"
# compose 版本depends_on.required 是 v2.20 才有的字段,老版本会解析失败
ver=$(docker compose version --short 2>/dev/null || echo 0)
# 取 min(2.20, ver):等于 2.20 就说明 ver 不低于它。别用 `sort -V -C`
# 那个判的是「这两行本来就有序」,对 ver 更新的情况会反过来判成太老(写错过一次)。
[ "$(printf '2.20\n%s\n' "$ver" | sort -V | head -1)" = "2.20" ] \
|| die "docker compose 版本太老($ver),需要 ≥ 2.20depends_on.required 是那个版本才有的字段)"
ok "docker compose $ver"
# --prebuilt 时产物就是构建输入。缺了的话 docker 只会甩一句 COPY 找不到文件,
# 这里提前拦下并说清楚该怎么办。
if [ "${ARTIFACTS:-build}" = prebuilt ]; then
for f in dist/oj2-api apps/web/dist/index.html; do
[ -e "$f" ] || die "--prebuilt 需要现成产物,但 $f 不存在。
要么让 CI 把产物 rsync 过来(.github/workflows/deploy.yml
要么去掉 --prebuilt 让这台机器自己编(慢,但不依赖任何外部环节)。"
done
ok "用现成产物($(du -sh dist/oj2-api apps/web/dist 2>/dev/null | tr '\n' ' ')"
# 没有 buildx 时 compose 会退回 classic builder构建日志末尾那行
# `LABEL com.docker.compose.image.builder=classic` 就是它。classic **不看
# 依赖图,所有阶段挨个跑**,于是 builder 照样 bun install + vite build 一遍,
# 产物白编了 —— 结果是对的,只是一分钟没省下来。
docker buildx version >/dev/null 2>&1 \
|| warn "没装 buildxcompose 会退回老 builder它不跳过用不到的阶段
—— 这次 --prebuilt 省不下时间(结果仍然正确)。
apt-get install docker-buildx-plugin"
else
ok "产物在镜像里现编(首次约 5 分钟CI 部署走的是 --prebuilt"
fi
cfg=$("${COMPOSE[@]}" config) || die "compose 配置解析失败,看上面的报错"
# ① DATA_DIR不生效的话卷会挂到 OJ2/data 这个空目录上 —— 站点起得来,
# 但没有测试点、题面图片 404而且**不报任何错**。这是唯一会静默走歪的地方。
bad=$(grep 'source:' <<<"$cfg" | grep 'OJ2/data' || true)
[ -z "$bad" ] || die "DATA_DIR 没生效,这些卷指向了 OJ2/data
$bad
检查 docker/.env 里的 DATA_DIR注意DATA_DIR= 空值等于没设,等号两边不能有空格)"
ok "数据卷都在 $(grep 'source:' <<<"$cfg" | head -1 | sed 's|.*source: ||; s|/backend.*||')"
# ② DB_HOST不生效会静默回落成 oj-postgres而试跑形态下那个容器根本没起
! grep -q 'DATABASE_URL: postgres://[^@]*@oj-postgres' <<<"$cfg" \
|| die "DB_HOST 没生效DATABASE_URL 还指着 oj-postgres —— 试跑形态下它不存在,起来必然连不上库"
ok "库指向 $(grep -m1 'DATABASE_URL:' <<<"$cfg" | sed 's|.*@||; s|/onlinejudge.*||')"
# ③ 判题机运行目录必须和旧栈分开,否则两个 judger 往同一个目录里写
judge_dir=$(grep -E '^JUDGE_STATE_DIR=' docker/.env | tail -1 | cut -d= -f2- || true)
[ -n "$judge_dir" ] || die "JUDGE_STATE_DIR 没设 —— 试跑期间新旧两个判题机会共用运行目录"
ok "判题机运行目录 $judge_dir"
# ④ 旧栈的 postgres / redis 得还活着,新栈要连它们
for c in oj-postgres oj-redis; do
docker ps --filter "name=$c" --filter status=running -q | grep -q . \
|| die "旧栈的 $c 没在跑。试跑形态依赖它们 —— 先 docker compose -f /root/OJDeploy/docker-compose.yml up -d $c"
done
ok "旧栈的 postgres / redis 都在跑"
port=$(grep -E '^WEB_PORT=' docker/.env | tail -1 | cut -d= -f2- || true)
port=${port:-8080}
ok "对外端口 $port"
[ "$CHECK_ONLY" -eq 0 ] || { printf '\n\033[1;32m✅ 自检通过(--check没动容器\033[0m\n'; exit 0; }
# ---------------------------------------------------------------- 起栈
mkdir -p "$judge_dir/log" "$judge_dir/run"
if [ "$BUILD" -eq 1 ]; then
say "构建镜像(首次约 5 分钟,之后走缓存很快)"
"${COMPOSE[@]}" build
fi
# ---------------------------------------------------------------- 迁移
# 在起栈**之前**跑schema 先就位,新代码再启动。
#
# --no-deps 是必须的:这一步只要 api 镜像里的二进制,不该顺带把 worker/web 拉起来。
# 库连的是 env 里的 DB_HOST和起栈用的是同一份配置。
#
# 含 DROP TABLE 这类破坏性语句的迁移会被拦下并退出 4需要显式放行
# OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
# 放行前先备份。这道闸门在 apps/api/src/db/migrate.ts。
say "数据库迁移"
"${COMPOSE[@]}" run --rm --no-deps \
-e OJ2_ALLOW_DESTRUCTIVE="${OJ2_ALLOW_DESTRUCTIVE:-}" \
api oj2-api migrate \
|| die "迁移没通过,已中止部署(旧容器还在跑,没动过)。
上面的输出说明了原因。破坏性迁移需要 OJ2_ALLOW_DESTRUCTIVE=1 显式放行。"
say "起栈"
"${COMPOSE[@]}" up -d
"${COMPOSE[@]}" ps
status=starting
for _ in $(seq 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' oj-api 2>/dev/null || echo starting)
[ "$status" = healthy ] && break
sleep 2
done
[ "$status" = healthy ] || die "oj-api 没起来(状态 $status
看日志docker logs oj-api --tail 50"
# ---------------------------------------------------------------- 冒烟
say "冒烟(端口 $port"
code() { curl -s -o /dev/null -w '%{http_code}' "http://localhost:$port$1"; }
printf ' 首页 %s\n' "$(code /)"
printf ' 站点配置 %s\n' "$(code /api/site)"
printf ' 未登录进后台 %s (期望 401)\n' "$(code /api/admin/dashboard)"
total=$(curl -s "http://localhost:$port/api/problems" | grep -oE '"total":[0-9]+' | head -1 | cut -d: -f2 || true)
printf ' 题目总数 %s\n' "${total:-读不出来}"
[ "${total:-0}" -gt 0 ] || die "题目数是 0 —— 连的不是生产库。查 DB_HOST / POSTGRES_PASSWORD"
cat <<EOF
$(printf '\033[1;32m✅ 起来了\033[0m')
https://oj2.xuyue.cc NPM 反代已配好,指向宿主机:$port
NPM 那边是一次性的,只有改了 WEB_PORT 才要回去动 proxy host 的端口。另外两项
别关Websockets Support关了学生那边「判题中…」永远不动
client_max_body_size 200M上传测试用例压缩包
自己点一遍:登录、提交一道题看结果实时刷出来、后台判题机列表、带图片的题面。
EOF