Files
OJ2/docker/judge/README.md
T
xuyueandClaude Opus 5.5 37e971b6b4 refactor(判题机): 换成 go-judge,空闲内存从几百 MB 降到 16 MB
原来的 JudgeServer 按核数起 gunicorn worker、每份提交再按核数开进程池,空闲内存跟着核数走
(2 核 52MB、16 核 305MB)。换成 go-judge 后空闲 16MB,同一批提交判题中 80–100MB(原来约 370MB)。

go-judge 只管在沙箱里跑命令,编译、逐点运行、比对输出、定状态码搬到 judge/sandbox.ts,
口径逐条照搬 JudgeServer,出参形状不变。拿生产库真实提交新旧逐点对比:C 2000 份 1989 一致、
C++ 891 份 890 一致、Python 2000 份全一致;不一致的是 system("pause")(现在不再判运行时错误)、
未定义行为、endl 死循环打印(运行时错误变运行超时),见 docker/judge/README.md。

- 判题容器改成 privileged(go-judge 要自己建 cgroup),镜像 oj2-judge-3
- 心跳改由 worker 每 5 秒问判题机 /version 代记,删掉 /api/judge-server/heartbeat
- 前端统一用 stripJudgePath 去掉判题机工作目录(新的 /w/ 和旧的 /judger/run/)
- 提交前编译检查只编译不运行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:03:57 +08:00

