Some checks failed
Deploy / deploy (push) Has been cancelled
一条线上的三件事:让 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>
182 lines
8.8 KiB
Bash
Executable File
182 lines
8.8 KiB
Bash
Executable File
#!/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.20(depends_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 "没装 buildx,compose 会退回老 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
|