Files
OJ2/docker/deploy.sh
T
xuyueandClaude Opus 5 8b4d8899f9
Deploy / deploy (push) Waiting to run
feat(判题机): 自建镜像升级工具链,语言收敛到 C / C++ / Python
上游 QingdaoU/JudgeServer 停更在 2024-04(registry 上的 latest 和 1.6.1 是同一份
镜像,编译器停在 gcc-13),没有新版可拉,所以自己重编。docker/judge/ 是只改工具链
的 Dockerfile 分叉,server/ 和 Judger/ 从上游固定 commit b28aa56 拉,一行没动。

镜像 oj2-judge-2(不在任何 registry 上:本机 build.sh --save → scp → docker load)
- gcc/g++ 13 → 14.2,Python 3.12 → 3.13.5,都是 trixie 默认
- Go / JDK / Node 整套删掉:前端的题目语言复选框从来只给 C / C++ / Python / SQL,
  12 万条提交里 Java 44 条、Golang 15、JavaScript 3,全是很早以前的
- 体积 1.1GB → 433MB;默认走清华源,构建 12 分钟 → 40 秒(--no-mirror 换回官方)
- deploy.sh 加一道自检:镜像不在本机就中止,并打印该跑的三条命令

C 的编译参数加三个 -Wno-error(implicit-function-declaration / int-conversion /
incompatible-pointer-types):gcc-14 把它们从 warning 提成了 error,而 -w 压不住。

语言值统一成 Python(迁移 0019 / 0020)
- 0019:Python3(104527 条提交)与 Python2(3 条)并成 Python,一并改掉 937 道题的
  languages、75 个 template 键、15 个 ast_rules 键、257 条 answers、1235 个用户的
  成就指标 _languages(languages_used 重算,总和 1928 → 1925,少的 3 个是同时用过
  两种 Python 的人)
- 0020:把 Java / JavaScript / Golang 从 84 道题的可选语言里摘掉 —— 不摘的话那些题
  的语言下拉还能选 Java,提交必 SYSTEM_ERROR
- 契约新增 normalizeLanguage() 别名表,判题侧一律走 judgeConfigFor():旧客户端
  localStorage 里的 Python3、迁移前排进队列的任务都还能判;协作的语言归一也走它,
  否则上线那一刻学生页面里的 Python3 会静默落到 C
- 回滚要连数据一起回,只滚代码会让所有 Python 提交变 SYSTEM_ERROR

实跑
- 判题冒烟 docker/judge/smoke.ts 13 条全过:三种语言、六种状态码、gcc 宽松度
- 拿备份里的真实代码逐文件比对新旧镜像的编译结果,0 差异:C 提交 1951 份
  (1725 过 / 226 CE)、C++ 882 份、Python 2000 份、20 篇 C 教程的 93 个代码块。
  不加那三个 -Wno-error 的话,C 有 26 份会从能过变成 CE
- 迁移在灌了 12.4 万行真实数据的一次性库里跑过:0 残留、没有题目被清空;
  dev 库用真正的执行器跑通
- check:ast 56 个 target 全过,前后端 typecheck 均 0

顺带记下一个升级之前就有的坑(现在随 Go 一起消失,写在 README 里):GOCACHE 指向
容器的 tmpfs,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒
的编译预算,于是重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 06:24:05 -06:00

