docs(阶段5): 用生产快照跑完整切换演练

演练用 compose.debian.yml **本身**在本机 Docker 里跑,不是简化版。
数据是 2026-08-07 的 pg_dumpall 快照:1710 用户 / 956 题 / 123140 提交。

## 出口标准达成

停旧栈 11s,起新栈 34s(镜像预先构建好),全链路验证约 2 分钟 ——
**停机不到 1 分钟**,远在 30 分钟内。真正的时间风险在构建镜像(首次约 5 分钟),
所以手册里第一条就是「镜像必须在停机窗口之前构建好」。

验证到位的:首页、站点配置、题目列表、标签、公告、登录(argon2 新哈希和
Django pbkdf2 旧哈希都支持)、个人页、排行榜、后台四个接口、判题机自动注册,
以及**完整判题**(提交 Python A+B → AC,1.2 秒,两个测试点全过)。

## 两件原以为要做、实测不用做的事

- **不需要任何 DDL**:生产 dump 和新后端在用的库逐列对比,两边都是 278 列,
  零差异。新后端直接跑在现有结构上。
- **不需要重置序列**:我在 phase3-coverage.md 里记的那条「切换必做:重置序列」
  **是错的**,来自我手工按显式 id 导入、又没补 setval 的本地库。真实的
  pg_dumpall 带 30 条 setval,且把快照里所有序列和 max(id) 逐个对过,错位 0 个。
  已在原文档上标注更正,没有删掉原文 —— 错误结论本身也是信息。

## 回滚保证已实测

新栈跑完登录、提交、判题之后,再和生产 dump 比一次结构:逐列一致,零差异。
加上数据目录布局照抄旧后端,回滚 = 停新栈 + 起旧栈,约 20 秒,不动任何数据。
(未实测的部分也写明了:本机没构建旧 Django 镜像,「起旧栈」这一步没跑过。)

## 演练抓到的真问题

**pg_dumpall 备份会覆盖数据库口令。** 恢复完快照,新后端立刻报
`password authentication failed` —— 因为 dump 里带
`ALTER ROLE onlinejudge ... PASSWORD 'md5…'`,把角色口令覆盖成了备份时生产的那个。
正常切换不受影响(根本不恢复备份),但灾难恢复时这一条不写下来,
现场会被一个看起来毫不相干的报错卡住。

**恢复备份前必须先停应用**,否则 dump 里的 DROP DATABASE 失败。演练时因为
目标库是空的,数据照样进去了 —— 那是运气,目标库有数据就是满屏主键冲突。

## 镜像体积没达标,写明了原因

api 镜像 487MB,设计文档写的是「数十 MB」。一半以上(269MB)是 clang-format
拖进来的 LLVM,光 libLLVM.so 就 124MB。旧 Python 镜像同样装了 clang-format,
所以新镜像仍明显更小,但当初估「数十 MB」时没把它算进去。
瘦身路径也记了(换静态 clang-format 可砍 265MB),暂不做。

## 清理

演练在 data/postgres 留下了一份完整的生产数据副本,含 1710 名学生的
raw_password 明文列,已删除。手册里留了提醒 —— 那不是测试数据。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 02:13:59 -06:00
parent ea521e7b0a
commit fb22b7d49f
4 changed files with 226 additions and 15 deletions

View File

@@ -14,7 +14,7 @@ services:
container_name: oj-postgres
restart: always
volumes:
- ./data/postgres:/var/lib/postgresql/data
- ../data/postgres:/var/lib/postgresql/data
environment:
POSTGRES_DB: onlinejudge
POSTGRES_USER: onlinejudge
@@ -33,7 +33,7 @@ services:
container_name: oj-redis
restart: always
volumes:
- ./data/redis:/data
- ../data/redis:/data
ports:
- "5446:6379"
healthcheck:
@@ -57,9 +57,9 @@ services:
tmpfs:
- /tmp
volumes:
- ./data/backend/test_case:/test_case:ro
- ./data/judge_server/log:/log
- ./data/judge_server/run:/judger
- ../data/backend/test_case:/test_case:ro
- ../data/judge_server/log:/log
- ../data/judge_server/run:/judger
environment:
SERVICE_URL: http://oj-judge:8080
# 心跳路径跟着新后端改了judge_server_heartbeat/ → judge-server/heartbeat
@@ -81,7 +81,7 @@ services:
oj-redis:
condition: service_healthy
volumes:
- ./data/backend:/data
- ../data/backend:/data
environment: &api-env
DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@oj-postgres:5432/onlinejudge
REDIS_URL: redis://oj-redis:6379
@@ -107,7 +107,7 @@ services:
depends_on:
- oj-api
volumes:
- ./data/backend:/data
- ../data/backend:/data
environment: *api-env
# 同一个镜像,换个子命令就是判题消费者
command: ["oj2-api", "worker"]
@@ -125,7 +125,7 @@ services:
- oj-api
volumes:
# Caddy 只往这里写访问日志,静态资源在镜像里
- ./data/backend/log:/data/log
- ../data/backend/log:/data/log
ports:
- "0.0.0.0:8080:8000"
mem_limit: 256m

