Files
OJ2/docker/deploy.sh
T
xuyueandClaude Opus 5.5 b046af4ba0
Deploy / deploy (push) Canceled after 0s
chore(部署): push 后先过检查再部署;判题机重启那几秒交的题不再变成系统错误;worker 挂了看得出来
按外部分析报告(reports/analysis)挑出来做的几条:
- CI 在编完前端之后跑 `bunx vp run verify`,有一项不过就不部署(必须排在编前端之后:
  前端类型检查要的 auto-imports.d.ts / components.d.ts 是 vite build 时才生成的)
- 连不上判题机(连接被拒、解析不到、被重置)时,正式判题等 2/5/10 秒重试,不再当场记成
  系统错误;超时、判题机回错、页面上的运行和自测不重试
- worker 每 10 秒刷一次「还活着」,compose 加 `oj2-api healthcheck --worker`,deploy.sh 起栈后
  也等 worker 健康(docker 不会因 unhealthy 自动重启,只是看得见、部署时拦得住)
- 三套 compose 的容器日志限 10MB × 3
- `/public/%` 这类畸形编码回 404 而不是 500;找不到的接口回统一的 JSON 错误
- 删掉没人用的「停用判题机」接口,契约不再给 task_number / is_disabled(列留在库里)
- 删掉腐化的 apps/web/tests/submitButtonState.test.ts;契约包 zod 范围对齐到 ^4.6.6
- 修几处和实际对不上的注释与文档(表数、编译并发、AST 语言数、deploy.sh 流程、校验处数)

⚠️ 改了 compose 的日志配置,这次部署会把 postgres / redis / 判题机也重建一遍,各停几秒

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

208 lines
11 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,
# 全部由 docker/compose.debian.yml 管。
#
# 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 直接改名(里面的值全是空的)"
# --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.*||')"
# ② 数据目录必须已经是一个 18 版本的库。
# 这是整个部署里唯一会**静默**走歪的地方:目录不对的话 postgres 当成全新部署,
# 在空目录上初始化一个空库 —— 站点起得来、能注册能登录,就是一道题都没有。
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/postgresql/18/docker/PG_VERSION"
[ -f "$pgver_file" ] || die "找不到 $pgver_file —— DATA_DIR 指的不是一个已有的 postgres 数据目录。
就这么起下去会得到一个空库。确认 DATA_DIR=$data_dir 对不对。"
pgver=$(cat "$pgver_file")
[ "$pgver" = 18 ] || die "$data_dir/postgresql 是 PostgreSQL $pgver 的数据目录,但 compose 里是 postgres:18-alpine,起不来。"
ok "数据目录 $data_dir/postgresql(PostgreSQL $pgver)"
# ③ 判题镜像是自建的(go-judge 的二进制 + 我们要的 gcc / python),
# registry 上没有这个 tag。忘了 docker load 的话,要到「起栈」那步 compose 去 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
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; }
# ---------------------------------------------------------------- 起栈
if [ "$BUILD" -eq 1 ]; then
say "构建镜像(首次约 5 分钟,之后走缓存很快)"
# --pull:每次都去查 FROM 的基础镜像有没有新版。不带的话 caddy:2-alpine、
# debian:trixie-slim 永远是这台机器第一次构建时拉的那份,oj-web 里的 Caddy 从来不升级
"${COMPOSE[@]}" build --pull
fi
# ---------------------------------------------------------------- 迁移
# 在起栈**之前**跑:schema 先就位,新代码再启动。
#
# **不要加 --no-deps。** `run` 只会拉起被点名服务的依赖,而 oj-api 的依赖恰好就是
# oj-postgres / oj-redis:必须靠它把 postgres 起来,否则这一步连不上库(加过 --no-deps,
# 踩过这个坑)。depends_on 的 condition: service_healthy 还顺带保证了库真的就绪。
# 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 插一行
· 连不上库 → oj-postgres 是否起来了(docker logs oj-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"
# worker 的健康检查每 30 秒一次,第一次也要等满 30 秒,所以这里多给一些时间。
# 它不起来的话站点照样能开,但交上去的题没人判 —— 学生那边一直「等待评分」
wstatus=starting
for _ in $(seq 45); do
wstatus=$(docker inspect -f '{{.State.Health.Status}}' oj-worker 2>/dev/null || echo starting)
[ "$wstatus" = healthy ] && break
sleep 2
done
[ "$wstatus" = healthy ] || die "oj-worker 没起来(状态 $wstatus)—— 站点能开,但提交没人判
看日志:docker logs oj-worker --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 —— 连的不是生产库。查 DATA_DIR"
cat <<EOF
$(printf '\033[1;32m✅ 起来了\033[0m')
https://oj.xuyue.cc (NPM 反代已配好,指向宿主机:$port)
NPM 那边是一次性的,只有改了 WEB_PORT 才要回去动 proxy host 的端口。另外两项
别关:Websockets Support(关了学生那边「判题中…」永远不动)、
client_max_body_size 200M(上传测试用例压缩包)。
自己点一遍:登录、提交一道题看结果实时刷出来、后台判题机列表、带图片的题面。
EOF