OJ平台远端判题子系统开发(三):Docker沙箱核心实现——双驱动、熔断、安全
经过前两周的需求分析和架构设计,本周正式进入判题核心模块的开发。主要内容是Docker CLI沙箱的编译运行能力实现、熔断降级机制和seccomp安全策略。从ACM参赛的角度来看,沙箱是整个判题系统中最核心的部分——它直接决定了代码能否被正确编译和运行,任何阶段的疏漏都可能导致判题结果不准确。
一、沙箱整体设计
1.1 架构分层
Judge Worker / Judger Service
│
▼
Circuit Breaker (熔断器)
│
▼
Sandbox Interface (接口抽象)
│
├── DockerCLISandbox (真实容器执行)
└── MockSandbox (测试用)
│
│ CLI: docker create / start / exec
│ File Transfer: bind mount / copy
▼
Resource Limits + Security Layer
1.2 接口定义
type Sandbox interface {
Compile(ctx context.Context, req ExecRequest) (ExecResult, error)
Run(ctx context.Context, req ExecRequest) (ExecResult, error)
}
Compile和Run是两个独立的方法,分别对应判题流程中的编译阶段和运行阶段。分步执行的原因有二:编译和运行需要不同的资源限制(编译阶段内存需求通常更大),且在编译失败时可以提前终止判题流程,不浪费运行阶段的资源。
二、CLI沙箱实现
2.1 容器生命周期管理
CLI驱动通过Go的 os/exec 调用系统docker命令。创建容器的核心参数:
cmd := exec.CommandContext(ctx, "docker", "run", "-d",
"--network", "none",
"--cpus", "1",
"--memory", "256m",
"--pids-limit", "64",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--tmpfs", "/workspace:size=64m,uid=1000,gid=1000,mode=0775,exec",
"--user", "1000:1000",
image,
"sleep", "3600",
)
参数含义逐一说明:
-d:后台运行容器sleep 3600:容器保持存活,编译和运行通过docker exec分步执行--network none:禁用网络,防止用户代码发起外部网络请求--cpus 1:限制1个CPU核心--memory 256m:内存硬限制--pids-limit 64:限制最大进程数,防止fork炸弹--cap-drop ALL:移除所有Linux Capability--security-opt no-new-privileges:禁止通过setuid等方式提权--tmpfs:工作目录使用内存文件系统,速度快且容器销毁后自动清除--user 1000:1000:非root用户运行
2.2 文件传输模式
实现了两种文件传输方式,通过 REMOTE_JUDGE_DOCKER_TRANSFER 环境变量切换。
Bind模式:通过 -v 参数将宿主机工作目录挂载到容器 /workspace。性能最好,零拷贝开销。但要求工作目录在宿主机上可访问——当Judger自身也运行在容器中时(如Docker Compose场景),由于宿主机目录不在Judger容器内可见,Bind模式失效。
Copy模式:通过 docker exec 将文件逐一复制到沙箱容器:
func (d *DockerCLISandbox) prepareWorkspace(ctx, containerID, workDir string) error {
cmd := exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"sh", "-c",
fmt.Sprintf("install -m 755 /dev/null %s && cat > %s", targetPath, targetPath),
)
cmd.Stdin = strings.NewReader(content)
return cmd.Run()
}
使用 install -m 755 而非 cat + chmod 的原因与 --cap-drop ALL 有关:CAP_FOWNER 被移除后容器内不能执行 chmod。install 命令直接在创建文件时设置权限,避免了权限修改的需求。
2.3 Bug:Copy模式下编译产物丢失
问题描述:编译阶段返回exitCode=0(编译成功),但运行阶段报 exec: "./main": no such file or directory。
排查过程:
- 第一步检查
prepareWorkspace函数的执行日志,确认源代码文件已正确复制到运行容器 - 检查运行容器内
/workspace的文件列表,发现编译产物main不存在 - 回溯编译和运行的完整流程,意识到Copy模式下编译和运行在两个不同的容器中执行
- 编译容器执行编译后,产物
main留在编译容器的tmpfs中。编译容器被销毁后,tmpfs也随之清除
编译容器 (tmpfs) 运行容器 (tmpfs) Judger工作区
/workspace/main.cpp /workspace/main.cpp main.cpp(仅源码)
/workspace/main (不存在) (不存在)
关键发现:Bind模式下不存在此问题,因为编译和运行容器共用了同一个挂载目录,编译产物直接出现在宿主机文件系统中。
解决方案:增加 collectOutput 步骤,编译成功后从沙箱容器将产物拷贝回Judger工作区:
if result.ExitCode == 0 {
d.collectOutput(timeoutCtx, containerID, req.WorkDir)
}
func (d *DockerCLISandbox) collectOutput(ctx, containerID, workDir string) {
files := d.listFiles(ctx, containerID, "/workspace")
for _, file := range files {
localPath := filepath.Join(workDir, filepath.Base(file))
exec.Command("sh", "-c",
fmt.Sprintf("docker exec %s cat %s > %s", containerID, file, localPath)).Run()
}
}
编译产物先回传到Judger工作区,Run步骤的 prepareWorkspace 再将产物复制到运行容器。修改后无论Bind模式还是Copy模式,编译产物都能正确传递到运行阶段。
三、熔断降级
3.1 设计动机
在ACM比赛中,如果评测机频繁出现System Error,说明评测系统本身有问题。同样,当沙箱执行持续失败(如Docker Daemon无响应或系统资源枯竭),继续接受判题请求只会让情况恶化——新的请求持续失败,旧的请求也得不到恢复。熔断器的设计目的是在检测到连续失败后暂时拒绝新请求,给故障恢复留出时间窗口。还有就是有些选手会写脚本去卡评测机,来间接干扰其他选手的提交,为了应对这种情况,熔断保证评测机不被卡死。
熔断器模式参考:Circuit Breaker Pattern - Martin Fowler
https://martinfowler.com/bliki/CircuitBreaker.html
3.2 状态机设计
熔断器包含三种状态和四条转换规则:
Closed ──连续3次失败──→ Open ──等待30秒──→ HalfOpen ──探测成功──→ Closed
│
└──探测失败──→ Open
| 状态 | 含义 | 行为 |
|---|---|---|
| Closed | 正常状态 | 请求正常通过 |
| Open | 熔断打开 | 直接拒绝请求,返回 ErrCircuitOpen |
| HalfOpen | 半开探测 | 允许一个请求通过,根据结果决定恢复或重新打开 |
3.3 核心实现
type CircuitBreaker struct {
mu sync.Mutex
state State
failures int
lastFail time.Time
threshold int // 触发阈值:3
halfOpenWait time.Duration // 半开等待:30s
sandbox Sandbox
}
func (cb *CircuitBreaker) execute(ctx context.Context, req ExecRequest) ExecResult {
cb.mu.Lock()
if cb.state == StateOpen {
if time.Since(cb.lastFail) > cb.halfOpenWait {
cb.state = StateHalfOpen
} else {
cb.mu.Unlock()
return ErrCircuitOpen
}
}
cb.mu.Unlock()
result := cb.sandbox.Execute(ctx, req)
cb.mu.Lock()
if result.Error != nil {
cb.failures++
if cb.failures >= cb.threshold {
cb.state = StateOpen
cb.lastFail = time.Now()
}
} else {
if cb.state == StateHalfOpen {
cb.state = StateClosed
}
cb.failures = 0
}
cb.mu.Unlock()
return result
}
3.4 参数选择
| 参数 | 值 | 理由 |
|---|---|---|
| threshold | 3 | 避免因偶发故障(如某次网络抖动)误触发熔断;连续3次足以判断为系统级故障 |
| halfOpenWait | 30s | 给足够的恢复时间;过短则来不及恢复,过长则恢复后系统空转时间太长 |
| 探测请求数 | 1 | 半开状态仅放行一个请求,最小化对故障系统的压力 |
四、Seccomp安全策略
4.1 Seccomp概述
seccomp(Secure Computing Mode)是Linux内核的安全机制,通过限制进程可以执行的系统调用来缩小内核攻击面。在沙箱场景中,即使攻击者在容器中获得了代码执行权限,seccomp也能阻止其调用mount、reboot、bpf等危险系统调用。
Docker Seccomp官方文档:https://docs.docker.com/engine/security/seccomp/
4.2 策略选型:黑名单 vs 白名单
| 策略 | 安全性 | 兼容性 | 开发成本 |
|---|---|---|---|
| 白名单(默认拒绝,按需允许) | 最高 | 低——遗漏一个必要syscall即崩溃 | 高——需穷举所有合法syscall |
| 黑名单(默认允许,按需拒绝) | 中等 | 高——不影响正常功能 | 低——仅列出已知危险调用 |
选择黑名单模式的原因:判题场景下的代码行为差异极大,C++编译器(g++)本身涉及大量系统调用,Go运行时也需要各种syscall支持。白名单模式需要穷举所有合法调用,这个工作量对开发周期来说不可接受。黑名单只需阻止mount、bpf、reboot等明显危险的系统调用,对于教学OJ场景已经足够。
4.3 配置文件
{
"defaultAction": "SCMP_ACT_ALLOW",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["mount", "umount2", "swapon", "swapoff",
"reboot", "halt", "poweroff",
"kexec_load", "bpf", "perf_event_open"],
"action": "SCMP_ACT_ERRNO"
}
]
}
被阻止的syscall分为三类:
- 文件系统破坏:mount、umount2、swapon、swapoff
- 系统控制:reboot、halt、poweroff、kexec_load
- 内核接口:bpf(可加载内核模块)、perf_event_open(可读取内核性能数据)
4.4 文件嵌入与Windows兼容
seccomp profile通过 //go:embed 在编译时嵌入二进制文件:
//go:embed seccomp_profile.json
var seccompProfileJSON []byte
Windows环境下的问题:Docker Desktop for Windows使用WSL2后端,Windows临时目录的路径格式(如 C:\Users\...\AppData\Local\Temp\)在WSL2中无法访问。当seccomp profile写入Windows临时目录时,WSL2后端加载失败。
解决方案:检测到Windows环境时自动跳过seccomp加载:
if runtime.GOOS == "windows" {
return "", func() {}, nil
}
Linux生产环境不受影响。
五、内存统计竞态问题
问题描述:执行时间极短的容器(如简单的C++程序在数十毫秒内完成),MemoryKB 字段始终为0。
根因分析:Docker stats的采样周期约为500ms-1s。容器执行在50ms内完成的场景下,stats采集尚未触发,容器已经退出。
影响范围:MLE(Memory Limit Exceeded)判定中,如果仅依赖MemoryKB字段,短容器可能漏判。
解决方案:MLE判定采用多信号综合判断:
- OOMKilled标志:容器被OOM Killer杀死时有明确标记,优先级最高
- 退出码137:OOM Kill后容器的标准退出码,与OOMKilled互为印证
- MemoryKB字段:仅作为辅助参考,为0时不做判定
这样即使stats采样未捕捉到内存使用数据,OOMKilled和退出码137仍能保证MLE判定正确。
六、测试验证
6.1 熔断器测试
测试命令:
go test -v -count=1 -timeout 120s ./internal/sandbox/ -run TestCircuitBreaker
| 测试用例 | 验证内容 | 结果 | 耗时 |
|---|---|---|---|
| TestCircuitBreakerOpensAfterFailures | 连续3次失败后熔断打开,第4次拒绝 | PASS | 0.02s |
| TestCircuitBreakerHalfOpenRecovery | 半开超时后探测成功,恢复到Closed | PASS | 0.02s |
| TestCircuitBreakerHalfOpenSingleProbe | 半开状态仅放行一个探测请求 | PASS | 0.02s |

