|
|
@@ -0,0 +1,422 @@
|
|
|
+package space.anyi.codesandbox;
|
|
|
+
|
|
|
+import com.github.dockerjava.api.DockerClient;
|
|
|
+import com.github.dockerjava.api.async.ResultCallback;
|
|
|
+import com.github.dockerjava.api.command.CreateContainerResponse;
|
|
|
+import com.github.dockerjava.api.command.ExecCreateCmdResponse;
|
|
|
+import com.github.dockerjava.api.model.Capability;
|
|
|
+import com.github.dockerjava.api.model.Frame;
|
|
|
+import com.github.dockerjava.api.model.HostConfig;
|
|
|
+import com.github.dockerjava.api.model.StreamType;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import space.anyi.serve.entity.contest.Submission;
|
|
|
+
|
|
|
+import java.io.ByteArrayInputStream;
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.Semaphore;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+
|
|
|
+/**
|
|
|
+ * @fileName: DockerCodeSandBox
|
|
|
+ * @projectName: serve
|
|
|
+ * @package: space.anyi.codesandbox
|
|
|
+ * @author: yangyi
|
|
|
+ * @date:14/10/2026 4:44 pm
|
|
|
+ * @description: 基于docker实现代码沙箱
|
|
|
+ */
|
|
|
+public class DockerCodeSandBox implements CodeSandBox {
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(DockerCodeSandBox.class);
|
|
|
+
|
|
|
+ private static final String JUDGE_IMAGE = "eclipse-temurin:17-jdk-alpine";
|
|
|
+ private static final String CODE_DIR = "/code";
|
|
|
+ private static final String SOURCE_FILE = "Main.java";
|
|
|
+ private static final long COMPILE_TIMEOUT_MS = 20_000;
|
|
|
+
|
|
|
+ /** 单次运行输出截断上限(字节),防止恶意输出耗尽内存 */
|
|
|
+ private static final int MAX_OUTPUT_LENGTH = 1024 * 1024;
|
|
|
+
|
|
|
+ /** 最大并发判题数,受宿主机 Docker 资源约束 */
|
|
|
+ private static final int MAX_CONCURRENT = 10;
|
|
|
+
|
|
|
+ /** 获取并发许可的超时(毫秒),超时返回SE */
|
|
|
+ private static final int CONCURRENCY_ACQUIRE_TIMEOUT_MS = 5_000;
|
|
|
+
|
|
|
+ /** 并发控制信号量 */
|
|
|
+ private static final Semaphore CONCURRENCY_LIMIT = new Semaphore(MAX_CONCURRENT);
|
|
|
+
|
|
|
+ private DockerClient dockerClient;
|
|
|
+
|
|
|
+ /** 判题镜像是否已就绪,避免每次判题都查询 Docker daemon */
|
|
|
+ private volatile boolean judgeImageReady = false;
|
|
|
+
|
|
|
+ public DockerCodeSandBox() {
|
|
|
+ }
|
|
|
+
|
|
|
+ public DockerCodeSandBox(DockerClient dockerClient) {
|
|
|
+ this.dockerClient = dockerClient;
|
|
|
+ }
|
|
|
+
|
|
|
+ public void setDockerClient(DockerClient dockerClient) {
|
|
|
+ this.dockerClient = dockerClient;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public CodeResponse process(CodeContext codeContext) {
|
|
|
+ // 并发控制:达到上限时超时返回SE,避免容器数量爆炸
|
|
|
+ boolean acquired;
|
|
|
+ try {
|
|
|
+ acquired = CONCURRENCY_LIMIT.tryAcquire(CONCURRENCY_ACQUIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "系统繁忙");
|
|
|
+ }
|
|
|
+ if (!acquired) {
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "系统繁忙");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return doProcess(codeContext);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("判题系统异常", e);
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "系统异常:" + e.getMessage());
|
|
|
+ } finally {
|
|
|
+ CONCURRENCY_LIMIT.release();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private CodeResponse doProcess(CodeContext codeContext) {
|
|
|
+ ensureJudgeImageAvailable();
|
|
|
+ String containerId = null;
|
|
|
+ try {
|
|
|
+ containerId = createSandboxContainer(codeContext);
|
|
|
+ log.info("沙箱容器已创建: {}", containerId);
|
|
|
+
|
|
|
+ injectSource(containerId, codeContext.code());
|
|
|
+ CompileResult compileResult = compile(containerId);
|
|
|
+ if (compileResult.systemError()) {
|
|
|
+ log.error("编译系统异常: {}", compileResult.output());
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, compileResult.output());
|
|
|
+ }
|
|
|
+ if (!compileResult.output().isEmpty()) {
|
|
|
+ log.warn("编译失败,判定 CE:\n{}", compileResult.output());
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_CE, compileResult.output());
|
|
|
+ }
|
|
|
+ log.info("编译通过");
|
|
|
+
|
|
|
+ List<TestCaseResponse> results = new ArrayList<>();
|
|
|
+ for (int i = 0; i < codeContext.testCases().size(); i++) {
|
|
|
+ TestCase tc = codeContext.testCases().get(i);
|
|
|
+ RunResult run = runCase(containerId, codeContext, tc);
|
|
|
+ String verdict = finalVerdict(run, tc);
|
|
|
+ results.add(new TestCaseResponse(tc.id(), verdict, run.durationMs(), 0L));
|
|
|
+ log.info("用例 {}/{} 判定: {} (耗时 {}ms, 退出码 {})",
|
|
|
+ i + 1, codeContext.testCases().size(), verdict, run.durationMs(), run.exitCode());
|
|
|
+ if (!Submission.STATUS_AC.equals(verdict)) {
|
|
|
+ // 出现首个非 AC 用例即返回,TLE 时容器已被杀死,后续用例无法继续
|
|
|
+ return new CodeResponse(codeContext.id(), verdict, getMaxTime(results),
|
|
|
+ getMinMemory(results), results, resolveMessage(verdict, run));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return new CodeResponse(codeContext.id(), Submission.STATUS_AC, getMaxTime(results),
|
|
|
+ getMinMemory(results), results, "");
|
|
|
+ } finally {
|
|
|
+ removeContainerQuietly(containerId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 强制删除容器,忽略清理过程中的异常
|
|
|
+ * @param containerId 容器 ID,为 null 时不做任何事
|
|
|
+ */
|
|
|
+ private void removeContainerQuietly(String containerId) {
|
|
|
+ if (containerId == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ dockerClient.removeContainerCmd(containerId).withForce(true).exec();
|
|
|
+ log.info("沙箱容器已清理: {}", containerId);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("清理容器失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ensureJudgeImageAvailable() {
|
|
|
+ if (judgeImageReady) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ synchronized (this) {
|
|
|
+ if (judgeImageReady) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ boolean present = dockerClient.listImagesCmd().exec().stream()
|
|
|
+ .anyMatch(img -> img.getRepoTags() != null
|
|
|
+ && Arrays.asList(img.getRepoTags()).contains(JUDGE_IMAGE));
|
|
|
+ if (!present) {
|
|
|
+ log.info("判题镜像 {} 不存在,开始拉取...", JUDGE_IMAGE);
|
|
|
+ try {
|
|
|
+ dockerClient.pullImageCmd(JUDGE_IMAGE)
|
|
|
+ .exec(new ResultCallback.Adapter<>()).awaitCompletion();
|
|
|
+ log.info("判题镜像 {} 拉取完成", JUDGE_IMAGE);
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ throw new IllegalStateException("判题镜像拉取被中断", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ judgeImageReady = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String createSandboxContainer(CodeContext codeContext) {
|
|
|
+ HostConfig hostConfig = buildSandboxHostConfig(codeContext);
|
|
|
+ // 容器主进程存活时间需覆盖全部用例的判题耗时,避免中途退出导致 exec 失败
|
|
|
+ long keepAliveSeconds = Math.max(300L,
|
|
|
+ codeContext.maxTime() * Math.max(1, codeContext.testCases().size()) / 1000L + 60L);
|
|
|
+ CreateContainerResponse container = dockerClient.createContainerCmd(JUDGE_IMAGE)
|
|
|
+ .withName("oj-sandbox-" + System.nanoTime())
|
|
|
+ .withHostConfig(hostConfig)
|
|
|
+ .withWorkingDir(CODE_DIR)
|
|
|
+ .withUser("nobody")
|
|
|
+ .withEnv("HOME=/tmp")
|
|
|
+ .withEntrypoint("sh", "-c")
|
|
|
+ .withCmd("sleep " + keepAliveSeconds)
|
|
|
+ .exec();
|
|
|
+ String containerId = container.getId();
|
|
|
+ try {
|
|
|
+ dockerClient.startContainerCmd(containerId).exec();
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 启动失败时容器 ID 尚未交给调用方,需在此清理,避免容器泄漏
|
|
|
+ removeContainerQuietly(containerId);
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ return containerId;
|
|
|
+ }
|
|
|
+
|
|
|
+ private HostConfig buildSandboxHostConfig(CodeContext codeContext) {
|
|
|
+ long memoryBytes = codeContext.maxMemory() * 1024L;
|
|
|
+ Map<String, String> tmpFs = Map.of(
|
|
|
+ CODE_DIR, "size=8m,mode=1777",
|
|
|
+ "/tmp", "size=8m,mode=1777");
|
|
|
+ return HostConfig.newHostConfig()
|
|
|
+ .withMemory(memoryBytes)
|
|
|
+ .withMemorySwap(memoryBytes * 2)
|
|
|
+ .withNanoCPUs(1_000_000_000L)
|
|
|
+ .withPidsLimit(64L)
|
|
|
+ .withNetworkMode("none")
|
|
|
+ .withCapDrop(Capability.ALL)
|
|
|
+ .withReadonlyRootfs(true)
|
|
|
+ .withTmpFs(tmpFs);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void injectSource(String containerId, String source) {
|
|
|
+ String encoded = Base64.getEncoder()
|
|
|
+ .encodeToString(source.getBytes(StandardCharsets.UTF_8));
|
|
|
+ ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
|
|
|
+ .withCmd("sh", "-c",
|
|
|
+ "printf %s '" + encoded + "' | base64 -d > " + CODE_DIR + "/" + SOURCE_FILE)
|
|
|
+ .withAttachStdout(true)
|
|
|
+ .withAttachStderr(true)
|
|
|
+ .exec();
|
|
|
+ try {
|
|
|
+ boolean completed = dockerClient.execStartCmd(exec.getId())
|
|
|
+ .withDetach(false)
|
|
|
+ .exec(new ResultCallback.Adapter<>()).awaitCompletion(COMPILE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
|
|
+ if (!completed) {
|
|
|
+ throw new IllegalStateException("源码注入超时");
|
|
|
+ }
|
|
|
+ log.info("源码 [{}] 已注入沙箱容器 ({} 字节)", SOURCE_FILE, source.length());
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ throw new IllegalStateException("源码注入被中断", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 编译执行结果
|
|
|
+ * @param output 编译输出,成功为空串
|
|
|
+ * @param systemError 是否为判题系统自身异常(超时/daemon 异常),而非选手代码的编译错误
|
|
|
+ */
|
|
|
+ private record CompileResult(String output, boolean systemError) {
|
|
|
+ }
|
|
|
+
|
|
|
+ private CompileResult compile(String containerId) {
|
|
|
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
|
|
|
+ try {
|
|
|
+ ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
|
|
|
+ .withCmd("javac", "-encoding", "UTF-8", "-d", CODE_DIR,
|
|
|
+ CODE_DIR + "/" + SOURCE_FILE)
|
|
|
+ .withAttachStdout(true)
|
|
|
+ .withAttachStderr(true)
|
|
|
+ .exec();
|
|
|
+ boolean completed = dockerClient.execStartCmd(exec.getId())
|
|
|
+ .withDetach(false)
|
|
|
+ .exec(new ResultCallback.Adapter<Frame>() {
|
|
|
+ @Override
|
|
|
+ public void onNext(Frame frame) {
|
|
|
+ output.writeBytes(frame.getPayload());
|
|
|
+ }
|
|
|
+ }).awaitCompletion(COMPILE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
|
|
+ String message = output.toString(StandardCharsets.UTF_8);
|
|
|
+ if (!completed) {
|
|
|
+ return new CompileResult("编译超时: " + message, true);
|
|
|
+ }
|
|
|
+ Long exitCode = dockerClient.inspectExecCmd(exec.getId()).exec().getExitCodeLong();
|
|
|
+ if (exitCode == null) {
|
|
|
+ return new CompileResult("编译状态未知: " + message, true);
|
|
|
+ }
|
|
|
+ return new CompileResult(exitCode == 0 ? "" : message, false);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("编译执行异常", e);
|
|
|
+ return new CompileResult("编译系统异常: " + e.getMessage(), true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 单个用例的运行结果
|
|
|
+ * @param stdout 标准输出
|
|
|
+ * @param stderr 标准错误输出
|
|
|
+ * @param exitCode 进程退出码,超时/异常为 -1
|
|
|
+ * @param durationMs 运行耗时(毫秒)
|
|
|
+ * @param verdict 用例判定,为 null 表示进程正常退出,待输出比对后定 AC/WA
|
|
|
+ */
|
|
|
+ public record RunResult(String stdout, String stderr, int exitCode, long durationMs, String verdict) {
|
|
|
+ }
|
|
|
+
|
|
|
+ private RunResult runCase(String containerId, CodeContext codeContext, TestCase tc) {
|
|
|
+ long startNanos = System.nanoTime();
|
|
|
+ // 按字节累积后统一解码,避免多字节 UTF-8 字符被 Docker Frame 边界切断
|
|
|
+ ByteArrayOutputStream stdout = new ByteArrayOutputStream();
|
|
|
+ ByteArrayOutputStream stderr = new ByteArrayOutputStream();
|
|
|
+ int[] bytes = {0};
|
|
|
+ boolean[] truncated = {false};
|
|
|
+
|
|
|
+ int heapKb = Math.max(8192, (int) (codeContext.maxMemory() - 16384));
|
|
|
+ String input = tc.input();
|
|
|
+ if (input != null && !input.endsWith("\n") && !input.endsWith("\r")) {
|
|
|
+ input = input + "\n";
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
|
|
|
+ .withAttachStdin(true)
|
|
|
+ .withAttachStdout(true)
|
|
|
+ .withAttachStderr(true)
|
|
|
+ .withCmd("java", "-Xmx" + heapKb + "k", "-cp", CODE_DIR, "Main")
|
|
|
+ .exec();
|
|
|
+
|
|
|
+ boolean timedOut = false;
|
|
|
+ ResultCallback.Adapter<Frame> callback = dockerClient.execStartCmd(exec.getId())
|
|
|
+ .withDetach(false)
|
|
|
+ .withStdIn(new ByteArrayInputStream((input == null ? "" : input).getBytes(StandardCharsets.UTF_8)))
|
|
|
+ .exec(new ResultCallback.Adapter<Frame>() {
|
|
|
+ @Override
|
|
|
+ public void onNext(Frame frame) {
|
|
|
+ byte[] payload = frame.getPayload();
|
|
|
+ bytes[0] += payload.length;
|
|
|
+ if (bytes[0] > MAX_OUTPUT_LENGTH) {
|
|
|
+ truncated[0] = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (frame.getStreamType() == StreamType.STDOUT
|
|
|
+ || frame.getStreamType() == StreamType.RAW) {
|
|
|
+ stdout.writeBytes(payload);
|
|
|
+ } else if (frame.getStreamType() == StreamType.STDERR) {
|
|
|
+ stderr.writeBytes(payload);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ boolean completed = callback.awaitCompletion(codeContext.maxTime(), TimeUnit.MILLISECONDS);
|
|
|
+ if (!completed) {
|
|
|
+ try {
|
|
|
+ dockerClient.killContainerCmd(containerId).withSignal("KILL").exec();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("杀死容器失败", e);
|
|
|
+ }
|
|
|
+ callback.close();
|
|
|
+ timedOut = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
|
|
|
+ String out = stdout.toString(StandardCharsets.UTF_8);
|
|
|
+ String err = stderr.toString(StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ if (timedOut) {
|
|
|
+ return new RunResult(out, err, -1, durationMs, Submission.STATUS_TLE);
|
|
|
+ }
|
|
|
+ Long exitCodeLong = dockerClient.inspectExecCmd(exec.getId()).exec().getExitCodeLong();
|
|
|
+ int exitCode = exitCodeLong == null ? -1 : exitCodeLong.intValue();
|
|
|
+ if (truncated[0] || exitCode != 0) {
|
|
|
+ return new RunResult(out, err, exitCode, durationMs, Submission.STATUS_RE);
|
|
|
+ }
|
|
|
+ return new RunResult(out, err, exitCode, durationMs, null);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("用例运行系统异常", e);
|
|
|
+ long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
|
|
|
+ return new RunResult(stdout.toString(StandardCharsets.UTF_8),
|
|
|
+ stderr.toString(StandardCharsets.UTF_8), -1, durationMs, Submission.STATUS_SE);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String finalVerdict(RunResult run, TestCase tc) {
|
|
|
+ if (run.verdict() != null) {
|
|
|
+ return run.verdict();
|
|
|
+ }
|
|
|
+ boolean matched = normalizeText(run.stdout()).equals(normalizeText(tc.output()));
|
|
|
+ return matched ? Submission.STATUS_AC : Submission.STATUS_WA;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成用例判定对应的提示信息,供落库展示
|
|
|
+ * @param verdict 用例判定
|
|
|
+ * @param run 用例运行结果
|
|
|
+ * @return 提示信息
|
|
|
+ */
|
|
|
+ private String resolveMessage(String verdict, RunResult run) {
|
|
|
+ if (Submission.STATUS_TLE.equals(verdict)) {
|
|
|
+ return "运行时间超限";
|
|
|
+ }
|
|
|
+ if (Submission.STATUS_WA.equals(verdict)) {
|
|
|
+ return "输出与预期不符";
|
|
|
+ }
|
|
|
+ if (Submission.STATUS_SE.equals(verdict)) {
|
|
|
+ return "系统异常";
|
|
|
+ }
|
|
|
+ // RE:输出为空时退回标准输出,否则展示错误输出
|
|
|
+ return run.stderr().isEmpty() ? run.stdout() : run.stderr();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String normalizeText(String text) {
|
|
|
+ if (text == null) {
|
|
|
+ text = "";
|
|
|
+ }
|
|
|
+ String normalized = text.replace("\r\n", "\n").replace("\r", "\n");
|
|
|
+ List<String> lines = new ArrayList<>();
|
|
|
+ for (String line : normalized.lines().map(String::stripTrailing).toList()) {
|
|
|
+ lines.add(line);
|
|
|
+ }
|
|
|
+ while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) {
|
|
|
+ lines.remove(lines.size() - 1);
|
|
|
+ }
|
|
|
+ return String.join("\n", lines);
|
|
|
+ }
|
|
|
+
|
|
|
+ private CodeResponse getCodeResponse(Long id, String status, String message) {
|
|
|
+ return new CodeResponse(id, status, 0L, 0L, Collections.emptyList(), message);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Long getMaxTime(List<TestCaseResponse> results) {
|
|
|
+ if (results == null || results.isEmpty()) {
|
|
|
+ return 0L;
|
|
|
+ }
|
|
|
+ return results.stream().map(TestCaseResponse::usedTime).max(Long::compareTo).orElse(0L);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Long getMinMemory(List<TestCaseResponse> results) {
|
|
|
+ if (results == null || results.isEmpty()) {
|
|
|
+ return 0L;
|
|
|
+ }
|
|
|
+ return results.stream().map(TestCaseResponse::usedMemory).min(Long::compareTo).orElse(0L);
|
|
|
+ }
|
|
|
+}
|