我最早被代码沙箱这个技术逼着去研究是在做在线评测平台的时候。用户提交一段代码服务端编译运行然后把结果返回给前端听起来再简单不过。结果上线第二天就有人提交了一段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 --netnone --timeout5 --rlimits-as1073741824 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 \ --memory256m \ --cpus0.5 \ --pids-limit64 \ --networknone \ --user sandbox \ --cap-dropALL \ --security-opt no-new-privileges \ -v $PWD/user_code.py:/workspace/main.py:ro \ sandbox-python \ python3 /workspace/main.py逐个解释这些参数的作用。--memory256m限制容器最多使用 256MB 内存超过直接被杀掉防止吃货内存。--cpus0.5限制 CPU 使用不超过半个核避免死循环占满 CPU。--pids-limit64是很多人忽略但很重要的参数它限制容器内最多同时存在 64 个进程可以防住 fork炸弹——我开头遇到的那种while True: os.fork()攻击靠这个参数就能挡住。--networknone直接切断网络沙箱里的代码无法访问外网也无法探测内网。--user sandbox用非 root 身份运行即使代码尝试写系统目录也会被拒绝。--cap-dropALL移除所有 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 客户端发送终止信号继而停止容器。但要说明一个细节如果容器里有进程在疯狂消耗 CPUDocker 停容器时可能不会立刻生效这时要配合--stop-timeout参数。比如docker run --rm --stop-timeout 3 --name sandbox-run ...这个参数限制 Docker 停止容器时的宽限期超过 3 秒就直接强制杀掉所有进程。与其让一段失控代码长时间占用资源不如快速止损让用户自己看超时日志改代码。3.4 处理需要读写的场景挂载临时目录与回收产物有些代码需要输出文件比如编译一个 C 程序、生成一份分析报告。这时不能给沙箱写宿主机的任意目录正确的做法是挂载一个临时可写目录。我用的是 tmpfs它是纯内存文件系统速度快而且容器一停止内容就自动消失不会污染宿主机磁盘docker run --rm \ --mount typetmpfs,destination/workspace/out,tmpfs-size67108864 \ ...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( imageIMAGE, commandRUN_CMD, stdin_openTrue, mem_limit256m, nano_cpusint(0.5 * 1e9), pids_limit64, network_disabledTrue, usersandbox, cap_drop[ALL], security_opt[no-new-privileges], tmpfs{/workspace/out: size67108864}, detachTrue, ) try: result container.wait(timeouttimeout) logs container.logs(stdoutTrue, stderrTrue).decode(utf-8, errorsreplace) 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(forceTrue)需要注意在实际代码里要把用户代码写入宿主机的临时文件再挂载进容器上面为了突出重点省略了这一步。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 网络隔离的取舍--networknone是最安全的网络配置沙箱里的代码完全无法连外网也就无法做数据外带、端口扫描、元数据探测。但对不少业务来说沙箱里的代码需要访问外部 API——比如某个数据分析任务需要调第三方接口。这时可在宿主机上架一个 HTTP 反向代理容器只能访问这个代理由代理做白名单和限流。更复杂的做法是使用 bridge 网络加 egress 防火墙规则但维护成本较高。我的经验是能断网就断网断不了就用代理白名单。线上环境里那个允许沙箱访问外网的端口往往是整个沙箱系统最大的风险敞口务必小心。4.5 其他值得关注的高级加固项--sysctl设置内核参数比如net.ipv4.icmp_echo_ignore_all1拒绝 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约 20MBFirejail 进程级沙箱45ms约 35MBDocker 容器沙箱420ms约 80MB微虚拟机 (Firecracker)约 1800ms约 150MB如果你的业务不能接受 400ms 级别的时延要么考虑进程级方案要么用预热容器池。另外容器启动时有一部分时间花在初始化网络栈上--networknone的容器比 bridge 网络的容器启动要快一些。5.4 审计与日志出问题时你能复盘吗沙箱不只负责“拦”还负责“记”。出安全问题的时候如果没有日志你连攻击者干了什么都不知道。我的做法是每次沙箱运行都至少记录以下信息代码文件内容的 SHA-256 哈希完整的运行参数内存限制、CPU限制、超时时间资源使用峰值CPU、内存、进程数退出码和运行时长容器生命周期内所有系统调用的采样利用 seccomp 的日志能力或定期docker stats采样。把这些结构化日志存到专门的日志系统里保留至少 30 天。平时看着没什么用真正出事的时候这就是你定位“攻击是从哪一步开始的”的唯一依据。6. 沙箱与 AIAgent 工具调用为什么也离不开它最后聊一个当下很热的方向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, timeoutrequest.timeout) elif language javascript: result run_in_node_sandbox(code, timeoutrequest.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”来报了。这个细节很小但对提升整套系统的易用性帮助极大。