代码执行环境的资源隔离:CPU、内存与网络的 cgroup 限制
代码执行环境的资源隔离CPU、内存与网络的 cgroup 限制一、深度引言与场景痛点一段无限循环的代码能让整个服务器瘫痪在线判题系统面临的最大安全威胁不是来自外部黑客而是来自合法的用户提交。一段看似正常的快排代码可能在某个输入下进入无限递归一次常见的 BFS 搜索可能因为队列无限增长导致 OOM。当你的判题服务部署在共享服务器上时这是小团队的常见情况一段恶意或失控的用户代码可能会耗尽整个服务器的资源影响其他所有服务。我曾亲眼见过一个测试环境因为一段没加内存限制的 DFS 代码整个 JVM 挂掉了。因此执行环境的资源隔离不是锦上添花的可选项而是系统安全的底线。二、底层机制与原理深度剖析Linux cgroup 资源控制机制cgroupControl Group是 Linux 内核提供的资源限制机制它允许对一组进程进行以下维度的控制子系统控制内容关键文件判题场景cpuCPU 使用时间cpu.cfs_quota_us, cpu.cfs_period_us限制单个判题最长执行时间memory内存使用量memory.limit_in_bytes防止内存泄漏耗尽系统内存blkio磁盘 IO 速率blkio.throttle.read_bps_device防止大量写入撑爆磁盘pids进程数量pids.max防止 fork 炸弹net_prio网络优先级—判题环境应完全断网三、生产级代码实现与最佳实践Java 端使用 cgroup 进行资源限制// Cgroup 管理器 —— 封装 Linux cgroup 文件系统操作 public class CgroupManager { private static final Path CGROUP_ROOT Path.of(/sys/fs/cgroup); private final Path groupPath; /** * 创建独立 cgroup每个判题任务对应一个唯一的 cgroup 子组 * 粒度设计任务级别隔离判题完成后清理 */ public CgroupManager(String taskId) throws IOException { this.groupPath CGROUP_ROOT.resolve(judge).resolve(taskId); Files.createDirectories(groupPath); } // 限制最大内存 —— 包括虚拟内存和物理内存之和 // 设置为 256MB既能覆盖大多数算法题的内存需求 // 又不会给系统带来过大压力 public void setMemoryLimit(long bytes) throws IOException { Files.writeString( groupPath.resolve(memory.max), String.valueOf(bytes) ); } // 限制 CPU 使用时间 —— 使用 CFS 带宽控制 // quota_us200000, period_us100000 表示可以使用 2 个 CPU 核心 // 但判题场景只需要 1 个核心设置为 quota100000 public void setCpuLimit(long quotaMicros) throws IOException { Files.writeString( groupPath.resolve(cpu.max), quotaMicros 100000 // quota period 格式 ); } // 限制最大进程数 —— 防止 fork 炸弹攻击 public void setPidLimit(int maxPids) throws IOException { Files.writeString( groupPath.resolve(pids.max), String.valueOf(maxPids) ); } // 将进程 PID 加入 cgroup 控制 // 只需把 PID 写入 cgroup.procs内核会自动将进程纳入控制 public void addProcess(long pid) throws IOException { Files.writeString( groupPath.resolve(cgroup.procs), String.valueOf(pid) ); } // 清理 cgroup 目录 —— 判题完成后必须清理防止目录堆积 public void cleanup() throws IOException { if (Files.exists(groupPath)) { // 先移除所有进程否则无法删除 cgroup 目录 try { Files.writeString( groupPath.resolve(cgroup.procs), ); } catch (IOException ignored) { // 如果 cgroup 已经为空写入会失败忽略即可 } Files.delete(groupPath); } } // 读取 cgroup 的内存峰值 —— 用于分析判题过程的实际内存消耗 public long getPeakMemory() throws IOException { String value Files.readString( groupPath.resolve(memory.peak) ).trim(); return Long.parseLong(value); } }// 受控进程执行器 —— 将 cgroup 管理与 ProcessBuilder 集成 public class ControlledProcessRunner { public ExecutionResult runWithLimits( String command, long memoryLimitBytes, long timeLimitMs ) throws IOException, InterruptedException { String taskId UUID.randomUUID().toString().substring(0, 8); // 1. 创建 cgroup 并设置资源限制 CgroupManager cgroup new CgroupManager(taskId); cgroup.setMemoryLimit(memoryLimitBytes); cgroup.setCpuLimit(timeLimitMs * 1000); // 转换为微秒 cgroup.setPidLimit(10); // 最多 10 个进程 Process process null; try { // 2. 启动判题进程 ProcessBuilder pb new ProcessBuilder(command.split( )); pb.redirectErrorStream(true); process pb.start(); // 3. 在进程启动后立即加入 cgroup long pid process.pid(); cgroup.addProcess(pid); // 4. 等待进程结束带超时兜底 boolean finished process.waitFor( timeLimitMs 1000, // 比 cgroup 限制略宽松避免竞态 TimeUnit.MILLISECONDS ); if (!finished) { process.destroyForcibly(); return ExecutionResult.timeLimitExceeded(); } // 5. 收集结果指标 int exitCode process.exitValue(); // 判断退出原因 if (exitCode 137) { // SIGKILL 128 9 // 进程被 OOM Killer 杀死 内存超限 return ExecutionResult.memoryLimitExceeded(); } long peakMemory cgroup.getPeakMemory(); return ExecutionResult.completed(exitCode, peakMemory); } finally { // 6. 无论判题成功与否都要清理 cgroup if (process ! null process.isAlive()) { process.destroyForcibly(); } cgroup.cleanup(); } } }#!/usr/bin/env python3 # 判题沙箱启动器 —— Python 版本的核心资源限制逻辑 使用示例 python3 sandbox.py --memory 256M --time 5s --pid-max 10 -- ./solution 这个脚本在执行用户代码前设置了完整的资源限制 任何超出限制的资源使用都会导致进程被内核强制终止。 import os import sys import argparse import resource # Python 的 resource 模块封装了 setrlimit def setup_limits(memory_mb: int, time_sec: int, max_pids: int): 设置所有资源限制 # 内存限制使用 RLIMIT_AS 限制虚拟内存 # 设置为 memory_mb 64MB给 JVM/Python 解释器留出运行空间 memory_bytes (memory_mb 64) * 1024 * 1024 resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes)) # CPU 时间限制 —— 软硬限制相同一旦超时立即杀死 resource.setrlimit(resource.RLIMIT_CPU, (time_sec, time_sec)) # 进程数限制 —— 设置为 1 意味着只能单进程运行 # 子进程的 fork 调用会直接失败 resource.setrlimit(resource.RLIMIT_NPROC, (max_pids, max_pids)) # 文件大小限制 —— 防止大量日志写满磁盘 file_limit 10 * 1024 * 1024 # 10MB resource.setrlimit(resource.RLIMIT_FSIZE, (file_limit, file_limit)) def main(): parser argparse.ArgumentParser() parser.add_argument(--memory, default256M) parser.add_argument(--time, typeint, default5) parser.add_argument(--pid-max, typeint, default10) parser.add_argument(command, nargsargparse.REMAINDER) args parser.parse_args() # 解析内存限制支持 256M / 1G 等格式 memory_mb parse_memory(args.memory) # 在执行用户程序前设置资源限制 setup_limits(memory_mb, args.time, args.pid_max) # exec 替换当前进程不额外创建进程 # 这样限制会直接应用到用户程序上 os.execvp(args.command[0], args.command) if __name__ __main__: main()四、边界分析与架构权衡cgroup v1 vs v2Linux 内核的 cgroup 有两个版本cgroup v1每个子系统有独立的层级。配置复杂但兼容性好。cgroup v2统一层级所有子系统在一个目录下管理。推荐使用但需要较新的内核4.5。建议如果内核支持优先使用 cgroup v2。统一层级的管理逻辑更简单不容易出现这个限制设置了但另一个漏了的问题。资源限制的阈值怎么定阈值设得太小用户正常的算法题也会被误判为超时/超内存体验极差。阈值设得太大恶意代码有更大的破坏空间。实践中我们采用这样的阈值题目类型内存限制时间限制进程限制简单题数组/字符串128MB1s5中等题DP/BFS/DFS256MB2s10困难题图论/复杂DP512MB5s15将阈值与题目绑定而不是全局统一能更好地适配不同类型的算法题。容器 vs cgroup 的再次比较在前文中我们讨论了容器方案与进程级方案的对比。cgroup 实际上是进程级方案的核心依赖——即使不使用 Docker也可以通过直接操作 cgroup 文件系统实现精细的资源控制。区别只在于Docker 帮你封装了这些操作但增加了启动开销。五、总结资源隔离是判题系统安全基座中最底层的一环。它的核心价值不在于防止已知的攻击那些可以通过白名单过滤而在于防止未知的、合法的代码行为失控——一段递归没有终止条件一个循环越写越大一个数组越开越多。三个实践经验每个判题任务独立一个 cgroup 子组——这是最小隔离粒度内存限制应该略高于题目的预期需求——给运行时环境留出空间cgroup 清理不能遗漏——否则目录堆积会影响性能资源隔离做得越好你就越敢让系统处理用户提交的代码。而这种敢是判题系统稳定运行的信心来源。