OjSandboxAPI.java 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. package space.anyi.docker;
  2. import com.github.dockerjava.api.DockerClient;
  3. import com.github.dockerjava.api.async.ResultCallback;
  4. import com.github.dockerjava.api.command.CreateContainerResponse;
  5. import com.github.dockerjava.api.command.ExecCreateCmdResponse;
  6. import com.github.dockerjava.api.model.Capability;
  7. import com.github.dockerjava.api.model.Frame;
  8. import com.github.dockerjava.api.model.HostConfig;
  9. import com.github.dockerjava.api.model.StreamType;
  10. import org.slf4j.Logger;
  11. import org.slf4j.LoggerFactory;
  12. import java.io.ByteArrayInputStream;
  13. import java.nio.charset.StandardCharsets;
  14. import java.util.ArrayList;
  15. import java.util.Base64;
  16. import java.util.List;
  17. import java.util.Map;
  18. import java.util.concurrent.TimeUnit;
  19. /**
  20. * Docker OJ(Online Judge)代码沙箱 MVP
  21. *
  22. * <p>判题流程:一次提交只使用<b>一个</b>容器——
  23. * 先注入源码并编译,再通过 docker exec 逐用例运行,前一个用例与后一个用例共享同一容器。</p>
  24. *
  25. * <pre>
  26. * 等价命令:
  27. * docker run -d --name oj-sandbox -w /code --user nobody --read-only \
  28. * --network none --cap-drop ALL -m 64m --cpus 1 --pids-limit 64 \
  29. * --tmpfs /code --tmpfs /tmp amazoncorretto:17 sh -c "sleep 300"
  30. * docker exec <cid> sh -c "printf %s '<BASE64>' | base64 -d > /code/Main.java"
  31. * docker exec <cid> javac -encoding UTF-8 -d /code /code/Main.java
  32. * docker exec -i <cid> java -Xmx48m -cp /code Main < case1.in
  33. * docker exec -i <cid> java -Xmx48m -cp /code Main < case2.in
  34. * docker rm -f <cid>
  35. * </pre>
  36. */
  37. public class OjSandboxAPI {
  38. private static final Logger log = LoggerFactory.getLogger(OjSandboxAPI.class);
  39. /** 判题镜像:本机已存在的 amazoncorretto JDK 镜像,自带 javac/java */
  40. private static final String JUDGE_IMAGE = "amazoncorretto:17";
  41. /** 容器内代码目录(挂在可写 tmpfs 上,与只读根文件系统配合) */
  42. private static final String CODE_DIR = "/code";
  43. /** 源码文件名(选手主类必须叫 Main) */
  44. private static final String SOURCE_FILE = "Main.java";
  45. /** 编译阶段超时(毫秒),远超单文件 javac 的正常耗时 */
  46. private static final long COMPILE_TIMEOUT_MS = 20_000;
  47. /** 容器内常驻主进程,让容器保持运行以便后续 exec */
  48. private static final String KEEP_ALIVE_CMD = "sleep 300";
  49. private final DockerClient dockerClient;
  50. public OjSandboxAPI() {
  51. this.dockerClient = DockerClientFactory.createDockerClient();
  52. }
  53. // ==================== 判题结果模型 ====================
  54. /** 判题结果分类 */
  55. public enum JudgeVerdict {
  56. /** Accepted 通过 */
  57. AC,
  58. /** Wrong Answer 答案错误 */
  59. WA,
  60. /** Compile Error 编译错误 */
  61. CE,
  62. /** Runtime Error 运行时错误(进程非 0 退出) */
  63. RE,
  64. /** Time Limit Exceeded 超出时间限制 */
  65. TLE,
  66. /** Memory Limit Exceeded 超出内存限制(MVP 保留位) */
  67. MLE,
  68. /** Output Limit Exceeded 输出超出限制(MVP 保留位) */
  69. OLE,
  70. /** System Error 判题系统自身异常 */
  71. SE
  72. }
  73. /** 判题配置:单次提交的资源限制 */
  74. public record JudgeConfig(int timeLimitMs, int memoryLimitMb, int outputLimitBytes) {
  75. }
  76. /** 单个测试用例:标准输入与期望输出 */
  77. public record TestCase(String stdin, String expected) {
  78. }
  79. /** 单个用例的运行结果(verdict 为空表示进程正常退出,等待输出比对后定 AC/WA) */
  80. public record RunResult(String stdout, String stderr, int exitCode, long durationMs,
  81. JudgeVerdict verdict) {
  82. }
  83. /** 单个用例的最终判定(含实际输出,便于展示与比对) */
  84. public record JudgeResult(int caseNo, TestCase testCase, RunResult run) {
  85. }
  86. /** 一次提交的完整判题结果 */
  87. public record JudgeOutcome(boolean compileOk, String compileOutput, List<JudgeResult> results) {
  88. }
  89. // ==================== 对外入口 ====================
  90. /**
  91. * 判题入口:一次提交只创建一个容器,完成源码注入 + 编译 + 逐用例运行
  92. * @param source 选手提交的 Java 源码(主类名必须为 Main)
  93. * @param config 判题资源配置
  94. * @param cases 测试用例列表
  95. * @return 判题结果(编译失败时 results 为空)
  96. */
  97. public JudgeOutcome judge(String source, JudgeConfig config, List<TestCase> cases) {
  98. String containerId = null;
  99. try {
  100. containerId = createSandboxContainer(config);
  101. log.info("沙箱容器已创建: {}", containerId);
  102. // 1) 注入源码
  103. injectSource(containerId, source);
  104. // 2) 编译(等价 docker exec <cid> javac)
  105. String compileOutput = compile(containerId);
  106. if (!"".equals(compileOutput)) {
  107. log.warn("编译失败,判定 CE:\n{}", compileOutput);
  108. return new JudgeOutcome(false, compileOutput, List.of());
  109. }
  110. log.info("编译通过");
  111. // 3) 逐用例运行(等价 docker exec -i <cid> java ... < case.in)
  112. List<JudgeResult> results = new ArrayList<>();
  113. for (int i = 0; i < cases.size(); i++) {
  114. TestCase tc = cases.get(i);
  115. RunResult run = runCase(containerId, config, tc);
  116. JudgeVerdict verdict = finalVerdict(run, tc);
  117. RunResult judged = new RunResult(run.stdout(), run.stderr(), run.exitCode(),
  118. run.durationMs(), verdict);
  119. results.add(new JudgeResult(i + 1, tc, judged));
  120. log.info("用例 {}/{} 判定: {} (耗时 {}ms, 退出码 {})",
  121. i + 1, cases.size(), verdict, judged.durationMs(), judged.exitCode());
  122. // TLE 时进程无法在容器内单独终止,整容器已被杀死,后续用例无法继续
  123. if (verdict == JudgeVerdict.TLE) {
  124. log.warn("用例超时已将沙箱容器杀死,其余用例不再执行");
  125. break;
  126. }
  127. }
  128. return new JudgeOutcome(true, compileOutput, results);
  129. } finally {
  130. if (containerId != null) {
  131. // 等价命令: docker rm -f <cid>
  132. dockerClient.removeContainerCmd(containerId).withForce(true).exec();
  133. log.info("沙箱容器已清理: {}", containerId);
  134. }
  135. }
  136. }
  137. // ==================== 容器创建 ====================
  138. /**
  139. * 创建并启动沙箱容器:只读根 + tmpfs 可写代码区 + 资源/权限全部收紧
  140. * @return 容器 ID
  141. */
  142. private String createSandboxContainer(JudgeConfig config) {
  143. // 等价命令:
  144. // docker run -d -w /code --user nobody --read-only --network none \
  145. // --cap-drop ALL -m 64m --cpus 1 --pids-limit 64 \
  146. // --tmpfs /code --tmpfs /tmp amazoncorretto:17 sh -c "sleep 300"
  147. HostConfig hostConfig = buildSandboxHostConfig(config);
  148. CreateContainerResponse container = dockerClient.createContainerCmd(JUDGE_IMAGE)
  149. .withName("oj-sandbox-" + System.nanoTime())
  150. .withHostConfig(hostConfig)
  151. .withWorkingDir(CODE_DIR)
  152. .withUser("nobody")
  153. .withEnv("HOME=/tmp")
  154. .withEntrypoint("sh", "-c")
  155. .withCmd(KEEP_ALIVE_CMD)
  156. .exec();
  157. dockerClient.startContainerCmd(container.getId()).exec();
  158. return container.getId();
  159. }
  160. /**
  161. * 沙箱安全基线:
  162. * 无网络、丢弃所有内核 capability、只读根文件系统(避免篡改)、
  163. * 内存/CPU/进程数限制,以及非 root 用户运行
  164. */
  165. private HostConfig buildSandboxHostConfig(JudgeConfig config) {
  166. long memoryBytes = config.memoryLimitMb() * 1024L * 1024L;
  167. // /code 与 /tmp 挂载可写 tmpfs(1777 即任何人可读写),与只读根互补
  168. Map<String, String> tmpFs = Map.of(
  169. CODE_DIR, "size=8m,mode=1777",
  170. "/tmp", "size=8m,mode=1777");
  171. return HostConfig.newHostConfig()
  172. .withMemory(memoryBytes) // 等价 -m 64m
  173. .withMemorySwap(memoryBytes * 2) // 需保持 swap >= memory,否则 409
  174. .withNanoCPUs(1_000_000_000L) // 等价 --cpus 1
  175. .withPidsLimit(64L) // 等价 --pids-limit 64(防 fork 炸弹)
  176. .withNetworkMode("none") // 等价 --network none(禁止联网)
  177. .withCapDrop(Capability.ALL) // 等价 --cap-drop ALL(丢弃全部特权)
  178. .withReadonlyRootfs(true) // 等价 --read-only(防篡改镜像层)
  179. .withTmpFs(tmpFs); // 等价 --tmpfs /code --tmpfs /tmp
  180. }
  181. // ==================== 编译阶段 ====================
  182. /**
  183. * 把源码写入容器内的 /code(tmpfs,可写)
  184. * 注意两点:
  185. * 1) 根文件系统只读时,docker cp(copyArchiveToContainerCmd)会被 daemon 拒绝
  186. * (400 container rootfs is marked read-only),故改用 exec 注入;
  187. * 2) 不通过 exec 的 stdin 传内容——httpclient5 半关闭语义下它不会向容器发送
  188. * EOF,程序(如 cat)会一直等待输入。因此把源码 base64 编码后放进命令行参数,
  189. * 在容器内解码写盘,天然与 stdin 无关。
  190. */
  191. private void injectSource(String containerId, String source) {
  192. String encoded = Base64.getEncoder()
  193. .encodeToString(source.getBytes(StandardCharsets.UTF_8));
  194. // 等价命令: docker exec <cid> sh -c "printf %s '<base64>' | base64 -d > /code/Main.java"
  195. // base64 字符集仅含 [A-Za-z0-9+/=],不含单引号,可安全内联到 shell 命令
  196. ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
  197. .withCmd("sh", "-c",
  198. "printf %s '" + encoded + "' | base64 -d > " + CODE_DIR + "/" + SOURCE_FILE)
  199. .withAttachStdout(true)
  200. .withAttachStderr(true)
  201. .exec();
  202. try {
  203. dockerClient.execStartCmd(exec.getId())
  204. .withDetach(false)
  205. .exec(new ResultCallback.Adapter<>() {
  206. }).awaitCompletion(COMPILE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
  207. log.info("源码 [{}] 已注入沙箱容器 ({} 字节)", SOURCE_FILE, source.length());
  208. } catch (InterruptedException e) {
  209. Thread.currentThread().interrupt();
  210. log.error("源码注入被中断: {}", e.getMessage());
  211. }
  212. }
  213. /**
  214. * 在容器内编译源码;成功返回空串,失败返回 javac 诊断信息
  215. */
  216. private String compile(String containerId) {
  217. StringBuilder output = new StringBuilder();
  218. try {
  219. // 等价命令: docker exec <cid> javac -encoding UTF-8 -d /code /code/Main.java
  220. ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
  221. .withCmd("javac", "-encoding", "UTF-8", "-d", CODE_DIR,
  222. CODE_DIR + "/" + SOURCE_FILE)
  223. .withAttachStdout(true)
  224. .withAttachStderr(true)
  225. .exec();
  226. boolean completed = dockerClient.execStartCmd(exec.getId())
  227. .withDetach(false)
  228. .exec(new ResultCallback.Adapter<>() {
  229. @Override
  230. public void onNext(Frame frame) {
  231. output.append(new String(frame.getPayload(), StandardCharsets.UTF_8));
  232. }
  233. }).awaitCompletion(COMPILE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
  234. if (!completed) {
  235. return "编译超时: " + output;
  236. }
  237. int exitCode = dockerClient.inspectExecCmd(exec.getId()).exec().getExitCode();
  238. return exitCode == 0 ? "" : output.toString();
  239. } catch (Exception e) {
  240. log.error("编译执行异常", e);
  241. return "编译系统异常: " + e.getMessage();
  242. }
  243. }
  244. // ==================== 运行阶段 ====================
  245. /**
  246. * 在沙箱容器内通过 exec 运行一个用例:
  247. * 标准输入经 withStdIn 注入,标准输出/错误输出按帧收集,并施加时间与输出上限
  248. */
  249. private RunResult runCase(String containerId, JudgeConfig config, TestCase tc) {
  250. long startNanos = System.nanoTime();
  251. StringBuilder stdout = new StringBuilder();
  252. StringBuilder stderr = new StringBuilder();
  253. int[] bytes = {0};
  254. boolean[] truncated = {false};
  255. // JVM 堆上限要比容器内存限制小,避免 JVM 启动即顶到 cgroup 内存上限
  256. int heapMb = Math.max(8, config.memoryLimitMb() - 16);
  257. // 用例输入与真实 OJ 一致以换行结尾:
  258. // 例如 Scanner 读取最后一个 token 仍需分隔符才能结束,而 httpclient5 无法向容器传达 stdin EOF
  259. String input = tc.stdin().endsWith("\n") ? tc.stdin() : tc.stdin() + "\n";
  260. try {
  261. // 等价命令: docker exec -i <cid> java -Xmx48m -cp /code Main < case1.in
  262. ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
  263. .withAttachStdin(true)
  264. .withAttachStdout(true)
  265. .withAttachStderr(true)
  266. .withCmd("java", "-Xmx" + heapMb + "m", "-cp", CODE_DIR, "Main")
  267. .exec();
  268. boolean timedOut = false;
  269. ResultCallback.Adapter<Frame> callback = dockerClient.execStartCmd(exec.getId())
  270. .withDetach(false)
  271. .withStdIn(new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)))
  272. .exec(new ResultCallback.Adapter<>() {
  273. @Override
  274. public void onNext(Frame frame) {
  275. byte[] payload = frame.getPayload();
  276. bytes[0] += payload.length;
  277. // 超过输出上限后不再累积,避免内存被输出撑爆
  278. if (bytes[0] > config.outputLimitBytes()) {
  279. truncated[0] = true;
  280. return;
  281. }
  282. String text = new String(payload, StandardCharsets.UTF_8);
  283. if (frame.getStreamType() == StreamType.STDOUT ||
  284. frame.getStreamType() == StreamType.RAW) {
  285. stdout.append(text);
  286. } else if (frame.getStreamType() == StreamType.STDERR) {
  287. stderr.append(text);
  288. }
  289. }
  290. });
  291. boolean completed = callback.awaitCompletion(config.timeLimitMs(), TimeUnit.MILLISECONDS);
  292. if (!completed) {
  293. // exec 进程无法单独终止,只能整容器 kill(等价 docker kill <cid>)
  294. log.warn("用例运行超时 ({}ms),杀死沙箱容器", config.timeLimitMs());
  295. dockerClient.killContainerCmd(containerId).withSignal("KILL").exec();
  296. callback.close();
  297. timedOut = true;
  298. }
  299. long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
  300. if (timedOut) {
  301. return new RunResult(stdout.toString(), stderr.toString(), -1, durationMs,
  302. JudgeVerdict.TLE);
  303. }
  304. int exitCode = dockerClient.inspectExecCmd(exec.getId()).exec().getExitCode();
  305. if (truncated[0]) {
  306. return new RunResult(stdout.toString(), stderr.toString(), exitCode, durationMs,
  307. JudgeVerdict.OLE);
  308. }
  309. if (exitCode != 0) {
  310. return new RunResult(stdout.toString(), stderr.toString(), exitCode, durationMs,
  311. JudgeVerdict.RE);
  312. }
  313. // 进程正常退出,输出比对由 finalVerdict 决定 AC/WA
  314. return new RunResult(stdout.toString(), stderr.toString(), exitCode, durationMs, null);
  315. } catch (Exception e) {
  316. log.error("用例运行系统异常", e);
  317. long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
  318. return new RunResult(stdout.toString(), stderr.toString(), -1, durationMs,
  319. JudgeVerdict.SE);
  320. }
  321. }
  322. /**
  323. * 对运行结果做最终判定:非空 verdict 直接返回,为空则比对输出
  324. */
  325. private JudgeVerdict finalVerdict(RunResult run, TestCase tc) {
  326. if (run.verdict() != null) {
  327. return run.verdict();
  328. }
  329. // 输出比对:忽略行尾空白、结尾空行、回车符差异
  330. boolean matched = normalizeText(run.stdout()).equals(normalizeText(tc.expected()));
  331. return matched ? JudgeVerdict.AC : JudgeVerdict.WA;
  332. }
  333. /**
  334. * 基准输出规整:\r\n 归一为 \n、去除每行行尾空白、丢弃末尾空行
  335. */
  336. private static String normalizeText(String text) {
  337. String normalized = (text == null ? "" : text)
  338. .replace("\r\n", "\n").replace("\r", "\n");
  339. List<String> lines = new ArrayList<>(normalized.lines().map(String::stripTrailing).toList());
  340. while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) {
  341. lines.remove(lines.size() - 1);
  342. }
  343. return String.join("\n", lines);
  344. }
  345. }