feat(切换): 支持并行试跑,新站先挂 oj2.xuyue.cc 跑几天

设计文档写的是「不双跑不灰度」,这次改主意了。理由:「只换前后端」形态下新栈
本来就不碰数据库进程,双跑的增量风险只剩「两个后端同时写同一个库」这一条;
换来的是**正式切换退化成改一行 NPM 上游**,比停机切换更稳,回滚也不用停任何容器。

## 两个新变量

    WEB_PORT          对外端口。8080 还被旧 backend 占着(机房是 81)
    JUDGE_STATE_DIR   判题机运行状态目录

JUDGE_STATE_DIR 是必须的:不设的话新旧两个 judger 会同时往
`data/judge_server/{run,log}` 里写。默认值用嵌套写法跟着 DATA_DIR 走
(`${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}`,compose 支持嵌套默认值,
试过),所以一次性切换那条路径完全不受影响。

test_case 和 public/upload 仍然共享 —— 那是故意的,测试点和题面图片两边必须
看到同一份。

## 手册

新增第四节「并行试跑」,后面章节顺移(原四~九 → 五~十),两处交叉引用一并改了。
第五节拆成两条路径:试跑过的只需改 NPM 上游 + 事后停旧栈;没试跑的走原来那套。
第七节回滚同理。

试跑那节写明了三件容易踩的:NPM 的 Websockets Support 必须打开(漏了的话页面
一切正常,唯独「判题中…」永远不动,而刷新一下结果就出来,自测很难发现)、
两边登录态不互通、以及双写的是真实数据不是沙盒(别在 oj2 上办正式比赛)。

## 验证

试跑形态 `config` 解析:判题机目录落到 judge_server_oj2、端口 8090、test_case
仍指向共享的那份;不设新变量时默认值一个没变(judge_server / 8080)。
四份 compose `config -q` 全通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 09:04:16 -06:00
parent cbff292551
commit 1a059d0b88
5 changed files with 135 additions and 15 deletions

View File

