feat(阶段5): 镜像、三套 compose,前端切回 /api 与 /ws

## 镜像

一个 Dockerfile 两个 target:api(单二进制)和 web(Caddy + 前端产物)。
旧后端是一个容器里用 supervisord 跑 caddy+gunicorn+dramatiq,这里拆成
oj-web / oj-api / oj-worker 三个容器 —— Docker 本身就是进程管理器,
拆开之后 worker 挂了能单独重启、日志也分得开,少一层 supervisord 要维护。

同一个镜像换个子命令就是 worker,镜像里只有一份运行时。
新增 healthcheck 子命令:运行镜像是 debian-slim,没有 curl/wget,
让二进制自己打 /health(只打 /health 不碰库 —— 库挂了该由库的 healthcheck 报,
不该让 api 跟着被判不健康然后被重启)。

**数据目录照抄旧后端**(test_case、public/upload、public/avatar)。
不是审美问题:切换那天不用搬动任何文件,回滚时旧后端立刻能找到自己的数据。
少一次几十 GB 的 mv,就少一个在停机窗口里出错的机会。

构建路上踩到三个坑,都是「本地能过、容器里过不了」那一类:

- 构建上下文吸进了 data/,judge_server/run 是判题沙箱用别的 uid 建的,
  docker 连 stat 都做不了,构建直接失败 → 补 .dockerignore
- mermaid@9.4.3(机房老 Chrome 的 legacy 依赖,不能砍)从容器里连
  registry.npmjs.com 稳定失败,主机上没问题 → 换 npmmirror,并重试两次
- 容器里 bun 用 isolated 布局,本地是扁平的。靠「提升」才能解析到的包
  在容器里一律解析不到:@node-rs/jieba-linux-x64-gnu(编译要 import 它的 .node)、
  以及前端的 @codemirror/{language,state,view} 和 @lezer/highlight。
  这些本来就是代码直接 import 的,补成直接依赖。顺手写了个脚本扫全仓,
  确认只有这 4 个。

## 前端切回 /api、/ws

迁移期用 /api2、/ws2 指新后端,/api、/ws 还指着 Django。端点已全部搬完,
临时前缀去掉。改动只有三处(api2.ts 的 baseURL、websocket.ts 的两个 URL),
`api2` 那些 import 是模块名不是路径,不动。vite 代理同步收敛成三条。

## compose

- debian:全套(含 postgres,对外开 5445 给机房连)
- school:**没有 postgres**,连服务器的库;本地 Redis + 本地判题沙箱
- 两边共用一个库但各有各的队列和 WS 推送,和旧后端的 Dramatiq/Channels 拓扑一致

密钥一律走 env 且带 `:?`,没设置就直接报错退出,不静默用弱默认值。
COOKIE_SECURE 在机房必须是 false(http 直连 IP,带 Secure 的 Cookie 发不回来,
表现是「登录成功但立刻又变未登录」)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 23:41:20 -06:00
parent e2c6ee69da
commit ea521e7b0a
14 changed files with 517 additions and 38 deletions

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"@node-rs/jieba": "^2.0.1",
"@node-rs/jieba-linux-x64-gnu": "2.0.1",
"@oj2/contract": "workspace:*",
"bullmq": "^6.0.9",
"drizzle-orm": "^0.45.2",

View File

