7-IsolateJob 沙箱执行任务
7-IsolateJob 沙箱执行任务

7-IsolateJob 沙箱执行任务

IsolateJob 是 Judge0 系统中负责在隔离沙箱环境中执行代码提交的核心任务类。它基于 Rails ActiveJob 框架构建,通过 Linux isolate 工具实现进程级别的资源隔离与执行控制。本文深入分析其架构设计、执行流程、错误处理机制以及与外部组件的协作关系。

核心架构设计

类继承结构

IsolateJob 继承自 ApplicationJob,后者是 Rails ActiveJob 框架的简单封装:

class ApplicationJob < ActiveJob::Base
end

Sources: application_job.rb

IsolateJob 本身声明了三个关键配置:

class IsolateJob < ApplicationJob
  retry_on RuntimeError, wait: 0.1.seconds, attempts: 100
  queue_as ENV["JUDGE0_VERSION"].to_sym

Sources: isolate_job.rb

配置项 值 说明
retry_on RuntimeError, 100次, 间隔0.1秒 运行时错误自动重试机制
queue_as JUDGE0_VERSION 环境变量 任务队列命名基于版本号
retry_jitter 未配置 重试间隔不添加随机抖动

文件路径常量定义

STDIN_FILE_NAME = "stdin.txt"
STDOUT_FILE_NAME = "stdout.txt"
STDERR_FILE_NAME = "stderr.txt"
METADATA_FILE_NAME = "metadata.txt"
ADDITIONAL_FILES_ARCHIVE_FILE_NAME = "additional_files.zip"

Sources: isolate_job.rb

这些常量定义了沙箱环境中标准输入/输出文件及元数据文件的命名规范,确保代码执行过程中产生的所有数据都有明确的存储位置。

任务调度入口:IsolateRunner

IsolateRunner 是连接控制器与 IsolateJob 的中间模块,位于 app/helpers/isolate_runner.rb:

module IsolateRunner
  MAX_WAIT_TIME_S = 600

  def self.perform_later(submission)
    submission.update(status: Status.queue, queued_at: DateTime.now, queue_host: ENV["HOSTNAME"])
    IsolateJob.perform_later(submission.id)
  end

  def self.perform_now(submission)
    IsolateRunner.perform_later(submission)
    # ... 轮询等待直到任务完成
  end
end

Sources: isolate_runner.rb

同步与异步执行模式

模式 方法 行为
异步 perform_later 立即入队,返回 token
同步 perform_now 入队后轮询等待最多 600 秒

同步模式采用指数退避等待策略:

  • 首次等待 2 秒
  • 次次等待 1 秒
  • 后续等待时间 = 0.5 × 迭代次数

Sources: isolate_runner.rb

控制器集成

在 SubmissionsController 中,任务的执行模式由 wait 参数决定:

def create
  submission = Submission.new(submission_params(params))
  if submission.save
    if @wait
      IsolateRunner.perform_now(submission)
      submission.reload
      render json: submission, status: :created, base64_encoded: @base64_encoded, fields: @requested_fields
    else
      IsolateRunner.perform_later(submission)
      render json: submission, status: :created, fields: [:token]
    end
  end
end

Sources: submissions_controller.rb

主执行流程:perform 方法详解

perform(submission_id) 是任务的入口点,执行流程分为六个阶段:

flowchart TD
    A[接收 submission_id] --> B[加载 Submission 并设置状态为 Processing]
    B --> C{循环执行 number_of_runs 次}
    C --> D[initialize_workdir 初始化工作目录]
    D --> E{compile 编译阶段}
    E -->|失败| F[cleanup 清理并返回]
    E -->|成功| G[run 执行程序]
    G --> H[verify 验证结果]
    H --> I[记录 time 和 memory]
    I --> J{是否 AC 状态?}
    J -->|是| C
    J -->|否| K[break 跳出循环]
    C -->|循环完成| L[计算平均值]
    L --> M[保存结果]
    M --> N{callback_url 存在?}
    N -->|是| O[call_callback 回调通知]
    N -->|否| P[结束]
    F --> P
    O --> P

Sources: isolate_job.rb