6.2 Seccomp测试
测试命令:
go test -v -count=1 -timeout 60s ./internal/sandbox/ -run TestPrepareSeccomp
| 测试用例 | 验证内容 | 结果 |
|---|---|---|
| TestPrepareSeccompProfile | 嵌入式seccomp profile能正确写入临时文件并加载 | PASS |
| TestPrepareSeccompProfileDisabled | 禁用模式返回空路径 | PASS |

6.3 CLI解析测试
| 测试用例 | 验证内容 | 结果 |
|---|---|---|
| TestParseMemoryField | Docker stats内存字段解析(MiB/KiB/GiB格式) | PASS |
七、本周总结
完成内容
- Docker CLI沙箱完整实现(容器创建、文件传输、编译运行分步控制)
- Bind/Copy两种文件传输模式,修复Copy模式下编译产物丢失的Bug
- 熔断器全状态机实现(Closed→Open→HalfOpen→Closed)
- Seccomp黑名单安全策略,兼容Windows/WSL2环境
- 内存统计竞态问题分析与综合判定方案
调研查阅的资料
- Circuit Breaker Pattern:https://martinfowler.com/bliki/CircuitBreaker.html
- Docker Seccomp Security Profiles:https://docs.docker.com/engine/security/seccomp/
- Docker Runtime Privilege and Capabilities:https://docs.docker.com/engine/containers/run/#runtime-privilege-and-linux-capabilities

浙公网安备 33010602011771号