Files
OJ2/docs/specs/phase5-cutover-runbook.md
yuetsh a47e051a49 docs(阶段5): 补验 WebSocket 经 Caddy 与机房那套 compose
演练报告里原来有两块是没验过的,补上:

## WebSocket 经 Caddy

学生盯着「判题中…」变成结果就靠这条路,而它经过 Caddy 的 handle /ws/*,
是配置最容易写错、又只在生产才暴露的一段。实测 upgrade 成功,301ms 内
收到两条推送:judging → finished(AC)。

## 机房那套 compose.school.yml

和服务器那套差别不小(没有 postgres、连远程库、端口 81、COOKIE_SECURE=false),
之前一次都没跑过。留下 oj-postgres 当「远程库」、其余换成 school 栈跑了一遍:
连库、首页、题目列表、登录、WS、完整判题全通。

其中特意验了 **Cookie 没带 Secure** —— 带了的话机房(http 直连 IP)会出现
「登录成功但立刻又变未登录」,是那种看起来毫无头绪的故障。

顺带确认机房的拓扑是「本地 Redis + 本地判题沙箱 + 远程库」,
判题不跨公网,只有数据库查询走公网。

演练用的是 docs/specs/schema.sql 只灌结构,盘上不留学生数据;跑完已清干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 03:33:00 -06:00

9.5 KiB
Raw Blame History

阶段 5切换手册与演练报告

演练日期2026-08-08 演练用快照:db_backup_2026_08_07_10_39_19.sqlpg_dumpall 集群备份230MB2026-08-07 10:39 演练方式:本机 Dockerdocker/compose.debian.yml 本身跑,不是简化版。

真实数据量:1710 用户 / 956 题 / 123140 提交 / 64 比赛 / 16 题单 / 38 成就


一、演练结论

出口标准30 分钟内完成,回滚路径已验证

实测
停旧栈 11s
起新栈(镜像已构建) 34s再次 start 6s
数据迁移 0不需要 —— 见下
全链路验证 约 2 分钟
合计 不到 1 分钟的停机,远在 30 分钟内

真正的时间风险不在切换本身,而在构建镜像:首次构建约 5 分钟(依赖下载占大头)。 镜像必须在停机窗口之前就构建好,见下面的检查清单。

两个原本以为要做、实测不需要做的事

1. 不需要执行任何 DDL。 把生产 dump 的表结构和新后端在用的库逐列对比: 两边都是 278 列,完全一致,没有新增、没有删除、没有新表。新后端直接跑在现有结构上。

2. 不需要重置序列。 我在 phase3-coverage.md 里记过一条「切换必做:重置各表 sequence」 那条是错的 —— 它来自我手工造的本地库(用显式 id 导入、没有 setval。真实的 pg_dumpall 备份带 30 条 setval,而且直接查生产快照里所有序列 vs max(id) 错位 0 个。切换当天不用管序列。

回滚保证(已实测)

新栈跑完登录、提交、判题之后,再和生产 dump 的结构比一次:逐列一致,零差异。 新后端不会给旧后端留下任何它不认识的东西。

加上数据目录布局照抄旧后端(test_casepublic/uploadpublic/avatar 原样不动), 回滚 = 停新栈 + 起旧栈,约 20 秒,不涉及任何数据操作。

未实测的部分:本机没有构建旧后端的 Django 镜像,所以「起旧栈」这一步本身没跑过。 但旧栈在服务器上一直是跑着的,它的镜像和 compose 都没动过。


二、演练验证了什么

全部通过真实生产数据、经 Caddy、在容器里跑

检查项 结果
首页 SPA 200Caddy 从镜像里伺服
站点配置 /api/site 200读出真实站点名「判题狗」
题目列表 200首条是 SQL09
题目标签 / 公告 200
未登录访问后台 401 login-required
登录 200argon2 新哈希 + Django pbkdf2 旧哈希都支持)
个人主页 / 排行榜 200真实学生数据
后台首页 / 题目 / 判题机 200userCount: 1711
判题机心跳 判题容器自行注册成功(新路径 /api/judge-server/heartbeat
完整判题 提交 Python A+B → AC1.2 秒,两个测试点全过

WebSocket 实时推送(经 Caddy

学生盯着「判题中…」变成结果就靠这条路,而它经过 Caddy 的 handle /ws/* 是配置最容易写错、又只在生产才暴露的一段。单独验过:

WS 经 Caddy upgrade: 已连接
收到 2 条推送301ms
  → {"type":"submission_update","result":7,"status":"judging"}
  → {"type":"submission_update","result":0,"status":"finished","time_cost":4,...}

机房那套(compose.school.yml

这套配置和服务器那套差别不小(没有 postgres、连远程库、端口 81、 COOKIE_SECURE=false),单独跑过一遍:留下 oj-postgres 当「远程库」, 其余容器换成 school 栈,DB_HOST 指向宿主机 IP。

检查项 结果
连上「远程」库 oj-api healthy日志零错误
首页 / 题目列表 200数据来自远程库
登录 200
Cookie 没带 Secure ✓ —— 带了的话机房http 直连 IP会「登录成功又立刻变未登录」
WS + 完整判题 连接成功300ms 内 judging → finishedAC

也就是说机房用的是本地 Redis + 本地判题沙箱 + 远程库,判题不跨公网, 只有数据库查询走公网。


三、切换前检查清单(停机窗口之前做完)

  • 先把镜像构建好docker compose -f docker/compose.debian.yml build 首次约 5 分钟。别在停机窗口里构建。
  • 填好 docker/.env(照 docker/.env.example)。 POSTGRES_PASSWORD 必须和生产库现有的口令一致 —— 库是原地不动的, 不是新建的,密码改不了。
  • OJ2_JUDGE_TOKEN 可以换新的(判题机和后端读同一个变量,一起换即可)。
  • AI_KEY 服务器和机房是两个不同的 key别填串。
  • 机房那份 env 里 COOKIE_SECURE=falsehttp 直连 IP带 Secure 的 Cookie 浏览器不回传,表现是「登录成功但立刻又变未登录」)。
  • 做一次 pg_dumpall 备份(不是为了迁移,是为了兜底)。
  • 确认 data/backend/test_casepublic/uploadpublic/avatar 都在。

四、切换步骤

两个站点都要切。 机房那套连的是服务器的库,只切一边的话,另一边的旧后端 还在读写同一个库。

服务器xuyue.cc

cd <部署目录>
docker compose -f docker-compose.debian.yml down     # 停旧栈,约 11s
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d

机房:

docker compose -f docker-compose.school.yml down
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school up -d

端口没变(服务器 8080机房 81前面的 Nginx Proxy Manager 不用动。

五、切换后验证(照着点一遍)

  1. 打开首页,能看到题目列表
  2. 用一个学生账号登录,看得到自己的提交历史
  3. 提交一道题,看判题结果是否实时刷出来(这一步同时验证了 WebSocket
  4. 后台 → 判题机列表,确认判题机在线
  5. 后台 → 题目列表能翻页
  6. 题面里带图片的题,图片能显示(/public/upload/*

六、回滚

docker compose -f OJ2/docker/compose.debian.yml down
docker compose -f docker-compose.debian.yml up -d

约 20 秒。不需要恢复数据库,不需要动任何文件。


七、演练中踩到的坑(写下来是因为它们只在容器里出现)

1. pg_dumpall 备份会覆盖数据库口令 ⚠️

演练时恢复完快照,新后端立刻报:

PostgresError: password authentication failed for user "onlinejudge"

原因:pg_dumpall 的集群备份里带

ALTER ROLE onlinejudge WITH SUPERUSER ... PASSWORD 'md5……';

恢复这份备份,会把角色口令覆盖成备份时生产的那个口令compose 里的 POSTGRES_PASSWORD 就对不上了。

  • 正常切换:不受影响,因为根本不恢复备份。
  • 灾难恢复(真要从备份重建):恢复之后要么把 POSTGRES_PASSWORD 设成生产的口令, 要么恢复后手动 ALTER ROLE onlinejudge PASSWORD '<新口令>'。 这一条不写下来,恢复现场会被一个看起来毫不相干的报错卡住。

(另记:该哈希是 md5PG16 默认已是 scram-sha-256。不影响切换但值得择日换掉。

2. 恢复备份前必须先停应用

应用连着库时dump 里的 DROP DATABASE 会失败:

ERROR: database "onlinejudge" is being accessed by other users

演练时因为目标库本来是空的,数据照样灌进去了 —— 那是运气。目标库有数据的话, 接下来就是满屏主键冲突。灾难恢复流程:先停 oj-api / oj-worker再恢复。

3. 「本地能过、容器里过不了」的三个坑(构建期)

都已修好并写进 Dockerfile 的注释,这里只留索引:

  • 构建上下文吸进 data/,判题沙箱用别的 uid 建的目录 docker 连 stat 都做不了 → .dockerignore
  • mermaid@9.4.3(机房老 Chrome 的 legacy 依赖)从容器里连 npmjs 稳定失败 → 换 npmmirror + 重试
  • 容器里 bun 用 isolated 布局、本地是扁平的,靠「提升」解析的包在容器里一律找不到 → 把真正直接 import 的 4 个包补成直接依赖

八、镜像体积(没达到设计文档的预期,说明原因)

镜像 体积
oj2-api 487MB
oj2-web 75MB

设计文档写的是「降至数十 MB」没做到。拆开看:

体积
clang-formatapt 269MB,其中 libLLVM.so.19.1 一个 124MB
oj2-api 二进制 112MBBun 运行时 + 内嵌的 wasm/原生模块/4.8MB 词典)
ruff 28MB
debian-trixie-slim 基底 79MB

也就是说一半以上是 clang-format 拖进来的 LLVM。旧的 Python 镜像同样装了 clang-format再加整个 Python 运行时和 Django 依赖,所以新镜像仍然明显更小, 但「数十 MB」是当初没把 clang-format 算进去。

想再瘦下来只有一条路:换成静态链接的 clang-format 独立二进制PyPI 的 clang-format wheel 里就是),能砍掉约 265MB。没做因为镜像是各站点本地构建的、 不走镜像仓库,磁盘不是瓶颈;等哪天真嫌大了再说。


九、演练产生的临时数据(已清理)

演练在 OJ2/data/postgres 下留下了一份完整的生产数据副本,其中包含 1710 名 学生的 raw_password 明文列。演练结束后已删除该目录。

以后再演练记得同样处理 —— 那不是测试数据,是真实学生数据。