我最早被代码沙箱这个技术逼着去研究,是在做在线评测平台的时候。用户提交一段代码,服务端编译运行,然后把结果返回给前端,听起来再简单不过。结果上线第二天,就有人提交了一段while True: os.fork(),整个服务器直接卡成幻灯片,重启都花了十分钟。那一刻我才真正意识到,运行不可信代码这个动作,本质上就是在自己的机器上给陌生人开了一个后门。他可能不是故意的,但只需要一次失误或一次恶意尝试,就能让你整个业务瘫痪。
代码沙箱技术解决的就是这件事:让你可以在一个可控的、受限的环境里执行不可信代码,同时保证宿主机的安全稳定。这篇博文我会把沙箱的问题本质、主流实现方案、最快跑通一个沙箱的实操步骤,以及在实际项目中踩过的坑都讲清楚。不管你是要做一个代码评测系统、插件市场、低代码平台,还是想给 AI Agent 加一个代码执行能力,这篇文章都能让你少走弯路。
1. 为什么需要沙箱:运行不可信代码这个动作比想象中危险
1.1 沙箱到底在防什么
先搞清楚概念。代码沙箱不是一个具体的软件,而是一整套隔离与限制机制,它的目标是把一段不可信的代码关进一个可控的执行环境里。这个环境要管住三件事:代码能做什么、能用多少资源、能碰到哪些数据。
第一类风险是恶意操作。用户在评测系统里提交的代码直接os.remove("/etc/passwd"),或者尝试读取你的数据库配置文件、云服务器的密钥、环境变量里的密码。这些操作在没有任何限制的环境里都是合法的,操作系统根本不会拦。第二类风险是资源耗尽,也就是我开头遇到的那种情况。死循环、无限递归、一次性申请几个 G 的内存、fork 炸弹,这些代码不一定会偷数据,但足以让你的服务器宕机。第三类风险是横向渗透——攻击者先拿下你沙箱里的 shell,再利用内网探测、端口扫描、访问云厂商的元数据服务,想办法拿到宿主机或同网段其他机器的权限。
这三种风险往往不是孤立的。一个熟练的攻击者会先写一段脚本探测网络,再尝试读取密钥文件,最后找漏洞提权。如果只做了其中一层的防护,另外两层迟早会出问题。
1.2 你可能已经在承受这种风险
很多人觉得沙箱离自己很远,其实它在软件开发里无处不在。
最常见的场景是代码评测:OJ 平台、在线笔试网站、培训机构的上机练习系统,每时每刻都在运行考生提交的代码。第二种是用户自定义脚本,比如游戏的 mod、数据分析平台里用户自己写的处理逻辑、自动化工具里的自定义插件。第三种是低代码/零代码平台的动态代码执行功能,用户在前端拖拽完流程,后端要把生成的逻辑跑起来。还有一种近几年特别火的,就是 AI Agent 的代码执行功能——大模型生成代码后,要让代码真正运行起来得到结果。
这些场景有一个共同特点:代码不是你写的,但你却要为它的执行结果和安全性负责。而且这类代码往往高度动态,无法提前审计,唯一可行的方案就是把它们放到沙箱里。
1.3 先判断:你的场景真的需要沙箱吗
这里泼一盆冷水。沙箱不是万能的,也不是所有项目都需要它。
如果你的代码只是你自己写的、跑在你自己信任的服务器上、输入输出都在你控制范围内,那你不需要沙箱。比如一个内部管理后台,所有代码都是团队写的,没有外部输入,直接跑就行。再比如那些完全离线的脚本工具,没有任何不可信输入,加沙箱纯粹是浪费。
真正需要沙箱的信号有两个:一是你要运行别人提交的代码或表达式,二是即使运行的不是恶意代码,你也希望某个模块崩溃或超时不影响主系统。后者在插件系统、中间件场景里很常见——一个第三方插件如果敢无限占用 CPU,主程序必须有能力把它掐死。这种情况下,沙箱的故障隔离价值甚至比安全价值更重要。
2. 沙箱技术选型:四种主流方案的定位与取舍
沙箱不是一个“装了就完事”的功能,它是一套分级体系。不同方案之间的隔离强度、启动开销、上手难度差别很大,先搞清楚每种方案的定位,比直接上手某个工具重要得多。
| 方案 | 隔离强度 | 启动开销 | 上手难度 | 典型场景 |
|---|---|---|---|---|
| 语言级沙箱 | 低 | 极低 | 低 | 执行表达式、受限脚本 |
| 进程级隔离 | 中 | 低 | 中 | Linux 主机上运行系统脚本 |
| 容器沙箱 | 较高 | 中 | 中 | 生产环境多语言代码执行 |
| 微虚拟机 | 最高 | 高 | 高 | 多租户强隔离场景 |
| Wasm 沙箱 | 中高 | 低 | 中 | 浏览器端、边缘计算 |
2.1 语言级沙箱:最快但只适合“半可信”代码
语言级沙箱是在程序内部构建一个受限的执行环境。比如 JavaScript 的vm模块、Python 的RestrictedPython、Java 的SecurityManager(虽然现在已经基本废弃)。这种方式不需要额外的进程或容器,启动开销几乎为零,嵌入到现有代码里特别方便。
但它的缺点也很明显——只能限制语言层面的行为,无法限制系统调用。一段通过ctypes调用 C 库的 Python 代码,或者直接利用某个解释器漏洞的代码,很可能绕过这些限制。我见过有人用RestrictedPython跑了一段看似无害的代码,结果通过__builtins__的隐式引用拿到了文件读取权限,直接把服务器上的配置给读走了。所以语言级沙箱只适合执行那些“半可信”代码,比如配置表达式、规则引擎里的简单条件判断,不适合运行用户提交的任意程序。
2.2 进程级隔离:Linux 上被低估的方案
在 Linux 上,我们可以直接利用内核提供的机制来做隔离。namespace可以隔离进程、文件系统、网络、用户等资源,cgroup可以限制 CPU、内存、进程数,seccomp可以过滤系统调用。把这些机制组合起来,就能构造一个完整的沙箱环境。
直接写这些底层配置很麻烦,但有一些工具已经把它们封装好了。Firejail就是其中最简单的一个,一条命令就能起一个受限的进程:
firejail --net=none --timeout=5 --rlimits-as=1073741824 python3 script.py这条命令的意思是:禁用网络、设置 5 秒超时、限制进程最大虚拟内存为 1GB,然后运行 Python 脚本。它的优点是不需要拉镜像、启动速度快、占用资源少,非常适合在单机环境下快速执行短任务。缺点是只能在 Linux 上用,而且隔离强度依赖内核配置,如果宿主机内核本身有漏洞,沙箱也可能被突破。
2.3 容器:生产环境的默认选择
容器是目前生产环境里最主流的沙箱形态。它的本质也是 namespace、cgroup、seccomp 这套机制,但是通过镜像、分层文件系统、容器运行时把复杂的配置标准化了。每个容器有独立的文件系统、独立的进程树、独立的网络栈,天然就是一套比较完整的隔离方案。
更重要的是,容器的生态非常成熟。镜像仓库里有各种语言的官方镜像,编排工具有 Kubernetes,监控和日志方案也都现成。团队成员即使没深入研究过沙箱原理,也能很快上手用docker run跑起一个隔离环境。
但有一点必须清醒:容器和宿主机共享内核,不是绝对的安全边界。一个掌握内核漏洞的攻击者是有可能突破容器隔离的。所以容器沙箱适合大多数业务场景,但如果你的用户全都是不怀好意的攻击者,那容器可能还不够,需要上强度更高的方案。
2.4 微虚拟机与 Wasm:更硬核的方向
微虚拟机是给容器套了一层虚拟机外壳,比如 Amazon 开源的 Firecracker、Google 的 gVisor,以及 Kata Containers。每个沙箱实际上是一个轻量级虚拟机,有自己的内核,与宿主机完全隔离。在这种方案下,即使攻击者拿到了沙箱里的 root 权限,也很难突破到宿主机。代价是启动速度和资源开销比容器大不少,更适合多租户 SaaS 场景或安全要求极高的平台。
WebAssembly 沙箱则是另一个方向。Wasm 本身设计为一种可移植、可嵌入的字节码格式,拥有内存隔离、能力受限等安全特性。用 Wasmtime、Wasmer 这类运行时,可以把不可信代码编译成 Wasm 字节码再执行,启动速度快、内存占用小,还能跨平台。缺点是很多原生库不支持 Wasm,生态还在成长中。它比较适合浏览器端插件、边缘计算、函数计算这些轻量场景。
2.5 我的选型建议
如果你是内部工具、单机环境,先考虑 Firejail 这种进程级隔离成本最低;如果要做对外服务的代码执行平台,直接用容器,按我下一章的操作跑通,再把端口暴露给业务层;如果做的是多租户 SaaS、用户之间绝对不能互相干扰,那就别省那点性能开销,直接上微虚拟机。Wasm 适合轻量函数执行,目前还没有成为主流,但值得持续关注。
3. 手把手实操:四步跑通一个基于容器的代码沙箱
下面是我最常用的一个方案,基于 Docker 容器实现,简单、可复现、够用。整个过程只需要四个步骤:准备镜像、写启动命令、加超时控制、封装成可调用的接口。
3.1 准备沙箱专用镜像
千万不要直接拿一个完整开发镜像当沙箱用,镜像越大,攻击面就越大,启动也越慢。建议单独构建一个专用的最小化执行镜像。
以 Python 为例,Dockerfile 可以写成这样:
FROM python:3.11-slim RUN useradd -m sandbox \ && mkdir -p /workspace \ && chown -R sandbox:sandbox /workspace USER sandbox WORKDIR /workspace构建命令:
docker build -t sandbox-python .这里有几个关键点:创建了一个非 root 用户sandbox,后续所有容器进程都用这个用户运行;/workspace作为代码工作目录;不安装任何额外的 Python 包,因为沙箱环境越干净,意外风险越少。如果业务需要某些第三方库,同样把它们装进镜像里,而不是在运行时临时安装。
3.2 最小运行命令:编译并执行一段用户代码
假设用户代码写在宿主机当前目录的user_code.py里,要在一个隔离环境里执行它,我实际用的命令是这样的:
docker run --rm \ --name sandbox-run \ --memory=256m \ --cpus=0.5 \ --pids-limit=64 \ --network=none \ --user sandbox \ --cap-drop=ALL \ --security-opt no-new-privileges \ -v "$PWD/user_code.py:/workspace/main.py:ro" \ sandbox-python \ python3 /workspace/main.py逐个解释这些参数的作用。
--memory=256m限制容器最多使用 256MB 内存,超过直接被杀掉,防止吃货内存。--cpus=0.5限制 CPU 使用不超过半个核,避免死循环占满 CPU。--pids-limit=64是很多人忽略但很重要的参数,它限制容器内最多同时存在 64 个进程,可以防住 fork炸弹——我开头遇到的那种while True: os.fork()攻击,靠这个参数就能挡住。--network=none直接切断网络,沙箱里的代码无法访问外网,也无法探测内网。--user sandbox用非 root 身份运行,即使代码尝试写系统目录也会被拒绝。--cap-drop=ALL移除所有 Linux capabilities,让容器内的进程失去特权操作能力。--security-opt no-new-privileges禁止进程提升权限,即使二进制文件设置了 setuid 位也没用。-v把用户代码以只读方式挂载进容器,这样代码连自己所在文件都改不了。
这些参数组合在一起,就是一个非常可靠的沙箱基础配置。日常跑个 Python 脚本、C 程序、Node 脚本,这套配置完全够用。
3.3 加入超时控制:不让死循环拖垮整个业务
Docker 的资源限制能管住内存、CPU、进程数,但管不住“无限运行的代码”。一个没有死循环但需要跑 10 个小时的代码,同样会拖垮业务。所以超时控制是刚需。
最外层用系统自带的timeout命令包一层:
timeout 10 docker run --rm --name sandbox-run ... sandbox-python python3 /workspace/main.py超过 10 秒,timeout会向 docker 客户端发送终止信号,继而停止容器。但要说明一个细节:如果容器里有进程在疯狂消耗 CPU,Docker 停容器时可能不会立刻生效,这时要配合--stop-timeout参数。比如:
docker run --rm --stop-timeout 3 --name sandbox-run ...这个参数限制 Docker 停止容器时的宽限期,超过 3 秒就直接强制杀掉所有进程。与其让一段失控代码长时间占用资源,不如快速止损,让用户自己看超时日志改代码。
3.4 处理需要读写的场景:挂载临时目录与回收产物
有些代码需要输出文件,比如编译一个 C 程序、生成一份分析报告。这时不能给沙箱写宿主机的任意目录,正确的做法是挂载一个临时可写目录。
我用的是 tmpfs,它是纯内存文件系统,速度快而且容器一停止内容就自动消失,不会污染宿主机磁盘:
docker run --rm \ --mount type=tmpfs,destination=/workspace/out,tmpfs-size=67108864 \ ...64MB 的临时文件空间足够大多数场景使用了。运行结束后,如果需要保留产物,可以把容器里/workspace/out下的内容拷贝出来:
docker cp sandbox-run:/workspace/out ./results但用--rm参数时容器一退出就删除了,没法docker cp。所以一套完整的流程通常是:先不用--rm运行,结束后立即docker cp,再手动docker rm。为了让这一套流程能复用,我把它封装成了一段 Python 脚本,方便后续通过 web 接口调用。
3.5 封装一个可复用的调用函数
下面这段 Python 脚本把我前面说的所有要素都打包了。它接收一段代码字符串,在沙箱容器中运行,返回标准输出、标准错误和退出码:
import docker import time client = docker.from_env() RUN_CMD = ["python3", "/workspace/main.py"] IMAGE = "sandbox-python" def run_in_sandbox(code: str, timeout: int = 10) -> dict: start = time.time() container = client.containers.run( image=IMAGE, command=RUN_CMD, stdin_open=True, mem_limit="256m", nano_cpus=int(0.5 * 1e9), pids_limit=64, network_disabled=True, user="sandbox", cap_drop=["ALL"], security_opt=["no-new-privileges"], tmpfs={"/workspace/out": "size=67108864"}, detach=True, ) try: result = container.wait(timeout=timeout) logs = container.logs(stdout=True, stderr=True).decode("utf-8", errors="replace") return { "exit_code": result["StatusCode"], "output": logs, "duration": round(time.time() - start, 3), } except requests.exceptions.ReadTimeout: container.kill() return { "exit_code": -1, "output": "timeout", "duration": round(time.time() - start, 3), } finally: container.remove(force=True)需要注意,在实际代码里要把用户代码写入宿主机的临时文件再挂载进容器,上面为了突出重点省略了这一步。container.wait的timeout在这里是等容器结束的最长时间,沙箱运行超时即视为用户代码失效,主动杀掉。
4. 进一步加固:让你的沙箱真正能扛住恶意代码
到这一步,你已经有一个可以跑起来的沙箱了,业务演示足够,但面对真正的恶意攻击者还差得远。这一节讲几个进阶加固项,按重要程度排序。
4.1 去掉 root 是底线
不管用不用容器,我都建议让沙箱里的代码始终以非 root 用户运行。Docker 里的--user sandbox只设置了容器内用什么用户执行,但还需要确保基础镜像里确实创建了这个用户。如果你直接运行docker run --user 1000 myimage ...,而镜像里没有 UID 1000 对应的用户,那么命令会在宿主机上以 UID 1000 找一个用户身份映射,这通常不是你想用的那个。
更稳妥的做法是像前面那样在 Dockerfile 里useradd,显式声明用户。宿主机上的 UID 和容器内的 UID 是两套体系,容器内 root 通常映射到宿主机的一个低权限用户,但这不意味着你可以省掉这一步。设置非 root 用户,目的在于即使攻击者拿到了代码执行权限,也无法对容器内文件系统做他想做的事。
4.2 默认 seccomp 和自定义 profile 的关系
Docker 默认会给容器加一个 seccomp profile,屏蔽大约 44 个危险系统调用。这意味着很多利用内核漏洞的手法在默认配置下就会被拦掉。但默认 profile 是“够用但不是最严”的,比如它保留了mount系统调用,虽然删除了CAP_SYS_ADMINcapability 后普通进程无法调用mount,但对于一个只想跑用户代码的沙箱来说,越严越好。
你可以自定义一个更严格的 profile,把用户代码几乎不可能用到的系统调用全部屏蔽掉。这里给一个简化版的片段:
{ "defaultAction": "SCMP_ACT_ERRNO", "archMap": [ { "architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"] } ], "syscalls": [ { "names": [ "read", "write", "openat", "close", "exit", "exit_group", "mmap", "munmap", "brk", "mprotect", "prlimit64" ], "action": "SCMP_ACT_ALLOW" } ] }这个配置的意思是:默认拒绝所有系统调用,只允许极少数基本调用。实际使用中不能这么极端,因为 Python 解释器启动本身就需要几百个系统调用,你得在“安全”和“能跑”之间做平衡。我的建议是先用strace -c跑一段正常的 Python 程序,看看它实际用到哪些系统调用,再维护一个“最小允许列表”。这个工作前期有点繁琐,但一旦做好,沙箱的安全性会提升一个档次。
4.3 只读根文件系统和 no-new-privileges
一个基础镜像被挂载进容器后,默认是可写的。攻击者即使没拿到宿主机权限,也可以把恶意脚本写到/tmp里,永久占据容器层空间,甚至通过一些技巧把脏数据留在镜像层。更干净的做法是让整个根文件系统只读:
docker run --read-only --tmpfs /tmp ... sandbox-python python3 /workspace/main.py--read-only会让容器根文件系统变成只读,所有写操作只能进入显式挂载的 tmpfs。配合把/tmp挂成 tmpfs,代码运行所需的临时文件依然能使用,但任何对镜像的修改都会在容器退出后消失。
no-new-privileges我前面已经提过,它的作用是禁止容器内发生任何提权操作。一个二进制文件即使设置了 setuid 位,也会因为这个参数被强制忽略。这条在安全加固里几乎是必加的,成本极低,收益很高。
4.4 网络隔离的取舍
--network=none是最安全的网络配置,沙箱里的代码完全无法连外网,也就无法做数据外带、端口扫描、元数据探测。但对不少业务来说,沙箱里的代码需要访问外部 API——比如某个数据分析任务需要调第三方接口。这时可在宿主机上架一个 HTTP 反向代理,容器只能访问这个代理,由代理做白名单和限流。
更复杂的做法是使用 bridge 网络加 egress 防火墙规则,但维护成本较高。我的经验是:能断网就断网,断不了就用代理白名单。线上环境里,那个允许沙箱访问外网的端口,往往是整个沙箱系统最大的风险敞口,务必小心。
4.5 其他值得关注的高级加固项
--sysctl设置内核参数,比如net.ipv4.icmp_echo_ignore_all=1拒绝 ICMP 请求;--ulimit限制文件大小、打开文件数、栈大小;--cgroup-parent指定自定义 cgroup,方便统一管理所有沙箱进程;- 关闭 swap 和启用内存 swap 限制,防止代码通过 swap 绕过内存上限;
- 挂载
/etc/resolv.conf为只读,防止攻击者篡改 DNS 设置来劫持流量; - 定期更新基础镜像,及时修复内核和运行时漏洞。
5. 性能、启动时延与我在实战中踩过的坑
5.1 容器启动时延:短任务场景的优化策略
容器沙箱的启动时延通常在几百毫秒到一两秒之间,主要是拉镜像层、创建容器、分配网络、启动进程的时间。对评测系统这种短任务密集场景,时延会直接影响用户体验。
最简单的优化是避免使用docker run每次新建容器,改用“预热容器池”。核心思路是:启动时预先创建几个容器,让它们进入等待状态,来了任务直接往容器里扔。但这套方案实现复杂,而且会引入容器复用带来的状态残留问题。如果你想简单一点,可以优先优化镜像大小、使用更快的容器运行时(比如 crun,它比默认的 runc 启动快不少)、关闭不必要的自定义网络。
特别提醒:不要在脚本里每次都执行docker pull。我见过有团队在每次提交代码时都执行docker pull python:latest,结果光拉镜像就花了半分钟,用户早就跑了。镜像应该在构建阶段就定好版本,运行时不再拉取。
5.2 一个真实的“逃逸模拟”:为什么加固后的容器还是出了问题
有一年我在给一个内部工具搭建代码沙箱时,自认为已经把前面那些参数都配齐了:非 root、断网、caps drop、seccomp 默认策略。结果测试团队做了一个安全测试,轻松突破了防线。他们当时用了一段现成的反弹脚本,打了个时间差,趁容器还没完全启动时在网络 namespace 里做了点手脚。这个案例给我留下的教训是:容器在启动过程中的某些阶段,安全策略还没有完全生效,存在一个极小的窗口期。
更隐蔽的坑是挂载了不该挂载的东西。比如用-v /var/run/docker.sock:/var/run/docker.sock给容器,那容器里的代码只要调用 Docker API,就能在宿主机上再起一个特权容器,从“沙箱逃逸”直接变成“宿主沦陷”。还有人图省事把宿主机/etc挂载进去让代码读配置,结果代码把私钥和数据库密码全读走了。
所以我后来定了一条铁律:沙箱容器只能挂载必要的数据卷,且数据卷一律只读;docker.sock永远不挂载;涉及到宿主机敏感目录的路径,一律通过异步接口由外层服务来读取,而不是把整个目录暴露给沙箱。
5.3 性能开销的基准数据
随手测了一组数据,用的是一台 4C8G 的云服务器,镜像为 python:3.11-slim,执行一个简单的print(1):
| 方案 | 平均耗时 | 内存开销 |
|---|---|---|
| 直接在宿主机运行 | 30ms | 约 20MB |
| Firejail 进程级沙箱 | 45ms | 约 35MB |
| Docker 容器沙箱 | 420ms | 约 80MB |
| 微虚拟机 (Firecracker) | 约 1800ms | 约 150MB |
如果你的业务不能接受 400ms 级别的时延,要么考虑进程级方案,要么用预热容器池。另外,容器启动时有一部分时间花在初始化网络栈上,--network=none的容器比 bridge 网络的容器启动要快一些。
5.4 审计与日志:出问题时你能复盘吗
沙箱不只负责“拦”,还负责“记”。出安全问题的时候,如果没有日志,你连攻击者干了什么都不知道。我的做法是每次沙箱运行都至少记录以下信息:
- 代码文件内容的 SHA-256 哈希;
- 完整的运行参数(内存限制、CPU限制、超时时间);
- 资源使用峰值(CPU、内存、进程数);
- 退出码和运行时长;
- 容器生命周期内所有系统调用的采样(利用 seccomp 的日志能力,或定期
docker stats采样)。
把这些结构化日志存到专门的日志系统里,保留至少 30 天。平时看着没什么用,真正出事的时候,这就是你定位“攻击是从哪一步开始的”的唯一依据。
6. 沙箱与 AI:Agent 工具调用为什么也离不开它
最后聊一个当下很热的方向:AI Agent。现在很多大模型应用,不只是聊天,还会生成代码、调用工具、操作数据库。这些代码通常由模型自动生成,建模的人完全不知道模型会写出什么——它可能为了完成一个数据分析任务,生成一段删除文件、读取敏感配置甚至尝试外联的命令。如果你让这段代码直接在业务服务器上跑,风险难以估量。
所以各大 AI 产品里的“代码解释器”功能,本质上都是一个沙箱服务。常见架构是:Agent 生成代码,后端把代码提交到沙箱执行器,沙箱返回 stdout、stderr、退出码和执行时间。Agent 根据结果再决定下一步操作。
搭建一个这样的沙箱服务并不复杂,在第 3 章的基础上,只需要包一层 HTTP 接口。我在实际项目里用过 FastAPI 做了这样一个接口,核心就一个路由:
@app.post("/api/v1/exec") def exec_code(request: ExecRequest): code = request.code language = request.language if language == "python": result = run_in_sandbox(code, timeout=request.timeout) elif language == "javascript": result = run_in_node_sandbox(code, timeout=request.timeout) else: return {"error": "unsupported language"} return result前端或 Agent 模块收到代码后直接 POST 给这个服务即可。注意这里要增加鉴权、限流和消息体大小限制,否则任何人都可以往你的沙箱里塞代码。
AI 场景下还有一个特殊点:Agent 任务往往需要多个执行步骤之间共享状态。比如“读文件 -> 改代码 -> 再运行”,每一步都新建沙箱显然不行。需要引入“持久化工作空间”概念——为每个 Agent 会话分配一个独立的沙箱,并在会话期间保留其文件系统状态。这个可以用 Docker 的 commit 方式做,但实践下来更推荐在所有容器外部挂载同一个带签名的共享存储卷,沙箱容器启动时按会话 ID 挂载对应子目录。隔离粒度依然是每会话一个沙箱,卷只负责传数据,不承担隔离职责。
对于只读场景,比如“请你分析这段代码的复杂度”,Agent 只需要读代码、跑一个检查器,这种场景直接挂载只读路径就行。而对于“帮你写一段脚本并执行”这种需要写文件的场景,就必须给沙箱分配可写空间了,这时要注意限制写入总量和文件数量。因为一个模型被恶意注入 prompt 后,可能生成一段凭空耗尽磁盘的代码,比如不断往磁盘写大文件。磁盘配额和 tmpfs 上限就变得非常重要。
我做了几个 AI 代码沙箱项目后的感受是:在 AI 场景里,代码不安全的概率比传统业务高得多——不是因为这些代码更“恶意”,而是模型对系统行为的理解有限,它会把一些疑似合理的操作直接写出来。因此沙箱的护栏要做得比传统场景更保守:默认断网、默认只读、默认非 root、默认超时。宁可让 Agent 因为“没有权限”而多试几次,也不能让它在服务器上横冲直撞。
最后分享一个实战中的小经验:给沙箱写调用文档时,一定要把“你能做什么”和“你不能做什么”写清楚,并且让用户代码的运行结果里能明确提示哪些限制被触发了。比如超时了就说“代码运行超时,请检查是否出现死循环或数据量过大”,而不是简简单单的“timeout”。有一次我部署完沙箱服务后,同事反馈说“代码经常被杀”,查日志才发现是他自己写的代码申请了 1GB 内存,触发了 256MB 的限制。我把报错信息改成了“内存使用超过 256MB 上限,请优化代码”,问题立刻就不再被当成“系统 bug”来报了。这个细节很小,但对提升整套系统的易用性帮助极大。