diff --git a/CLAUDE.md b/CLAUDE.md index 1ba6a79..bfa40d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,4 +113,14 @@ Drizzle schema 是从生产库 `drizzle-kit pull` 出来的,**不写迁移** **机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库, 但各有各的 Redis 和判题沙箱 —— 所以上线那天**两边必须一起切**。 +`compose.debian.yml` 有两种形态,靠 env 切换: + +- **只换前后端**(上线用这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST`, + 沿用旧栈已经在跑的 postgres 和 redis,只起 api / worker / web / judge。 +- **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data`。 + +⚠️ `DATA_DIR` 默认值 `../data` 是 **`OJ2/data`**,不是部署目录的 `data/`。 +沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404), +而且**不报错** —— 这是切换当天唯一会静默走歪的地方。 + 细节和演练结果都在 `docs/specs/phase5-cutover-runbook.md`。 diff --git a/docker/.env.example b/docker/.env.example index 35c4f4c..171798d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -19,9 +19,34 @@ JUDGE_CONCURRENCY=2 # DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。 AI_KEY= -# --- 只有机房那套需要 --- -# 服务器地址。默认值写在 compose.school.yml 里,换机器时在这里覆盖。 +# --- 数据在哪 --- +# +# 这三个变量决定新栈是「自带 postgres/redis」还是「接着用旧栈的」。 +# +# DATA_DIR 是所有数据卷的根(库、测试点、上传的图片、判题机日志)。 +# **不设的话默认是 `../data`,也就是 `OJ2/data` —— 不是部署目录的 `data/`。** +# 沿用旧数据时必须写成旧目录的绝对路径,例如服务器上: +# +# DATA_DIR=/root/OJDeploy/data +# +# 不设它就等于开一套空数据:空库(站点没有用户没有题)、没有测试点、题面图片 404。 +DATA_DIR= + +# 库和 redis 的位置。留空 = 用本 compose 自己起的容器(要加 `--profile local-data`)。 +# 沿用旧栈那两个容器时这样填(它们已经把端口发布在宿主机上了): +# +# DB_HOST=host.docker.internal +# DB_PORT=5445 +# REDIS_HOST=host.docker.internal +# REDIS_PORT=5446 +# +# host.docker.internal 由 compose 里的 extra_hosts 映射到 host-gateway,不出本机。 +# 机房那套没有本地库,DB_HOST 不填时默认指向服务器(默认值在 compose.school.yml 里)。 DB_HOST= DB_PORT= +REDIS_HOST= +REDIS_PORT= + +# --- 只有机房那套需要 --- # 机房走 http 直连 IP,没有 TLS,必须 false,否则 Cookie 发不回来。 COOKIE_SECURE=false diff --git a/docker/compose.debian.yml b/docker/compose.debian.yml index 0239cd2..eb0114f 100644 --- a/docker/compose.debian.yml +++ b/docker/compose.debian.yml @@ -7,14 +7,35 @@ # 和旧的 docker-compose.debian.yml 的对应关系: # oj-backend(supervisord 跑 caddy+gunicorn+dramatiq)→ 拆成 oj-web + oj-api + oj-worker # 端口、数据目录、判题机配置全部保持不变,这样回滚只是换回旧 compose。 +# +# ## 两种形态 +# +# **自带数据**(演练用的就是这个):postgres 和 redis 也由本文件起,要显式加 profile。 +# +# docker compose -f docker/compose.debian.yml --env-file docker/.env --profile local-data up -d +# +# **只换前后端**(默认,上线用这个):旧栈的 postgres / redis 容器继续跑, +# 这里只起 api / worker / web / judge, +# 通过 env 指过去。切换当天数据库进程根本不重启,库和文件都不用挪位置。 +# +# docker compose -f docker/compose.debian.yml --env-file docker/.env up -d +# +# 后者要在 env 里设 `DB_HOST` / `REDIS_HOST` / `DATA_DIR`,见 `.env.example` 末尾。 +# +# ⚠️ `DATA_DIR` 默认值 `../data` 解析出来是 **`OJ2/data`**,不是部署目录的 `data/`。 +# 旧栈用的是 `<部署目录>/data/`,两者不是一个地方 —— 直接用默认值切过去,postgres 会在 +# 空目录上初始化一个全新的空库,测试点和题面图片也全都不在。**用旧数据就必须设 `DATA_DIR`。** services: oj-postgres: image: postgres:16-alpine container_name: oj-postgres restart: always + # 只在「自带数据」形态下启动。用旧栈的库时不启动它 —— 否则 5445 端口会和 + # 旧的 postgres 撞,而且两个进程开同一个数据目录本来也起不来。 + profiles: ["local-data"] volumes: - - ../data/postgres:/var/lib/postgresql/data + - ${DATA_DIR:-../data}/postgres:/var/lib/postgresql/data environment: POSTGRES_DB: onlinejudge POSTGRES_USER: onlinejudge @@ -32,8 +53,12 @@ services: image: redis:7-alpine container_name: oj-redis restart: always + # 同上:旧栈的 redis 也发布了 5446,两个一起跑会撞端口。 + # redis 里没有非丢不可的东西(会话、判题队列),用旧的那个也无所谓 —— + # 旧后端已经停了,新后端独占它,剩下的 Django / dramatiq 残留键前缀不同,互不干扰。 + profiles: ["local-data"] volumes: - - ../data/redis:/data + - ${DATA_DIR:-../data}/redis:/data ports: - "5446:6379" healthcheck: @@ -57,9 +82,9 @@ services: tmpfs: - /tmp volumes: - - ../data/backend/test_case:/test_case:ro - - ../data/judge_server/log:/log - - ../data/judge_server/run:/judger + - ${DATA_DIR:-../data}/backend/test_case:/test_case:ro + - ${DATA_DIR:-../data}/judge_server/log:/log + - ${DATA_DIR:-../data}/judge_server/run:/judger environment: SERVICE_URL: http://oj-judge:8080 # 心跳路径跟着新后端改了:judge_server_heartbeat/ → judge-server/heartbeat @@ -76,15 +101,25 @@ services: container_name: oj-api restart: always depends_on: + # required: false —— 「只换前后端」时这两个服务不在启动集合里(profile 未启用), + # 严格的 depends_on 会让整个 project 直接判定 invalid(实测过,不是猜的)。 + # 代价:自带数据形态下 postgres 起不来时,compose 只警告不中止,oj-api 照样起, + # 然后自己 crash 循环。看 `docker compose ps`,oj-api 会是 unhealthy。 oj-postgres: condition: service_healthy + required: false oj-redis: condition: service_healthy + required: false + # 用旧栈的库时,库在宿主机上(旧 postgres 发布了 5445),走 host-gateway 过去, + # 不出本机、不走公网。DB_HOST 填 host.docker.internal 即可。 + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - - ../data/backend:/data + - ${DATA_DIR:-../data}/backend:/data environment: &api-env - DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@oj-postgres:5432/onlinejudge - REDIS_URL: redis://oj-redis:6379 + DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@${DB_HOST:-oj-postgres}:${DB_PORT:-5432}/onlinejudge + REDIS_URL: redis://${REDIS_HOST:-oj-redis}:${REDIS_PORT:-6379} JUDGE_SERVER_URL: http://oj-judge:8080 JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2} @@ -106,8 +141,10 @@ services: restart: always depends_on: - oj-api + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - - ../data/backend:/data + - ${DATA_DIR:-../data}/backend:/data environment: *api-env # 同一个镜像,换个子命令就是判题消费者 command: ["oj2-api", "worker"] @@ -125,7 +162,7 @@ services: - oj-api volumes: # Caddy 只往这里写访问日志,静态资源在镜像里 - - ../data/backend/log:/data/log + - ${DATA_DIR:-../data}/backend/log:/data/log ports: - "0.0.0.0:8080:8000" mem_limit: 256m diff --git a/docker/compose.school.yml b/docker/compose.school.yml index b324bc0..fe260a9 100644 --- a/docker/compose.school.yml +++ b/docker/compose.school.yml @@ -10,6 +10,10 @@ # 旧后端的 Channels 也是这样,不是回归; # - **切换那天两边都要切。** 只切一边的话,另一边的旧后端仍在读写同一个库, # 而库结构已经按新后端迁过了。 +# +# ⚠️ `DATA_DIR` 默认值 `../data` 解析出来是 **`OJ2/data`**,不是机房部署目录的 `data/`。 +# 测试点(`data/backend/test_case`)和题面图片(`data/backend/public/upload`)都在旧目录里, +# 不设 `DATA_DIR` 就会挂一堆空目录上去:判题全错、图片 404。**沿用旧数据必须设它。** services: oj-redis: @@ -17,7 +21,7 @@ services: container_name: oj-redis restart: always volumes: - - ../data/redis:/data + - ${DATA_DIR:-../data}/redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -39,9 +43,9 @@ services: tmpfs: - /tmp volumes: - - ../data/backend/test_case:/test_case:ro - - ../data/judge_server/log:/log - - ../data/judge_server/run:/judger + - ${DATA_DIR:-../data}/backend/test_case:/test_case:ro + - ${DATA_DIR:-../data}/judge_server/log:/log + - ${DATA_DIR:-../data}/judge_server/run:/judger environment: SERVICE_URL: http://oj-judge:8080 BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat @@ -61,7 +65,7 @@ services: oj-redis: condition: service_healthy volumes: - - ../data/backend:/data + - ${DATA_DIR:-../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 +93,7 @@ services: depends_on: - oj-api volumes: - - ../data/backend:/data + - ${DATA_DIR:-../data}/backend:/data environment: *api-env command: ["oj2-api", "worker"] mem_limit: 2g @@ -105,7 +109,7 @@ services: depends_on: - oj-api volumes: - - ../data/backend/log:/data/log + - ${DATA_DIR:-../data}/backend/log:/data/log ports: - "81:8000" mem_limit: 256m diff --git a/docs/specs/phase5-cutover-runbook.md b/docs/specs/phase5-cutover-runbook.md index 17f9fc6..e86ce02 100644 --- a/docs/specs/phase5-cutover-runbook.md +++ b/docs/specs/phase5-cutover-runbook.md @@ -94,59 +94,211 @@ WS 经 Caddy upgrade: 已连接 --- -## 三、切换前检查清单(停机窗口之前做完) +## 三、切换前准备(停机窗口之前做完) -- [ ] **先把镜像构建好**:`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` 都在。 +### ⚠️ 演练没暴露的一个坑:两套 compose 的数据目录不是同一个地方 -## 四、切换步骤 +演练时是把生产 dump 恢复进 `OJ2/data/postgres` 的(见第九节),所以这件事被盖住了。 +真实部署目录 `/root/OJDeploy` 的实际情况: -**两个站点都要切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端 -还在读写同一个库。 +| | 旧栈(`docker-compose.yml`) | 新栈默认值 | +|---|---|---| +| 库 | `/root/OJDeploy/data/postgres` | `/root/OJDeploy/OJ2/data/postgres` | +| 测试点 / 上传 / 头像 | `/root/OJDeploy/data/backend/` | `/root/OJDeploy/OJ2/data/backend/` | -服务器(xuyue.cc): +新 compose 在 `OJ2/docker/` 下,`../data` 解析到的是 `OJ2/data`。**照默认值切过去, +postgres 会在一个空目录上初始化一个全新的空库** —— 站点能起来,但没有用户、没有题、 +判题全挂、题面图片 404。旧数据完好无损(回滚正常),但当天会白吓一场。 + +因此 `compose.debian.yml` / `compose.school.yml` 加了 `DATA_DIR` 等三组变量, +**下面的流程按「只换前后端」形态写**:旧栈的 postgres / redis 容器继续跑, +新栈只起 api / worker / web / judge。数据库进程根本不重启,库和文件一个字节都不用挪。 + +### 已经做掉的(2026-08-16) + +- **`docker/.env.school` 已写好**(`.gitignore` 排除,不进版本库):判题 token 新生成一条、 + `JUDGE_CONCURRENCY=4`、`DB_HOST=150.158.29.156`、`DB_PORT=5445`、`COOKIE_SECURE=false`。 + `docker compose config` 验过插值正确。**还差两个值**,见下面第 1 步。 +- **构建复验通过。** 演练之后又改过两次前端(`/api2` 前缀漏改 5 处、接回配置推送与 + MaxKB),在本机重新构建:`oj2-web` 75MB 构建通过、单起容器首页 200;`oj2-api` + 完全命中缓存,说明后端源码在演练之后没动过。 + 这只证明「还能构建出来」—— 镜像不走镜像仓库,是各站点本地构建的, + **服务器和机房当地各自还要 build 一次**。 +- **「只换前后端」形态本机实跑验过。** 用 `docs/specs/schema.sql` 起了一个发布在宿主机 + 5445 的 postgres 冒充旧栈,新栈按下面的 env 起来:4 个容器(没有 postgres / redis)、 + `oj-api` healthy、首页与 `/api/site` `/api/problems` 200、未登录进后台 401。 + 读写两个方向都验了 —— 那个库的 `pg_stat_activity` 里有一条来自 172.17.0.1 的 + `postgres.js` 连接,`judge_server` 表里也出现了新判题机写进去的心跳行。 + +### 1. 填两份 env(各站一份,都不进版本库) + +服务器 `docker/.env`(照 `docker/.env.example` 拷一份再填): + +| 变量 | 填什么 | +|---|---| +| `POSTGRES_PASSWORD` | **生产库现有的口令**。库是原地不动的、不是新建的,这个值改不了(旧 compose 里是 `onlinejudge`) | +| `DATA_DIR` | `/root/OJDeploy/data` —— **旧数据目录的绝对路径**。不填就是上面那个空数据坑 | +| `DB_HOST` / `DB_PORT` | `host.docker.internal` / `5445` —— 旧 postgres 已经把 5445 发布在宿主机上,走 host-gateway 过去,不出本机 | +| `REDIS_HOST` / `REDIS_PORT` | `host.docker.internal` / `5446` —— 同理,旧 redis 发布的是 5446 | +| `OJ2_JUDGE_TOKEN` | 可以换新的,`openssl rand -hex 32`;后端和判题机读同一个变量,一起换即可 | +| `JUDGE_CONCURRENCY` | 服务器 `2` | +| `AI_KEY` | 服务器那把 DeepSeek key | + +机房 `docker/.env.school`:已写好,只差三个 —— + +| 变量 | 填什么 | +|---|---| +| `POSTGRES_PASSWORD` | 同上,**和服务器那份一模一样**(连的是同一个库) | +| `DATA_DIR` | 机房那台的旧数据目录绝对路径。**机房也有测试点和上传文件**,同样不能用默认值 | +| `AI_KEY` | 机房那把,**和服务器不是同一把,别填串** | + +漏填 `POSTGRES_PASSWORD` 不会静默起一个坏服务:compose 里写的是 `${POSTGRES_PASSWORD:?}`, +没填直接报错退出。**但 `DATA_DIR` 漏填不会报错**,它有默认值 —— 这条只能靠人盯。 + +> 想让新栈自带 postgres / redis(本机开发、或将来旧栈彻底拆掉):不设 `DB_HOST` +> `REDIS_HOST` `DATA_DIR`,起栈时加 `--profile local-data` 即可。 + +### 2. 先把镜像构建好(**别在停机窗口里干这件事**) + +服务器: ```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 +docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env build ``` 机房: ```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 +docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school build ``` -端口没变(服务器 8080,机房 81),前面的 Nginx Proxy Manager 不用动。 +首次约 5 分钟,依赖下载占大头。构建完确认两个镜像都在: -## 五、切换后验证(照着点一遍) +```bash +docker images | grep oj2- +# oj2-api latest 487MB +# oj2-web latest 75MB +``` + +### 3. 兜底备份 + +```bash +docker exec oj-postgres pg_dumpall -U onlinejudge > ~/oj-before-cutover-$(date +%F).sql +``` + +**不是为了迁移**(不需要 DDL、不需要迁数据),是为了出事有退路。真要用它重建, +先读第七节第 1 条 —— 这份备份会把角色口令覆盖回备份当时的值。 + +### 4. 确认 `DATA_DIR` 指对了 + +```bash +ls /root/OJDeploy/data/backend/test_case \ + /root/OJDeploy/data/backend/public/upload \ + /root/OJDeploy/data/backend/public/avatar +``` + +判题测试点、题面图片、头像都在这三个目录里。**这个路径必须和 env 里的 `DATA_DIR` 一致。** +再用 compose 自己确认一遍解析结果,别靠脑补: + +```bash +docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env config | grep source: +# 每一条都应该在 /root/OJDeploy/data 下,出现 OJ2/data 就是 DATA_DIR 没生效 +``` + +### 5. 出发前对一遍 + +- [ ] 两份 env 都填完了,两边的 `POSTGRES_PASSWORD` 一致 +- [ ] **两边的 `DATA_DIR` 都指向旧数据目录**,`config | grep source:` 核对过 +- [ ] 两边镜像都构建完了 +- [ ] 备份做了 +- [ ] 端口没变(服务器 8080、机房 81),前面的 Nginx Proxy Manager 不用动 + +## 四、切换步骤(当天,两边一起切) + +**两个站点必须同一天切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端 +还在读写同一个库。 + +服务器(`/root/OJDeploy`): + +```bash +cd /root/OJDeploy + +# 只停应用,postgres / redis 留着继续跑 —— 新栈接着用它们 +docker compose -f docker-compose.yml stop oj-backend oj-judge +docker compose -f docker-compose.yml ps # 确认 oj-postgres / oj-redis 还在 Up + +docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d +docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env ps +# 应该正好 4 个:oj-api / oj-worker / oj-web / oj-judge,等 oj-api 变 healthy +``` + +旧判题机必须一起停:它和新判题机会争同一个 `data/judge_server/run`。 +旧 backend 也必须停:8080 端口要交给 `oj-web`。 + +机房: + +```bash +cd <机房部署目录> +docker compose -f docker-compose.yml stop oj-backend oj-judge +docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school up -d +docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school ps +``` + +(机房本来就没有 postgres;它的 redis 是新栈自带的,和旧 redis 不冲突 —— +旧的那个没往宿主机发布端口。) + +起不来先看这两条日志,绝大多数问题在里面直说了: + +```bash +docker logs oj-api --tail 50 # 连不上库、token 不对都在这里 +docker logs oj-judge --tail 20 # 判题机注册不上看这条 +``` + +## 五、切换后验证 + +先用命令快速过一遍(服务器 8080,机房 81): + +```bash +BASE=http://localhost:8080 +curl -s -o /dev/null -w '首页 %{http_code}\n' $BASE/ +curl -s -o /dev/null -w '站点配置 %{http_code}\n' $BASE/api/site +curl -s -o /dev/null -w '题目列表 %{http_code}\n' $BASE/api/problems +curl -s -o /dev/null -w '未登录进后台 %{http_code}\n' $BASE/api/admin/dashboard # 期望 401 +``` + +前三条 200、第四条 401 才算过。两个失败模式各有各的症状,别搞混: + +- **题目列表 `"total":0`** → 连错库了(`DB_HOST` 没设,或误加了 `--profile local-data` + 起了个自带的空 postgres)。立刻停下来查,别往下走。 +- **库是对的,但判题全错、题面图片 404** → `DATA_DIR` 指错了,挂上去一堆空目录。 + +然后照着点一遍 —— 下面这几步是命令测不到的: 1. 打开首页,能看到题目列表 2. 用一个学生账号登录,看得到自己的提交历史 3. 提交一道题,**看判题结果是否实时刷出来**(这一步同时验证了 WebSocket) -4. 后台 → 判题机列表,确认判题机在线 +4. 后台 → 判题机列表,确认判题机在线(心跳走 `/api/judge-server/heartbeat`) 5. 后台 → 题目列表能翻页 6. 题面里带图片的题,图片能显示(`/public/upload/*`) +机房那边额外确认一条:**登录之后刷新页面还是登录态**。如果「登录成功又立刻变未登录」, +就是 `COOKIE_SECURE` 没设成 false。 + ## 六、回滚 ```bash -docker compose -f OJ2/docker/compose.debian.yml down -docker compose -f docker-compose.debian.yml up -d +cd /root/OJDeploy +docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env down +docker compose -f docker-compose.yml start oj-backend oj-judge ``` -约 20 秒。**不需要恢复数据库,不需要动任何文件。** +机房同理,换成 `compose.school.yml`。 + +约 20 秒,比演练时还快一点 —— **postgres / redis 全程没停过**,回滚只是把旧的 +backend 和判题机再 start 起来。**不需要恢复数据库,不需要动任何文件。** + +这也是「只换前后端」形态的主要好处:切换和回滚都不碰数据库进程, +库出问题的可能性从流程里被整个拿掉了。 ---