View File

@@ -17,7 +17,7 @@ services:
container_name: oj-redis
restart: always
volumes:
- ./data/redis:/data
- ../data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@@ -39,9 +39,9 @@ services:
tmpfs:
- /tmp
volumes:
- ./data/backend/test_case:/test_case:ro
- ./data/judge_server/log:/log
- ./data/judge_server/run:/judger
- ../data/backend/test_case:/test_case:ro
- ../data/judge_server/log:/log
- ../data/judge_server/run:/judger
environment:
SERVICE_URL: http://oj-judge:8080
BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat
@@ -61,7 +61,7 @@ services:
oj-redis:
condition: service_healthy
volumes:
- ./data/backend:/data
- ../data/backend:/data
environment: &api-env
# 库在服务器上走公网。DB_HOST 默认值就是服务器地址,换机器改 env 文件
DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@${DB_HOST:-150.158.29.156}:${DB_PORT:-5445}/onlinejudge
@@ -89,7 +89,7 @@ services:
depends_on:
- oj-api
volumes:
- ./data/backend:/data
- ../data/backend:/data
environment: *api-env
command: ["oj2-api", "worker"]
mem_limit: 2g
@@ -105,7 +105,7 @@ services:
depends_on:
- oj-api
volumes:
- ./data/backend/log:/data/log
- ../data/backend/log:/data/log
ports:
- "81:8000"
mem_limit: 256m

View File

@@ -152,6 +152,14 @@
## 阶段 5 切换必做项(阶段 4 施工时发现,记在这里以免忘)
> **2026-08-08 更正:这条不是「切换必做项」,降级为「手工造库时的注意事项」。**
>
> 阶段 5 演练时实测了真实的 pg_dumpall 备份:里面带 30 条 `setval`
> 并且把生产快照里所有序列和 `max(id)` 逐个对过,**错位 0 个**。
> 下面这个现象只出现在我手工按显式 id 导入、又没补 setval 的本地库上,
> 对正常的备份/恢复不成立。切换当天不用管序列。详见
> [phase5-cutover-runbook.md](phase5-cutover-runbook.md)。
**导入数据后必须重置全部序列。** 本地库是按显式 id 从生产导入的,
`problem_tag_id_seq` 停在 6 而表里 max(id)=87于是第一次新建标签就撞
`duplicate key value violates unique constraint "problem_tag_pkey"`500

View File

@@ -0,0 +1,203 @@
# 阶段 5切换手册与演练报告
演练日期2026-08-08
演练用快照:`db_backup_2026_08_07_10_39_19.sql`pg_dumpall 集群备份230MB2026-08-07 10:39
演练方式:本机 Docker`docker/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_case``public/upload``public/avatar` 原样不动),
**回滚 = 停新栈 + 起旧栈,约 20 秒,不涉及任何数据操作。**
> 未实测的部分:本机没有构建旧后端的 Django 镜像,所以「起旧栈」这一步本身没跑过。
> 但旧栈在服务器上一直是跑着的,它的镜像和 compose 都没动过。
---
## 二、演练验证了什么
全部通过真实生产数据、经 Caddy、在容器里跑
| 检查项 | 结果 |
|---|---|
| 首页 SPA | 200Caddy 从镜像里伺服 |
| 站点配置 `/api/site` | 200读出真实站点名「判题狗」 |
| 题目列表 | 200首条是 `SQL09` |
| 题目标签 / 公告 | 200 |
| 未登录访问后台 | 401 `login-required` |
| 登录 | 200argon2 新哈希 + Django pbkdf2 旧哈希都支持) |
| 个人主页 / 排行榜 | 200真实学生数据 |
| 后台首页 / 题目 / 判题机 | 200`userCount: 1711` |
| **判题机心跳** | 判题容器自行注册成功(新路径 `/api/judge-server/heartbeat` |
| **完整判题** | 提交 Python A+B → **AC1.2 秒**,两个测试点全过 |
---
## 三、切换前检查清单(停机窗口之前做完)
- [ ] **先把镜像构建好**`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=false`http 直连 IP带 Secure 的 Cookie
浏览器不回传,表现是「登录成功但立刻又变未登录」)。
- [ ] 做一次 `pg_dumpall` 备份(不是为了迁移,是为了兜底)。
- [ ] 确认 `data/backend/``test_case``public/upload``public/avatar` 都在。
## 四、切换步骤
**两个站点都要切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端
还在读写同一个库。
服务器xuyue.cc
```bash
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
```
机房:
```bash
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/*`
## 六、回滚
```bash
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` 的集群备份里带
```sql
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-format`apt | **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` 明文列。演练结束后已删除该目录。
以后再演练记得同样处理 —— 那不是测试数据,是真实学生数据。