@@ -6,10 +6,11 @@
* (见 judge/sql/index.ts。三个入口就得编三个二进制、镜像里塞三份运行时。
* 一个二进制 + 子命令镜像里只有一份compose 里改 command 就能换角色。
*
* oj2-api # 等同 serve
* oj2-api serve # HTTP + WebSocket
* oj2-api worker # BullMQ 判题消费者
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调
* oj2-api # 等同 serve
* oj2-api serve # HTTP + WebSocket
* oj2-api worker # BullMQ 判题消费者
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调
*
* 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -31,6 +32,20 @@ switch (command) {
await runSqlChild()
break
}
// 运行镜像是 debian-slim没有 curl/wget探活让二进制自己做。
// 只打 /health不碰库 —— 库挂了该由库自己的 healthcheck 报,
// 不该让 api 容器跟着被判成不健康、进而被重启。
case "healthcheck": {
const { config } = await import("./config")
try {
const response = await fetch(`http://127.0.0.1:${config.port}/health`, {
signal: AbortSignal.timeout(3000),
})
process.exit(response.ok ? 0 : 1)
} catch {
process.exit(1)
}
}
default:
console.error(`未知子命令:${command}\n可用serve | worker | sql-child`)
process.exit(2)

View File

@@ -24,7 +24,13 @@
* 用 `isCompiled` 判断也来不及dev 一样会撞上上面那个报错。
*
* 平台写死 linux-x64-gnu部署目标是 debian 基底的容器,本机开发也是 x64 glibc。
* 换基底镜像或 CPU 架构必须同步改这里,否则编译能过、启动就崩。
* 换基底镜像或 CPU 架构必须**同时**改两处 —— 这里的 import和 apps/api/package.json
* 里 `@node-rs/jieba-linux-x64-gnu` 那条依赖。
*
* 那条依赖为什么要显式写:它本是 `@node-rs/jieba` 的 optionalDependency本机装出来的
* node_modules 是扁平的,靠提升就能解析到,所以本地构建一直是好的。但容器里 bun 用
* isolated 布局(包都在 `node_modules/.bun/` 下),提升不到,`bun build` 直接报
* `Could not resolve`。代码既然真的直接 import 它,就该是直接依赖。
*/
import { isCompiled } from "../runtime"

View File

@@ -11,11 +11,15 @@
"fmt": "prettier --write src *.ts"
},
"dependencies": {
"@oj2/contract": "workspace:*",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"@oj2/contract": "workspace:*",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",

View File

@@ -308,7 +308,7 @@ class SubmissionWebSocket extends BaseWebSocket<SubmissionUpdate> {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({
path: "submission",
url: `${protocol}//${window.location.host}/ws2/submissions`,
url: `${protocol}//${window.location.host}/ws/submissions`,
})
}
@@ -450,7 +450,7 @@ class FlowchartWebSocket extends BaseWebSocket<FlowchartEvaluationUpdate> {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({
path: "flowchart",
url: `${protocol}//${window.location.host}/ws2/submissions`,
url: `${protocol}//${window.location.host}/ws/submissions`,
})
}
@@ -520,7 +520,7 @@ class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({
path: "config",
url: `${protocol}//${window.location.host}/ws2/config`,
url: `${protocol}//${window.location.host}/ws/config`,
})
}

View File

@@ -30,7 +30,7 @@ interface Api2Client {
}
const instance = axios.create({
baseURL: "/api2",
baseURL: "/api",
withCredentials: true,
})

View File

@@ -92,14 +92,10 @@ function injectMaxkb(maxkbUrl: string | undefined): Plugin {
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "PUBLIC_")
const proxyConfig = {
target: env["PUBLIC_OJ_URL"],
changeOrigin: true,
}
const wsProxyConfig = {
target: env["PUBLIC_WS_URL"],
ws: true,
// 开发时一律指向本机新后端。PUBLIC_OJ_URL / PUBLIC_WS_URL 是迁移期指向
// 旧 Django 用的,端点搬完后不再参与代理。
const newBackend = {
target: "http://localhost:3000",
changeOrigin: true,
}
@@ -166,24 +162,12 @@ export default defineConfig(({ mode }) => {
server: {
port: 5173,
proxy: {
"/api2": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api2/, "/api"),
},
"/ws2": {
target: "ws://localhost:3000",
ws: true,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/ws2/, "/ws"),
},
"/api": proxyConfig,
"/public/avatar": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/public": proxyConfig,
"/ws": wsProxyConfig,
// 迁移期间 /api2、/ws2 是「新后端」的临时前缀,/api、/ws 还指着 Django。
// 端点已全部搬完,临时前缀去掉、统一指向新后端 —— 生产的 Caddy 也只认
// /api、/ws、/public 这三段docker/Caddyfile
"/api": newBackend,
"/public": newBackend,
"/ws": { ...newBackend, ws: true },
},
},
}