状态转换与元数据更新

def perform(submission_id)
  @submission = Submission.find(submission_id)
  submission.update(status: Status.process, started_at: DateTime.now, execution_host: ENV["HOSTNAME"])

  time = []
  memory = []

  submission.number_of_runs.times do
    # ... 执行逻辑
  end

  submission.time = time.inject(&:+).to_f / time.size
  submission.memory = memory.inject(&:+).to_f / memory.size
  submission.save
rescue Exception => e
  submission.update(message: e.message, status: Status.boxerr, finished_at: DateTime.now)
  cleanup(raise_exception: false)
ensure
  call_callback
end

Sources: isolate_job.rb

工作目录初始化

initialize_workdir 方法负责设置隔离执行环境:

sequenceDiagram
    participant IJ as IsolateJob
    participant OS as isolate 命令
    participant FS as 文件系统

    IJ->>OS: isolate --cg -b box_id --init
    OS->>FS: 创建 /var/local/lib/isolate/{box_id}/box
    OS-->>IJ: 返回 workdir 路径
    IJ->>FS: 创建 stdin.txt, stdout.txt, stderr.txt, metadata.txt
    IJ->>FS: 写入 source_code 到 boxdir/source_file
    IJ->>FS: 写入 stdin 到 workdir/stdin.txt
    IJ->>FS: extract_archive 解压附加文件

沙箱 ID 生成算法

def initialize_workdir
  @box_id = submission.id % 2147483647  # 2^31 - 1, 最大有符号整数
  @cgroups = (!submission.enable_per_process_and_thread_time_limit || 
              !submission.enable_per_process_and_thread_memory_limit) ? "--cg" : ""
  @workdir = `isolate #{cgroups} -b #{box_id} --init`.chomp
  # ...
end

Sources: isolate_job.rb

字段 计算方式 用途
box_id submission.id % 2147483647 沙箱容器唯一标识
cgroups 条件表达式 控制组隔离开关
workdir isolate --init 命令返回 沙箱工作目录
boxdir workdir + "/box" 代码文件存储位置

Cgroups 隔离策略

@cgroups = (!submission.enable_per_process_and_thread_time_limit || 
            !submission.enable_per_process_and_thread_memory_limit) ? "--cg" : ""

Sources: isolate_job.rb

用户配置 Cgroups 标志 效果
启用进程级时间限制 不使用 --cg 使用 /proc 计时
启用进程级内存限制 不使用 --cg 使用 /proc 内存统计
禁用上述任一选项 使用 --cg 启用 cgroups v1 隔离

编译阶段

编译阶段仅对需要编译的语言执行:

flowchart LR
    A[检查语言配置] --> B{compile_cmd 存在?}
    B -->|Python/JS| C[跳过编译]
    B -->|C/Java/Python| D[生成 compile.sh]
    D --> E[执行 isolate run]
    E --> F{exit code = 0?}
    F -->|成功| G[继续执行]
    F -->|失败| H[设置状态为 CE]
    H --> I[保存结果并返回]

编译命令执行

def compile
  unless submission.is_project
    return :success unless submission.language.compile_cmd
  end
  # ...
  command = "isolate #{cgroups} \
    -s \
    -b #{box_id} \
    -M #{metadata_file} \
    -i /dev/null \
    -t #{Config::MAX_CPU_TIME_LIMIT} \
    -w #{Config::MAX_WALL_TIME_LIMIT} \
    -k #{Config::MAX_STACK_LIMIT} \
    -p#{Config::MAX_MAX_PROCESSES_AND_OR_THREADS} \
    -f #{Config::MAX_MAX_FILE_SIZE} \
    -E HOME=/tmp \
    -E PATH=\"...\" \
    -d /etc:noexec \
    --run \
    -- /bin/bash compile.sh"
  `#{command}`
  # ...
end

Sources: isolate_job.rb

Isolate 命令参数对照表