256 lines
14 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*' --exclude dist --exclude 'apps/web/dist' \
# ~/Projects/OJ/OJ2/ root@服务器:/root/OJDeploy/OJ2/
#
# 那两条 --exclude dist 别去掉。不带 --prebuilt 时服务器上根本不需要本机的产物,
# 而 .dockerignore 末尾为 prebuilt 开的例外(`!apps/web/dist`)让它照样进构建上下文
# ——于是本机重编一次前端,服务器上 `COPY apps/web` 那层的缓存键就变了,明明没改
# 源码也要重编 160s。CI 那条路(--prebuilt)反过来必须传产物,它自己的 rsync 在
# .github/workflows/deploy.yml 里,别照抄这条。
#
# 前提:docker/.env 已经填好(照 docker/.env.example 拷一份再填,拓扑见 docs/deploy.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 直接改名(里面的值全是空的)"
# 部署形态。判据用 DB_HOST 而不是另加一个开关:它本来就决定了 DATABASE_URL 指向谁,
# 两个变量各说各话的话,迟早会出现「起了自带的库、却连到别处」这种自相矛盾的状态。
#
# DB_HOST 为空 → 自带数据:本栈起 oj-postgres / oj-rediscompose 里挂在 local-data profile 下)
# DB_HOST 非空 → 外接数据:连别处的库,试跑期间连的是旧栈那两个容器
db_host=$(grep -E '^DB_HOST=' docker/.env | tail -1 | cut -d= -f2- || true)
if [ -z "$db_host" ]; then
LOCAL_DATA=1
COMPOSE+=(--profile local-data)
ok "形态:自带数据(postgres / redis 由本栈起)"
else
LOCAL_DATA=0
ok "形态:外接数据(库在 $db_host"
fi
# 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.*||')"
# ② 库指向必须和形态一致,两个方向都要查
if [ "$LOCAL_DATA" -eq 0 ]; then
# 外接:DB_HOST 不生效会静默回落成 oj-postgres,而这形态下本栈不起那个容器
! grep -q 'DATABASE_URL: postgres://[^@]*@oj-postgres' <<<"$cfg" \
|| die "DB_HOST 没生效,DATABASE_URL 还指着 oj-postgres —— 外接形态下本栈不起它,必然连不上库"
else
# 自带:反过来必须指向服务名 + 容器内端口。连 :5432 一起查,是因为清了 DB_HOST 却
# 忘了清 DB_PORT 的话会拼出 oj-postgres:5445 —— 那是宿主机映射端口,容器网络里不通。
grep -q 'DATABASE_URL: postgres://[^@]*@oj-postgres:5432/' <<<"$cfg" \
|| die "自带数据形态(DB_HOST 为空),但 DATABASE_URL 指向 $(grep -m1 'DATABASE_URL:' <<<"$cfg" | sed 's|.*@||; s|/onlinejudge.*||')
docker/.env 里 DB_PORT / REDIS_PORT 是不是也得一并清空?"
fi
ok "库指向 $(grep -m1 'DATABASE_URL:' <<<"$cfg" | sed 's|.*@||; s|/onlinejudge.*||')"
# ②b 自带形态下数据目录必须已经是一个 16 版本的库。
# 这是整个部署里唯一会**静默**走歪的地方:目录不对的话 postgres 当成全新部署,
# 在空目录上初始化一个空库 —— 站点起得来、能注册能登录,就是一道题都没有。
if [ "$LOCAL_DATA" -eq 1 ]; then
data_dir=$(grep -E '^DATA_DIR=' docker/.env | tail -1 | cut -d= -f2- || true)
[ -n "$data_dir" ] || die "自带数据形态必须显式设 DATA_DIR。
默认值 ../data 解析出来是 OJ2/data,那是个空目录,postgres 会在上面初始化一个全新的空库,而且不报任何错。"
pgver_file="$data_dir/postgres/PG_VERSION"
[ -f "$pgver_file" ] || die "找不到 $pgver_file —— DATA_DIR 指的不是一个已有的 postgres 数据目录。
就这么起下去会得到一个空库。确认 DATA_DIR=$data_dir 对不对。"
pgver=$(cat "$pgver_file")
[ "$pgver" = 16 ] || die "$data_dir/postgres 是 PostgreSQL $pgver 的数据目录,但 compose 里是 postgres:16-alpine,起不来。"
ok "数据目录 $data_dir/postgresPostgreSQL $pgver"
fi
# ③ 判题机运行目录必须和旧栈分开,否则两个 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"
# ④ 判题镜像是自建的(上游 JudgeServer 停更,官方镜像的编译器停在 gcc-13),
# registry 上没有这个 tag。忘了 docker load 的话,要到「起栈」那步 compose 去 pull
# 才失败 —— 不如在这里就把该跑的三条命令说清楚。
# 回滚到官方镜像时这段自动跳过:那个 tag 是 pull 得到的。
judge_image=$(grep -m1 -E '^[[:space:]]*image: oj2-judge' <<<"$cfg" | awk '{print $2}' || true)
if [ -n "$judge_image" ]; then
docker image inspect "$judge_image" >/dev/null 2>&1 \
|| die "判题镜像 $judge_image 不在这台机器上,而且 registry 上也没有(它是自建的)。
本机:docker/judge/build.sh --save
scp dist/${judge_image/:/-}.tar root@这台机器:/root/OJDeploy/
这里:docker load -i /root/OJDeploy/${judge_image/:/-}.tar
构建和回滚见 docker/judge/README.md。"
ok "判题镜像 $judge_image 在本机"
fi
# ⑤ 外接形态才需要预检:库不归本栈管,得确认它已经活着。
# 自带形态下这两个容器就是本栈自己起的,起栈那步会拉起来,这里没什么可查。
if [ "$LOCAL_DATA" -eq 0 ]; then
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 都在跑"
else
ok "postgres / redis 由本栈起,不预检"
fi
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。** `run` 只会拉起被点名服务的依赖,而 oj-api 的依赖恰好就是
# oj-postgres / oj-redis
# · 自带数据形态 —— 必须靠它把 postgres 起来,否则这一步连不上库(加过 --no-deps,
# 踩过这个坑)。depends_on 的 condition: service_healthy 还顺带保证了库真的就绪。
# · 外接形态 —— 那两个服务不在启用的 profile 里,depends_on 上的 required: false
# 让 compose 直接跳过,不会多起任何东西。
# 两种形态下 worker / web 都不会被带起来,它们不是 api 的依赖。
#
# 含 DROP TABLE 这类破坏性语句的迁移会被拦下并退出 4,需要显式放行:
# OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
# 放行前先备份。这道闸门在 apps/api/src/db/migrate.ts。
say "数据库迁移"
"${COMPOSE[@]}" run --rm \
-e OJ2_ALLOW_DESTRUCTIVE="${OJ2_ALLOW_DESTRUCTIVE:-}" \
oj-api oj2-api migrate \
|| die "迁移没通过,已中止部署(容器没动过)。**原因看上面 migrate 自己的输出**,
它对每种情况都打印了具体该做什么,别照搬下面这几条猜。常见的三种:
· 破坏性语句被拦 → 确认备份后 OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
· 缺迁移基线记录 → 按它打印的 SQL 插一行
· 连不上库 → 自带数据形态下 postgres 是否起来了、DATA_DIR 对不对"
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