149 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 判题沙箱镜像
判题机跑的是 [go-judge](https://github.com/criyle/go-judge):一个 Go 写的沙箱服务,
只管「在沙箱里跑一条命令、量出用了多少时间和内存」。**编译、逐个测试点运行、比对输出、
定状态码都在后端** `apps/api/src/judge/sandbox.ts`。
这个镜像里只有两样东西:go-judge 的单个二进制(从官方镜像 `criyle/go-judge` 里拷出来),
和学生程序要用的工具链。
| | 版本 |
|---|---|
| go-judge | **v1.13.3**(`Dockerfile` 的 `GO_JUDGE_VERSION`) |
| gcc / g++ | **14.2**(trixie 默认) |
| Python | **3.13.5**(trixie 默认) |
| base | debian:trixie-slim |
| 镜像体积 | 384 MB |
⚠️ **换 Python 大版本时,api 镜像(`docker/Dockerfile` 的 `python3-minimal`)要一起换。**
提交前的语法检查在 api 那边用 CPython 编译一遍,前端再按报错原文翻成中文(翻译表在
`apps/web/src/utils/pythonError.ts`)。两边版本不一致的话,同一份代码
在提交前和判题时报的句式可能不同,翻译表只能对上其中一边。
## 为什么从 JudgeServer 换过来(2026-10)
之前跑的是 QingdaoU/JudgeServer(上游停更在 2024-04,我们自己重编过工具链,那是 `oj2-judge-2`)。
它的 gunicorn 按核数起 worker、每份提交再按核数开进程池,**空闲内存跟着机器核数走**:
| | 空闲 | 判题中(同一批提交) |
|---|---|---|
| JudgeServer,2 核(= 服务器) | 52 MB | — |
| JudgeServer,16 核 | 305 MB | 约 370 MB |
| go-judge | **16 MB** | **80–100 MB** |
判题中那一列是在 16 核的开发机上、两边同时跑同一批真实提交时量的。go-judge 同时跑几个
程序由 `-parallelism` 管(默认等于核数),不再随提交数翻倍。
## 判定和原来一样吗
后端逐条照搬了 JudgeServer 的口径(输出 rstrip 后比 md5、stdout 和 stderr 合在一起比、
地址空间限两倍内存、16MB 输出上限、墙钟是 CPU 时限的 3 倍、libjudger 的状态码顺序),
细节见 `sandbox.ts` 顶部。换之前拿**生产库里的真实提交**,同一份代码同时送进新旧两台判题机,
逐个测试点比状态码:
| 语料 | 份数 | 一模一样 | 不一样 |
|---|---|---|---|
| C 提交(共 19757,随机抽样) | 2000 | 1989 | 11 |
| C++ 提交(全量) | 891 | 890 | 1 |
| Python 提交(共 115492,随机抽样) | 2000 | 2000 | 0 |
不一样的全部是下面三种,都是有意接受的:
- **`system("pause")`**(Dev-C++ 留下的习惯):原来 seccomp 直接杀,运行时错误、信号 31;
现在学生程序开不了子进程,`system()` 返回 -1,程序照常往下跑、按输出判。全库这样判成
运行时错误的一共 63 条,2026-09 以后一条没有。
- **未定义行为**:读越界的数组元素(`max = num[10]`)、没读进值的变量。读到的垃圾值取决于
栈上原来有什么,换了沙箱就换了一批垃圾。库里存的结果本来就和旧判题机现在重跑的对不上。
- **死循环里用 `endl` 打印**(C++ 那 1 条):原来 CPU 时间只算用户态,先写满 16MB 判运行时错误;
现在内核态也算(每次 `endl` 刷新都是一次系统调用),先到 1 秒判运行超时。都是错,说法不同。
另外几处数字会变,不影响判定:
- **内存读数变小**:现在是 cgroup 的峰值,原来是 ru_maxrss。C 的空程序 0.25 MB(原来
1.5 MB),Python 2–5 MB(原来 8 MB)。程序打印的输出也算在内存里(写在 tmpfs 上)。
- **CPU 时间含内核态**,原来只算用户态。读大输入的程序会多出几毫秒。
- **Python 的回溯路径**是 `/w/solution.py`,原来是 `/judger/run/<随机串>/solution.py`。
前端显示前两种都会去掉(`apps/web/src/utils/judge.ts` 的 `stripJudgePath`)。
编译错误是相对路径(`main.c:3:5`、`File "solution.py"`),不受影响。
## 判题机容器为什么是 privileged
go-judge 要给每个学生程序建 namespace(看不到网络、别的进程、宿主的文件)和 cgroup(限内存、
数进程),这些在普通容器里做不了。试过不给特权:要同时加 `SYS_ADMIN`、关掉 Docker 的
seccomp 和 AppArmor、去掉 `/proc` `/sys` 的遮罩、再在容器里把 cgroup 重新挂成可写 ——
那几乎就是特权了,还更脆,换台宿主机就可能起不来。所以照 go-judge 官方的做法给 `privileged: true`。
学生程序本身是关在 go-judge 自己的沙箱里的(独立的 mount / pid / net / ipc / uts / user
namespace、cgroup、seccomp),比原来 libjudger 只有 seccomp + setuid 关得更严。
**宿主机要求**:cgroup v2(Debian 12 起默认就是),内核 ≥ 5.19 时内存峰值用 `memory.peak`,
更老的内核退回 maxrss(读数偏大一点)。起不来时 `docker logs oj-judge` 里会有
`create environment builder failed`。
## 镜像源
默认走清华源(`Dockerfile` 顶部两个 ARG)。官方源在这边实测 **197 KB/s**,清华
**3.9 MB/s**。
`build.sh --no-mirror` 换回官方源。debian 那两个只能用 http —— 改 sources 发生在
装 ca-certificates 之前,base 镜像里没有 CA 根证书,https 一律
`certificate verify failed`(这个坑踩过)。
go-judge 那层是从 Docker Hub 拉 `criyle/go-judge:<版本>`,构建机要能拉到它。
## 构建与分发
```bash
docker/judge/build.sh --save # 本机构建 + 导出 dist/oj2-judge-3.tar
scp dist/oj2-judge-3.tar root@服务器:/root/OJDeploy/
ssh root@服务器 'docker load -i /root/OJDeploy/oj2-judge-3.tar'
# 机房那台同样来一遍 —— 两个站点各有各的判题沙箱
```
之后正常 `docker/deploy.sh` 即可。**判题机和 api / worker 要一起换**:新后端只会说
go-judge 的话,旧判题机只认旧后端。
⚠️ **这个名字不在任何 registry 上。** 服务器上忘了 `docker load`,`deploy.sh` 的自检会拦下。
⚠️ **改工具链或升 go-judge 就把末尾的序号 +1**(下一版叫 `oj2-judge-4`:`build.sh` 里的
`IMAGE`、三个 compose 里的 image,一起改)。`docker compose up -d` 不带 `--pull`,名字没变会静默
用机器上的旧镜像。
回滚:JudgeServer 那版(`oj2-judge-2`)只认 2026-10 之前的后端,要连代码一起回退到换判题机之前
的提交再部署。别在服务器上 `docker image prune` 把 `oj2-judge-2` 清掉。
## 和后端的分工
- **编译和运行命令在 `apps/api/src/judge/languages.ts`**,不在镜像里。镜像只负责把
`/usr/bin/gcc`、`/usr/bin/g++`、`/usr/bin/python3` 这些绝对路径挂到正确的版本上
(Dockerfile 里的 `update-alternatives`)。恢复一种语言要两边一起改,缺一个都是静默失败。
- **沙箱里能看到哪些目录在 `mount.yaml`**:`/usr`、`/lib` 这些只读,工作目录 `/w` 和 `/tmp`
各是一块 128MB 的 tmpfs。
- **测试点不经过 api**:学生程序的 stdin 直接从判题机容器里的 `/test_case/<id>/N.in` 开
(镜像的 `-src-prefix=/test_case`,别的路径一律拒)。比对用的 `stripped_output_md5` 由 api
从同一个目录的 `info` 里读。所以 compose 里判题机和 api 挂的必须是同一个测试点目录。
- **程序输出不内联回传**:每个测试点的输出(最多 16MB)留在判题机的文件存储里,api 用
`GET /file/:id` 按原始字节取回来比 md5,然后删掉。直接塞在 JSON 里的话,死循环打印的
16MB 会在 worker 里膨胀成几倍。api 半路崩了没删的,判题机 10 分钟后自己清(`-file-timeout`)。
- **心跳**:go-judge 不主动报,worker 每 5 秒问一次 `/version`,替它在 `judge_server` 表里记一笔
(`apps/api/src/judge/heartbeat.ts`)。后台列表里两台分别叫「服务器」「机房」(compose 的
`JUDGE_SERVER_NAME`)。
- **鉴权**:`Authorization: Bearer <OJ2_JUDGE_TOKEN>`,go-judge 从 `ES_AUTH_TOKEN` 读。原来
JudgeServer 收的是 token 的 sha256,现在是原文。
gcc-14 把隐式函数声明、int↔指针互赋、不兼容指针类型从 warning 提成了 error,`-w`
压不住。`languages.ts` 里的 `cLooseErrors` 里的 `-Wno-error=` 就是为此加的 ——
2026-09 升 gcc-14 时实测 1951 份历史 C 提交和 20 篇 C 教程的 93 个代码块,加了之后与
gcc-13 逐个文件结果完全一致;不加的话有 26 份会从「能过」变成 CE(按比例算全库约 260 条),
全是忘了 `#include <string.h>` 之类的隐式函数声明。
## 换镜像后怎么验
```bash
bun docker/judge/smoke.ts # 三种语言 + 六种状态码 + gcc 宽松度 + 几条照搬的口径
```
它走的就是线上那份 `sandbox.ts`,不需要起后端、不需要库里有题,测试点内联传。
判题机装好之后,后台「判题机列表」应该能看到它上线(worker 起来 5 秒内)。