参数 值来源 说明
-s 固定 共享网络命名空间
-b box_id 沙箱标识符
-M metadata_file 元数据输出文件
-i /dev/null 标准输入重定向
-t MAX_CPU_TIME_LIMIT CPU 时间限制(秒)
-w MAX_WALL_TIME_LIMIT 墙钟时间限制(秒)
-k MAX_STACK_LIMIT 栈大小限制(KB)
-p MAX_MAX_PROCESSES 最大进程/线程数
-m / --cg-mem MAX_MEMORY_LIMIT 内存限制(KB)
-f MAX_MAX_FILE_SIZE 最大文件大小(KB)
-E 环境变量 设置沙箱内环境变量
-d /etc:noexec 禁止执行目录
--run 固定 执行模式标志

Sources: isolate_job.rb

编译失败处理

return :success if process_status.success?

if metadata[:status] == "TO"
  submission.compile_output = "Compilation time limit exceeded."
end

submission.finished_at = DateTime.now
submission.time = nil
submission.wall_time = nil
submission.memory = nil
submission.stdout = nil
submission.stderr = nil
submission.exit_code = nil
submission.exit_signal = nil
submission.message = nil
submission.status = Status.ce  # Compilation Error
submission.save

return :failure

Sources: isolate_job.rb

执行阶段

run 方法在隔离环境中执行编译后的程序:

sequenceDiagram
    participant IJ as IsolateJob
    participant ISO as isolate
    participant Box as 沙箱环境
    participant UserProg as 用户程序

    IJ->>ISO: isolate -M metadata.txt [参数] --run -- bash run.sh
    ISO->>Box: 创建隔离环境
    Box->>UserProg: 执行用户代码
    UserProg->>Box: 读取 stdin.txt
    Box->>UserProg: 写入 stdout.txt, stderr.txt
    UserProg->>Box: 退出
    Box-->>ISO: 返回元数据
    ISO-->>IJ: 返回 metadata.txt 内容

执行命令构建

