diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..810c59a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# 构建上下文是仓库根,这里把不该进去的挡掉。 +# +# data/ 尤其重要:测试用例和上传文件可以有几十 GB,而且 judge_server/run 下 +# 是判题沙箱以别的 uid 建的目录,docker 连 stat 都做不了,不排除的话构建直接失败。 + +data/ +dist/ + +node_modules/ +**/node_modules/ + +.git/ +.github/ + +# 本地产物与缓存 +**/dist/ +**/.vite/ +**/*.log + +# 密钥绝不进镜像层 +.env +.env.* +docker/.env +docker/.env.* +!.env.example +!docker/.env.example + +# 前端 dist 由构建阶段自己生成,本地那份不要带进去污染 +apps/web/dist/ diff --git a/apps/api/package.json b/apps/api/package.json index 10adbb2..d62807d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b3b2fa8..e5edfdf 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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) diff --git a/apps/api/src/vendor/jieba.ts b/apps/api/src/vendor/jieba.ts index e2ff83c..f84d8af 100644 --- a/apps/api/src/vendor/jieba.ts +++ b/apps/api/src/vendor/jieba.ts @@ -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" diff --git a/apps/web/package.json b/apps/web/package.json index 1776ccb..aa47d09 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/shared/composables/websocket.ts b/apps/web/src/shared/composables/websocket.ts index daa22c0..23a7e84 100644 --- a/apps/web/src/shared/composables/websocket.ts +++ b/apps/web/src/shared/composables/websocket.ts @@ -308,7 +308,7 @@ class SubmissionWebSocket extends BaseWebSocket { 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 { 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 { 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`, }) } diff --git a/apps/web/src/utils/api2.ts b/apps/web/src/utils/api2.ts index be817be..53e86f4 100644 --- a/apps/web/src/utils/api2.ts +++ b/apps/web/src/utils/api2.ts @@ -30,7 +30,7 @@ interface Api2Client { } const instance = axios.create({ - baseURL: "/api2", + baseURL: "/api", withCredentials: true, }) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5b8e23e..ed4b671 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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 }, }, }, } diff --git a/bun.lock b/bun.lock index 553824f..0079103 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "version": "0.0.0", "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", @@ -40,6 +41,10 @@ "@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", @@ -635,7 +640,7 @@ "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "https://registry.npmjs.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], - "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], + "@types/emscripten": ["@types/emscripten@1.41.5", "https://registry.npmjs.com/@types/emscripten/-/emscripten-1.41.5.tgz", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], "@types/estree": ["@types/estree@1.0.9", "https://registry.npmjs.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -659,7 +664,7 @@ "@types/retry": ["@types/retry@0.12.2", "https://registry.npmjs.com/@types/retry/-/retry-0.12.2.tgz", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="], - "@types/sql.js": ["@types/sql.js@1.4.11", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="], + "@types/sql.js": ["@types/sql.js@1.4.11", "https://registry.npmjs.com/@types/sql.js/-/sql.js-1.4.11.tgz", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmjs.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..35c4f4c --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,27 @@ +# 部署用的环境变量。拷成 .env(服务器)或 .env.school(机房)后填。 +# +# cp docker/.env.example docker/.env +# +# 仓库里不留任何真实密钥。带 `:?` 的变量没设置时 docker compose 会直接报错退出, +# 不会静默起一个用弱默认值的服务。 + +# 数据库口令。机房那套连的是服务器的库,两边必须一致。 +POSTGRES_PASSWORD= + +# 判题机 token。后端和判题机容器读的是同一个变量,自己生成: +# openssl rand -hex 32 +OJ2_JUDGE_TOKEN= + +# 判题并发。受判题沙箱能力限制,不是越大越好。 +# 服务器 2,机房机器宽裕可以给 4。 +JUDGE_CONCURRENCY=2 + +# DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。 +AI_KEY= + +# --- 只有机房那套需要 --- +# 服务器地址。默认值写在 compose.school.yml 里,换机器时在这里覆盖。 +DB_HOST= +DB_PORT= +# 机房走 http 直连 IP,没有 TLS,必须 false,否则 Cookie 发不回来。 +COOKIE_SECURE=false diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..aa5e448 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,82 @@ +# 前端静态资源 + 反向代理。移植自旧后端 deploy/caddy/Caddyfile, +# 上游从「同容器里的 gunicorn」换成「api 容器」,其余策略照搬。 + +{ + admin off + + servers { + # TLS 由前面的 Nginx Proxy Manager 终止,转发进来是明文 http。 + # 只信任内网来源的 X-Forwarded-For,{client_ip} 才解析得出学生的真实 IP。 + trusted_proxies static private_ranges + } +} + +# 站点地址只写端口,Caddy 就不会去申请证书(auto-HTTPS 自动关闭)。 +:8000 { + encode gzip + + # 和 NPM 那一层的 client_max_body_size 保持一致,上传测试用例压缩包用得上。 + # 必须写 MiB:Caddy 的 MB 按 10^6 算,200MB 比 nginx 的 200M 小了将近 10M。 + request_body { + max_size 200MiB + } + + # Caddy 的 header 会跨层叠加,不像 nginx 的 add_header 一旦在子块出现就不再继承, + # 所以这里写一次就够,下面各 handle 里只管自己的 Cache-Control。 + header { + X-XSS-Protection "1; mode=block" + X-Frame-Options SAMEORIGIN + X-Content-Type-Options nosniff + } + + log { + output file /data/log/caddy_access.log { + roll_size 10MiB + roll_keep 10 + } + } + + # 判题机心跳每秒一次;静态资源量大且带永久缓存。两者都不记访问日志。 + # 路径跟着新后端改了:judge_server_heartbeat → judge-server/heartbeat + @nolog path /api/judge-server/heartbeat /api/judge-server/heartbeat/ /assets/* + log_skip @nolog + + handle /api/* { + reverse_proxy oj-api:3000 { + header_up X-Real-IP {client_ip} + # AI 分析走 SSE。X-Accel-Buffering 是 nginx 专有的,Caddy 不认, + # 这里显式关掉响应缓冲,保证流式输出逐块下发。 + flush_interval -1 + } + } + + handle /ws/* { + reverse_proxy oj-api:3000 { + header_up X-Real-IP {client_ip} + } + } + + # 头像和题面图片。旧配置是 Caddy 直接读 /data 下的盘,这里改成反代给后端: + # 后端本来就要处理头像缺省回退,开发环境(Vite 代理)也走同一条路, + # 少一处「只在生产才生效」的分支。文件不大、量也不大,多一跳无所谓。 + handle /public/* { + reverse_proxy oj-api:3000 + } + + # 构建产物文件名带内容 hash,内容一变文件名就变,可以永久缓存。 + # 命中后浏览器直接读磁盘,不再发条件请求。 + handle /assets/* { + root * /srv + header Cache-Control "public, max-age=31536000, immutable" + file_server + } + + # index.html 引用着带 hash 的文件名,必须每次回源校验, + # 否则发新版后学生刷不到。no-cache 是「缓存但每次校验」,命中走 304。 + handle { + root * /srv + header Cache-Control "no-cache" + try_files {path} /index.html + file_server + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..e763941 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,84 @@ +# OJ2 镜像。一次构建产出两个 target: +# +# api —— 单二进制后端(serve / worker / sql-child 三个子命令共用一份运行时) +# web —— Caddy + 前端构建产物 +# +# 构建上下文是**仓库根**,不是 docker/: +# docker compose -f docker/compose.debian.yml build +# +# 旧后端是一个容器里用 supervisord 跑 caddy + gunicorn + dramatiq 三个进程。 +# 这里拆开:Docker 自己就是进程管理器,拆开之后 worker 挂了能单独重启、 +# 也能单独看日志,少一层 supervisord 要维护。 + +# ---------------------------------------------------------------- 构建 +FROM oven/bun:1 AS builder +WORKDIR /build + +# 走国内镜像源。不是图快(总耗时差不多),是图能装上: +# mermaid@9.4.3 那个 11.9MB 的包从容器里连 registry.npmjs.com 稳定失败 +# (主机上没问题,容器网络这一跳过不去),换源之后 1238 个包一次装齐。 +# 这个包是机房老 Chrome 的 mermaid-legacy 依赖,不能砍。 +ARG NPM_REGISTRY=https://registry.npmmirror.com +ENV BUN_CONFIG_REGISTRY=${NPM_REGISTRY} + +# 先只拷 manifest,依赖没变时这一层能命中缓存 +COPY package.json bun.lock ./ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +COPY packages/contract/package.json packages/contract/ +# 重试两次:网络抖动不该让整次构建从头再来 +RUN bun install --frozen-lockfile \ + || bun install --frozen-lockfile \ + || bun install --frozen-lockfile + +COPY . . + +# 后端:编译成不依赖 node_modules 的单二进制。 +# 能这么编是因为 wasm / 原生模块 / 词典都在源码里用 `with { type: "file" }` +# 内嵌成了资源,详见 apps/api/src/vendor/jieba.ts 的注释。 +RUN bun build --compile --target=bun-linux-x64 apps/api/src/main.ts --outfile /build/oj2-api + +# 前端:产物直接进镜像,不走挂载。 +# 一次构建 = 一个版本,切换那天不会出现「后端换了前端忘了拷」这种半新半旧状态。 +RUN cd apps/web && bun run build + +# ---------------------------------------------------------------- 后端运行时 +# 用 debian 基底而不是 alpine:内嵌的 jieba 原生模块是 linux-x64-**gnu**, +# musl 基底跑不起来(换基底必须同步改 vendor/jieba.ts 里写死的导入)。 +FROM debian:trixie-slim AS api +ENV NODE_ENV=production + +# clang-format 给 C 代码格式化用,对应旧镜像 apt 装的那个。 +# ca-certificates 给 AI 接口的 https 出站用。 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates clang-format \ + && rm -rf /var/lib/apt/lists/* + +# ruff 给 Python 代码格式化用。旧后端是 pip 装的 ruff==0.15.12,这里取同版本的 +# 官方静态二进制 —— 不为它引入整个 Python 运行时。 +COPY --from=ghcr.io/astral-sh/ruff:0.15.12 /ruff /usr/local/bin/ruff + +COPY --from=builder /build/oj2-api /usr/local/bin/oj2-api + +# /data 下是要持久化的东西:测试用例、上传的图片、头像。 +# 相对路径按 cwd 解析(见 apps/api/src/runtime.ts),所以 workdir 设成 /data; +# 但下面的路径全写绝对值,不靠这个巧合。 +# +# 目录布局**照抄旧后端**(test_case、public/upload、public/avatar)。 +# 这不是审美问题:切换那天不用搬动任何文件,回滚时旧后端也立刻能找到自己的数据。 +# 少一次几十 GB 的 mv,就少一个在停机窗口里出错的机会。 +WORKDIR /data +ENV TEST_CASE_DIRECTORY=/data/test_case \ + UPLOAD_DIRECTORY=/data/public/upload \ + AVATAR_DIRECTORY=/data/public/avatar \ + PORT=3000 + +EXPOSE 3000 +# 默认起 HTTP 服务;worker 容器在 compose 里把 command 换成 ["oj2-api","worker"] +CMD ["oj2-api", "serve"] + +# ---------------------------------------------------------------- 前端 + 反代 +FROM caddy:2-alpine AS web +COPY --from=builder /build/apps/web/dist /srv +COPY docker/Caddyfile /etc/caddy/Caddyfile +EXPOSE 8000 diff --git a/docker/compose.debian.yml b/docker/compose.debian.yml new file mode 100644 index 0000000..4ff6100 --- /dev/null +++ b/docker/compose.debian.yml @@ -0,0 +1,131 @@ +# 服务器(xuyue.cc)。数据库在这里,机房那套连的就是这里的 5445。 +# +# docker compose -f docker/compose.debian.yml --env-file docker/.env up -d --build +# +# 构建上下文是仓库根,所以 build.context 写 ..(相对本文件)。 +# +# 和旧的 docker-compose.debian.yml 的对应关系: +# oj-backend(supervisord 跑 caddy+gunicorn+dramatiq)→ 拆成 oj-web + oj-api + oj-worker +# 端口、数据目录、判题机配置全部保持不变,这样回滚只是换回旧 compose。 + +services: + oj-postgres: + image: postgres:16-alpine + container_name: oj-postgres + restart: always + volumes: + - ./data/postgres:/var/lib/postgresql/data + environment: + POSTGRES_DB: onlinejudge + POSTGRES_USER: onlinejudge + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请在 docker/.env 里设置 POSTGRES_PASSWORD} + # 机房那套要连这个端口,必须对外暴露 + ports: + - "5445:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U onlinejudge -d onlinejudge"] + interval: 5s + timeout: 3s + retries: 10 + + oj-redis: + image: redis:7-alpine + container_name: oj-redis + restart: always + volumes: + - ./data/redis:/data + ports: + - "5446:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + oj-judge: + image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 + container_name: oj-judge + restart: always + read_only: true + cap_drop: + - SETPCAP + - MKNOD + - NET_BIND_SERVICE + - SYS_CHROOT + - SETFCAP + - FSETID + tmpfs: + - /tmp + volumes: + - ./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 + BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat + TOKEN: ${OJ2_JUDGE_TOKEN:?请在 docker/.env 里设置 OJ2_JUDGE_TOKEN} + mem_limit: 512m + + oj-api: + build: &build + context: .. + dockerfile: docker/Dockerfile + target: api + image: oj2-api:latest + container_name: oj-api + restart: always + depends_on: + oj-postgres: + condition: service_healthy + oj-redis: + condition: service_healthy + volumes: + - ./data/backend:/data + environment: &api-env + DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@oj-postgres:5432/onlinejudge + REDIS_URL: redis://oj-redis:6379 + JUDGE_SERVER_URL: http://oj-judge:8080 + JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} + JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2} + AI_KEY: ${AI_KEY:-} + # 走 NPM 终止 TLS,浏览器侧是 https,Cookie 必须带 Secure + COOKIE_SECURE: "true" + healthcheck: + test: ["CMD", "oj2-api", "healthcheck"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + mem_limit: 512m + + oj-worker: + build: *build + image: oj2-api:latest + container_name: oj-worker + restart: always + depends_on: + - oj-api + volumes: + - ./data/backend:/data + environment: *api-env + # 同一个镜像,换个子命令就是判题消费者 + command: ["oj2-api", "worker"] + mem_limit: 512m + + oj-web: + build: + context: .. + dockerfile: docker/Dockerfile + target: web + image: oj2-web:latest + container_name: oj-web + restart: always + depends_on: + - oj-api + volumes: + # Caddy 只往这里写访问日志,静态资源在镜像里 + - ./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 new file mode 100644 index 0000000..c73bca0 --- /dev/null +++ b/docker/compose.school.yml @@ -0,0 +1,111 @@ +# 机房。**这里没有数据库** —— 连的是服务器那台的 5445(见 compose.debian.yml)。 +# +# docker compose -f docker/compose.school.yml --env-file docker/.env.school up -d --build +# +# 两个站点共用一个库,但各有各的 Redis、判题沙箱和后端。这意味着: +# +# - 判题队列是每站独立的(BullMQ 在本地 Redis 上),学生在哪边提交就在哪边判, +# 和旧后端的 Dramatiq 拓扑一致; +# - WebSocket 推送也走本地 Redis pub/sub,所以只推得到连在**本站**的学生。 +# 旧后端的 Channels 也是这样,不是回归; +# - **切换那天两边都要切。** 只切一边的话,另一边的旧后端仍在读写同一个库, +# 而库结构已经按新后端迁过了。 + +services: + oj-redis: + image: redis:7-alpine + container_name: oj-redis + restart: always + volumes: + - ./data/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + oj-judge: + image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 + container_name: oj-judge + restart: always + read_only: true + cap_drop: + - SETPCAP + - MKNOD + - NET_BIND_SERVICE + - SYS_CHROOT + - SETFCAP + - FSETID + tmpfs: + - /tmp + volumes: + - ./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 + TOKEN: ${OJ2_JUDGE_TOKEN:?请在 env 文件里设置 OJ2_JUDGE_TOKEN} + # 机房机器内存宽裕,判题给足 + mem_limit: 2g + + oj-api: + build: &build + context: .. + dockerfile: docker/Dockerfile + target: api + image: oj2-api:latest + container_name: oj-api + restart: always + depends_on: + oj-redis: + condition: service_healthy + volumes: + - ./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 + REDIS_URL: redis://oj-redis:6379 + JUDGE_SERVER_URL: http://oj-judge:8080 + JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} + JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-4} + AI_KEY: ${AI_KEY:-} + # 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传, + # 学生会「登录成功但立刻又是未登录」。这里必须是 false。 + COOKIE_SECURE: ${COOKIE_SECURE:-false} + healthcheck: + test: ["CMD", "oj2-api", "healthcheck"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + mem_limit: 4g + + oj-worker: + build: *build + image: oj2-api:latest + container_name: oj-worker + restart: always + depends_on: + - oj-api + volumes: + - ./data/backend:/data + environment: *api-env + command: ["oj2-api", "worker"] + mem_limit: 2g + + oj-web: + build: + context: .. + dockerfile: docker/Dockerfile + target: web + image: oj2-web:latest + container_name: oj-web + restart: always + depends_on: + - oj-api + volumes: + - ./data/backend/log:/data/log + ports: + - "81:8000" + mem_limit: 256m