第一次把 Judge0 部署起来其实是被项目逼的。团队要做一个在线编程笔试平台用户交上来一段代码后端得在一个干净、受限的环境里把它跑起来再把输出结果回传。刚开始我以为这不就是fork一个子进程的事结果自己造了一周轮子之后发现超时控制、内存限制、多语言编译、沙箱隔离这些坑一个比一个深。后来我把目光转向开源代码沙箱对比了若干方案之后选中了 Judge0这才算把执行引擎这块稳了下来。这篇文章就是把我在 Judge0 部署过程中的问题、排查思路、安全加固和调优经验整理出来。里面没有太多源码层面的东西更多是一个普通后端开发者在真实服务器上折腾出的经验。如果你也在准备接在线评测、编程练习、代码笔试这类功能这篇文章应该能帮你少走不少弯路。1. 为什么是Judge0代码沙箱的选型逻辑1.1 在线判题场景里“执行代码”到底难在哪很多刚接触在线判题的人第一反应是不就是在服务器上跑一下用户提交的程序吗fork一个子进程、把标准输入塞进去、拿到标准输出就完了。这个想法在玩具项目里成立一旦面对真实的不可信用户代码问题就成串地冒出来。第一个问题是隔离。用户的代码不一定只干好事。它可以读你服务器上的敏感文件、扫内网端口、甚至用rm -rf把整个目录删干净。你不希望用户提交的代码能和你自己的数据库、缓存、文件系统有任何交集。第二个问题是资源控制。一段死循环的代码就能把整台服务器的 CPU 打满一个malloc(1024 * 1024 * 1024)就能把内存耗尽。第三个问题更难缠你不可能只支持一种语言。C 要 gcc、Python 要解释器、Java 要 JVM、Go 要完整编译链每加一种语言你就得多维护一套编译和运行环境。把这些全部自己从头做一遍没有几个月下不来。所以理想的方案是找一个现成的开源的代码执行沙箱它把隔离 资源限制 多语言运行环境 判题结果回调这些都封装好我只需要部署起来、调好参数、对接 API。Judge0 属于这个方向上比较成熟的选项这也是我最初选择它的直接原因。它的架构我之前了解过属于典型的API 服务 异步任务队列 隔离执行器三段式设计。1.2 Judge0的架构与一次提交的全过程Judge0 的核心组件有四个judge0-api是一个基于 Ruby on Rails 的 API 服务负责接收提交、管理语言列表、维护任务状态worker是一个基于 Sidekiq 的异步消费者通过 Redis 队列拉取待执行的任务runner是真正干活的隔离执行器它会在受限容器环境里编译并运行用户代码后台的 PostgreSQL 用来存提交记录和判题结果。整个链路可以打个比方API 是前台收银台Redis 是餐厅的点菜队列worker 是传菜员runner 是后厨。用户点了一道菜提交代码传菜员把菜单送到后厨后厨做完菜再端回前台。具体到一次提交流程大致是客户端调用POST /submissions提交源代码、语言 ID 和标准输入API 把任务写入 Redis 队列同时返回一个tokenworker 消费队列通知 runner 启动隔离环境runner 在容器内部完成编译、运行把 stdout、stderr、退出码和时间内存数据交给 workerworker 把结果写回 PostgreSQL。客户端拿着之前的token调用GET /submissions/{token}就能拿到最终结果。这个异步设计不是没事找事而是为了避免 HTTP 请求长时间挂住也方便后端批量处理任务。1.3 适合与不适合的场景场景类型是否适合原因在线编程测评、OJ、笔试平台很适合自带常用语言运行时和超时限制开箱即用云 IDE 的后端执行引擎部分适合需要二次开发Judge0 更偏重执行完返回结果不是交互终端允许公网多租户直接提交代码要谨慎默认配置不足以直接暴露公网需要额外加固和限流企业内部工具、教学实验比较适合用户可信度较高维护成本低如果你只是想在公网环境跑一个让陌生人随便提交代码的开放服务我的建议是需要多做一层安全设计和防滥用机制。但如果是给内部笔试系统用或者给教学平台用Judge0 的部署回报率是非常高的。2. 部署前的环境规划版本、资源与Docker基础2.1 硬件与系统要求Judge0 官方推荐用 Docker 部署整个编排里的服务不少同时还有 runner 要临时拉起隔离容器所以硬件不能太抠。我这里给一个实际经验的参考值如果是本地测试2 核 4G 内存勉强能跑但并发一上来 CPU 立刻打满如果是要给一个真实业务承载流量建议至少 4 核 8G 起步8 核 16G 会更稳。磁盘方面容易被忽略。镜像本身占几个 GB 不算什么真正消耗磁盘的是每次执行任务时 runner 创建的临时容器、编译产物和文件系统层。如果并发比较高或者有人提交了恶意的大文件代码磁盘可能很快被临时数据占满。所以我在生产服务器上会给 Docker 单独挂一块数据盘限制一下运行目录的可用容量同时给/tmp设置 noexec 挂载参数双保险。系统层面我这边用的 Debian 系Docker 版本 20.10 以上Docker Compose 直接用 V2 的docker compose命令。操作系统本身不需要特别配置唯一要注意的是 Docker 服务要设置开机自启别机器重启一次代码执行服务整个就没了。2.2 镜像版本与网络准备官方镜像在 Docker Hub 上可以直接拉取项目源码和文档在 GitHub 上维护。部署时我强烈建议固定版本号不要用latest。原因很简单Judge0 迭代比较快不同版本之间 API 返回字段、语言 ID、环境变量名都可能有变化生产环境跟着latest跑某天重新拉镜像后行为就可能变了这是很典型的线上事故源。我在对接时使用的版本是 2.x 系列下面的配置和 API 行为都以这个版本为基础。如果你的服务器拉 Docker 镜像比较慢可以配置镜像加速。这里不做具体推荐但思路是在/etc/docker/daemon.json里写好registry-mirrors然后重启 Docker 服务。systemctl restart docker之后再用docker info检查一下镜像源是否生效。记住改完配置一定要重启 Docker否则不会加载新配置。2.3 端口与持久化规划Judge0 默认的 API 端口是 3000官方 Demo 前端页面占用 8080。生产环境里我建议不要让 3000 直接暴露到公网而是绑定在内网地址或者只监听 127.0.0.1前面再挂一层 Nginx 做反向代理、HTTPS 终结和简单的速率限制。你想一下直接对外暴露一个能让你执行任意代码的服务这本身就是个安全隐患。持久化的重点在两个地方PostgreSQL 的数据目录和 Judge0 的配置数据目录。如果这两个没有做 volume 挂载容器一重建所有提交记录、判题结果、语言配置全部蒸发。我吃过这个亏后来在 compose 文件里老老实实加了命名 volume。3. docker compose实战编排文件与服务启动3.1 一份可落地的docker-compose.yml下面这份是我在实际项目中用过的简化版 compose去掉了官方自带的演示用 Nginx 和前端只保留核心服务并且添加了持久化挂载。生产环境可以直接抄但要记得把里面的密码换成自己的。version: 3.8 services: db: image: postgres:13-alpine restart: always environment: POSTGRES_USER: judge0 POSTGRES_PASSWORD: change_me_db_password POSTGRES_DB: judge0 volumes: - postgres-data:/var/lib/postgresql/data cache: image: redis:7-alpine restart: always command: redis-server --appendonly yes volumes: - redis-data:/data server: image: judge0/judge0:2.0.0 restart: always ports: - 127.0.0.1:3000:3000 environment: RAILS_ENV: production POSTGRES_USER: judge0 POSTGRES_PASSWORD: change_me_db_password POSTGRES_DB: judge0 POSTGRES_HOST: db REDIS_URL: redis://cache:6379/0 RUNNER_RS_MAX_MEMORY: 256 RUNNER_RS_MAX_FILE_SIZE: 1024 RUNNER_RS_MAX_PROCESSES: 30 RUNNER_RS_MAX_TIME: 10 RUNNER_RS_MAX_WALL_TIME: 10 RUNNER_MEMORY_LIMIT: 536870912 RUNNER_CPU_LIMIT: 128 depends_on: - db - cache worker: image: judge0/judge0:2.0.0 restart: always command: bundle exec sidekiq environment: RAILS_ENV: production POSTGRES_USER: judge0 POSTGRES_PASSWORD: change_me_db_password POSTGRES_DB: judge0 POSTGRES_HOST: db REDIS_URL: redis://cache:6379/0 RUNNER_RS_MAX_MEMORY: 256 RUNNER_RS_MAX_FILE_SIZE: 1024 RUNNER_RS_MAX_PROCESSES: 30 RUNNER_RS_MAX_TIME: 10 RUNNER_RS_MAX_WALL_TIME: 10 RUNNER_MEMORY_LIMIT: 536870912 RUNNER_CPU_LIMIT: 128 depends_on: - db - cache - server volumes: postgres-data: redis-data:这里有一个很关键的设置server 和 worker 用的镜像和大部分环境变量都一样因为 worker 本质上就是同一个镜像里跑了一条不同的命令。官方文档里 server 会带一个judge0-nginx组件我这里直接让 server 只监听本机 3000 端口把对外暴露全部交给业务侧自己的反代网络链路更简单。3.2 服务起不来常见报错与完整的排查链路部署 Judge0 最常见的四个坑我按踩到的概率排序一下。第一个是端口占用。服务器上如果已经跑着别的 Rails 服务或者调试工具占用了 3000 端口server 容器会在日志里报出地址被占用的错误。排查命令很简单netstat -tlnp | grep 3000或者ss -tlnp | grep 3000找到占用进程处理掉。如果你不想动原来的服务那就把 compose 里 server 的端口映射改成别的宿主机端口比如127.0.0.1:3001:3000。第二个是数据库连不上。这个问题极易出现在第一次启动时。PostgreSQL 容器初始化需要时间而 server 启动后立刻尝试连库如果连不上Rails 进程会反复报connection refused。我在 compose 里加了depends_on但depends_on只保证容器启动了不保证容器内部服务就绪。正确做法是把 server 的启动命令加上等待逻辑或者干脆在宿主机上先手动启动 db 和 cache等十几秒确认健康再启动 server 和 worker。这不算优雅但在小规模部署里非常实用。第三个是内存不足被 OOM Killer 杀掉。尤其是 2G 内存的小机器server 容器、worker 容器、PostgreSQL、Redis 再加 runner 临时容器全部同时跑内存很快就爆掉。现象是某个容器反复重启你用dmesg -T | tail -50能看到宿主机内核的 OOM 记录。解决办法要么升级内存要么在 compose 里给非核心服务加mem_limit保住 PostgreSQL 和 server。第四个是当前用户没有 Docker 操作权限。如果你用普通用户跑docker compose up报出 permission denied说明该用户不在 docker 用户组里。可以用sudo usermod -aG docker $USER后重新登录解决生产环境我更建议直接用系统服务托管不要随便给普通账号 docker 权限。这张排查表是我现场调试时总结出来的可以直接对照使用。症状最快排查路径常见根因server 容器一直重启docker logs 容器名看报错数据库连接失败、端口占用提交任务卡在列队看 worker 日志Redis 连接失败、worker 启动失败执行结果出来很慢top/docker stats资源缩水、runner 参数配置过高API 能启动但页面 5xx检查 PostgreSQL 连接数连接池耗尽、密码不匹配3.3 提交一直IN QUEUE的一次现场记录这里分享一个我印象很深的现场排查。部署完成后API 正常返回 token但任务状态一直停在IN QUEUE无论怎么等都不变。我最初的直觉是 worker 挂了但看了容器状态明明在 running。后来我直接看 worker 日志发现里面刷着一行行Redis::CannotConnectError: Error connecting to Redis。这时候问题很明确worker 连不上 Redis。但我检查了 compose 里的REDIS_URL: redis://cache:6379/0服务名也拼对了。仔细再想原来问题出在启动顺序上。redis 容器没有做任何持久化配置被反复重启后容器 IP 变了而 worker 启动较早持有的是一个旧 IP。因为 Redis 走的是 TCP 连接worker 里某些旧连接还在重试日志看起来很像是 Redis 一直故障。解决思路有两个一是给 cache 容器设置restart: always并挂载数据卷减少 IP 变化概率二是 server 和 worker 容器里增加等待 Redis 就绪的探针等真正连通后再执行业务逻辑。我最终加了健康检查之后这个问题再没出现过。这类服务没死但业务卡住的问题日志和docker inspect永远是最优先的排查入口。4. 安全边界默认配置与生产加固4.1 Judge0到底靠什么隔离用户代码Judge0 的 runner 本质上依赖 Linux 容器来做进程隔离但它不是简单地把用户代码丢进一个普通容器。实际执行时runner 会为每一次提交构建独立的隔离环境用 namespace 做进程、网络、文件系统的隔离用 cgroup 限制 CPU 和内存占用再用 seccomp 过滤危险的系统调用。这样用户代码就算想调用fork轰炸系统也会被资源限制挡住想执行特权操作也会被系统调用过滤器拦下。但我要强调一个从业者都知道但经常被忽略的原则容器不是安全边界。容器提供的是多层限制而不是物理级隔离。如果 Docker 守护进程本身以 root 权限运行某些逃逸漏洞一旦被利用后果是灾难性的。所以我在生产环境里从不给 Docker daemon 加--privileged也不把宿主机目录挂到 runner 容器里默认的普通容器权限就够用越少暴露能力越安全。4.2 必做的三层安全配置第一层是认证。Judge0 默认允许无认证调用 API这在你自己的内网里也许够用但只要有任何外部访问路径就必须开启 token 校验。启用方式是在环境变量里配置AUTH_TOKEN调用 API 时带上X-Auth-Token请求头。没有这个 token直接返回 401。第二层是网络隔离。API 服务监听 127.0.0.1只允许 Nginx 或内部网关访问。外网用户只能访问你的 Nginx 端口不能直接碰到 Judge0 API。这层一定要做不要嫌麻烦。第三层是资源限额。在环境变量里设置单个任务的执行时间、内存上限、最大输出文件大小、最大进程数。我常用的配置是单任务运行时间 10 秒内、内存上限 256MB、文件大小不超过 1MB、进程数不超过 30 个。具体数值可以按业务调但限制一定要存在否则一个恶意提交就能把服务打瘫。4.3 语言白名单与资源上限的落地配置Judge0 默认支持几十种语言但你的业务不一定需要全部打开。比如内部笔试平台往往只需要 Python、Java、C、Go 这几种。语言白名单可以通过环境变量ALLOWED_LANGUAGES控制配置成逗号分隔的语言 ID 列表。这样做的好处很实际支持的运行时越少镜像越小攻击面越小维护成本也越低。还要注意一个细节Judge0 的语言 ID 在不同版本里不是完全固定的。虽然官方文档给出常见语言 ID比如 Python、Java 分别是某个固定值但不同版本之间 ID 会偏移。我在对接时从来不硬编码语言 ID全部通过GET /languages拉取最新列表动态映射。这样升级版本后不会突然发现语言 ID 对不上。最后是防滥用。就算有认证和网络隔离只要业务层允许多次提交攻击者还是可以用大量任务把队列塞爆。我建议在业务系统里自己做限流比如每个用户每分钟最多提交 5 次每次提交必须带用户 ID方便定位恶意账号。Judge0 本身不帮你做用户体系所以这些只能靠接入方来实现。5. 压测与调优从单机小规模到多Worker承载5.1 核心参数与它的意义Judge0 调优的核心不是 API 线程数而是 runner 的资源参数。这些参数表面上是限制用户代码实际上决定了你的执行引擎能扛多少并发。我常用的几个参数解释一下RUNNER_RS_MAX_TIMECPU 时间上限单位秒。不是墙钟时间是程序真正消耗 CPU 的时间。编译和运行都算在内。RUNNER_RS_MAX_WALL_TIME墙钟时间上限也就是程序从开始到结束的真实耗时。通常要比 CPU 时间大一些因为等 IO 的时间不算 CPU 时间。RUNNER_RS_MAX_MEMORY单个程序的最大内存单位 MB。设成 256 意味着用户代码最多用 256MB超过直接判内存超限。RUNNER_RS_MAX_FILE_SIZE单个文件最大体积单位 KB。限制输出文件大小防止用户代码疯狂写日志把磁盘打满。RUNNER_RS_MAX_PROCESSES最大进程数限制 fork 炸弹这类恶意行为。RUNNER_CPU_LIMITCPU 配额单位是百分之一核。设成 128 表示最多使用 1.28 核。这些参数要同时考虑业务够用和资源可控两个目标。比如教学平台需要运行复杂的算法代码内存上限设 256MB 肯定不够我一般放到 512MB但如果只是简单的函数题256MB 完全够用放太宽只会让单个任务更容易拖垮整机。调优的本质就是找到一个刚好够用的阀值。5.2 一次真实压测的观察结果我在一台 4 核 8G 的服务器上做过一次粗粒度压测提交的任务是 Python 循环 100 次加一个简单求和当作典型的小负载。先跑单个 worker并发 10 个请求时接口能及时返回 token但真正的排队耗时主要在 worker 执行阶段。单个 worker 情况下这类简单任务大约每秒钟能完成 3 到 5 个。这个数字看起来不高原因是每个任务都要重建临时容器环境开销远大于普通函数调用。把 worker 扩展到两个之后吞吐量接近翻倍能到每秒 6 到 8 个任务。但内存占用也随之上涨因为每个 worker 都会 fork 出 runner 容器。对于简单任务来说单 worker 每秒完成几个任务的低吞吐不是 bug这是为每次执行做完整隔离的代价。如果你的业务确实要求更高吞吐优先加 worker而不是盲目调大单 worker 的并发线程数。压测时我还会同时观察docker stats和top。如果 CPU 已经打满加 worker 的作用有限如果内存吃紧说明 runner 参数给得太高。压测的目的就是找到当前硬件下的合理 worker 数而不是无脑堆容器。5.3 横向扩展的路线图Judge0 的水平扩展本质上是加 worker。worker 容器共享同一套 PostgreSQL 和 Redis只要数据库连接和 Redis 能扛住加 worker 就能提升执行吞吐。我的扩展路线图分三步走第一步单机上把 worker 从 1 个加到 3 到 4 个观察 CPU 和内存水位这是零成本调优。第二步如果业务量继续增长给 server 前面加一层 Nginx 负载均衡把 API 请求分散到多台机器每台机器上仍然跑自己的 worker共享同一套 Redis 和 PgSQL。第三步把 Redis 和 PostgreSQL 分别做成独立的高可用集群把执行引擎从单机服务升级成平台服务。这里要提醒一点横向扩展最怕的不是加机器而是共享存储成为瓶颈。进入多机阶段后数据库的慢查询和 Redis 的队列长度必须加监控否则排查问题时会很痛苦。6. 业务对接的落地方案提交、回调与结果解析6.1 标准提交流程与token轮询Judge0 的标准对接方式非常简单。客户端先把源代码、语言 ID、标准输入发过来服务端调用POST /submissionsbody 大概是这样{ source_code: print(input()), language_id: 71, stdin: hello, base64_encoded: false, wait: false }wait为 false 时接口会立刻返回一个token为 true 时接口会一直阻塞到任务执行完成才返回结果。我在真实业务里基本都用 false让请求快速返回然后由后端去轮询或者等回调。拿到token之后用GET /submissions/{token}查询结果最终返回的关键字段包括status状态对象包含编号和描述比如编译错误、运行超时、内存超限、答案正确等。stdout程序标准输出如果提交时用了base64_encoded这里的值也是 base64 编码的。stderr标准错误输出。compile_output编译阶段输出编译错误信息主要看这里。time程序实际用时。memory程序峰值内存。这里status.id的语义需要熟悉一下3 表示成功执行4 表示答案错误5 表示超时6 表示编译错误7 到 12 表示运行时错误比如段错误13 表示服务端错误14 表示内存超限。我在业务系统里最常处理的组合就是先判断编译是否成功再判断运行是否超时最后再按用户的预期输出做比对不能只盯着一个通过/不通过的状态码。6.2 回调方式与失败兜底轮询的缺点是如果任务执行比较慢你需要反复调用接口浪费请求也可能撞上 API 的限流。Judge0 支持在提交时指定callback_url任务执行完成后Judge0 会往这个地址发送一个 POST 请求body 就是完整的执行结果。这个设计很适合异步推送场景。我在接入回调时踩过一个坑回调 URL 必须是 Judge0 服务端能访问到的地址。如果 Judge0 跑在公司内网而你的业务 API 在另一个内网环境网络不通回调就会静默失败。更麻烦的是回调失败后 Judge0 不会自动重试太多次结果任务执行完了业务侧却永远收不到通知。所以我最终的方案是双通道兜底优先依赖回调同时业务侧对超过一定时间还没收到回调结果的 token主动调用查询接口兜底。这样既有回调的实时性又有轮询的可靠性。代码层面就是写一个定时任务每 30 秒扫一次已提交但未完成的任务列表主动去 Judge0 查一次状态。这个方案在真实生产里非常稳。下面是一个用 Python 写的提交示例requests库直接发起任务import requests resp requests.post( http://127.0.0.1:3000/submissions, headers{X-Auth-Token: your_token}, json{ source_code: print(1 1), language_id: 71, stdin: , base64_encoded: False, wait: False }, timeout5 ) token resp.json()[token] print(token)6.3 我在对接中踩过的几个细节坑第一个坑是 base64 编码。如果用户提交的代码里包含中文注释或者标准输入里有换行、特殊字符建议统一开启base64_encoded: true。提交时把源代码和 stdin 都做 base64 编码取结果时对 stdout、stderr、compile_output 再做 base64 解码。我的项目里为了省事一开始没开 base64结果中文注释把 JSON 解析搞挂了改完 base64 之后再没出过编码问题。第二个坑是语言 ID 动态映射。前面提到过Judge0 的语言 ID 可能随版本变化所以我每次启动后都先从/languages拉一次映射表存到配置中心。后续前端选择语言时后端查表拿到对应的 language_id再提交。千万不要在业务代码里写死 ID。第三个坑是超时边界的兜底。Judge0 的wait模式最长阻塞时间有限超过之后即使任务没完成也会返回。所以不管用什么方式提交业务层必须实现自己的任务超时判断比如 30 秒内没有最终结果就直接标记为失败并通知用户。执行引擎可以被拖住但你的业务链路不能被拖死。第四个坑是金额不高但很烦的runner 容器执行结束后临时文件不一定立刻清理干净。如果发现服务器的/tmp或者 Docker 容器目录越来越大需要检查是否有人提交了频繁创建大文件的任务必要时把RUNNER_RS_MAX_FILE_SIZE调小再加一层定时清理任务。最后再补充一点我的个人习惯Judge0 本身部署不算复杂但我第二次部署时明显比第一次顺利核心原因是把所有配置都版本化了。compose 文件、环境变量、反代配置全部放进 Git 仓库服务器上只拉代码、起服务、看日志任何一台新机器都能在半小时内复现这套环境。而且因为配置可追溯出问题时不需要靠记忆猜之前的参数改了没有。另外一个心得是任何代码沙箱产品无论文档怎么说上线前都要用自己的恶意用例做一轮测试包括无限循环、申请大内存、fork 大量进程、读取/etc/passwd这种明显的越权行为。Judge0 在多数场景下能拦住这些操作但测试过和听说过之间差距很大。只要这一步做到了后面业务再怎么扩展执行引擎这一层都能睡得着觉。
阅读完成 · 觉得有帮助?