|
@@ -1476,6 +1476,7 @@ try {
|
|
|
| `VolumeNetworkAPI` | Chapter 9 | 数据卷与网络管理(卷的生命周期、自定义网络、DNS 互联) |
|
|
| `VolumeNetworkAPI` | Chapter 9 | 数据卷与网络管理(卷的生命周期、自定义网络、DNS 互联) |
|
|
|
| `RegistryPushAPI` | Chapter 10 | 私有镜像仓库(本地 registry、认证、推送拉取闭环) |
|
|
| `RegistryPushAPI` | Chapter 10 | 私有镜像仓库(本地 registry、认证、推送拉取闭环) |
|
|
|
| `MaintenanceOpsAPI` | Chapter 11 | 运行监控与日常维护(stats/top/logs -f、diff、commit/export、update) |
|
|
| `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` 是唯一推荐的生产构建方式。
|
|
`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,也能快速上手。
|
|
> Docker Java SDK 的 API 设计遵循"命令对象模式":每个操作对应一个 `*Cmd` 接口,通过 Builder 模式链式设置参数,最后通过 `.exec()` 统一执行。掌握这个模式后,即使遇到本教程未覆盖的 API,也能快速上手。
|