从零构建一个代码沙箱
如何安全地执行不受信任的用户代码或AI生成的代码?
更新于 2026.08.07
本文目录 65 节

项目地址:https://github.com/Ezzzi-Y/ezzi-code-sand-box
如何安全地执行不受信任的用户代码或AI生成的代码?
一、项目背景与定位
1.1 问题:执行不受信任的代码
当你构建一个在线编程平台时,最核心也最危险的需求是:执行用户提交的任意代码。
用户可能提交的不只是 Hello World——它可能是一个 fork 炸弹、一段尝试读取 /etc/passwd 的恶意脚本、或者一个试图建立反向 Shell 的网络程序。你必须在保证代码能正常执行的同时,确保它无法伤害你的服务器。
直接使用 Runtime.exec() 或 subprocess.run() 执行用户代码的后果是灾难性的:
| 风险类型 | 典型攻击 | 后果 |
|---|---|---|
| 资源耗尽 | while(1) fork() | 进程数爆炸,系统崩溃 |
| 内存炸弹 | 无限分配内存 | OOM,其他服务被杀 |
| 信息泄露 | 读取 /etc/passwd | 服务器用户列表泄露 |
| 网络攻击 | 建立反向 Shell | 服务器被完全控制 |
| 持久化攻击 | 写入定时任务 | 长期后门 |
1.2 Ezzi Code Sandbox 的定位
Ezzi Code Sandbox 是一个独立部署的代码执行引擎。它不是一个完整的 OJ 平台,而是其中最核心、最危险的那一层——专门负责:
- 接收代码和输入数据
- 在隔离环境中编译/运行
- 返回结构化的执行结果(stdout、stderr、耗时、内存、退出码)
它被设计为”可被上游业务系统调用的微服务”,典型调用方包括:
| 场景 | 调用方式 |
|---|---|
| 在线评测平台(OJ) | 判题服务通过 HTTP 调用沙箱 |
| 编程教学平台 | 课程系统提交学生代码到沙箱 |
| AI 代码执行 | LLM 生成代码后交给沙箱验证 |
| 面试/笔试系统 | 候选人代码在沙箱中运行 |
1.3 技术栈一览
| 组件 | 技术选型 | 版本 |
|---|---|---|
| 运行时 | Java | 21 |
| 框架 | Spring Boot | 3.5.7 |
| 容器化 | Docker | 20.10+ |
| Docker SDK | docker-java + zerodep transport | 3.3.4 |
| HTTP 下载 | Hutool | 5.8.38 |
| 压缩处理 | Apache Commons Compress | 1.26.0 |
二、系统架构
2.1 整体架构
系统采用经典的分层架构,自上而下分为 API 层、Service 层、Core 层和 Infrastructure 层:
┌──────────────────────────────────────────────────────────────────┐
│ 上游业务服务 │
│ (OJ 判题服务 / 教学平台 / AI Agent) │
└────────────────────────────┬─────────────────────────────────────┘
│ HTTP (POST /execute/*)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Ezzi Code Sandbox (:6060) │
│ │
│ ┌─ API Layer ─────────────────────────────────────────────────┐ │
│ │ ExecuteController (/execute/*) HealthController (/health)│ │
│ └───────────┬─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─ Service Layer ─────────────────────────────────────────────┐ │
│ │ ExecutionService InputDataService LanguageStrategyFactory│ │
│ └───────────┬──────────────┬──────────────────────────────────┘ │
│ │ │ │
│ ┌─ Core Layer ────────────┼───────────────────────────────────┐ │
│ │ DockerCodeExecutor ContainerManager ContainerPool │ │
│ └───────────┬──────────────────────────────────────────────────┘ │
│ │ │
│ ┌─ Infrastructure ────────────────────────────────────────────┐ │
│ │ Docker Engine Local Disk Cache HTTP Download │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
关键设计原则:沙箱服务本身运行在一个 Docker 容器中(控制面),通过挂载宿主机的 Docker Socket (/var/run/docker.sock) 来管理执行容器(数据面)。控制面有网络访问权限(需要下载输入数据),而执行容器的网络被完全禁用。
2.2 一次代码执行的完整旅程
让我们跟踪一个请求从进入到返回的全过程:
1. 客户端 POST /execute/single,携带 code + language + input
│
2. ExecuteController 接收请求,参数校验
│
3. ExecutionServiceImpl 编排执行流程
│
├── 3a. LanguageStrategyFactory 获取对应语言策略
├── 3b. 设置默认超时和内存限制
│
4. DockerCodeExecutor.execute() 核心执行
│
├── 4a. 危险代码扫描(正则黑名单)
├── 4b. 从容器池获取预热容器(或创建新容器)
├── 4c. 通过 docker exec + base64 写入源代码
├── 4d. 编译(如需要)
├── 4e. 逐个运行测试用例,采集 stdout/stderr/time/memory
├── 4f. 清理任务目录
└── 4g. 归还容器到池中
│
5. 构建 SingleExecuteResponse 返回
2.3 包结构
com.github.ezzziy.codesandbox
├── CodeSandBoxApplication.java # 启动类
├── common/ # 通用模块
│ ├── enums/ # ExecutionStatus, LanguageEnum
│ └── result/Result.java # 统一响应包装
├── config/ # 配置层
│ ├── DockerConfig.java # Docker 客户端配置
│ └── ExecutionConfig.java # 执行限制配置
├── controller/ # API 层
│ ├── ExecuteController.java # 代码执行接口
│ └── HealthController.java # 健康检查接口
├── service/impl/ # 服务层
│ ├── ExecutionServiceImpl.java # 执行服务
│ └── InputDataServiceImpl.java # 输入数据缓存
├── executor/ # 执行器层(核心)
│ ├── DockerCodeExecutor.java # Docker 代码执行器
│ ├── ContainerManager.java # 容器生命周期管理
│ └── CommandResult.java # 命令执行结果
├── pool/ # 容器池
│ ├── ContainerPool.java # 池管理器
│ └── PooledContainer.java # 池化容器对象
├── strategy/ # 语言策略
│ ├── LanguageStrategy.java # 策略接口
│ ├── LanguageStrategyFactory.java # 策略工厂
│ ├── CLanguageStrategy.java # C
│ ├── CppLanguageStrategy.java # C++
│ ├── Java8LanguageStrategy.java # Java 8
│ ├── Java17LanguageStrategy.java # Java 17
│ └── Python3LanguageStrategy.java # Python 3
├── model/ # 数据模型
│ ├── dto/ # 请求/内部 DTO
│ └── vo/ # 响应 VO
├── exception/ # 异常体系
│ ├── SandboxException.java # 基类(携带 ExecutionStatus)
│ ├── CompileException.java
│ ├── DangerousCodeException.java
│ ├── TimeLimitException.java
│ ├── MemoryLimitException.java
│ └── GlobalExceptionHandler.java # 全局异常处理器
└── util/
├── OssUrlParser.java # 预签名 URL 解析
└── JavaUnicodeDecoder.java # Unicode 转义预处理
三、多语言支持与策略模式
3.1 支持的语言
| 语言 | 标识 | Docker 镜像 | 编译器/解释器 | 编译命令 |
|---|---|---|---|---|
| C | c | sandbox-gcc | GCC (C11) | gcc -std=c11 -O2 -Wall -Wextra -fno-asm -lm |
| C++ | cpp11 | sandbox-gcc | G++ (C++11) | g++ -std=c++11 -O2 -Wall -Wextra -fno-asm |
| Java 8 | java8 | sandbox-java8 | OpenJDK 8 | javac -encoding UTF-8 |
| Java 17 | java17 | sandbox-java17 | OpenJDK 17 | javac -encoding UTF-8 |
| Python 3 | python3 | sandbox-python | Python 3.10 | 无(解释型) |
3.2 策略模式的设计
每种语言的编译、运行、安全扫描规则都不同。项目采用策略模式(Strategy Pattern)来解耦语言差异:
public interface LanguageStrategy {
LanguageEnum getLanguage(); // 语言标识
String getDockerImage(); // 使用哪个镜像
String getSourceFileName(); // 源文件名(main.c / Main.java / main.py)
String[] getCompileCommand(...); // 编译命令(解释型返回 null)
String[] getRunCommand(...); // 运行命令
String getExecutableFileName(); // 可执行文件名
List<Pattern> getDangerousPatterns(); // 危险代码正则
default boolean needCompile() {
return getCompileCommand("", "") != null;
}
default String checkDangerousCode(String code) {
for (Pattern pattern : getDangerousPatterns()) {
if (pattern.matcher(code).find()) {
return pattern.pattern();
}
}
return null;
}
}
每种语言实现这个接口,通过 Spring 的自动扫描注册为 Bean:
@Component
public class CLanguageStrategy implements LanguageStrategy { ... }
@Component
public class Python3LanguageStrategy implements LanguageStrategy { ... }
工厂类使用 EnumMap 管理所有策略,@PostConstruct 时自动注册:
@PostConstruct
public void init() {
for (LanguageStrategy strategy : strategies) {
strategyMap.put(strategy.getLanguage(), strategy);
log.info("注册语言策略: {} -> {}", strategy.getLanguage().getCode(),
strategy.getClass().getSimpleName());
}
}
3.3 扩展新语言只需三步
策略模式的优势在于扩展极其简单,添加一门新语言(如 Rust)只需要:
- 枚举中加一项:
RUST("rust", "Rust", "rust:1.70", ".rs") - 写一个策略类:实现
LanguageStrategy接口,标注@Component - 构建沙箱镜像:在
sandbox-images/下添加 Dockerfile
无需修改工厂、无需改配置文件——Spring 会自动发现并注册新策略。
3.4 编译参数的安全考量
编译参数的选择不仅影响性能,也关系安全:
-fno-asm:禁止内联汇编。如果不加这个标志,用户可以用asm volatile("syscall" ...)直接调用系统调用,绕过所有应用层限制-O2:开启优化。OJ 场景下需要真实的性能表现-Wall -Wextra:编译警告全开,帮助发现潜在问题- Java
-Djava.security.manager=default(Java 8):启用安全管理器,限制反射等敏感操作 - Python
-u:禁用输出缓冲,确保 stdout 实时刷新,避免输出丢失
四、Docker 容器隔离——安全的核心
这是整个项目最关键、涉及底层知识最多的部分。让我们逐层拆解沙箱的安全机制。
4.1 为什么选择 Docker?
代码沙箱的隔离方案有很多:
| 方案 | 隔离级别 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 进程级 (seccomp/ptrace) | 低 | 极高 | 高 | 单语言、极致性能 |
| 容器级 (Docker/nsjail) | 中 | 高 | 中 | 多语言、生产级 |
| 虚拟机 (Firecracker/gVisor) | 高 | 中 | 高 | 极高安全要求 |
Docker 的选择是安全性与工程复杂度的平衡:
- Namespace 隔离:PID、网络、文件系统、用户命名空间全面隔离
- Cgroup 资源限制:CPU、内存、进程数的精确控制
- 成熟生态:docker-java SDK、compose 编排、镜像管理
- 多语言友好:不同语言用不同镜像,天然隔离运行时
4.2 Linux Namespace——让容器看到自己的世界
Docker 容器的隔离基础是 Linux Namespace。每个执行容器拥有独立的:
| Namespace | 隔离内容 | 安全意义 |
|---|---|---|
| PID Namespace | 进程 ID | 容器内进程看不到宿主机进程 |
| Network Namespace | 网络栈 | 容器无法访问宿主机网络 |
| Mount Namespace | 文件系统 | 容器有独立的挂载视图 |
| UTS Namespace | 主机名 | 隔离主机标识 |
| IPC Namespace | 进程间通信 | 隔离共享内存、信号量 |
当我们创建容器时设置 .withNetworkDisabled(true),Docker 甚至不会为容器创建网络设备——这比普通的网络 Namespace 隔离更彻底。容器内的进程连 lo 以外的网络接口都没有,无法发送或接收任何网络数据包。
4.3 Cgroup——精确的资源限制
Linux Control Groups (cgroups) 是内核提供的资源限制机制。项目中的资源配置全部通过 ContainerManager.buildHostConfig() 集中构建。
4.3.1 内存限制
.withMemory(memoryBytes) // 硬内存限制(默认 256MB)
.withMemorySwap(memoryBytes) // 设为等于 memory → 禁用 swap
.withOomKillDisable(false) // 允许 OOM Killer 终止进程
底层原理:这对应 cgroup 的 memory.limit_in_bytes 和 memory.memsw.limit_in_bytes。当进程的 RSS(Resident Set Size,常驻内存集合)达到限制时,内核的 OOM Killer 会终止该进程。
关键点是 withMemorySwap 设为等于 withMemory。在 Docker 中:
memorySwap = memory表示总内存(物理 + swap)等于物理内存限制,即 swap 可用量为 0- 如果不设置
memorySwap,默认为memory * 2,容器可以使用等量的 swap,实际可用内存翻倍 - 在 OJ 场景下,我们需要精确控制内存,必须禁用 swap
4.3.2 CPU 限制
.withCpuPeriod(100000L) // CFS 调度周期:100ms
.withCpuQuota(cpuQuota) // CFS 配额:cpuLimit * 100000
底层原理:Linux 的 CFS(Completely Fair Scheduler,完全公平调度器)通过 cpu.cfs_period_us 和 cpu.cfs_quota_us 实现 CPU 限制。例如 cpuLimit = 1.0 意味着:
- 周期 = 100,000μs(100ms)
- 配额 = 100,000μs
- 含义:每 100ms 调度周期内,容器最多使用 100ms CPU 时间 = 1 个完整核心
如果设为 0.5,则每 100ms 只能用 50ms,相当于半个核心。当配额用尽时,内核调度器会暂停该 cgroup 内的所有线程,直到下一个周期开始。
4.3.3 进程数限制(防 Fork Bomb)
.withPidsLimit(1024L) // cgroup pids.max
底层原理:对应 cgroup 的 pids.max。经典的 Fork Bomb:
while(1) fork(); // 指数级创建进程,秒级拖垮系统
import os
while True: os.fork()
没有 PID 限制,一个 fork bomb 可以在几秒内创建数万个进程,耗尽系统进程表,导致整台服务器无法创建新进程(包括 SSH 登录都无法建立)。pidsLimit = 1024 将容器内的进程总数(包括线程)硬限制在 1024 个,Fork Bomb 达到上限后 fork() 返回 -1(EAGAIN),无法继续扩散。
4.4 Linux Capabilities——精确的权限控制
Linux Capabilities 是对传统 root 权限的细粒度拆分。传统 Unix 权限模型只有两种状态:root(全部权限)和普通用户(受限权限)。Capabilities 将 root 的特权拆分为约 40 个独立能力:
.withCapDrop(Capability.ALL) // 删除所有 capabilities
这意味着即使容器内以某种方式获得了 root 身份,也无法执行特权操作。被删除的关键能力包括:
| Capability | 功能 | 不删除的风险 |
|---|---|---|
CAP_NET_RAW | 原始套接字 | 可以进行 ARP 欺骗、网络嗅探 |
CAP_SYS_ADMIN | 系统管理 | 可以挂载文件系统、操作命名空间——这是最危险的 capability |
CAP_SYS_PTRACE | 进程追踪 | 可以调试其他进程、读取其内存 |
CAP_SYS_MODULE | 内核模块 | 可以加载内核模块(最高危) |
CAP_MKNOD | 创建设备文件 | 可以创建设备节点访问硬件 |
CAP_SETUID/SETGID | 切换用户 | 可以提权为其他用户 |
CAP_DAC_OVERRIDE | 绕过文件权限 | 可以读写任何文件,无视 rwx 权限位 |
配合 no-new-privileges:true 安全选项:
.withSecurityOpts(List.of("no-new-privileges:true"))
这个内核参数确保容器进程无法通过 execve() setuid 二进制文件或其他方式获取额外权限。即使容器里有一个 setuid-root 的程序,执行它也不会获得 root 权限。
4.5 只读根文件系统与 tmpfs
.withReadonlyRootfs(true) // 只读根文件系统
.withTmpFs(Map.of(
"/tmp", "rw,noexec,nosuid,size=64m",
"/sandbox/workspace", "rw,exec,nosuid,size=64m"
))
设计思路:
容器的根文件系统设为只读,这意味着容器内的 /usr、/bin、/etc 等目录都无法写入。用户代码无法修改系统二进制文件、无法篡改配置文件、无法在系统目录下植入恶意程序。
但编译和运行需要写权限,通过 tmpfs 精确开放两个目录:
| 路径 | 权限 | 大小 | 用途 |
|---|---|---|---|
/sandbox/workspace | rw, exec, nosuid | 64MB | 工作目录——编译产物需要可执行 |
/tmp | rw, noexec, nosuid | 64MB | 临时文件——不允许执行 |
tmpfs 的底层原理:tmpfs 是 Linux 内核提供的内存文件系统,数据存储在页缓存(page cache)和 swap 中(但容器禁用了 swap,所以纯内存)。它受 cgroup 内存限制约束,容器销毁后自动清空,不会残留数据到磁盘。
挂载选项的安全含义:
noexec:该分区上的文件不能被执行(设置了x位也不行),防止在/tmp下放置恶意可执行文件nosuid:忽略 setuid/setgid 位,防止通过特殊权限位提权size=64m:限制 tmpfs 大小,防止通过大量写入耗尽内存
4.6 Seccomp——系统调用过滤
Docker 默认启用 seccomp(Secure Computing Mode)配置文件,禁止约 44 个危险系统调用:
| 被禁系统调用 | 功能 | 风险 |
|---|---|---|
reboot | 重启系统 | 直接关机 |
mount/umount | 挂载/卸载文件系统 | 逃逸容器 |
init_module/finit_module | 加载内核模块 | 内核级攻击 |
kexec_load | 加载新内核 | 替换运行中的内核 |
keyctl | 内核密钥管理 | 密钥泄露 |
ptrace | 进程追踪 | 调试攻击 |
底层原理:seccomp 工作在内核层面,通过 BPF(Berkeley Packet Filter)规则在系统调用入口处过滤。当用户态程序执行 syscall 指令时,内核会先检查 seccomp 规则,匹配则拒绝(返回 EPERM 或直接终止进程),不匹配才继续正常的系统调用处理。即使用户代码通过内联汇编直接触发 syscall 指令,seccomp 也能拦截——因为它在内核的系统调用分发器(do_syscall_64)之前执行。
4.7 用户隔离
// exec 时指定 sandbox 用户
dockerClient.execCreateCmd(containerId)
.withUser("sandbox") // uid=1000, gid=1000
.withCmd(cmd)
.exec();
沙箱镜像在构建时创建了专用用户:
RUN addgroup -g 1000 sandbox && \
adduser -D -u 1000 -G sandbox sandbox
所有用户代码都以 sandbox 身份执行,不是 root。这是纵深防御的重要一环——即使 capabilities 和 seccomp 都被绕过,非 root 用户仍然受到传统 Unix DAC(Discretionary Access Control)权限模型的约束。
4.8 Ulimit——用户级资源限制
.withUlimits(List.of(
new Ulimit("nofile", 256, 256), // 最大打开文件描述符
new Ulimit("nproc", 1024, 1024), // 最大进程数(用户级)
new Ulimit("fsize", 65536, 65536) // 最大文件大小(字节)
))
Ulimit 在用户级别提供额外的资源限制,与 cgroup 形成互补:
- nofile = 256:限制文件描述符数量。恶意代码可能尝试打开大量文件描述符耗尽系统资源
- nproc = 1024:与 cgroup 的
pidsLimit双重限制进程数 - fsize = 65536:限制单个文件的最大大小(64KB),防止生成巨大文件耗尽 tmpfs
4.9 安全防护总览
用户代码
│
▼
┌─────────────────────────────────────────────┐
│ 第一层:危险代码正则扫描(应用层) │
│ • 拦截 system(), fork(), eval() 等 │
│ • Java Unicode 转义预处理 │
│ • C/C++ 连字符检测 │
│ • Python dunder 反射链拦截 │
└─────────────┬───────────────────────────────┘
│
┌─────────────▼───────────────────────────────┐
│ 第二层:容器隔离(内核层,不可绕过) │
│ • Namespace 隔离(PID/Net/Mount/UTS/IPC) │
│ • Cgroup 资源限制(CPU/Memory/PID) │
│ • Capabilities 全部删除 │
│ • Seccomp 系统调用过滤 │
│ • 只读根文件系统 + tmpfs │
│ • 网络完全禁用 │
│ • 非 root 用户执行 │
│ • no-new-privileges │
│ • Ulimit (nofile/nproc/fsize) │
└─────────────────────────────────────────────┘
即使第一层被绕过(黑名单本质是”尽力而为”),第二层的容器隔离确保:
- 无敏感数据可读——只读根文件系统 + 无宿主机目录挂载
- 无网络可外传——网络完全禁用
- 无持久化路径可写——仅 tmpfs 可写,容器销毁后清空
- 无特权可提升——capabilities 全删 + no-new-privileges
五、容器池——高吞吐的秘密
5.1 为什么需要容器池?
创建一个 Docker 容器的开销并不小:
docker create → docker start → 等待容器就绪 → 执行代码 → docker stop → docker rm
每次创建/销毁容器大约需要 500ms~2s。对于 OJ 场景(批量测试用例、高并发提交),这个开销不可接受。
容器池的核心思想:预先创建一批容器保持运行,每次请求从池中取一个、用完归还,避免频繁创建/销毁。
5.2 容器池的架构
┌─────────────────────────────────────────────────┐
│ ContainerPool │
│ │
│ C/C++ Pool: [Container-1] [Container-2] │
│ Java8 Pool: [Container-3] [Container-4] │
│ Java17 Pool: [Container-5] [Container-6] │
│ Python Pool: [Container-7] [Container-8] │
│ │
│ 配置: minSize=2, maxSize=10, maxUseCount=100 │
└─────────────────────────────────────────────────┘
容器生命周期:
create → start → [在池中等待] → acquire → 执行代码 → release → [回到池中]
│
(使用次数 ≥ 100)
│
remove → 重建新容器
5.3 并发控制
容器池使用 ReentrantLock + Condition 实现线程安全的获取/归还:
private static class PoolState {
private final ConcurrentLinkedQueue<PooledContainer> containers;
private final ReentrantLock lock = new ReentrantLock();
private final Condition available = lock.newCondition();
}
获取容器的三级策略:
- 优先复用:从池中找到一个空闲且存活的容器
- 按需创建:池未满时创建新容器
- 等待归还:池已满时等待其他线程归还(最多 30 秒)
public PooledContainer acquireContainer(LanguageStrategy strategy) {
state.lock.lock();
try {
// 1. 优先获取空闲容器
PooledContainer container = findAvailableContainerLocked(state, now);
if (container != null) return container;
// 2. 池未满,创建新容器
if (state.containers.size() < maxPoolSize) {
return createAndAddContainer(strategy, state);
}
// 3. 池已满,等待归还
long nanos = TimeUnit.SECONDS.toNanos(30);
while (nanos > 0) {
nanos = state.available.awaitNanos(nanos);
container = findAvailableContainerLocked(state, now);
if (container != null) return container;
}
throw new RuntimeException("等待容器超时");
} finally {
state.lock.unlock();
}
}
归还时通过 signal() 唤醒等待线程:
container.setInUse(false);
state.available.signal(); // 唤醒一个等待的线程
5.4 任务隔离——同一容器的安全复用
容器复用带来一个问题:如何确保前一个任务的文件不影响下一个任务?
解决方案是任务子目录隔离:
/sandbox/workspace/ # 容器工作目录(长期存在)
├── job-req-001/ # 任务1的独立目录
│ ├── Main.java
│ └── Main.class
└── job-req-002/ # 任务2的独立目录
└── main.py
每个请求创建独立的子目录(job-{requestId}),所有编译和运行都在该子目录内执行。任务完成后在容器内执行 rm -rf 清理,然后归还容器。
清理操作有安全校验——只允许清理 /sandbox/workspace/ 下的目录:
public void cleanupTaskDirectory(String containerId, String taskDir) {
// 安全检查:确保只清理 /sandbox/workspace/ 下的目录
if (!taskDir.startsWith("/sandbox/workspace/")) {
log.error("非法的任务目录路径,拒绝清理: {}", taskDir);
return;
}
// 以 sandbox 用户执行 rm -rf
dockerClient.execCreateCmd(containerId)
.withUser("sandbox")
.withCmd("rm", "-rf", taskDir)
.exec();
}
5.5 定时维护
容器池通过 Spring @Scheduled 实现三种自动维护:
| 定时任务 | 频率 | 功能 |
|---|---|---|
cleanIdleContainers | 每 60 秒 | 清理空闲超时容器(超过 10 分钟未使用) |
cleanZombieContainers | 每 10 分钟 | 检查并移除已停止的僵尸容器 |
destroy(@PreDestroy) | 应用关闭时 | 销毁所有池内容器 |
空闲清理时会保证每种语言至少保留 minPoolSize 个容器,不会全部回收。
六、代码执行的底层细节
6.1 源代码写入——base64 巧妙绕过只读文件系统
由于容器的根文件系统是只读的,Docker 的 copyArchiveToContainer API(基于 tar 归档)无法写入文件——该 API 试图在容器的 overlay 文件系统上创建文件,被 readonlyRootfs 阻止。
项目采用了一种巧妙的方式——通过 docker exec 在容器内部执行命令写入 tmpfs:
byte[] codeBytes = code.getBytes(StandardCharsets.UTF_8);
String base64Code = Base64.getEncoder().encodeToString(codeBytes);
String filePath = taskDir + "/" + fileName;
// 合并 mkdir 和写文件为 1 次 exec 调用
String writeCmd = "mkdir -p " + taskDir +
" && echo '" + base64Code + "' | base64 -d > " + filePath;
dockerClient.execCreateCmd(containerId)
.withUser("sandbox")
.withCmd("sh", "-c", writeCmd)
.exec();
为什么能写入? 因为 readonlyRootfs 限制的是容器的 overlay 文件系统层,而 /sandbox/workspace 是 tmpfs 挂载——从容器内部视角,这是一个独立的可写文件系统。docker exec 在容器的 mount namespace 内执行命令,对 tmpfs 的写入不受 readonlyRootfs 影响。
为什么用 base64? 源代码可能包含单引号、双引号、反斜杠、换行符等各种特殊字符,直接通过 shell 命令传递会导致注入问题。base64 编码将任意二进制数据转换为安全的 ASCII 字符集(A-Z, a-z, 0-9, +, /, =),避免了所有 shell 转义问题。
6.2 精确计时——纳秒级时间测量
OJ 场景对执行时间的测量精度要求很高(差 10ms 可能决定是 AC 还是 TLE)。项目没有使用 Java 层面的时间测量(包含 Docker API 通信开销),而是在容器内部通过 shell 脚本精确测量:
MEMFILE=/tmp/mem_$$;
START=$(date +%s%N); # 纳秒时间戳
printf '%s' '<input>' | /usr/bin/time -f '%M' -o $MEMFILE sh -c 'exec <cmd>';
EXIT_CODE=$?; # 捕获真实退出码
END=$(date +%s%N); # 纳秒时间戳
echo '__EXEC_TIME_NS__:'$((END-START)); # 输出时间标记
MEM=$(cat $MEMFILE 2>/dev/null || echo 0); rm -f $MEMFILE;
echo '__EXEC_MEM_KB__:'$MEM; # 输出内存标记
exit $EXIT_CODE # 传递退出码给 Docker
这段脚本有几个精妙之处:
date +%s%N:获取纳秒级时间戳(epoch 秒数 + 纳秒部分),精度远高于毫秒级。Java 层再将纳秒差值除以 1,000,000 转换为毫秒sh -c 'exec <cmd>':exec系统调用让用户程序替换当前 shell 进程,不创建子进程。这确保/usr/bin/time统计的是用户程序本身的资源消耗,而非 shell 进程/usr/bin/time -f '%M' -o $MEMFILE:GNU time 的%M输出最大 RSS(单位 KB),-o将结果写入临时文件,不污染 stdout/stderr。$$是当前 shell 的 PID,确保并发时文件名不冲突EXIT_CODE=$?+exit $EXIT_CODE:在用户命令后立即用$?捕获退出码(后续的date、echo命令会覆盖$?),最后用exit传递给 Docker exec__EXEC_TIME_NS__:和__EXEC_MEM_KB__::约定的标记前缀,Java 层从 stdout 末尾lastIndexOf提取后截取移除,用户程序的正常输出不受影响
6.3 内存测量的原理
/usr/bin/time 是 GNU time 工具(不是 bash 内建的 time 关键字),其 %M 格式符输出进程的最大驻留集大小(Maximum Resident Set Size)。
底层原理:GNU time 通过 wait4() 系统调用获取子进程的 struct rusage,其中 ru_maxrss 字段记录了进程整个生命周期内 RSS 的峰值(单位 KB)。RSS 是进程实际占用物理内存的大小,不包含 swap 或共享库的未加载部分。
这是内核级别的精确测量,优于:
- Docker Stats API 轮询(有延迟、有开销)
/proc/[pid]/status读取(需要额外的监控进程)- cgroup
memory.max_usage_in_bytes(包含内核缓存,不够精确)
6.4 超时处理
boolean completed = callback.awaitCompletion(timeoutMs, TimeUnit.MILLISECONDS);
if (!completed) {
containerManager.killContainer(containerId); // 发送 SIGKILL
return CommandResult.timeout(timeoutMs);
}
超时使用 Docker exec 的 awaitCompletion 带超时版本。如果在指定时间内未完成,直接 kill 容器——底层是 docker kill,发送 SIGKILL 信号。SIGKILL(信号 9)是 Linux 中不可被捕获、不可被忽略、不可被阻塞的信号,确保即使是死循环、睡眠、或捕获了 SIGTERM 的进程也能被终止。
6.5 Shell 输入转义
用户输入通过 printf '%s' '<input>' 管道传递给程序的 stdin。输入中的单引号需要特殊处理:
private String escapeShellInput(String input) {
// 在单引号字符串中,处理单引号本身:'text'\''more'
// 结束当前单引号 → 添加转义的单引号 → 开始新的单引号
return input.replace("'", "'\\''");
}
这是 shell 中处理单引号的标准技巧——单引号内的内容是完全字面的(不做任何转义),唯一的方法是结束单引号、用 \' 表示一个单引号、再开始新的单引号。
七、危险代码检测——正则黑名单与反绕过
7.1 为什么需要黑名单?
容器隔离是安全兜底,但不意味着我们应该让恶意代码到达容器层。正则黑名单是第一道防线——成本低、速度快(微秒级),能拦截绝大部分明显的恶意代码,避免不必要的容器资源浪费。
7.2 各语言的黑名单策略
C/C++ 黑名单(约 35-40 条规则)
拦截类别:系统调用、文件操作、网络、内联汇编、危险头文件、动态库加载
Pattern.compile("\\bsystem\\s*\\("), // 系统命令执行
Pattern.compile("\\bexec[lv]?[pe]?\\s*\\("), // exec 族函数
Pattern.compile("\\bfork\\s*\\("), // 创建子进程
Pattern.compile("\\bsocket\\s*\\("), // 网络 socket
Pattern.compile("\\b__asm__\\b"), // GCC 内联汇编
Pattern.compile("\\basm\\s*\\("), // 内联汇编
Pattern.compile("\\bptrace\\s*\\("), // 进程追踪
Pattern.compile("\\bmmap\\s*\\("), // 内存映射
Pattern.compile("\\bdlopen\\s*\\("), // 动态库加载
Pattern.compile("\\bfopen\\s*\\("), // 文件打开
Pattern.compile("#include\\s*<sys/socket\\.h>"), // 网络头文件
Pattern.compile("%:\\s*include"), // 连字符绕过检测
Java 黑名单(约 40 条规则)
拦截类别:命令执行、反射、文件操作、网络、类加载器、JNI、Unsafe
Pattern.compile("Runtime\\.getRuntime\\(\\)"), // 命令执行
Pattern.compile("ProcessBuilder"), // 进程构建器
Pattern.compile("Class\\.forName\\s*\\("), // 反射加载
Pattern.compile("setAccessible\\s*\\(\\s*true"), // 绕过访问控制
Pattern.compile("System\\.exit"), // 退出 JVM
Pattern.compile("sun\\.misc\\.Unsafe"), // Unsafe 操作
Pattern.compile("ClassLoader"), // 类加载器
Pattern.compile("import\\s+java\\.net\\."), // 网络类导入
Pattern.compile("\\bFiles\\."), // NIO Files 操作
Python 黑名单(约 50 条规则)
拦截类别:系统命令、eval/exec、文件操作、网络、ctypes、pickle、dunder 反射链
Pattern.compile("\\bos\\.system\\s*\\("), // 系统命令
Pattern.compile("\\bsubprocess\\."), // subprocess 模块
Pattern.compile("\\beval\\s*\\("), // eval 执行
Pattern.compile("\\bexec\\s*\\("), // exec 执行
Pattern.compile("import\\s+ctypes"), // C 接口
Pattern.compile("__subclasses__"), // 子类链遍历
Pattern.compile("__globals__"), // 全局变量访问
Pattern.compile("__builtins__"), // 内建函数访问
Pattern.compile("\\bgetattr\\s*\\("), // 动态属性获取
Pattern.compile("\\btype\\s*\\("), // 元类操作
7.3 反绕过:三类已知攻击的防御
攻击 1:Java Unicode 转义绕过
原理:Java 编译器在词法分析之前解析 \uXXXX 转义(JLS 3.3 规范)。这是 Java 语言的一个独特设计——Unicode 转义发生在编译的最早阶段,甚至早于注释的识别。
\u0052untime.getRuntime().exec("whoami"); // \u0052 = 'R'
// 正则看到的是 \u0052untime,匹配不到 Runtime
防御:在正则匹配前,通过 JavaUnicodeDecoder 预处理源码,将所有 \u+XXXX 还原为实际字符:
// JavaUnicodeDecoder - 遵循 JLS 3.3,支持 \uuuuXXXX 多 u 形式
private static final Pattern UNICODE_ESCAPE = Pattern.compile("\\\\u+([0-9a-fA-F]{4})");
public static String decode(String source) {
Matcher matcher = UNICODE_ESCAPE.matcher(source);
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
char ch = (char) Integer.parseInt(matcher.group(1), 16);
matcher.appendReplacement(sb, Matcher.quoteReplacement(String.valueOf(ch)));
}
matcher.appendTail(sb);
return sb.toString();
}
Java 8 和 Java 17 的策略类都覆盖了 checkDangerousCode(),先解码再检测。
攻击 2:C/C++ 连字符(Digraph)绕过
原理:C11/C++11 标准定义了连字符(digraph),%: 等价于 #:
%:include <sys/socket.h> // 等价于 #include <sys/socket.h>
防御:为每条 #include 规则增加对应的 %:include 版本,使用 %:\\s*include 匹配(允许 %: 和 include 之间有空格)。
攻击 3:C/C++ 宏 Token Pasting
#define CONCAT(a,b) a##b
CONCAT(sys,tem)("whoami"); // 预处理后变成 system("whoami")
无法用正则解决——宏展开是 C 预处理器的工作,在编译管线中远早于我们能介入的时机。
防御策略:依赖容器层兜底。即使 system("whoami") 执行成功,容器内没有网络外传数据、没有敏感文件可读、没有持久化路径可写。
攻击 4:Python dunder 反射链
原理:Python 的对象模型极其灵活,通过 __class__、__bases__、__subclasses__() 等特殊属性可以遍历整个类继承树:
().__class__.__bases__[0].__subclasses__() # 获取 object 的所有子类
# 从中找到 os._wrap_close 等类,进而获取 os 模块
防御:拦截所有 dunder 属性访问(__builtins__、__subclasses__、__globals__ 等)和反射函数(getattr、setattr、type)。这些在正常 OJ 代码中不会使用——OJ 只需要 input()/print()/数据结构/算法,误杀率极低。
八、沙箱镜像的构建
8.1 统一的沙箱用户模型
所有沙箱镜像遵循统一的模式:
官方基础镜像 (alpine / eclipse-temurin / python)
│
├── 安装必要工具(bash, coreutils)
├── 创建 sandbox 用户 (uid=1000, gid=1000)
├── 创建 /sandbox/workspace 目录
└── CMD ["tail", "-f", "/dev/null"] ← 保持容器运行
8.2 以 GCC 镜像为例
FROM alpine:3.18
# 安装 GCC 工具链
RUN apk add --no-cache gcc g++ musl-dev libstdc++ bash coreutils
# 创建沙箱用户(固定 UID/GID 确保跨容器一致性)
RUN addgroup -g 1000 sandbox && \
adduser -D -u 1000 -G sandbox sandbox
# 创建工作目录(sticky bit 确保安全)
RUN mkdir -p /sandbox/workspace && \
chown sandbox:sandbox /sandbox && \
chmod 1777 /sandbox/workspace
WORKDIR /sandbox/workspace
USER root
# 保持容器运行(Alpine 的 busybox sleep 不支持 infinity)
CMD ["tail", "-f", "/dev/null"]
为什么用 tail -f /dev/null 而不是 sleep infinity? Alpine Linux 使用 busybox 提供的精简版命令,其 sleep 不支持 infinity 参数。tail -f /dev/null 是一种通用的、几乎零资源消耗的”保持容器运行”方案——它打开 /dev/null(永远为空),然后 tail -f 阻塞等待新数据(永远不会有),进程保持运行但不消耗 CPU。
为什么使用 Alpine? Alpine Linux 基于 musl libc 和 busybox,基础镜像体积仅约 5MB。小镜像意味着更小的攻击面(更少的系统工具和库可被利用)和更快的构建/拉取速度。
chmod 1777 中的 sticky bit:1 是 sticky bit,在目录上设置时意味着只有文件所有者才能删除自己的文件,防止不同任务(虽然在当前实现中不会并行使用同一容器)互相删除文件。
8.3 镜像构建
项目在 docker-compose.yml 中使用 profiles: ["build-only"] 管理构建镜像:
sandbox-gcc:
build:
context: ./sandbox-images/gcc
image: sandbox-gcc:latest
profiles: ['build-only'] # 只构建不启动
同时提供 build.sh 脚本一键构建所有镜像。
九、输入数据缓存体系
9.1 批量执行的数据流
对于批量测试(如 OJ 判题),输入数据以 ZIP 包形式存储在远端(如 OSS),通过预签名 URL 下载:
调用方 → POST /execute/batch { inputDataUrl, inputDataVersion }
│
▼
InputDataService.getInputDataSet()
│
├── 本地缓存存在且版本匹配?→ 直接返回(零网络请求)
│
└── 缓存不存在或版本不匹配?→ GET 下载 ZIP → 解压 *.in → 写入缓存
9.2 版本号驱动的缓存策略
模式 A:版本号驱动(推荐,零网络请求)
- 调用方传入
inputDataVersion(推荐 sha256) - 解析预签名 URL,提取 ObjectKey 定位本地缓存目录
- 读取本地
_meta.properties中的version字段 - 字符串精确比较——一致则直接读取本地
*.in文件 - 不一致则 GET 下载、解压、更新版本号
模式 B:GET 回退(未传版本号时)
- GET 下载 ZIP + 响应头
- 从 ETag 推导版本号
- 与本地比对——一致则丢弃下载内容、用本地缓存
9.3 本地缓存结构
/var/lib/sandbox-inputs/ # 缓存根目录
└── judgedata/ # ObjectKey 路径
└── problem-1/ # 去掉 .zip 后缀
├── _meta.properties # version=sha256:abcd..., updatedAt=...
├── 1.in # 测试用例输入
├── 2.in
└── 3.in
ZIP 解压时只接受匹配 ^\d+\.in$ 的文件,按数字排序组成输入列表。非法文件名直接忽略。
十、API 设计
10.1 单次执行
curl -X POST 'http://localhost:6060/execute/single' \
-H 'Content-Type: application/json' \
-d '{
"requestId": "demo-001",
"language": "python3",
"code": "print(sum(map(int, input().split())))",
"input": "10 20",
"timeLimit": 1000,
"memoryLimit": 256
}'
响应:
{
"code": 1,
"message": "success",
"data": {
"status": "SUCCESS",
"result": {
"index": 1,
"status": "SUCCESS",
"output": "30",
"errorOutput": "",
"time": 15,
"memory": 2048,
"exitCode": 0
},
"totalTime": 32
}
}
10.2 批量执行
curl -X POST 'http://localhost:6060/execute/batch' \
-H 'Content-Type: application/json' \
-d '{
"requestId": "batch-001",
"language": "java17",
"code": "import java.util.*; public class Main { ... }",
"inputDataUrl": "https://oss.example.com/data/1001.zip?sign=xxx",
"inputDataVersion": "sha256:abcd1234",
"timeLimit": 2000,
"memoryLimit": 256
}'
批量执行会返回每个测试用例的独立结果,以及汇总统计:
{
"data": {
"status": "RUNTIME_ERROR",
"results": [...],
"summary": { "total": 3, "success": 2, "failed": 1 },
"totalTime": 5600
}
}
10.3 执行状态枚举
| 状态码 | 枚举值 | 含义 |
|---|---|---|
| 0 | SUCCESS | 执行成功 |
| 1 | COMPILE_ERROR | 编译错误 |
| 2 | RUNTIME_ERROR | 运行时错误(段错误、非零退出码等) |
| 3 | TIME_LIMIT_EXCEEDED | 超时 |
| 4 | MEMORY_LIMIT_EXCEEDED | 内存超限 |
| 5 | OUTPUT_LIMIT_EXCEEDED | 输出超限(超过 64KB) |
| 6 | SYSTEM_ERROR | 系统内部错误 |
| 7 | DANGEROUS_CODE | 危险代码被拦截 |
10.4 健康检查
GET /health/ping → 简单存活检测
GET /health/liveness → K8s 存活探针(始终返回 UP)
GET /health/readiness → K8s 就绪探针(检查 Docker 连接)
GET /health → 详细信息(JVM 状态、Docker 状态、容器池统计)
十一、部署架构
11.1 Docker-out-of-Docker 模式
沙箱服务本身运行在容器中,通过挂载 Docker Socket 管理执行容器:
services:
sandbox:
build: .
user: root # 需要 root 才能访问 docker.sock
ports:
- '6060:6060'
volumes:
- /var/run/docker.sock:/var/run/docker.sock # 核心:Docker Socket
- /var/lib/sandbox-inputs:/var/lib/sandbox-inputs
healthcheck:
test: ['CMD-SHELL', 'curl -f http://localhost:6060/health/ping || exit 1']
interval: 20s
retries: 5
注意区分:这是 Docker-out-of-Docker(DooD),不是 Docker-in-Docker(DinD)。DooD 方式下,沙箱服务调用宿主机的 Docker daemon,执行容器直接运行在宿主机上,与沙箱服务是兄弟关系而非父子关系。这比 DinD 更高效(无嵌套虚拟化开销)、更稳定(无嵌套存储驱动问题)。
11.2 多阶段构建
主服务使用 Docker 多阶段构建,最终镜像基于精简的 JRE:
# 阶段 1:Maven 构建
FROM maven:3.9-eclipse-temurin-21-alpine AS builder
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests -B
# 阶段 2:运行时镜像
FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache docker-cli curl tzdata
COPY --from=builder /build/target/*.jar app.jar
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
--mount=type=cache 利用 Docker BuildKit 的缓存挂载,Maven 依赖只需在首次构建时下载。
十二、异常处理体系
12.1 异常层级
SandboxException (基类,携带 ExecutionStatus + requestId)
├── CompileException → COMPILE_ERROR
├── DangerousCodeException → DANGEROUS_CODE(携带匹配的 pattern)
├── RuntimeErrorException → RUNTIME_ERROR(携带 exitCode + errorOutput)
├── TimeLimitException → TIME_LIMIT_EXCEEDED(携带 timeLimit)
└── MemoryLimitException → MEMORY_LIMIT_EXCEEDED(携带 memoryLimit + actualMemory)
12.2 业务异常返回 HTTP 200
关键设计决策:所有沙箱异常返回 HTTP 200。
@ExceptionHandler(SandboxException.class)
public ResponseEntity<ExecuteResponse> handleSandboxException(SandboxException e) {
return ResponseEntity.ok(ExecuteResponse.builder()
.status(e.getStatus())
.errorMessage(e.getMessage())
.build());
}
这不是疏忽,而是有意为之——对调用方来说,“编译失败”或”超时”不是服务器错误,而是正常的业务结果。调用方只需检查 response.data.status 即可,不需要同时处理 HTTP 状态码和业务状态码。只有参数校验失败(400)和未预期的系统异常(500)才返回非 200 状态码。
12.3 路径脱敏
执行结果中的错误信息会经过路径脱敏处理,防止容器内部路径泄露:
private String sanitizeInternalPath(String output) {
return output
.replaceAll("/sandbox/workspace/job-[a-f0-9\\-]+/", "")
.replace("/sandbox/workspace/", "");
}
// 编译错误:"/sandbox/workspace/job-abc123/Main.java:5: error"
// → "Main.java:5: error"
这确保用户看到的错误信息中不包含容器内部路径,减少信息面暴露。
十三、配置参数速查
sandbox:
docker:
host: unix:///var/run/docker.sock # Docker Socket 地址
connect-timeout: 30 # 连接超时(秒)
response-timeout: 60 # 读取超时(秒)
max-connections: 100 # 最大连接数
pool:
enabled: true # 启用容器池
min-size: 2 # 每语言最小池大小
max-size: 10 # 每语言最大池大小
max-idle-minutes: 10 # 空闲超时(分钟)
max-use-count: 100 # 单容器最大使用次数
execution:
compile-timeout: 30 # 编译超时(秒)
run-timeout: 10 # 运行超时(秒)
total-timeout: 300 # 总超时(秒)
memory-limit: 256 # 默认内存限制(MB)
cpu-limit: 1.0 # CPU 限制(核数)
output-limit: 65536 # 最大输出(字节 = 64KB)
max-processes: 1024 # 最大进程数
max-open-files: 256 # 最大文件描述符
max-test-cases: 100 # 最大测试用例数
enable-code-scan: true # 启用危险代码扫描
max-concurrent-containers: 10 # 最大并发容器数
input-data:
storage-dir: /var/lib/sandbox-inputs # 缓存目录
download-timeout: 30000 # 下载超时(毫秒)
max-file-size: 10485760 # ZIP 最大大小(10MB)
十四、设计哲学与权衡
14.1 纵深防御
安全不是单一机制能解决的。项目采用纵深防御(Defense in Depth)策略——多层安全机制层层叠加,每一层都假设上一层可能被突破:
| 防御层 | 机制 | 可被绕过? |
|---|---|---|
| 应用层 | 正则黑名单 | 可以(宏、编码等手段) |
| 内核层 | Namespace 隔离 | 需要内核漏洞 |
| 内核层 | Cgroup 限制 | 基本不可绕过 |
| 内核层 | Capabilities 删除 | 需要内核漏洞 |
| 内核层 | Seccomp 过滤 | 需要内核漏洞 |
| 文件系统层 | 只读 rootfs + tmpfs | 基本不可绕过 |
| 网络层 | 完全禁用 | 不可绕过 |
14.2 黑名单是”尽力而为”
项目文档明确指出:黑名单是”尽力而为”的第一道防线,容器层兜底是真正的安全边界。
这是务实的安全观——承认黑名单必然存在绕过方式(尤其是 C/C++ 的宏系统),但通过容器层的硬隔离确保即使绕过也无法造成实质伤害。对比两种极端做法:
- 过于严格的黑名单:大量误杀正常代码,用户体验差
- 没有黑名单:所有恶意代码都到达容器层,浪费资源
当前方案在两者之间取得平衡:拦截明显恶意代码 + 容器兜底未知攻击。
14.3 简洁优于复杂
项目刻意避免过度工程:
- 没有 Caffeine/Redis 缓存框架——磁盘缓存 + 版本号比对足够
- 没有独立的
SecurityConfigBuilder——安全配置集中在ContainerManager一个方法中 - 没有独立的
DangerousCodeScanner——扫描逻辑内聚在语言策略中 - 没有 Swagger/SpringDoc——API 通过文档说明
- 没有
@EnableAsync——同步执行足以满足需求
每一个”没有”都是有意的取舍——在当前需求规模下,简单方案已经足够好。
十五、总结
Ezzi Code Sandbox 展示了如何构建一个生产级的代码执行沙箱。它认真对待了安全、性能、可扩展性这三个核心关注点:
| 关注点 | 解决方案 |
|---|---|
| 安全 | 纵深防御:正则黑名单 → Namespace → Cgroup → Capabilities → Seccomp → 只读FS → 网络禁用 → 非 root 执行 |
| 性能 | 容器池预热复用、tmpfs 内存文件系统、容器内纳秒级计时、输入数据缓存 |
| 可扩展 | 策略模式 + Spring 自动注册,添加新语言只需三步 |
| 可运维 | K8s 健康检查、Docker Compose 一键部署、定时容器维护、路径脱敏 |
核心经验:
- 安全没有银弹——多层防御比单一完美方案更可靠
- 容器池是性能瓶颈的关键优化——从秒级降到毫秒级
- 策略模式让多语言支持变得简单——扩展成本极低
- 容器内测量比容器外测量更精确——消除了 API 通信开销
如果你正在构建在线编程平台、AI 代码执行引擎,或者任何需要安全执行不受信任代码的系统,希望这篇文章能为你提供有价值的参考。
评论
无需登录,审核通过后公开。只有博主可以回复。
正在加载…
已公开的评论