2026/10/21 23:05:19

OJ评测沙盒技术选型:Docker、NsJail与seccomp方案深度对比

OJ评测沙盒技术选型:Docker、NsJail与seccomp方案深度对比 干 OJ 开发的人都知道评测沙盒是整个系统的命门。一个开源在线评测系统搭起来并不难题目管理、用户系统、排行榜这些模块都有现成方案但真正让 OJ 具备上线价值、经得起用户提交考验的永远是那个负责运行不可信代码的隔离环境。本文把这几年折腾沙盒方案的思路整理出来围绕隔离强度、资源限制精度、性能开销和维护成本四个维度做一次横向对比适合自建 OJ、校内判题服务维护者以及想给比赛平台做技术选型的开发者参考。1. 先把需求盘明白OJ 的评测沙盒到底要防什么1.1 评测系统的真实威胁模型很多人一上来就讨论 Docker 和 seccomp 哪个安全其实问题的起点不是选型而是威胁模型。OJ 的沙盒和一般生产环境里的服务隔离有一个本质差异生产环境跑的大多是可信代码边界防御是为了对抗外部入侵OJ 里要跑的是用户提交上来、可能被刻意构造过的东西。从判题机的角度看常见攻击方式大致分几类。第一类是信息窃取用户代码通过读文件系统拿到编译机上的隐藏数据比如测试用例、密码、环境变量甚至读取其他选手的提交第二类是资源耗尽典型的是 fork 炸弹、无限递归、超大内存申请目的是把判题机拖垮上演拒绝服务第三类是提权逃逸利用内核漏洞、容器配置不当、setuid 程序从受限环境跳到宿主机。还有一类比较隐蔽是通过网络外传数据比如把窃取的信息发送到外部服务器或者连接内部网络做横向探测。所以判题沙盒的职责非常明确限制文件系统视图、限制进程与 CPU 时间、限制内存与并发进程数、阻断网络、尽可能降低内核攻击面。这个需求清单决定了后面所有方案设计的出发点。1.2 OJ 沙盒和普通安全沙箱的差异普通安全沙箱追求“跑不起来”OJ 沙盒追求“跑得起来还要跑得快跑得公平”。这是评判标准上最容易踩坑的地方。比如一个 BSD jails 或者 firejail 的桌面配置会把系统调用过滤得极严但评测代码是正常编译的程序可能需要 clone、socketpair、present 调试接口之类的调用。沙盒限制太多选手程序直接崩溃评测结果完全失真。另一个差异是可观测性。安全沙箱通常只需判断“有没有被黑”而 OJ 沙盒需要精确区分超时、超内存、运行时错误、系统调用违规这几种结果再映射到 TLE、MLE、RE 等评测状态。这就要求沙盒具备资源统计能力比如峰值内存、累计 CPU 时间、退出信号来源。很多方案安全上达标了但统计能力欠缺导致结果不可信这在比赛场景下会被选手投诉到怀疑人生。1.3 评价沙盒方案的四个维度结合实践我把沙盒方案的评价维度收敛成四个。隔离强度说的是能否有效阻止文件访问、网络访问和权限提升这是安全底线。资源限制能力要能精确限制 CPU 时间、内存峰值、进程数、磁盘写入并且能实时读取状态。性能开销包括启动延迟和运行期损耗大规模提交时这个数字非常敏感一次提交如果多出几百毫秒启动开销整体吞吐会很难看。维护成本包括配置复杂度、依赖的程度、是否需要 root、出问题好不好排查。这四个维度基本能覆盖所有主流沙盒方案的对比框架。2. 主流的五类沙盒方案逐个拆解2.1 Docker 容器Docker 是很多人第一个想到的方案因为它生态成熟、命令简单、镜像可以固化编译环境。一个跑用户代码的容器本质上是 Linux namespacePID、Mount、Network、UTS、IPC、User加上 cgroup 资源限制再叠加 seccomp、AppArmor 或 SELinux 这些内核安全模块。Docker 的优势很明显镜像分发解决了编译环境一致性的问题团队协作时不用反复解释 GCC 版本和依赖库docker run一条命令就能拿到一个隔离网络和文件系统的运行环境开发门槛低底层有 runC 这个 OCI 标准运行时理论上是行业规范。对课设级别的小型 OJ 来说Docker 是性价比很高的起点。但它的问题在 OJ 场景下同样突出。首先是守护进程依赖docker run 需要 dockerd 在线整个判题链路多了一个故障点。其次是并发和镜像管理的坑如果每个提交都去docker run镜像是第一次拉取会带来巨大 IO 抖动几十个并发提交就能把判题机的磁盘带宽打满。还有一个容易被忽略的点Docker 的隔离强度是“足够用”而不是“绝对安全”它仍然共享宿主机内核跑在容器里的恶意代码一旦利用内核漏洞逃逸到宿主机的风险是存在的。所以 Docker 更适合作为编译环境的隔离方案运行用户代码时需要叠加 seccomp profile、cap-drop、limit 等一系列加固措施而不是指望它自带安全。2.2 NsJailNsJail 是 Google 开源出来的进程级沙箱最早用于内部 CTF 比赛后来在不少在线评测项目里被当成核心运行时。它的思路和 Docker 不同不是启动一个完整容器而是通过 clone 系统调用直接为单个进程创建新的 namespace配合 rlimit、cgroup、seccomp-bpf 做资源约束。NsJail 最大的卖点是轻量。启动一个新 namespace 的进程大约是毫秒级比 Docker 的容器生命周期短一个数量级。在判题场景里评测进程的启动频率极高这个优势会被放大得很明显。另外它的配置是文本格式可以把各种 namespace、mount 点、rlimit 参数、seccomp 策略写成配置文件接入代码里读一个文件就能用这一点对运维非常友好。不过 NsJail 对使用者的内核知识要求比 Docker 高。你需要理解 mount namespace 的传播方式知道 bind mount 的优先级明白 seccomp 白名单怎么写才不会误伤动态链接器。还有一点NsJail 需要 root 权限来完成 namespace 创建和 user namespace 映射实际部署时通常配合降权用户使用。它的文档不算丰富排障时依赖 strace、dmesg 这类基础工具对新手是个门槛。2.3 FirejailFirejail 是面向桌面应用设计的沙箱工具也基于 namespace 和 seccomp但它提供了一堆现成的 profile比如给浏览器、播放器用的配置用户基本不用自己写规则。在很多教程里Firejail 被拿来“隔离任意命令”看起来和 OJ 需求很接近。实际用下来Firejail 在 OJ 判题场景有点别扭。它面向的是长期运行的图形应用配置文件的语义也偏向桌面场景比如 X11 权限、声音设备访问这些选项。判题服务需要的是精确控制 CPU 时间、内存峰值、输出大小、进程数Firejail 这些方面都能做但把它嵌入一个高并发判题调度流程时会发现它的进程模型和资源回收方式不如 NsJail 干净。如果项目里恰好已经依赖 Firejail也可以用但专门为 OJ 引入它并不是最优解。2.4 seccomp-bpfseccompSecure Computing Mode是 Linux 内核提供的系统调用过滤机制seccomp-bpf 是它的 BPF 扩展版本允许用户态程序通过一组 BPF 规则决定系统调用是放行、拒绝还是触发特定行为比如直接杀掉进程并通知进程。它不负责文件系统视图隔离也不负责网络隔离只做一件事限制用户代码能触碰的内核接口面。在 OJ 场景里seccomp-bpf 通常是和其他机制组合使用的chroot 或 mount namespace 限制文件系统setrlimit 限制 CPU 和内存网络 namespace 或 seccomp 规则禁用 socket 相关调用。如果设计得当这套组合可以做到接近“最小可用”的状态用户程序只能执行计算相关的调用read、write、brk、mmap、exit、gettimeofday 等其他全部拦截。seccomp-bpf 的优点是性能和精确度。BPF 规则在内核态执行开销极小而且每次系统调用都能被拦截规则粒度比 rlimit 细得多。缺点是需要自己处理架构差异x86_64、arm64、riscv 的系统调用号不一样动态链接器在不同 libc 版本下会触发额外调用所以白名单的维护相当考验功力。对团队里有人熟内核、愿意投入维护的seccomp-bpf 是终极归宿反之很容易陷进“今天 A 程序跑不了明天 B 库缺调用”的泥潭。2.5 chroot setrlimit 传统组合在容器和 namespace 普及之前很多老牌 OJ 走的是 chroot 加 setrlimit 的路子。chroot 把进程的根目录切换到某个临时目录setrlimit 用RLIMIT_CPU、RLIMIT_AS、RLIMIT_NPROC限制资源。这套方案的优点是极轻、极快、无守护进程依赖缺点是隔离强度不足。chroot 本身并不是安全机制设计目标只是限制文件系统视角存在绕过它的历史手段。setrlimit 对进程数、内存、CPU 能起到约束但管不了网络也管不了内核漏洞利用。要在 2024 年用这套方案打底必须自己补上 seccomp 和 capability 处理实际上就变成了前面说的自制组合方案。对只想快速跑通一个演示 OJ 的开发者这套老方案仍然可以一战但对要上公网面对真实用户的系统不推荐。3. 性能与隔离强度实测对比3.1 怎么设计一场有说服力的压测纸上谈兵只能给参考选型前一定要有自己的压测数据。我给判题机做性能对比时会准备三类负载纯 CPU 计算比如亿次浮点加法内存压力比如连续申请释放大块内存系统调用密集比如频繁的文件读写或进程创建。这三类负载分别测试 CPU 算力损耗、内存统计精度、系统调用过滤开销。考虑到判题机的真实负载非常碎片化还需要同时压“启动延迟”和“短任务吞吐”。正确做法是先跑一个基准脚本统计从发起评测到拿到结果的时间再在不同沙盒方案下对比整个流程的时间差。只测运行阶段没意义因为 OJ 的整体吞吐最终取决于每个提交的端到端耗时。3.2 实测结果与横向对比表下面是经验值环境8 核判题机CPU 限定单核内存 256MB运行一个冒泡排序加内存申请的混合程序每组跑 100 次取中位数。维度原生进程Docker 容器NsJailseccomp chroot启动延迟约 1ms约 150-400ms约 5-15ms约 2-5msCPU 计算损耗0%2%-5%0-1%0-1%内存统计精度依赖工具cgroup 统计含缓存偏差cgroup rlimit 双控精准rlimit 峰值精准文件系统隔离强度无强挂载隔离强mount ns中chroot 可被特定调用穿透逃逸防御-共享内核需加固共享内核配置好较稳需要补 seccomp 才稳运维成本-中依赖 docker daemon中需懂 namespace高规则完全自维护表格里的数字只是一个参考起点。Docker 的启动延迟在容器池预创建模式下会大幅下降seccomp 方案如果白名单维护得不好程序运行时可能因为缺少某个调用直接崩溃。重点是理解不同方案的损耗来源Docker 的额外开销来自 namespace 创建、存储驱动和网络栈初始化NsJail 的损耗集中在 mount namespace 的挂载操作上seccomp 方案几乎为零但它的安全能力需要你自己补齐。3.3 数据之外安全和维护成本才是关键评测性能调到极致如果安全出问题那一切归零。从实际线上案例看Docker 方案最容易出的安全问题是镜像本身带的可执行文件或 setuid 位没有清理干净配合未限制的 capability让用户代码拿到不该有的权限。NsJail 的常见坑是只做了 userns 映射没限制 uid 范围导致用户进程在特定条件下仍然可以访问宿主机资源。而 seccomp 方案最大的坑不是性能而是维护节奏每升级一次 glibc、内核或者编程语言运行时白名单就可能崩一次。我的建议是性能对比决定你愿意用哪个方案安全和维护成本决定你能不能长期用下去。这两个维度一定要在选型时同时打分而不是只看评测耗时。4. 选型建议与接入实操4.1 按业务规模直接给结论做了这么多对比最终还是要落回自己的场景。如果你维护的是一个校内课程 OJ用户量几十人并发提交个位数Docker 是方便的选择把编译环境和运行环境都容器化维护成本最低。如果你在做一个几百人同时在线的比赛平台对启动延迟和公平性有要求NsJail 这类进程级沙箱更合适配合预编译资源池可以做到近乎原生进程的启动速度。如果你有内核背景、团队愿意投入自研 seccomp 方案能获得最好的灵活度和性能但必须做好长期维护的准备。预算充足、对隔离强度要求极高、不怕硬件开销的场景可以看 gVisor 或者 Kata Containers 这类虚拟机级隔离方案。它们在隔离强度上比 namespace 方案高一个层级因为用户代码运行在独立的内核态之上性能损耗也肉眼可见。目前大多数 OJ 项目用不到这个级别但如果是托管服务、公共判题平台这个方向可以评估一下。4.2 Docker 接入判题机的简化实践Docker 方案要避免“每个提交都新起容器”的坑工程上通常是预创建容器池或者用docker exec进入一个已存在的容器执行编译和运行任务。核心运行命令长这样docker run \ --rm \ --network none \ -m 256m \ --cpus 1 \ --pids-limit 64 \ --cap-drop ALL \ --security-opt no-new-privileges \ --security-opt seccomp./oj-seccomp.json \ -v /tmp/judge/$submission:/work:ro \ -v /tmp/judge/$submission_tmp:/tmp:rw \ oj-runtime:1.0 \ /work/a.out解释一下每个参数的用意。--network none直接斩断网络用户代码即使有外传逻辑也发不出去-m 256m是内存硬上限超过直接 kill--cpus 1限制 CPU 配额--pids-limit 64限制进程树规模防 fork 炸弹--cap-drop ALL丢弃全部 capability让用户进程没有任何特权操作能力--security-opt no-new-privileges防止通过 setuid 程序获得额外权限seccomp 配置文件再对系统调用做白名单约束。工作目录只读挂载临时目录才允许写避免用户代码污染判题机文件系统。编译阶段可以放宽限制因为编译行为本身不构成严重威胁但要保证编译环境的可复现性。我的做法是编译和运行分离编译在一个带完整工具链的镜像里执行只开放出网能力运行阶段再切到精简运行时镜像保证运行环境的攻击面尽量小。4.3 NsJail 接入方式与配置说明NsJail 常用的接入方式是把配置写成一个独立文件启动程序时通过参数指定。配置文件大致长这样name: oj-run description: judge runtime sandbox mode: ONCE use_pidns: true use_mountns: true use_netns: true rlimit: { as: 268435456, cpu: 5, nproc: 64, fsize: 1048576 } mount: [ { src: /usr, dst: /usr }, { src: /lib, dst: /lib }, { src: /lib64, dst: /lib64 }, { src: /bin, dst: /bin }, { src: /tmp/judge/work, dst: /work } ] seccomp_policy: oj_whitelist.policymode: ONCE意味着启动一个进程并等它退出这和判题流程的“运行一个用户程序、获取退出码”模型刚好匹配。use_pidns让用户进程看不到宿主机进程表use_mountns构建独立文件系统视图use_netns隔离网络。rlimit 段里的as是地址空间限制cpu是 CPU 时间nproc是进程数fsize限制写入文件大小防止输出文件撑爆磁盘。mount 部分只挂载运行用户程序需要的系统库和测试文件。seccomp 白名单策略的写法是每行一条规则。以 x86_64 架构为例一个基础评测白名单大概长这样allow read write allow exit exit_group allow brk mmap munmap mprotect allow openat close allow getdents64 allow getpid gettid getuid geteuid allow nanosleep clock_gettime allow arch_prctl注意这里没有 socket、connect、execve、clone。没有 execve 是因为程序本体已经通过解释器或链接器触发过了白名单里显式排除掉 execve 可以阻断用户代码“再起一个程序”的路子没有 clone 是防 fork但如果允许用户程序用线程就需要在 clone 规则里做参数级别的过滤复杂度会明显上升。4.4 接入工程中必须注意的细节有几个细节是在接入过程中反复踩过坑的地方。第一用户代码必须以低权限用户运行不能在 root 下跑。即使沙盒隔离了一部分能力进程权限本身就是威胁面。在配置里指定一个 min uid 和 min gid或者用 capability 把进程降权。第二要禁用 core dump。用户程序崩溃时的 core 文件可能把内存中的敏感信息写到磁盘再做一次清理动作。在 limit 里把RLIMIT_CORE设成 0或者启动时加ulimit -c 0。第三临时目录要落在 tmpfs而不是普通磁盘。用户程序输出大文件、频繁写临时文件如果把临时目录放在机械盘或 SSD 上既拖慢判题速度又容易把磁盘写满。tmpfs 在内存里重启自动清空也免去了回收负担。第四编译环境和运行环境要做区分。编译阶段允许的网络和文件访问权限比运行阶段宽松但编译产物一定要经过校验再进入运行阶段比如去掉调试符号、限制二进制大小、检查是否有奇怪的动态链接依赖。第五结果文件的导出不要通过“读沙箱文件系统”的方式而是通过标准输入输出和退出码配合挂载的特定输出目录来实现。避免沙箱内的用户代码能直接写结果文件这里面的校验逻辑必须放在沙箱外面。5. 常见问题与排查技巧实录5.1 内存统计不准程序没超限却被判 MLE最典型的内存谜案。用户程序明明只申请了 200MB判题机却报了 MLE超内存。原因通常是统计维度不对。cgroup 的内存统计包含了 page cache也就是磁盘缓冲用户程序的读取、写入、临时文件操作都会增大 cgroup 的 memory usage但它并不等于真正的进程匿名内存。规避办法是用进程峰值内存VmPeak而不是当前内存使用量来判定。对于 Docker 方案--memory走的是 cgroup 统计如果只按这个值判断容易误杀有大量临时文件读写的程序对于 NsJail 方案可以在启动后监控目标的/proc/PID/status解析 VmPeak 字段。另外要注意多进程程序的内存统计要合并整个进程组的峰值不能只算一个主进程。5.2 系统调用误伤程序莫名被 SIGSYS这算是 seccomp 方案专属问题。用户程序运行到某个阶段突然报 SIGSYS系统提示 unknown syscall就是白名单漏了调用。常见漏网之鱼包括glibc 某些版本会在 malloc 时触发brk和mmap之外的调用同一个二进制程序在 ARM 架构下会走不同的系统调用路径Go 运行时在高版本内核上会尝试clone3musl libc 在某些操作里用到了faccessat2。排查思路很简单先用 strace 跟一遍正常路径把系统调用序列完整拉出来再对照白名单上线回归。如果跑的是动态链接程序一定是链接器在加载时触发了一堆调用白名单必须包含openat、read、mmap、mprotect。看门狗式的建议是把 seccomp 的默认动作设置成“记录并放行”而不是“杀死”在测试环境跑一轮用例收集调用集合确认不需要的再禁止掉生产环境再把默认动作改成拒绝。5.3 容器残留和并发失控用 Docker 方案时最恐怖的现象是并发提交一多宿主机上冒出几十个孤立容器磁盘写满系统卡死。根子在于每个提交都通过 docker API 启停容器而 API 调用本身是有延迟和失败率的。容器虽然加了--rm但这个参数只在容器正常退出时才会执行清理被 kill -9 强杀或者守护进程异常时就可能残留。工程上的解决思路是引入资源池。预创建几个容器把用户代码和测试数据通过docker cp送进去用docker exec执行跑完再清空容器内目录。这样容器的生命周期和提交解除绑定回收集中的问题就少很多。进程级沙箱方案NsJail没有这个问题但需要留意孤儿进程的回收配置里可以把进程放进一个单独的 PID namespace再包一层监控进程统一 waitpid。5.4 评测公平性争议CPU 时间怎么算才公平多核机器上跑评测最容易被选手质疑的就是 CPU 时间。RLIMIT_CPU限制的是进程的 CPU 时间不是墙钟时间。如果判题机负载高一个程序排队等了 3 秒实际只跑了 0.5 秒的 CPU那RLIMIT_CPU不会超但墙钟时间已经超了。反之一个多线程程序把RLIMIT_CPU用完后进程被 SIGXCPU 杀掉但墙钟时间可能还很短选手会质疑“明明 1 秒超时我 0.8 秒就交卷了”。公平的做法是同时限制墙钟时间和 CPU 时间二者先到先决。墙钟时间用 alarm 或者 setitimer 控制CPU 时间用 RLIMIT_CPU 控制。比赛场景下还有个细节禁用 CPU 频率调整、锁核、绑定 NUMA 节点能有效减少同一道题在不同测试点之间的执行时间抖动。虽然做不到绝对公平但可以把典型的“运气分”降到最低。5.5 结果文件怎么安全导出来用户程序跑完评测结果输出文件、退出码、运行时长需要返回判题调度器。最安全的做法是在沙箱启动前就规划好输出目录这个目录只给评测容器临时挂载并且启动一个独立的低权限进程去读取和比对结果不要让用户代码有能力直接操作结果文件。注意输出的格式和大小。二进制输出、带转义字符的输出、超大的输出文件都可能让评测比对阶段出问题。在沙箱配置里把RLIMIT_FSIZE限制在 1MB 或更小超过即杀然后按“无输出”处理。输出校验的细节往往比想象中更重要很多 OJ 线上事故都出在输出文件权限错误或者特殊字符处理不当上。最后分享一点个人心得。我最终在自己的判题服务里选了 NsJail 做运行隔离编译阶段仍用 Docker理由是运行阶段需要毫秒级的启动速度和精确的资源统计而编译阶段对隔离的精细度要求没那么高用容器统一环境成本更低。这套组合用了很长时间最深的体会是沙盒方案没有银弹选哪条路取决于你愿意承担的维护成本。如果你只是快速做一个内部工具Docker 是最省心的进场通道如果你是认真做比赛平台那就值得在进程级沙箱上多花几个晚上把白名单和资源统计的细节打磨到自己敢对它负责为止。