@@ -118,6 +118,9 @@ Drizzle schema 是从生产库 `drizzle-kit pull` 出来的,**不写迁移**
- **只换前后端**(上线用这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST` - **只换前后端**(上线用这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST`
沿用旧栈已经在跑的 postgres 和 redis只起 api / worker / web / judge。 沿用旧栈已经在跑的 postgres 和 redis只起 api / worker / web / judge。
- **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data` - **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data`
- **并行试跑**(上线前先挂 `oj2.xuyue.cc` 跑几天):在「只换前后端」基础上再加
`WEB_PORT`8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。
这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。
⚠️ `DATA_DIR` 默认值 `../data`**`OJ2/data`**,不是部署目录的 `data/` ⚠️ `DATA_DIR` 默认值 `../data`**`OJ2/data`**,不是部署目录的 `data/`
沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404 沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404

View File

@@ -47,6 +47,25 @@ DB_PORT=
REDIS_HOST= REDIS_HOST=
REDIS_PORT= REDIS_PORT=
# --- 并行试跑oj2.xuyue.cc才需要 ---
#
# 新站和旧站同时跑的时候,这两个必须设,否则会跟旧栈抢资源。
#
# 对外端口。旧 backend 占着 8080机房 81新栈换一个NPM 那边把
# oj2.xuyue.cc 指到这个端口。**NPM 的记录要打开 Websockets Support**
# 否则学生那边「判题中…」永远不动。
WEB_PORT=
# 判题机的运行状态目录。不设的话是 `$DATA_DIR/judge_server`,和旧判题机同一份 ——
# 试跑期间两个 judger 都在跑,必须分开,例如:
#
# JUDGE_STATE_DIR=/root/OJDeploy/data/judge_server_oj2
#
# 建完记得 `mkdir -p .../{log,run}`。
# `test_case` 和 `public/upload` 仍然共享,那是**故意的** —— 测试点和题面图片
# 两边必须看到同一份。
JUDGE_STATE_DIR=
# --- 只有机房那套需要 --- # --- 只有机房那套需要 ---
# 机房走 http 直连 IP没有 TLS必须 false否则 Cookie 发不回来。 # 机房走 http 直连 IP没有 TLS必须 false否则 Cookie 发不回来。
COOKIE_SECURE=false COOKIE_SECURE=false

View File

@@ -22,6 +22,10 @@
# #
# 后者要在 env 里设 `DB_HOST` / `REDIS_HOST` / `DATA_DIR`,见 `.env.example` 末尾。 # 后者要在 env 里设 `DB_HOST` / `REDIS_HOST` / `DATA_DIR`,见 `.env.example` 末尾。
# #
# **并行试跑**(新站挂在 oj2.xuyue.cc、旧站原样不动是「只换前后端」再加两个变量
# `WEB_PORT`8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。
# 这种形态下旧栈一个容器都不用停。见手册第四节。
#
# ⚠️ `DATA_DIR` 默认值 `../data` 解析出来是 **`OJ2/data`**,不是部署目录的 `data/`。 # ⚠️ `DATA_DIR` 默认值 `../data` 解析出来是 **`OJ2/data`**,不是部署目录的 `data/`。
# 旧栈用的是 `<部署目录>/data/`,两者不是一个地方 —— 直接用默认值切过去postgres 会在 # 旧栈用的是 `<部署目录>/data/`,两者不是一个地方 —— 直接用默认值切过去postgres 会在
# 空目录上初始化一个全新的空库,测试点和题面图片也全都不在。**用旧数据就必须设 `DATA_DIR`。** # 空目录上初始化一个全新的空库,测试点和题面图片也全都不在。**用旧数据就必须设 `DATA_DIR`。**
@@ -83,8 +87,11 @@ services:
- /tmp - /tmp
volumes: volumes:
- ${DATA_DIR:-../data}/backend/test_case:/test_case:ro - ${DATA_DIR:-../data}/backend/test_case:/test_case:ro
- ${DATA_DIR:-../data}/judge_server/log:/log # 判题机的运行状态目录。默认跟着 DATA_DIR 走,和旧栈是同一份 ——
- ${DATA_DIR:-../data}/judge_server/run:/judger # 一次性切换时无所谓(旧判题机已经停了),但**并行试跑时必须单独设 JUDGE_STATE_DIR**
# 否则新旧两个 judger 同时往一个 run/log 目录里写。
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger
environment: environment:
SERVICE_URL: http://oj-judge:8080 SERVICE_URL: http://oj-judge:8080
# 心跳路径跟着新后端改了judge_server_heartbeat/ → judge-server/heartbeat # 心跳路径跟着新后端改了judge_server_heartbeat/ → judge-server/heartbeat
@@ -164,5 +171,6 @@ services:
# Caddy 只往这里写访问日志,静态资源在镜像里 # Caddy 只往这里写访问日志,静态资源在镜像里
- ${DATA_DIR:-../data}/backend/log:/data/log - ${DATA_DIR:-../data}/backend/log:/data/log
ports: ports:
- "0.0.0.0:8080:8000" # 并行试跑oj2.xuyue.cc时换一个端口8080 还被旧 backend 占着。
- "0.0.0.0:${WEB_PORT:-8080}:8000"
mem_limit: 256m mem_limit: 256m

View File

@@ -44,8 +44,9 @@ services:
- /tmp - /tmp
volumes: volumes:
- ${DATA_DIR:-../data}/backend/test_case:/test_case:ro - ${DATA_DIR:-../data}/backend/test_case:/test_case:ro
- ${DATA_DIR:-../data}/judge_server/log:/log # 并行试跑时必须单独设 JUDGE_STATE_DIR否则新旧两个 judger 共用一个运行目录
- ${DATA_DIR:-../data}/judge_server/run:/judger - ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger
environment: environment:
SERVICE_URL: http://oj-judge:8080 SERVICE_URL: http://oj-judge:8080
BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat
@@ -111,5 +112,6 @@ services:
volumes: volumes:
- ${DATA_DIR:-../data}/backend/log:/data/log - ${DATA_DIR:-../data}/backend/log:/data/log
ports: ports:
- "81:8000" # 并行试跑时换一个端口81 还被旧 backend 占着
- "${WEB_PORT:-81}:8000"
mem_limit: 256m mem_limit: 256m

View File

@@ -98,7 +98,7 @@ WS 经 Caddy upgrade: 已连接
### ⚠️ 演练没暴露的一个坑:两套 compose 的数据目录不是同一个地方 ### ⚠️ 演练没暴露的一个坑:两套 compose 的数据目录不是同一个地方
演练时是把生产 dump 恢复进 `OJ2/data/postgres` 的(见第节),所以这件事被盖住了。 演练时是把生产 dump 恢复进 `OJ2/data/postgres` 的(见第节),所以这件事被盖住了。
真实部署目录 `/root/OJDeploy` 的实际情况: 真实部署目录 `/root/OJDeploy` 的实际情况:
| | 旧栈(`docker-compose.yml` | 新栈默认值 | | | 旧栈(`docker-compose.yml` | 新栈默认值 |
@@ -187,7 +187,7 @@ docker exec oj-postgres pg_dumpall -U onlinejudge > ~/oj-before-cutover-$(date +
``` ```
**不是为了迁移**(不需要 DDL、不需要迁数据是为了出事有退路。真要用它重建 **不是为了迁移**(不需要 DDL、不需要迁数据是为了出事有退路。真要用它重建
先读第节第 1 条 —— 这份备份会把角色口令覆盖回备份当时的值。 先读第节第 1 条 —— 这份备份会把角色口令覆盖回备份当时的值。
### 4. 确认 `DATA_DIR` 指对了 ### 4. 确认 `DATA_DIR` 指对了
@@ -211,13 +211,94 @@ docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env confi
- [ ] **两边的 `DATA_DIR` 都指向旧数据目录**`config | grep source:` 核对过 - [ ] **两边的 `DATA_DIR` 都指向旧数据目录**`config | grep source:` 核对过
- [ ] 两边镜像都构建完了 - [ ] 两边镜像都构建完了
- [ ] 备份做了 - [ ] 备份做了
- [ ] 端口没变(服务器 8080、机房 81前面的 Nginx Proxy Manager 不用动 - [ ] 决定好走哪条路:先并行试跑(第四节,推荐),还是直接切(第五节)
## 四、切换步骤(当天,两边一起切 ## 四、并行试跑oj2.xuyue.cc
正式切换之前,先让新栈挂在一个独立域名上跑几天,**旧站原样不动、一个容器都不用停**。
这是双跑 —— 设计文档原本明确否掉过(「不双跑不灰度」)。改主意的理由是:
「只换前后端」形态下新栈本来就不碰数据库进程,双跑的增量风险只剩「两个后端同时写同一个库」
这一条;而换来的是**正式切换退化成改一行 NPM 上游**,反而比停机切换更稳。
代价见下面「试跑期间要知道的」,不是零。
### 1. env 比「只换前后端」多两个
```
WEB_PORT=8090
JUDGE_STATE_DIR=/root/OJDeploy/data/judge_server_oj2
```
`WEB_PORT` 是因为 8080 还被旧 backend 占着。`JUDGE_STATE_DIR` 是因为**两个判题机不能
共用运行目录** —— 不设的话新旧两个 judger 会同时往 `data/judge_server/{run,log}` 里写。
`test_case``public/upload` 仍然共享,**那是故意的**:测试点和题面图片两边必须看到同一份。
### 2. 起
```bash
mkdir -p /root/OJDeploy/data/judge_server_oj2/log /root/OJDeploy/data/judge_server_oj2/run
cd /root/OJDeploy
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8090/ # 期望 200
```
旧站这时候完全没受影响8080 上照常服务。
### 3. NPM 加一台 proxy host
| 项 | 值 |
|---|---|
| Domain | `oj2.xuyue.cc`(先把 DNS 解析加上) |
| Forward | `<宿主机 IP>` : `8090` |
| **Websockets Support** | **必须打开** |
| SSL | 签一张证书,`COOKIE_SECURE=true` 依赖 https |
| client_max_body_size | `200M`,和 Caddyfile 里的 `200MiB` 对齐(上传测试用例压缩包) |
WebSocket 那个开关是双跑最容易漏的一格:漏了的话页面一切正常,唯独学生盯着的
「判题中…」永远不动 —— 而这恰恰是最不容易在自测里发现的一条,因为刷新一下结果就出来了。
### 4. 试跑期间要知道的
- **两边登录态不互通**。旧站是 Django session新站是 Redis opaque token。
学生到 oj2 要重新登录一次,这不是 bug。
- **同一个库,双写**。结构完全兼容不会写坏,但提交、统计、成就都是**真实数据**
不是沙盒。别拿它做破坏性试验。
- 后台判题机列表会出现**两台**(新旧各自心跳),正常。
- 比赛排名等缓存两边各存各的 Redis可能短暂不一致。
**试跑期间不要在 oj2 上办正式比赛。**
### 5. 试跑要盯的是这些
都是只有真实数据 + 真实浏览器才暴露的:
- 机房那种 **Chrome < 94** 打开正常(`mermaid-legacy` 这条 fallback 只在老浏览器上生效)
- 带图片的题面(`/public/upload/*` 走的是反代,不是 Caddy 直接读盘)
- AI 分析的 **SSE 流式输出**经过 NPM 之后还是不是逐块下发(这一层最容易被缓冲住)
- WebSocket 在 NPM 后面长时间挂着稳不稳(超时断连会不会自动重连)
- 后台**上传测试用例压缩包**这条 200MB 的路
- 老师日常用的后台:出题、建比赛、题单
## 五、正式切换
**两个站点必须同一天切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端 **两个站点必须同一天切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端
还在读写同一个库。 还在读写同一个库。
### 试跑过了 —— 那就只是改一行上游
新栈已经在 8090 上跑着、验过了,切换不需要重启任何容器:
1. NPM 里把 `xuyue.cc` 那台 proxy host 的上游从 `8080` 改成 `8090`**Websockets Support 同样要开**
2. 看一眼首页和一次提交,正常
3. 确认没问题之后再停旧栈:`docker compose -f docker-compose.yml stop oj-backend oj-judge`
**停机时间约等于零**,回滚就是把 NPM 那一行改回 `8080` —— 旧栈这时候都还没停。
这正是设计文档最初写的那个回滚故事:「改一行上游」。
`oj2.xuyue.cc` 可以留着,它和 `xuyue.cc` 指向同一个新栈,没有坏处。
### 没试跑、直接切 —— 走下面这套
服务器(`/root/OJDeploy` 服务器(`/root/OJDeploy`
```bash ```bash
@@ -254,7 +335,9 @@ docker logs oj-api --tail 50 # 连不上库、token 不对都在这里
docker logs oj-judge --tail 20 # 判题机注册不上看这条 docker logs oj-judge --tail 20 # 判题机注册不上看这条
``` ```
## 、切换后验证 ## 、切换后验证
这一节试跑刚起来时也照着跑一遍,只是端口换成试跑用的(`WEB_PORT`,例如 8090
先用命令快速过一遍(服务器 8080机房 81 先用命令快速过一遍(服务器 8080机房 81
@@ -284,7 +367,12 @@ curl -s -o /dev/null -w '未登录进后台 %{http_code}\n' $BASE/api/admin/das
机房那边额外确认一条:**登录之后刷新页面还是登录态**。如果「登录成功又立刻变未登录」, 机房那边额外确认一条:**登录之后刷新页面还是登录态**。如果「登录成功又立刻变未登录」,
就是 `COOKIE_SECURE` 没设成 false。 就是 `COOKIE_SECURE` 没设成 false。
## 、回滚 ## 、回滚
**试跑之后切的**推荐路径NPM 里把 `xuyue.cc` 的上游从 `8090` 改回 `8080`
旧栈这时候还跑着,**秒级生效,什么都不用停不用起**。等确认稳定了再决定何时收掉新栈。
**直接切的**
```bash ```bash
cd /root/OJDeploy cd /root/OJDeploy
@@ -302,7 +390,7 @@ backend 和判题机再 start 起来。**不需要恢复数据库,不需要动
--- ---
## 、演练中踩到的坑(写下来是因为它们只在容器里出现) ## 、演练中踩到的坑(写下来是因为它们只在容器里出现)
### 1. pg_dumpall 备份会覆盖数据库口令 ⚠️ ### 1. pg_dumpall 备份会覆盖数据库口令 ⚠️
@@ -350,7 +438,7 @@ ERROR: database "onlinejudge" is being accessed by other users
--- ---
## 、镜像体积(没达到设计文档的预期,说明原因) ## 、镜像体积(没达到设计文档的预期,说明原因)
| 镜像 | 体积 | | 镜像 | 体积 |
|---|---| |---|---|
@@ -376,7 +464,7 @@ clang-format再加整个 Python 运行时和 Django 依赖,所以新镜像
--- ---
## 、演练产生的临时数据(已清理) ## 、演练产生的临时数据(已清理)
演练在 `OJ2/data/postgres` 下留下了**一份完整的生产数据副本**,其中包含 1710 名 演练在 `OJ2/data/postgres` 下留下了**一份完整的生产数据副本**,其中包含 1710 名
学生的 `raw_password` 明文列。演练结束后已删除该目录。 学生的 `raw_password` 明文列。演练结束后已删除该目录。