OJ平台远端代码沙箱开发第三周:C++单语言判题沙箱核心开发与完整闭环实现

承接前两周的架构设计与gRPC框架搭建,本周正式进入判题沙箱核心功能开发阶段。作为整个OJ平台的执行核心,本周的目标是将Go Docker SDK与gRPC服务深度整合,基于第一周构建的C++轻量判题镜像,完成C++代码的编译、运行、资源限制、结果采集、状态判定全流程开发,最终实现“gRPC请求接入→Docker沙箱执行→标准化结果返回”的真实判题闭环。本周没有再停留在模拟接口层面,而是让沙箱真正具备了可用的C++代码判题能力,以下是详细的开发、调试与问题解决记录。

一、本周核心开发目标

  1. 基于Go Docker SDK实现C++代码的隔离编译、安全运行、资源限制
  2. 完成宿主机临时目录管理,实现代码/测试用例在容器与宿主机间的安全传递;
  3. 实现基础判题结果标准化:支持AC/CE/RE三种核心状态判定;
  4. 将Docker沙箱逻辑完整集成到gRPC的SubmitJudge接口,替换原有模拟实现;
  5. 完成端到端测试,验证C++代码判题全流程可用。

二、C++判题沙箱核心执行流程设计

在动手编码前,先明确单次C++代码判题的标准执行链路,所有代码逻辑都围绕该流程展开,保证执行稳定、可追溯、无残留:

  1. 参数解析:从gRPC请求中提取代码、语言、测试输入、时间/内存限制;
  2. 临时环境创建:在宿主机生成唯一临时目录,写入用户代码与测试用例文件;
  3. 容器配置:基于C++判题镜像创建容器,配置资源限制、网络禁用、文件挂载、只读文件系统;
  4. 编译执行:在容器内执行g++编译,编译成功后运行程序并传入测试输入;
  5. 结果采集:捕获编译日志、运行输出、错误信息、程序退出码;
  6. 状态判定:根据退出码与输出内容,判定判题状态(AC/CE/RE);
  7. 资源清理:强制销毁容器,删除宿主机临时目录,避免资源泄漏;
  8. 结果返回:将判题状态、输出、耗时、内存等封装为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判题的标准规则:

  1. CE(编译错误):编译命令退出码≠0 → 直接返回CE,附带编译错误信息;
  2. RE(运行时错误):编译成功,但运行退出码≠0 → 返回RE,附带运行错误;
  3. 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 测试场景

  1. 正确C++代码:返回AC,输出正确;
  2. 语法错误代码:返回CE,携带g++错误信息;
  3. 除零/数组越界代码:返回RE,携带运行错误。

7.2 典型问题与解决

  1. 问题:容器无权限写入编译文件
    解决:挂载目录权限改为0755,确保容器可读写挂载目录。
  2. 问题:编译后程序无法运行
    解决:Alpine镜像需安装libstdc++,第一周的镜像已提前处理。
  3. 问题:容器残留
    解决:启用AutoRemove: true,并增加defer强制删除容器。
  4. 问题:测试用例无法传入程序
    解决:运行命令改为./main < input.txt,从文件读取输入。

八、本周总结与下周规划

8.1 本周总结

本周成功完成了C++单语言判题沙箱的完整闭环开发

  • 实现了Docker容器全生命周期的代码化管理;
  • 完成了安全隔离配置(禁网、只读FS、资源限制);
  • 实现了AC/CE/RE三种基础判题状态;
  • 将沙箱逻辑完整集成到gRPC服务,支持远端真实调用;
  • 完成多场景测试,沙箱可稳定执行C++代码判题。

至此,远端代码沙箱从“框架”真正变成了可用的判题服务

8.2 下周规划

下周将进入多语言扩展与接口完善阶段,核心目标:

  1. 构建Go语言轻量判题镜像,实现Go代码的编译与运行;
  2. 封装多语言统一判题接口,通过配置化管理不同语言的编译/运行命令;
  3. 完善Protobuf接口,增加批量测试点、判题日志、资源使用真实统计;
  4. 为gRPC请求增加参数校验,防止非法参数导致沙箱异常;
  5. 实现C++/Go双语言判题,为后续并发优化打下基础。

下周将让沙箱支持项目要求的第二种语言,进一步完善服务能力。

参考资料

  1. Go Docker SDK 执行命令文档:https://pkg.go.dev/github.com/docker/docker/api/types/exec
  2. Docker 容器资源限制官方文档:https://docs.docker.com/config/containers/resource_constraints/
  3. gRPC Go 上下文与超时控制:https://grpc.io/docs/languages/go/deadlines/
  4. Alpine Linux C++ 环境配置:https://wiki.alpinelinux.org/wiki/C++
posted @ 2026-04-11 20:45  宋佳奇  阅读(1)  评论(0)    收藏  举报