浏览代码

docs: add Chapter 12 OJ sandbox tutorial, update code structure table and todo plan

yangyi 1 月之前
父节点
当前提交
86a79e4a34
共有 2 个文件被更改,包括 194 次插入 和 1 次删除
  1. 180 0
      docker-java-sdk-tutorial.md
  2. 14 1
      todo.md

+ 180 - 0
docker-java-sdk-tutorial.md

@@ -1476,6 +1476,7 @@ try {
 | `VolumeNetworkAPI` | Chapter 9 | 数据卷与网络管理(卷的生命周期、自定义网络、DNS 互联) |
 | `RegistryPushAPI` | Chapter 10 | 私有镜像仓库(本地 registry、认证、推送拉取闭环) |
 | `MaintenanceOpsAPI` | Chapter 11 | 运行监控与日常维护(stats/top/logs -f、diff、commit/export、update) |
+| `OjSandboxAPI` | Chapter 12 | OJ 代码沙箱(单容器编译 + 逐用例 exec 判题,AC/WA/CE/RE/TLE) |
 
 ***
 
@@ -1823,6 +1824,185 @@ dockerClient.updateContainerCmd(containerId)
 
 `docker commit` 把容器的完整文件系统合并成一个不透明的镜像层,无法追溯修改历史、无法复现构建过程、镜像体积无法优化。`docker build` 通过 Dockerfile 定义构建步骤,每条指令产生一个可缓存的层,支持版本控制、增量构建和安全审查。`docker commit` 适用于紧急现场保存,而 `docker build` 是唯一推荐的生产构建方式。
 
+## OJ 代码沙箱:一次提交只使用一个容器
+
+在线评测系统(Online Judge,OJ)需要运行**不可信的用户代码**:既要按时按量完成编译和测试用例验证,又要保证沙箱外的宿主机甚至网络完全不受影响。传统方案用 chroot + rlimits + seccomp 层层加固,而 Docker 天然提供了更完整的隔离边界。
+
+本章基于 `OjSandboxAPI` 实现一个最小可用的 OJ 代码沙箱:**一次提交只创建一个容器**,在这个容器内完成「注入源码 → 编译 → 逐用例运行」,所有数据交互都通过标准输入、标准输出和错误输出完成,最后把容器删除。
+
+### 为什么用容器做沙箱
+
+把用户程序放进容器后,"运行代码"就变成了"运行容器":
+
+| 安全诉求 | 容器实现 |
+|----------|----------|
+| 不能联网 | `--network none` |
+| 不能越权 | `--cap-drop ALL`(丢弃全部内核能力) |
+| 不能篡改镜像 | `--read-only` 只读根文件系统 |
+| 不能占满内存 | `-m 64m`(并同步 `--memory-swap`) |
+| 不能吃光整机 CPU | `--cpus 1` |
+| 不能 fork 炸弹 | `--pids-limit 64` |
+| 不能读取宿主文件 | 容器文件系统天然隔离 + 非 root 用户 `nobody` |
+
+### 沙箱容器创建(Hardened HostConfig)
+
+对应一次 `docker run`,docker-java 用一条 `createContainerCmd` 就能表达全部安全参数:
+
+```java
+// 等价命令:
+//   docker run -d -w /code --user nobody --read-only --network none \
+//     --cap-drop ALL -m 64m --cpus 1 --pids-limit 64 \
+//     --tmpfs /code --tmpfs /tmp amazoncorretto:17 sh -c "sleep 300"
+
+HostConfig hostConfig = HostConfig.newHostConfig()
+        .withMemory(memoryBytes)             // -m 64m
+        .withMemorySwap(memoryBytes * 2)     // 必须 >= memory,否则创建报 409
+        .withNanoCPUs(1_000_000_000L)        // --cpus 1
+        .withPidsLimit(64L)                  // --pids-limit 64
+        .withNetworkMode("none")             // --network none
+        .withCapDrop(Capability.ALL)         // --cap-drop ALL
+        .withReadonlyRootfs(true)            // --read-only
+        .withTmpFs(Map.of(                   // --tmpfs 提供可写空间
+                "/code", "size=8m,mode=1777",
+                "/tmp", "size=8m,mode=1777"));
+
+CreateContainerResponse container = dockerClient.createContainerCmd("amazoncorretto:17")
+        .withHostConfig(hostConfig)
+        .withWorkingDir("/code")
+        .withUser("nobody")                  // 非 root 运行
+        .withEntrypoint("sh", "-c")
+        .withCmd("sleep 300")                // 常驻进程,为后续 exec 保持容器运行
+        .exec();
+```
+
+要点:
+
+- 根文件系统只读后,`/code` 与 `/tmp` 用 **tmpfs**(内存文件系统)挂载成可写目录,作为编译产物与临时文件的家;
+- 容器没有业务主进程,用 `sleep 300` 保持运行——**判题的每次用例都是容器里的一个 `docker exec` 进程**,这正是"一次提交只用一个容器"的关键。
+
+### 注入源码:为什么不用 docker cp
+
+把源码放进容器,最直观的做法是 `copyArchiveToContainerCmd`(`docker cp`)——但实测发现:
+
+```log
+Status 400: {"message":"container rootfs is marked read-only"}
+```
+
+只读根文件系统下,daemon 会拒绝任何写入型 `docker cp`,包括写入到 tmpfs 挂载点。因此这里换一种注入方式:把源码 **base64 编码后作为 exec 命令行参数**传入,在容器内解码写盘:
+
+```java
+// 等价命令: docker exec <cid> sh -c "printf %s '<base64>' | base64 -d > /code/Main.java"
+// base64 字符集仅含 [A-Za-z0-9+/=],不含单引号,可安全内联进 shell 命令
+String encoded = Base64.getEncoder().encodeToString(source.getBytes(StandardCharsets.UTF_8));
+ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
+        .withCmd("sh", "-c",
+                "printf %s '" + encoded + "' | base64 -d > /code/Main.java")
+        .exec();
+dockerClient.execStartCmd(exec.getId()).withDetach(false)
+        .exec(new ResultCallback.Adapter<>() {}).awaitCompletion();
+```
+
+**为什么不是 exec 的 stdin?** docker-java 的 httpclient5 传输在 HTTP 半关闭语义下无法向容器传达 stdin 的 EOF。像 `cat` 这类"读完全部输入再结束"的程序会永远等下去(正如下面运行阶段会遇到的一样)。把内容放进命令行参数,天然绕开了 EOF 问题。
+
+### 编译与判定的关系
+
+编译同样是容器内的一次 exec,退出码通过 `inspectExecCmd` 获取:
+
+```java
+// 等价命令: docker exec <cid> javac -encoding UTF-8 -d /code /code/Main.java
+ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
+        .withCmd("javac", "-encoding", "UTF-8", "-d", "/code", "/code/Main.java")
+        .withAttachStdout(true).withAttachStderr(true)
+        .exec();
+dockerClient.execStartCmd(exec.getId()).withDetach(false)
+        .exec(new ResultCallback.Adapter<>() {...}).awaitCompletion(COMPILE_TIMEOUT_MS, MILLISECONDS);
+
+int exitCode = dockerClient.inspectExecCmd(exec.getId()).exec().getExitCode();
+// exitCode != 0 → Compile Error(源码编译失败,后续用例不再执行)
+```
+
+### 逐用例运行:标准输入喂数据,标准/错误输出按帧收集
+
+每个测试用例都是一次 `docker exec`,标准输入通过 `withStdIn(InputStream)` 注入,标准输出与错误输出以帧(`Frame`,带 `StreamType`)回调收集:
+
+```java
+// 等价命令: docker exec -i <cid> java -Xmx48m -cp /code Main < case1.in
+ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
+        .withAttachStdin(true).withAttachStdout(true).withAttachStderr(true)
+        .withCmd("java", "-Xmx48m", "-cp", "/code", "Main")
+        .exec();
+
+ResultCallback.Adapter<Frame> callback = dockerClient.execStartCmd(exec.getId())
+        .withDetach(false)
+        .withStdIn(new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)))
+        .exec(new ResultCallback.Adapter<>() {
+            @Override
+            public void onNext(Frame frame) {
+                bytes += frame.getPayload().length;      // 输出上限防护(OLE)
+                if (frame.getStreamType() == StreamType.STDOUT) stdout.append(新帧);
+                else if (frame.getStreamType() == StreamType.STDERR) stderr.append(新帧);
+            }
+        });
+
+boolean completed = callback.awaitCompletion(config.timeLimitMs(), TimeUnit.MILLISECONDS);
+```
+
+注意 `awaitCompletion(long, TimeUnit)` 超时返回 `false` 而不是抛异常。返回 `false` 时说明进程超时(TLE),由于 exec 进程无法被单独终止,只能 `killContainerCmd(containerId).withSignal("KILL")` 杀死整个容器:
+
+```java
+// 等价命令: docker kill <cid>(TLE 时 exec 进程杀不掉,只能整容器回收)
+if (!completed) {
+    dockerClient.killContainerCmd(containerId).withSignal("KILL").exec();
+    callback.close();
+    // 后续用例不能再执行,本次提交以 TLE 收场
+}
+```
+
+进程正常退出后,再用 `inspectExecCmd(exec.getId()).exec().getExitCode()` 取得退出码:非 0 判 RE(运行时错误),为 0 则对输出与期望值做空白规范化后比对,得出 AC 或 WA。
+
+### 判题结果分类
+
+| 判定 | 触发条件 |
+|------|----------|
+| AC | 编译通过,进程 0 退出,输出与期望一致(忽略行尾空白) |
+| WA | 进程正常退出但输出不一致 |
+| CE | javac 退出码非 0(编译诊断即为反馈) |
+| RE | 运行进程退出码非 0(如未捕获异常导致 exit 1) |
+| TLE | `awaitCompletion` 超时 → 整容器 kill,提交终止 |
+| MLE / OLE / SE | MVP 预留位:内存超限、输出超限、判题系统异常 |
+
+### 运行效果
+
+同一份 A+B 解答的两个用例:
+
+```log
+OjSandboxAPI -- 沙箱容器已创建: e1e48440...
+OjSandboxAPI -- 源码 [Main.java] 已注入沙箱容器 (233 字节)
+OjSandboxAPI -- 编译通过
+OjSandboxAPI -- 用例 1/2 判定: AC (耗时 140ms, 退出码 0)
+OjSandboxAPI -- 用例 2/2 判定: AC (耗时 173ms, 退出码 0)
+OjSandboxAPI -- 沙箱容器已清理: e1e48440...
+```
+
+死循环程序(`timeLimitMs = 1500`):
+
+```log
+OjSandboxAPI -- 用例 1/1 判定: TLE (耗时 1561ms, 退出码 -1)
+OjSandboxAPI -- WARN 用例超时已将沙箱容器杀死,其余用例不再执行
+```
+
+### 一个 echo 提示:用例输入必须以换行结尾
+
+docker-java 无法向容器传达 stdin 的 EOF(与注入源码时是同一个原因)。因此若用例输入为 `"1 2"`,`Scanner` 读完最后一个数字后还会等待分隔符,进程会一直阻塞到超时。MVP 的约定是**给用例输入补上一个换行符**(真实 OJ 中输入文件天然以换行结尾);真正的生产判题机通常改用文件重定向或交互式连接来完整地传递 EOF 语义。
+
+**思考:** 为什么判题(AC/WA 比对)要用非 TTY 管道,而不能用 `docker exec -it` 的伪终端?
+
+因为 TTY 会做行缓冲、回显、`\r\n` 转换并合并 stdout/stderr 为单一 RAW 流,这些"终端友好"的行为会污染逐字节输出比对,导致真实输出与期望输出难以精确匹配。TTY 只适合交互式题目(如 REPL 判题),标准 OJ 判题必须用非 TTY 管道——这正是本章 `withDetach(false)` + 帧回调分离 stdout/stderr 的原因。
+
+**思考:** TLE 时为什么只能杀死整个容器而不是结束某个 exec 进程?
+
+`docker exec` 的进程与容器进程共享同一 cgroup,Docker 没有"按 exec 终止单个进程"的 API;而 C 级 `kill` 对 JVM 这类线程化运行时并不总能生效。在"一次提交一个容器"的模型下,超时即回收整个容器,是简单且自洽的选择。若希望单个用例超时不连坐,可回退到前文探讨过的"每用例独立容器"方案。
+
 ***
 
 > Docker Java SDK 的 API 设计遵循"命令对象模式":每个操作对应一个 `*Cmd` 接口,通过 Builder 模式链式设置参数,最后通过 `.exec()` 统一执行。掌握这个模式后,即使遇到本教程未覆盖的 API,也能快速上手。

+ 14 - 1
todo.md

@@ -99,6 +99,16 @@
 - [x] JUnit 5 测试用例(8 项通过)
 - [x] Review & Commit
 
+### Chapter 12: OJ 代码沙箱 MVP ✅
+- [x] 每次提交只使用一个容器(compile + 逐用例 exec 共享容器)
+- [x] 沙箱安全基线 HostConfig(network none / cap-drop ALL / read-only / tmpfs / pids / memory)
+- [x] 源码注入(base64 命令行注入,规避只读根下 docker cp 400 与 stdin EOF 问题)
+- [x] 编译 exec + inspectExecCmd 取退出码(CE 判定)
+- [x] 逐用例 exec:withStdIn 喂输入、帧回调收 stdout/stderr、awaitCompletion 看门狗(TLE → kill 容器)
+- [x] 输出比对归一化(\r\n / 行尾空白 / 结尾空行)→ AC / WA
+- [x] JUnit 5 测试用例(AC/WA/CE/RE/TLE,5 项通过)
+- [x] Review & Commit
+
 ## 源码结构
 | 类名 | 说明 |
 |------|------|
@@ -112,6 +122,7 @@
 | `VolumeNetworkAPI` | 数据卷与网络(卷 CRUD/持久化/自定义网络/DNS) |
 | `RegistryPushAPI` | 私有镜像仓库(registry/auth/tag/push/pull 闭环) |
 | `MaintenanceOpsAPI` | 运维监控(stats/top/logs -f/diff/commit/export/update/prune) |
+| `OjSandboxAPI` | OJ 代码沙箱(单容器编译 + 逐用例 exec 判题,AC/WA/CE/RE/TLE) |
 
 ## 编码规范
 - 日志输出统一使用 SLF4J Logger,禁止 `System.out.println`
@@ -131,4 +142,6 @@
 - `feat: add Chapter 8 advanced container creation (HostConfig) and Chapter 9 volume/network management`
 - `feat: add Chapter 10 - image push to local registry (docker login/tag/push round trip)`
 - `feat: add Chapter 11 - monitoring and maintenance ops (stats/top/logs/commit/export/prune)`
-- `docs: add Chapter 8-11 tutorial, fix pingCmd and add prune to cheat sheet`
+- `docs: add Chapter 8-11 tutorial, fix pingCmd and add prune to cheat sheet`
+- `feat: add Chapter 12 - OJ code sandbox MVP (single container compile + per-case exec judging)`
+- `docs: add Chapter 12 OJ sandbox tutorial, update code structure table and todo plan`