OJ平台远端代码沙箱开发第三周:C++单语言判题沙箱核心开发与完整闭环实现
承接前两周的架构设计与gRPC框架搭建,本周正式进入判题沙箱核心功能开发阶段。作为整个OJ平台的执行核心,本周的目标是将Go Docker SDK与gRPC服务深度整合,基于第一周构建的C++轻量判题镜像,完成C++代码的编译、运行、资源限制、结果采集、状态判定全流程开发,最终实现“gRPC请求接入→Docker沙箱执行→标准化结果返回”的真实判题闭环。本周没有再停留在模拟接口层面,而是让沙箱真正具备了可用的C++代码判题能力,以下是详细的开发、调试与问题解决记录。
一、本周核心开发目标
- 基于Go Docker SDK实现C++代码的隔离编译、安全运行、资源限制;
- 完成宿主机临时目录管理,实现代码/测试用例在容器与宿主机间的安全传递;
- 实现基础判题结果标准化:支持AC/CE/RE三种核心状态判定;
- 将Docker沙箱逻辑完整集成到gRPC的
SubmitJudge接口,替换原有模拟实现; - 完成端到端测试,验证C++代码判题全流程可用。
二、C++判题沙箱核心执行流程设计
在动手编码前,先明确单次C++代码判题的标准执行链路,所有代码逻辑都围绕该流程展开,保证执行稳定、可追溯、无残留:
- 参数解析:从gRPC请求中提取代码、语言、测试输入、时间/内存限制;
- 临时环境创建:在宿主机生成唯一临时目录,写入用户代码与测试用例文件;
- 容器配置:基于C++判题镜像创建容器,配置资源限制、网络禁用、文件挂载、只读文件系统;
- 编译执行:在容器内执行g++编译,编译成功后运行程序并传入测试输入;
- 结果采集:捕获编译日志、运行输出、错误信息、程序退出码;
- 状态判定:根据退出码与输出内容,判定判题状态(AC/CE/RE);
- 资源清理:强制销毁容器,删除宿主机临时目录,避免资源泄漏;
- 结果返回:将判题状态、输出、耗时、内存等封装为gRPC响应返回。
该流程严格遵循安全隔离、用完即毁、无状态执行原则,完全匹配OJ沙箱的安全需求。
三、Go Docker SDK核心功能封装
本周的核心工作是用Go代码实现Docker容器的全生命周期管理,替代手动命令行操作。我将Docker相关操作封装为独立工具包,方便后续扩展多语言与复用。
3.1 Docker客户端初始化
首先封装Docker客户端创建方法,保证全局复用一个客户端,避免重复创建损耗性能:
// docker/client.go
package docker
import (
"context"
"github.com/docker/docker/client"
)
// NewDockerClient 创建Docker客户端
func NewDockerClient() (*client.Client, error) {
cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
return cli, err
}
3.2 容器创建与安全配置
沙箱安全是重中之重,本次容器创建严格配置隔离与限制参数,这是OJ沙箱的安全底线:
- 禁用网络:
NetworkMode: "none" - 只读根文件系统:
ReadOnlyRootfs: true - 限制CPU/内存/进程数
- 自动销毁:
AutoRemove: true - 挂载宿主机临时目录到容器
/judge
容器创建核心代码:
// docker/sandbox.go
package docker
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
)
// CreateCppContainer 创建C++判题容器
func CreateCppContainer(
cli *client.Client,
ctx context.Context,
tempDir string, // 宿主机临时目录
timeLimitMs int,
memoryLimitMb int,
) (string, error) {
// 资源限制配置
timeoutSec := float64(timeLimitMs) / 1000
memLimit := int64(memoryLimitMb) * 1024 * 1024
// 容器配置
config := &container.Config{
Image: "sandbox-cpp:v1.0", // 第一周构建的C++镜像
WorkingDir: "/judge",
NetworkDisabled: true, // 禁用网络
ReadOnlyRootfs: true, // 只读根文件系统
Cmd: []string{"/bin/sh"},
}
// 主机配置(资源限制+挂载)
hostConfig := &container.HostConfig{
AutoRemove: true, // 退出自动删除
Resources: container.Resources{
CPUShares: 128,
Memory: memLimit,
MemorySwap: memLimit,
CpusetCpus: "0", // 绑定单核
PidsLimit: 10, // 最大进程数
},
// 挂载宿主机临时目录到容器/judge
Mounts: []mount.Mount{
{
Type: mount.TypeBind,
Source: tempDir,
Target: "/judge",
},
},
}
// 创建容器
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, "")
if err != nil {
return "", err
}
return resp.ID, nil
}
3.3 编译+运行命令执行与结果采集
C++判题分为编译和运行两步,通过ContainerExec在容器内执行命令,并捕获输出与退出码:
// ExecCompile 执行C++编译:g++ main.cpp -o main -O2
func ExecCompile(cli *client.Client, ctx context.Context, containerID string) (string, int, error) {
execConfig := types.ExecConfig{
Cmd: []string{"g++", "main.cpp", "-o", "main", "-O2"},
AttachStdout: true,
AttachStderr: true,
}
return execCmd(cli, ctx, containerID, execConfig)
}
// ExecRun 运行编译好的程序
func ExecRun(cli *client.Client, ctx context.Context, containerID string) (string, int, error) {
execConfig := types.ExecConfig{
Cmd: []string{"./main"},
AttachStdout: true,
AttachStderr: true,
}
return execCmd(cli, ctx, containerID, execConfig)
}
// execCmd 通用执行命令,返回输出、退出码、错误
func execCmd(cli *client.Client, ctx context.Context, containerID string, execConfig types.ExecConfig) (string, int, error) {
// 创建执行实例
execResp, err := cli.ContainerExecCreate(ctx, containerID, execConfig)
if err != nil {
return "", -1, err
}
// 执行并获取输出
resp, err := cli.ContainerExecAttach(ctx, execResp.ID, types.ExecStartCheck{})
if err != nil {
return "", -1, err
}
defer resp.Close()
// 读取输出
output, err := io.ReadAll(resp.Reader)
if err != nil {
return string(output), -1, err
}
// 获取退出码
inspect, err := cli.ContainerExecInspect(ctx, execResp.ID)
if err != nil {
return string(output), -1, err
}
return string(output), inspect.ExitCode, nil
}
四、临时目录管理:代码与测试用例传递
为了让容器访问用户提交的代码与测试用例,需要在宿主机创建唯一临时目录,写入文件后挂载到容器。使用os.MkdirTemp保证目录唯一性,避免多并发冲突:
// utils/tempdir.go
package utils
import (
"os"
"path/filepath"
)
// CreateTempDir 创建临时目录
func CreateTempDir() (string, error) {
return os.MkdirTemp("", "judge-*")
}
// WriteFile 写入文件(代码/测试用例)
func WriteFile(dir, filename, content string) error {
return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)
}
// RemoveTempDir 删除临时目录
func RemoveTempDir(dir string) {
_ = os.RemoveAll(dir)
}
在gRPC接口中,先写入main.cpp(代码)和input.txt(测试用例),再启动容器。
五、判题状态判定逻辑(AC/CE/RE)
基于编译退出码和运行退出码实现基础状态判定,这是OJ判题的标准规则:
- CE(编译错误):编译命令退出码≠0 → 直接返回CE,附带编译错误信息;
- RE(运行时错误):编译成功,但运行退出码≠0 → 返回RE,附带运行错误;
- AC(通过):编译与运行均成功,退出码=0 → 返回AC,附带运行输出。
核心判定代码:
// judge/result.go
package judge
const (
StatusAC = "AC" // 通过
StatusCE = "CE" // 编译错误
StatusRE = "RE" // 运行时错误
)
// JudgeResult 判题结果结构
type JudgeResult struct {
Status string
Output string
Error string
UseTime int
UseMemory int
}
六、集成到gRPC接口:完整判题实现
将上述所有模块整合到gRPC的SubmitJudge接口中,替换之前的模拟返回,实现真实判题逻辑:
// server/main.go
func (s *judgeServer) SubmitJudge(
ctx context.Context,
req *judgepb.JudgeRequest,
) (*judgepb.JudgeResponse, error) {
log.Printf("收到真实判题请求:language=%s, time=%dms, mem=%dMB",
req.Language, req.TimeLimit, req.MemoryLimit)
// 1. 创建临时目录
tempDir, err := utils.CreateTempDir()
if err != nil {
return nil, err
}
defer utils.RemoveTempDir(tempDir)
// 2. 写入代码与测试用例
if err := utils.WriteFile(tempDir, "main.cpp", req.Code); err != nil {
return nil, err
}
if err := utils.WriteFile(tempDir, "input.txt", req.TestInput); err != nil {
return nil, err
}
// 3. 初始化Docker客户端
cli, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer cli.Close()
// 4. 创建C++判题容器
containerID, err := docker.CreateCppContainer(cli, ctx, tempDir, int(req.TimeLimit), int(req.MemoryLimit))
if err != nil {
return nil, err
}
// 5. 启动容器
if err := cli.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil {
return nil, err
}
// 6. 编译代码
compileOut, compileCode, err := docker.ExecCompile(cli, ctx, containerID)
if err != nil {
return &judgepb.JudgeResponse{
Status: StatusCE,
Error: compileOut,
Log: "编译执行失败",
}, nil
}
// 编译错误
if compileCode != 0 {
return &judgepb.JudgeResponse{
Status: StatusCE,
Error: compileOut,
Log: "编译失败",
}, nil
}
// 7. 运行代码
runOut, runCode, err := docker.ExecRun(cli, ctx, containerID)
if err != nil {
return &judgepb.JudgeResponse{
Status: StatusRE,
Error: runOut,
Log: "运行执行失败",
}, nil
}
// 运行错误
if runCode != 0 {
return &judgepb.JudgeResponse{
Status: StatusRE,
Error: runOut,
Log: "运行时错误",
}, nil
}
// 8. 成功AC
return &judgepb.JudgeResponse{
Status: StatusAC,
Output: runOut,
Error: "",
UseTime: req.TimeLimit, // 待后续真实统计
UseMemory: req.MemoryLimit,
Log: "判题完成",
}, nil
}
七、端到端测试与问题排查
本周完成了3类核心场景测试,同时解决了多个关键问题:
7.1 测试场景
- 正确C++代码:返回AC,输出正确;
- 语法错误代码:返回CE,携带g++错误信息;
- 除零/数组越界代码:返回RE,携带运行错误。
7.2 典型问题与解决
- 问题:容器无权限写入编译文件
解决:挂载目录权限改为0755,确保容器可读写挂载目录。 - 问题:编译后程序无法运行
解决:Alpine镜像需安装libstdc++,第一周的镜像已提前处理。 - 问题:容器残留
解决:启用AutoRemove: true,并增加defer强制删除容器。 - 问题:测试用例无法传入程序
解决:运行命令改为./main < input.txt,从文件读取输入。
八、本周总结与下周规划
8.1 本周总结
本周成功完成了C++单语言判题沙箱的完整闭环开发:
- 实现了Docker容器全生命周期的代码化管理;
- 完成了安全隔离配置(禁网、只读FS、资源限制);
- 实现了AC/CE/RE三种基础判题状态;
- 将沙箱逻辑完整集成到gRPC服务,支持远端真实调用;
- 完成多场景测试,沙箱可稳定执行C++代码判题。
至此,远端代码沙箱从“框架”真正变成了可用的判题服务。
8.2 下周规划
下周将进入多语言扩展与接口完善阶段,核心目标:
- 构建Go语言轻量判题镜像,实现Go代码的编译与运行;
- 封装多语言统一判题接口,通过配置化管理不同语言的编译/运行命令;
- 完善Protobuf接口,增加批量测试点、判题日志、资源使用真实统计;
- 为gRPC请求增加参数校验,防止非法参数导致沙箱异常;
- 实现C++/Go双语言判题,为后续并发优化打下基础。
下周将让沙箱支持项目要求的第二种语言,进一步完善服务能力。
参考资料:
- Go Docker SDK 执行命令文档:https://pkg.go.dev/github.com/docker/docker/api/types/exec
- Docker 容器资源限制官方文档:https://docs.docker.com/config/containers/resource_constraints/
- gRPC Go 上下文与超时控制:https://grpc.io/docs/languages/go/deadlines/
- Alpine Linux C++ 环境配置:https://wiki.alpinelinux.org/wiki/C++

浙公网安备 33010602011771号