def run
  run_script = boxdir + "/" + "run.sh"
  unless submission.is_project
    command_line_arguments = submission.command_line_arguments.to_s.strip
      .encode("UTF-8", invalid: :replace)
      .gsub(/[$&;<>|`]/, "")  # 安全过滤
    File.open(run_script, "w") { |f| f.write("#{submission.language.run_cmd} #{command_line_arguments}")}
  end

  command = "isolate #{cgroups} \
    -s \
    -b #{box_id} \
    -M #{metadata_file} \
    #{submission.redirect_stderr_to_stdout ? '--stderr-to-stdout' : ''} \
    #{submission.enable_network ? '--share-net' : ''} \
    -t #{submission.cpu_time_limit} \
    -x #{submission.cpu_extra_time} \
    -w #{submission.wall_time_limit} \
    -k #{submission.stack_limit} \
    -p#{submission.max_processes_and_or_threads} \
    #{submission.enable_per_process_and_thread_time_limit ? ... : '--cg-timing'} \
    #{submission.enable_per_process_and_thread_memory_limit ? '-m' : '--cg-mem='}#{submission.memory_limit} \
    -f #{submission.max_file_size} \
    --run \
    -- /bin/bash run.sh \
    < #{stdin_file} > #{stdout_file} 2> #{stderr_file}"
  `#{command}`
end

Sources: isolate_job.rb

关键安全措施

安全机制 实现位置 目的
命令注入过滤 `gsub(/[$&;\<> ]/)
路径隔离 boxdir 目录 限制文件访问范围
网络控制 --share-net 可选启用网络
/etc:noexec 禁止执行 防止执行危险二进制

结果验证阶段

verify 方法解析元数据并确定最终状态:

def verify
  submission.finished_at = DateTime.now
  metadata = get_metadata

  program_stdout = File.read(stdout_file)
  submission.stdout = program_stdout.empty? ? nil : program_stdout

  submission.time = metadata[:time]
  submission.wall_time = metadata[:"time-wall"]
  submission.memory = (cgroups.present? ? metadata[:"cg-mem"] : metadata[:"max-rss"])
  submission.exit_code = metadata[:exitcode].try(:to_i) || 0
  submission.exit_signal = metadata[:exitsig].try(:to_i)
  submission.message = metadata[:message]
  submission.status = determine_status(metadata[:status], submission.exit_signal)
end

Sources: isolate_job.rb

元数据解析

def get_metadata
  metadata = File.read(metadata_file).split("\n").collect do |e|
    { e.split(":").first.to_sym => e.split(":")[1..-1].join(":") }
  end.reduce({}, :merge)
  return metadata
end

Sources: isolate_job.rb

Isolate 工具输出的元数据格式为每行 key:value,例如:

time:0.012
time-wall:0.034
max-rss:4096
exitcode:0
status:OK

状态判定逻辑

flowchart TD
    A[metadata :status] --> B{状态值}
    B -->|TO| C[TLE - 时间超限]
    B -->|SG| D[根据 exit_signal 判定]
    B -->|RE| E[NZEC - 非零退出码]
    B -->|XX| F[Internal Error]
    B -->|OK| G{stdout == expected_output?}
    G -->|相等| H[AC - Accepted]
    G -->|不等| I[WA - 答案错误]
def determine_status(status, exit_signal)
  if status == "TO"
    return Status.tle
  elsif status == "SG"
    return Status.find_runtime_error_by_status_code(exit_signal)
  elsif status == "RE"
    return Status.nzec
  elsif status == "XX"
    return Status.boxerr
  elsif submission.expected_output.nil? || strip(submission.expected_output) == strip(submission.stdout)
    return Status.ac
  else
    return Status.wa
  end
end

Sources: isolate_job.rb

Status 枚举完整定义

class Status < Enumerations::Base
  values queue:     { id:  1, name: 'In Queue' },
         process:   { id:  2, name: 'Processing' },
         ac:        { id:  3, name: 'Accepted' },
         wa:        { id:  4, name: 'Wrong Answer' },
         tle:       { id:  5, name: 'Time Limit Exceeded' },
         ce:        { id:  6, name: 'Compilation Error' },
         sigsegv:   { id:  7, name: 'Runtime Error (SIGSEGV)' },
         sigxfsz:   { id:  8, name: 'Runtime Error (SIGXFSZ)' },
         sigfpe:    { id:  9, name: 'Runtime Error (SIGFPE)' },
         sigabrt:   { id: 10, name: 'Runtime Error (SIGABRT)' },
         nzec:      { id: 11, name: 'Runtime Error (NZEC)' },
         other:     { id: 12, 'Runtime Error (Other)' },
         boxerr:    { id: 13, name: 'Internal Error' },
         exeerr:    { id: 14, name: 'Exec Format Error' }

  def self.find_runtime_error_by_status_code(status_code)
    case status_code.to_i
      when 11 then Status.sigsegv    # SIGSEGV
      when 25 then Status.sigxfsz    # SIGXFSZ
      when 8  then Status.sigfpe     # SIGFPE
      when 6  then Status.sigabrt    # SIGABRT
      else Status.other
    end
  end
end

Sources: status.rb

清理与回调

沙箱清理

flowchart TD
    A[cleanup] --> B[fix_permissions 修复权限]
    B --> C[删除 boxdir 和 tmpdir 内容]
    C --> D[删除 stdin/stdout/stderr/metadata 文件]
    D --> E[isolate --cleanup 清理沙箱]
    E --> F{workdir 仍存在?}
    F -->|是| G[抛出异常]
    F -->|否| H[成功返回]
def cleanup(raise_exception = true)
  fix_permissions
  `sudo rm -rf #{boxdir}/* #{tmpdir}/*`
  [stdin_file, stdout_file, stderr_file, metadata_file].each do |f|
    `sudo rm -rf #{f}`
  end
  `isolate #{cgroups} -b #{box_id} --cleanup`
  raise "Cleanup of sandbox #{box_id} failed." if raise_exception && Dir.exists?(workdir)
end

Sources: isolate_job.rb

回调通知机制

def call_callback
  return unless submission.callback_url.present?

  serialized_submission = ActiveModelSerializers::SerializableResource.new(
    submission,
    serializer: SubmissionSerializer,
    base64_encoded: true,
    fields: SubmissionSerializer.default_fields
  ).to_json

  Config::CALLBACKS_MAX_TRIES.times do
    begin
      response = HTTParty.put(
        submission.callback_url,
        body: serialized_submission,
        headers: { "Content-Type" => "application/json" },
        timeout: Config::CALLBACKS_TIMEOUT
      )
      break
    rescue Exception => e
      # 重试直到达到最大次数
    end
  end
rescue Exception => e
  # 静默处理回调错误,避免影响主流程
end

Sources: isolate_job.rb

配置参数体系

IsolateJob 依赖 Config 模块读取环境变量配置:

module Config
  CPU_TIME_LIMIT = (ENV["CPU_TIME_LIMIT"].presence || 5).to_f
  MAX_CPU_TIME_LIMIT = (ENV["MAX_CPU_TIME_LIMIT"].presence || 15).to_f
  MEMORY_LIMIT = (ENV["MEMORY_LIMIT"].presence || 128000).to_i  # KB
  MAX_MEMORY_LIMIT = (ENV["MAX_MEMORY_LIMIT"].presence || 512000).to_i
  STACK_LIMIT = (ENV["STACK_LIMIT"].presence || 64000).to_i
  MAX_STACK_LIMIT = (ENV["MAX_STACK_LIMIT"].presence || 128000).to_i
  MAX_PROCESSES_AND_OR_THREADS = (ENV["MAX_PROCESSES_AND_OR_THREADS"].presence || 60).to_i
  # ... 更多配置
end

Sources: config.rb

参数分类 变量名 默认值 单位
时间限制 CPU_TIME_LIMIT 5 秒
时间限制 MAX_CPU_TIME_LIMIT 15 秒
时间限制 WALL_TIME_LIMIT 10 秒
内存限制 MEMORY_LIMIT 128000 KB
内存限制 MAX_MEMORY_LIMIT 512000 KB
栈限制 STACK_LIMIT 64000 KB
进程限制 MAX_MAX_PROCESSES_AND_OR_THREADS 120 数量
文件限制 MAX_FILE_SIZE 1024 KB
运行次数 NUMBER_OF_RUNS 1 次

Sources: judge0.conf

错误处理与容错

重试策略

retry_on RuntimeError, wait: 0.1.seconds, attempts: 100
  • 触发条件: RuntimeError 异常
  • 重试间隔: 0.1 秒(固定值,无抖动)
  • 最大次数: 100 次

异常捕获层级

def perform(submission_id)
  # 主逻辑
rescue Exception => e
  raise e.message unless submission
  submission.update(message: e.message, status: Status.boxerr, finished_at: DateTime.now)
  cleanup(raise_exception: false)
ensure
  call_callback  # 即使出错也会执行回调
end

Sources: isolate_job.rb

异常场景 处理方式 状态码
Isolate 命令失败 重试 RuntimeError
资源超限 记录后继续 Status.tle/Status.oom
沙箱内部错误 设置 boxerr Status.boxerr
回调失败 静默忽略 无状态变化

与外部组件的交互

消息队列:Resque

# config/application.rb
config.active_job.queue_adapter = :resque

Sources: application.rb

# config/initializers/resque.rb
Resque.redis = Redis.new(
  host: ENV["REDIS_HOST"],
  port: ENV["REDIS_PORT"],
  password: ENV["REDIS_PASSWORD"],
  thread_safe: true
)

Sources: resque.rb

任务队列命名

queue_as ENV["JUDGE0_VERSION"].to_sym

Sources: isolate_job.rb

队列名称动态生成,例如 v1 或 v13。

Worker 启动脚本

# scripts/workers
while [[ $run_resque -eq 1 ]]; do
    rake resque:scheduler &
    rails resque:workers &
done

Sources: workers

性能考量

多轮执行平均值

submission.number_of_runs.times do
  # ... 执行 ...
  time << submission.time
  memory << submission.memory
  cleanup
  break if submission.status != Status.ac  # 提前终止优化
end

submission.time = time.inject(&:+).to_f / time.size
submission.memory = memory.inject(&:+).to_f / memory.size

Sources: isolate_job.rb

提前终止优化

当某次运行结果不是 Accepted 状态时,循环立即终止,不再执行后续轮次。

扩展阅读

相关条目

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注