这两年做大模型应用,特别是做 Agent 或代码解释器相关功能的人,应该都遇到过同一个尴尬:模型写代码的能力越来越强,但把代码真正跑起来的那一刻,心是悬着的。它可能只是想算一道题,但生成出来的代码如果要读你的配置、扫内网端口、或者把下载到的东西写进启动目录,单靠"提示词约束"根本拦不住。OpenSandbox 这个方向,就是专门解决这个问题的:给大模型生成的代码一个受控的隔离执行环境,让模型放心写、系统放心跑。这篇文章我从头到尾拆一遍这里面的设计思路、核心机制和落地经验,适合正在做代码解释器、Agent 工具调用、或者想把"AI 执行代码"做成产品的同学参考。
1. 大模型执行代码的真实需求与风险边界
1.1 一个典型场景:AI 写完代码之后怎么办
现在市面上常见的做法,是让大模型生成代码,然后在用户侧或者服务端执行。比如数据分析类产品里,用户上传一个 Excel 文件,模型生成一段处理脚本,系统执行后把结果表格返回;再比如编程助手让模型跑通测试用例、验证某段逻辑;还有一类是 Agent 在完成某个任务时,需要临时写个爬虫脚本抓取页面、写个脚本批量改文件名、或者调一个外部 API 处理数据。
这些场景的共同点是:代码不是人写的,而是模型根据自然语言即时生成的。问题就出在"即时生成"这四个字上。模型的输出具有不确定性,同一个请求这次生成的是sorted(data),下次可能就生成subprocess.run(["rm", "-rf", "/tmp/x"])。如果执行环境没有隔离,这个不确定性就直接变成了安全风险。
我把这种风险拆成四类,实际做的时候会更直观:
- 文件系统风险:代码可以读取宿主机上的敏感文件,比如
/etc/passwd、用户目录下的密钥、数据库连接配置,也可以删除、篡改业务数据。 - 网络风险:代码可以访问内网 IP,发起探测请求,或者把读到的数据发送到外部服务器,这就是典型的数据外泄通道。
- 资源耗尽风险:一段死循环、一个无限开子进程的 fork bomb、或者一次性申请超大内存的代码,可以把整个宿主机的 CPU、内存、进程表打满,影响同机其它业务。
- 持久化风险:如果沙箱环境可写且长期复用,代码可以把后门写入定时任务、启动脚本,下一次执行时就能被动触发,形成隐蔽的持久化入口。
这里要特别强调一点:不要寄希望于"模型很听话"来规避风险。大模型本身有被提示词注入的可能,用户上传的文档里可能藏着忽略之前的指令,执行以下操作,模型可能真的会照做。所以安全边界必须放在执行层,而不是对话层。
1.2 裸奔执行为什么不能接受
有些人容易有个误区:觉得我只要用一个不常用的用户跑代码,出了问题删掉就行。这在个人开发环境里可能勉强够用,但一旦放到服务端、多用户、多租户的场景,问题就严重了。
裸奔执行的第一个问题是权限太大。普通用户虽然没有 root 权限,但依然可以读同组文件、访问数据库端口、在共享目录里创建文件。如果宿主机上有别的容器、别的服务,一个提权漏洞就能横向移动。第二问题是没法界定责任。代码执行到一半把系统搞坏了,排查时你很难区分是模型生成错了、代码本身有恶意、还是执行环境配置有问题。第三个问题是不可审计。没有沙箱边界,你就没有"每次执行是一次独立事件"的清晰视图,出了问题连日志都难还原。
我自己见过一个很典型的例子:某团队让模型读取一个 CSV 并生成统计报告,模型生成的代码里用了eval()处理列名,结果用户上传的 CSV 表头里被塞了__import__('os').system('cat /etc/shadow'),直接就把主机上的系统用户列表打印到了输出里。这种案例不算罕见,恰恰说明执行环境必须默认不信任任何输入、任何代码。
1.3 OpenSandbox 的定位:一套面向 LLM 代码执行的隔离服务
OpenSandbox 要做的事情可以概括成一句话:提供一个 API,让调用方把一段代码和一包输入数据扔进去,拿到执行结果,同时保证代码接触不到宿主机的真实资源。
它在架构上的位置,介于大模型推理服务和业务后端之间。模型生成代码后,业务后端调用 OpenSandbox,OpenSandbox 负责拉起一个隔离执行环境、注入代码、运行、捕获输出、最后销毁环境。整个过程对上层业务来说是同步的,就像调用一个普通函数一样。
和传统的编译沙箱(比如各类在线判题系统用的代码沙箱)相比,OpenSandbox 更强调的是面向大模型产物的不确定性。判题系统的代码通常来自选手,逻辑相对可控,而模型生成的代码格式不固定、依赖不明确、行为不可预测,所以 OpenSandbox 需要在兼容性和安全性之间做更多的容错设计。这也是它区别于传统沙箱的核心点:它默认接进来的代码是不可信的、格式可能是残缺的、行为可能是任意的。
2. 整体设计思路:先划边界,再谈功能
2.1 隔离层次怎么选:进程级、容器级、虚拟机级
设计沙箱的第一件事,不是写代码,而是决定把代码关在"哪一层笼子"里。这一层选择决定了后面所有的工作量上限。
大致有三条路可以走:
| 方案 | 隔离强度 | 启动速度 | 兼容性 | 资源开销 |
|---|---|---|---|---|
| 进程级隔离(操作系统权限限制) | 低 | 极快 | 高 | 极低 |
| 容器级隔离(命名空间 + 资源限制) | 中 | 快 | 较高 | 低 |
| 微虚拟机级隔离(独立内核) | 高 | 较慢 | 中 | 高 |
进程级隔离通常就是fork一个子进程,然后用操作系统提供的降权机制(比如切换用户、限制系统调用)把子进程关进笼子。优点是快,缺点是隔离面太窄,文件系统、网络、进程表还是共享的,一旦代码拿到一个可逃逸漏洞,整个宿主机就暴露了。只适合处理完全可信的代码,或者作为容器之上的一层补充。
容器级隔离是当前的主流选择。它基于 Linux 的命名空间与资源控制机制,把进程放进独立的文件系统视图、网络栈、进程表、宿主名称空间里,再配合只读根文件系统、系统调用过滤、能力(capability)裁剪,能做到比较强的隔离。启动速度在百毫秒级别,足够大多数场景使用。
微虚拟机级隔离更彻底,每个执行环境有独立内核,理论上代码无论如何提权,都逃不出这个虚拟机。代价是启动慢、内存占用多。如果业务对隔离要求极高(比如处理不可信的外部代码、多租户强隔离),可以考虑这一层;但对大多数 Agent 应用来说,容器级加严格配置已经够用。
我的建议是:从容器级隔离起步,把安全参数扎扎实实配到位,再去考虑是否升级到更强的隔离层。一上来就上微虚拟机,运营成本陡增,却未必解决真正的核心问题——大部分安全问题出在配置不当,而不是隔离内核不够强。
2.2 "最小权限"是第一原则,不是选项
沙箱设计里最容易犯的错误,是权限给多了。很多人在配容器网络的时候图省事,直接开成默认网络,结果沙箱里的代码能访问宿主机局域网;配挂载的时候把整个临时目录绑进去,结果代码能遍历宿主机上的其它临时文件。
最小权限原则落实下来就是几个具体动作:
- 运行进程的用户必须是非 root 用户,容器内也要显式指定
user,不能依赖默认值。 - 容器的 capability 全部 drop,只保留绝对必要的(很多时候一个都不需要保留)。
- 根文件系统设置为只读,所有可写内容挂在独立的临时内存盘上,执行完销毁。
- 网络默认关闭,如果业务确实需要联网,用代理或网关白名单控制出口,绝不能直接开宿主网络。
- 系统调用层面用 seccomp 过滤掉危险调用,比如
mount、ptrace、reboot等。
这四条看着简单,每一条都对应一类真实攻击路径。去掉一个,就少一条路。配置的时候宁可让代码跑不了,也不能让代码跑了不该跑的事情——执行沙箱的可用性要放在安全性之后。
2.3 资源控制:四个维度的硬上限
资源控制解决的是"代码把机器拖垮"的问题。最少要管四个维度:
- CPU:限制执行环境可以使用的 CPU 份额和配额。模型生成的代码经常写出死循环,没有 CPU 配额,一个请求就能耗光单核。配额设成
0.5核,最多让单个请求拖慢沙箱自身,影响不到宿主。 - 内存:必须设硬上限。Python 里一条
[0] * 10**9就能轻松吃几个 G,没有硬限制,OOM 会波及宿主机。设置方式以字节为单位,建议给常用解释器预留一点余量,比如基础运行时给 512MB。 - 进程数:设进程表上限,防止 fork bomb。
pids限制直接写成 256 或者更小,即使代码疯狂 fork,也只是在沙箱内部触发创建失败。 - 存储与文件描述符:磁盘写入量限制、临时目录大小限制、文件描述符数量限制。代码如果疯狂写文件打满磁盘,会影响整个宿主文件系统,所以临时盘必须限制大小,文件描述符数量限制防止资源泄漏。
这四个维度不是可选项,而是沙箱的默认配置。每少一项,就有一个"代码写崩宿主机"的入口敞着。
3. 核心机制拆解:从代码进来到结果返回,一条完整链路
3.1 请求接入与代码校验:先用规则挡住明显的恶意输入
代码进入沙箱之前,先过一道静态校验。这个环节不能完全防住恶意代码,但能挡住低级的误操作和明显的恶意载荷。
校验要做这几件事:
- 代码长度限制:单段代码限制在合理范围内,比如 100KB。超长代码直接拒绝,防止有人把二进制内容塞进"代码"里绕过文件类型检查。
- 格式检查:如果是期望 Python,就用语法解析器解析一遍,语法错误的代码根本不需要进沙箱。解析失败返回明确的错误码,而不是让沙箱莫名其妙退出。
- 危险模式检测:基于正则或 AST 扫描常见危险调用,比如
eval、exec、os.system、subprocess、socket、ctypes等。命中的代码可以标记为高危,但不能直接拦截——因为正常数据分析任务里也可能用到subprocess,一刀切会误伤。 - 依赖检查:如果代码声明要安装依赖,检查依赖名和版本来源,最好用内部镜像源,禁止从不可信源拉取。
静态校验的定位是"降低噪声",真正拦恶意代码靠的是隔离。我说一个真实的体会:如果一个代码片段真的包含了恶意逻辑,静态扫描通常只能扫出部分特征,挡不住精心构造的混淆。所以校验环节不要追求拦截率,追求的是快速失败、可审计、可记录。
3.2 创建隔离执行环境:每个关键参数背后的逻辑
接下来是核心环节——创建执行环境。以容器方案为例,下面是我用过的参数组合,每一步都对应上一节说的原则:
image: sandbox-python:3.12 # 预装常用库的精简镜像,不包含包管理器网络访问 network: none # 默认无网络 memory: 512m # 内存硬限制 memory-swap: 512m # 禁用 swap,防止内存超卖 cpus: 0.5 # CPU 配额 pids-limit: 256 # 进程数上限 read-only-rootfs: true # 根文件系统只读 tmpfs: /tmp:size=100m # 可写临时目录,大小受限 user: nobody # 非 root 运行 cap-drop: ALL # 删除所有特权能力 security-opt: no-new-privileges # 禁止提权逐个解释一下这些参数为什么这么设:
read-only-rootfs是最有效的一招。代码想往系统目录里写任何东西都会失败,即使模型生成了写入/usr/local/bin/x.py的代码,也直接报只读错误。所有可写需求集中在/tmp,而这个目录是内存盘,执行完随容器销毁,不会有残留。
cap-drop: ALL相当于把容器进程的所有内核特权能力全部移除。即使代码找到了某个用户态漏洞,没有能力位,很多提权路径直接堵死。no-new-privileges进一步防止通过setuid这类机制重新获得权限。
network: none把网络直接关掉。没有网络接口,代码里所有联网行为都会失败,数据外泄通道被物理切断。这个参数对大多数代码执行场景完全够用——模型算数据、跑测试、生成图表,根本不需要联网。如果确实有联网需求,我会用一个受限的代理,只允许访问显式域名列表,而不是放开网卡。
内存限制 512MB 是给解释器留了余量的数字。Python 解释器自身占用大约 30-50MB,剩下 460MB 足够跑常规数据处理任务,但又不足以让代码做大规模内存耗尽攻击。如果你发现业务场景经常跑大 DataFrame,可以放宽到 1-2GB,但绝不能取消限制。
3.3 执行、采集、销毁:闭环里最容易漏的三件事
环境创建好之后,把代码注入到工作目录,运行命令,接下来是采集和销毁。这三个环节看着简单,实际踩坑不少。
输出采集要注意两个问题。一是 stdout 和 stderr 要分开捕获,很多调试信息走 stderr,混在一起会让上层业务解析不到真实结果。二是输出量必须设上限。一个print()生成几万行日志的情况很常见,如果不限制,光捕获输出就能把沙箱宿主的内存打爆。实际做法是流式读取,累计超过阈值(比如 1MB)就停止读取,并返回"输出超限"错误。
退出码和信号的处理也很关键。代码执行超时后,杀掉容器进程,外层拿到的不应该是一个裸的超时错误,而应该记录明确原因:是 CPU 超时、内存超限、还是进程数超限。这些不同的失败原因对应不同的用户提示,对排查问题非常有帮助。
销毁环节绝对不能省。执行完成后的容器必须立即销毁,不能放到缓冲池里复用。复用容器有两大风险:一是上一个任务在临时目录里留下的数据被下一个任务读到,造成跨任务数据泄露;二是残留进程和文件积累久了变成持久化漏洞。销毁动作要保证在异常路径上也执行——用defer或者finally来兜底,不要相信某个分支会正常走到销毁代码。
4. 实操落地:一个最小可用的沙箱执行服务怎么搭
4.1 基础设施准备
动手之前,先把宿主环境准备好。我会用一台干净的 Linux 主机或者虚拟机,内核版本不要太老,因为要依赖完整的命名空间、seccomp、cgroup 特性。这个宿主最好是专用机器,不要和其它业务混部,即使沙箱逃逸了,爆炸半径也只在沙箱这一亩三分地。
镜像准备是第一个要花时间的地方。基础镜像越瘦越好,预装固定的运行时版本(比如 Python 3.12、Node.js 20),把常用的数据处理库、HTTP 客户端库、测试框架都打进去。两个原因:一是省去运行时安装依赖的等待时间和网络风险;二是让执行环境可复现,不至于同一个请求今天能跑明天缺包。
瘦身的具体做法是:用基础系统镜像,安装运行时和依赖,然后清理掉包管理器缓存、文档、临时文件,最后导出为沙箱专用镜像。这个过程我建议脚本化,镜像每次更新都走同一套流程,避免人为操作引入不确定性。
4.2 核心执行流程的实现思路
整个服务的核心是一个"提交-执行-返回"的接口。我用类 Python 伪代码表示主流程,你按自己的语言重写即可:
def run_code(code: str, input_data: dict, timeout: int = 30): # 1. 静态校验 validation = validate_code(code) if validation.failed: return {"ok": False, "error": validation.reason} # 2. 创建隔离环境 sandbox = create_sandbox( image="sandbox-python:3.12", network="none", memory_mb=512, pids_limit=256, timeout=timeout, ) try: # 3. 注入代码与数据 sandbox.write_file("/work/main.py", code) if input_data: sandbox.write_file("/work/input.json", json.dumps(input_data)) # 4. 执行并捕获结果 result = sandbox.run( cmd=["python3", "/work/main.py"], workdir="/work", stdout_limit_mb=1, ) return format_result(result) finally: # 5. 无论成败,销毁环境 sandbox.destroy()这个流程里最容易被忽视的其实是第 2 步的timeout参数。超时要同时配在沙箱创建参数和宿主侧的强制杀进程逻辑上,两道保险。创建参数里的超时是给沙箱内部的"软超时",宿主侧再设一个"硬超时"(比如软超时 + 5 秒),到点直接强制销毁。这样即使沙箱内部超时机制失效,外层还能兜底。
另一个容易忽视的点是并发控制。沙箱执行是重资源操作,单宿主能同时跑的沙箱数量有限。一定要在服务入口做并发队列,超出的请求排队等待,而不是无限创建沙箱。实际经验是:单宿主同时放行的沙箱数量不要超过 CPU 核数的 1.5 倍,再高性能就开始下降,还会互相争抢资源。
4.3 关键配置速查表
下面是我整理的一份快速上线检查表,每一项都可以对照自己的部署逐条验收:
| 检查项 | 推荐配置 | 验收标准 |
|---|---|---|
| 运行用户 | 非 root 低权限用户 | 沙箱内id -u非 0 |
| 根文件系统 | 只读 | 尝试写/报错 |
| 临时目录 | tmpfs,限制大小 | dd写入超过限制失败 |
| 网络 | 默认关闭 | ping外网失败 |
| 内存 | 硬限制 256-1024MB | 大数组分配触发 OOM 被杀 |
| CPU | 配额 0.5-1 核 | 死循环只占满该配额 |
| 进程数 | 256 以内 | fork 上百次后失败 |
| 能力裁剪 | 全部 drop | 尝试 mount 报权限错误 |
| seccomp | 启用默认过滤配置 | unshare等调用被拦截 |
| 执行超时 | 30 秒 | 长时间任务被杀并返回超时原因 |
| 输出限制 | 1MB | 大量输出被截断并告警 |
每一行都值得在实际环境里跑一遍验证命令,不要只看配置项"设了就算数"。沙箱安全靠的不是某一个配置,而是所有配置叠加后的整体效果。
5. 常见问题与排查实录
5.1 高频问题速查表
实际跑了一段时间之后,会遇到一批很典型的问题。我把高频问题整理成表,每个都附了排查方向:
| 现象 | 可能原因 | 排查与解法 |
|---|---|---|
| 代码一跑就超时,但逻辑看起来没问题 | 模型生成了死循环或驻留进程 | 先看超时原因,CPU 超时还是进程驻留;调低配额观察是否有改善 |
| 沙箱内安装依赖失败 | 镜像无网络或镜像源不可达 | 确认network: none配置;改用预装依赖的镜像策略 |
| 代码读不到用户上传的文件 | 挂载目录未正确绑定 | 检查挂载参数,确认文件写入的是/work而不是其它路径 |
| 输出里出现乱码 | 沙箱内 locale 和字符集未配置 | 镜像里显式设置LANG=C.UTF-8,并在执行命令前导出环境变量 |
| 沙箱销毁后内存不释放 | 僵尸进程或残留 cgroup | 检查宿主进程列表,确认销毁流程里有没有漏杀子进程 |
| 同一份代码这次能跑下次跑不了 | 宿主机资源被占满或并发超限 | 看监控面板,确认是不是并发队列堆积导致的资源饥饿 |
| 代码能访问到不该访问的内网地址 | 网络配置不是none,或者开了受限代理但白名单过宽 | 立即收紧出口网络,审计代理规则,改成默认拒绝 |
| 大量输出导致返回体过大 | 输出限制未生效或设置过大 | 确认 stdout 读取逻辑有大小判断,超出即中断读取 |
模型代码里用了subprocess被误拦截 | 静态校验的规则过于激进 | 把拦截改为告警,靠隔离兜底,而不是靠静态规则兜底 |
| 容器内出现僵死进程(defunct) | 缺少 init 进程回收子进程 | 在容器内以轻量 init 进程作为 1 号进程,统一回收子进程 |
这里面最值得展开的是僵死进程问题。容器里的 1 号进程如果是用户代码自己,它不会回收子进程,子进程退出后会变成僵尸,堆积多了会占满进程表。解决办法是在沙箱内先启动一个轻量 init 进程,再让 init 去拉起用户代码,这样所有子进程退出后由 init 统一回收。
5.2 一次完整的排查实录:沙箱里代码"神秘消失"
说一个我自己经历过的例子。有段时间接到反馈,说某段数据处理代码在沙箱里跑着跑着就没了,没有报错,也没有输出,进程直接消失。一开始我以为是超时被杀,但日志里没有超时记录。
排查路径大概是这样的:先看宿主机的内核日志,发现没有任何异常退出记录;然后看 cgroup 的事件统计,也没有 OOM 记录;最后检查代码本身才发现,它写了一个很大的中间变量,然后又调用了某个库做排序,把内存峰值顶到了限制值之上。由于内存限制是硬限制,内核直接根据配置触发了 OOM Killer,进程被秒杀。
但为什么 cgroup 事件里没有 OOM 记录?因为那个版本的统计接口在某些内核参数下不会记录所有 OOM 事件,这是内核行为差异导致的。后来我调整了策略:沙箱内启动一个监控脚本,定期读取当前内存占用,接近阈值时先输出警告日志,同时把 OOM 事件的采集从单一接口换成多个来源交叉验证。这次之后,这种"莫名其妙消失"的情况基本都能在日志里找到准确原因。
这类问题给我的启发是:沙箱的每一个限制项,都要配合可观测性设计。限制不是目的,限制之后能让上层业务快速定位失败原因,才是完整的设计。
6. 几点实操后的个人体会
沙箱做到一定程度,你会发现真正难的不是技术选型,而是平衡"让代码跑起来"和"让代码跑不坏"之间的度。我试过把参数配得极其严格,结果模型生成的正常代码频繁失败,用户反馈体验很差;也试过放宽网络限制方便调试,结果很快就遇到了数据外泄的苗头。最后沉淀下来的经验是:安全参数默认全紧,然后按业务场景逐个放行,每次放行都要有日志和审计。
另一个体会是,沙箱里尽量不要放任何真实数据。即使做了隔离,数据在沙箱内被代码读取后,一旦输出被拼接进对话返回给用户,仍然可能通过模型或者提示词注入被带出去。所以更稳妥的做法是"最小数据"原则:沙箱只拿到完成任务所必需的数据子集,敏感字段提前脱敏,返回值不直接把数据拼进上下文,尽量只返回统计结果或处理状态。
关于测试,我建议每个沙箱服务至少维护一组"恶意样本集"。里面放上典型的反向 shell 代码、fork bomb、磁盘占满脚本、内网探测脚本、提权尝试脚本,每次更新安全配置后跑一遍,确认这些样本全部失败。这组样本集的维护成本不高,但能让你在改配置的时候有底气。
OpenSandbox 这个项目后续还可以扩展的方向也不少:比如把隔离策略做成可插拔的,按用户信任等级选择不同的隔离层级;比如把沙箱执行记录接入审计平台,形成完整的事故回溯链路;再比如针对不同编程语言维护不同的预装镜像和限制参数。每一步扩展,都还是在"安全执行"这个核心命题上做深,而不是贪大求全。对我来说,这个项目最迷人的地方就在于:它把"大模型能写代码"这件事,从玩具变成了真正可以面向用户交付的生产能力。