1. AI Sandbox 的现状与核心矛盾
AI Sandbox 这个词最近两年被提得越来越多,尤其是在大模型应用落地之后,几乎所有做 AI 基础设施的团队都在讨论怎么给模型生成的代码、Agent 执行的动作、用户提交的不可信脚本提供一个"安全围栏"。但真正动手做过的人都知道,这个领域目前的现状可以用四个字概括:各显神通,各踩各坑。
我自己在过去一年多的时间里,先后用 Docker、gVisor、Firecracker、seccomp 这几套方案搭过不同规模的沙箱环境,从本地开发机上的单机验证,到跑在云上的多租户执行平台,踩过的坑足够写一本小册子。这篇文章不打算给你讲教科书上的"沙箱原理综述",而是想把我在实际项目里看到的怪现状、做过的取舍、以及那些文档里不会写的细节,原原本本摊开来讲。
先说清楚这篇文章适合谁看:如果你正在做 AI Agent 的代码执行环境、在线判题系统、插件运行沙箱、或者任何需要"跑别人代码但不想被炸"的场景,那这篇内容应该能帮你少走至少两三个月的弯路。如果你只是听说过 Firecracker 和 gVisor 的名字,想知道它们到底差在哪、什么时候该用哪个,那也能从里面找到答案。我会尽量用大白话把隔离级别、性能开销、运维复杂度这几件事讲透,同时给出可以直接抄的配置和排查思路。
所谓"怪现状",核心矛盾其实就一句话:隔离强度、启动速度、资源开销、运维复杂度,这四个指标你最多只能同时要两个。市面上所有的方案,本质上都是在这四个维度上做取舍,没有银弹。下面我按方案类型逐个拆。
2. 四大主流方案的真实取舍
2.1 Docker:最顺手,但隔离边界最薄
Docker 几乎是所有人做沙箱的第一选择,原因很简单:生态成熟、上手快、镜像分发方便。你在 Ubuntu 上apt install docker.io或者装个 Docker Desktop,几分钟就能跑起来一个容器,把用户代码丢进去执行,看起来就"隔离"了。
但这里有个巨大的认知误区:Docker 的隔离本质上是 namespace + cgroup + capability 的组合,共享的是同一个宿主机内核。这意味着一旦内核有漏洞(比如著名的脏牛、或者各种提权 CVE),容器逃逸就是分分钟的事。对于跑自己代码的场景,Docker 完全够用;但对于跑 AI 生成的、来源不可信的代码,Docker 的隔离强度是不够的。
我实测过一个典型的逃逸路径:容器里如果以 root 运行,且没有 drop 掉CAP_SYS_ADMIN,配合某些内核版本,可以直接挂载宿主机设备。所以用 Docker 做 AI Sandbox,必须做几件事:
- 容器内绝对不能用 root 跑用户代码,用
--user 1000:1000指定非特权用户 - 必须
--cap-drop=ALL,然后按需--cap-add最小集合 - 必须加
--security-opt no-new-privileges,防止 setuid 提权 - 必须限制资源:
--memory、--cpus、--pids-limit,否则一个 fork 炸弹就能把宿主机拖垮 - 网络默认
--network none,需要联网时走白名单代理
一个我常用的最小化启动命令长这样:
docker run --rm \ --user 1000:1000 \ --cap-drop=ALL \ --security-opt no-new-privileges \ --network none \ --memory 256m --memory-swap 256m \ --cpus 0.5 \ --pids-limit 64 \ --read-only \ --tmpfs /tmp:size=64m,noexec,nosuid \ -v /sandbox/code:/code:ro \ sandbox-base:latest \ python3 /code/main.py注意--read-only配合--tmpfs这个组合,很多人会漏掉。只读根文件系统能挡住大量写文件类的攻击,但程序总需要临时目录,所以给/tmp挂一个带noexec的 tmpfs,既满足需求又防止在临时目录里执行恶意二进制。
提示:
--pids-limit这个参数极其重要,我见过不止一次因为没限制进程数,用户代码里一个while True: os.fork()直接把宿主机 PID 打满,SSH 都登不上去。
Docker 的另一个怪现状是启动速度。冷启动一个容器,从docker run到进程真正跑起来,实测在普通 SSD 上大概 300ms 到 1s 不等,取决于镜像层数和存储驱动。对于需要频繁创建销毁沙箱的场景(比如每个 AI 请求一个沙箱),这个开销累积起来很可观。有人用容器池预热来解决,但池子管理本身又是一套复杂度。
2.2 gVisor:用户态内核,隔离和兼容性的平衡点
gVisor 是 Google 开源的方案,思路很巧妙:它实现了一个用户态的 Linux 内核(叫 Sentry),用户程序的所有系统调用都被 Sentry 拦截并在用户态处理,只有极少数必要的调用才透传给宿主机内核。这样即使程序攻破了 Sentry,它面对的仍然是一个普通用户态进程,离宿主机内核还隔着一层。
用 gVisor 最爽的地方是它和 Docker 的集成非常顺滑,你只要在 Docker 的 daemon 配置里注册一个runscruntime,然后docker run --runtime=runsc就切换过去了。对上层应用几乎透明,镜像、命令、参数都不用改。
// /etc/docker/daemon.json { "runtimes": { "runsc": { "path": "/usr/local/bin/runsc" } } }改完systemctl restart docker,然后:
docker run --rm --runtime=runsc \ --user 1000:1000 --cap-drop=ALL \ --network none --memory 256m \ sandbox-base:latest python3 /code/main.py但 gVisor 的怪现状在于系统调用兼容性。因为 Sentry 是重新实现的 Linux 系统调用接口,不是所有调用都支持得完美。我遇到过的情况包括:某些 io_uring 操作不支持、部分ptrace相关功能受限、一些冷门的 socket option 行为不一致。跑 Python、Node 这类常规语言基本没问题,但如果你要跑的东西依赖特殊系统调用(比如某些数据库、或者用了特定内核特性的程序),就得先测。
性能上,gVisor 的 syscall 开销比原生 Docker 高,因为多了一层用户态拦截。我做过一个粗略的对比测试,跑一个纯计算任务(比如矩阵运算),gVisor 和原生 Docker 差距在 5% 以内;但跑一个 syscall 密集的任务(比如大量文件读写、网络收发),gVisor 可能慢 30% 到 2 倍不等。所以它适合计算为主、syscall 不密集的 AI 代码执行场景。
启动速度方面,gVisor 比 Docker 略慢一点点,因为要初始化 Sentry,实测大概多 100-300ms。这个开销在可接受范围内。
2.3 Firecracker:微虚拟机,隔离最强但最重
Firecracker 是 AWS 开源的 microVM 方案,本质上是基于 KVM 的轻量级虚拟机。每个沙箱是一个独立的虚拟机,有自己的内核,隔离强度直接拉满——因为攻击者要逃逸,得先攻破 guest 内核,再攻破 KVM,难度比容器逃逸高好几个数量级。
Firecracker 的启动速度在虚拟机里算是极快的,官方宣称 125ms 以内,实测在配置好的机器上大概 150-300ms 能起来。但注意,这个数字是裸 Firecracker的,如果你要在上面跑一个完整的 Linux 用户空间,还得加上内核启动、init 进程、应用加载的时间,实际端到端可能到 500ms 到 1s。
用 Firecracker 的怪现状是运维复杂度陡增。它不像 Docker 那样有现成的镜像生态,你得自己准备 rootfs、自己管理内核镜像、自己写 API 调用来创建和销毁 VM。虽然现在有firecracker-containerd、Kata Containers这类项目帮你封装,但配置起来依然比 Docker 麻烦得多。
一个典型的 Firecracker 启动流程大概是这样:
# 启动 firecracker 进程,监听一个 unix socket firecracker --api-sock /tmp/firecracker.sock --config-file vm_config.json其中vm_config.json要指定内核、rootfs、内存、CPU、网络等:
{ "boot-source": { "kernel_image_path": "/opt/vmlinux.bin", "boot_args": "console=ttyS0 reboot=k panic=1 pci=off" }, "drives": [ { "drive_id": "rootfs", "path_on_host": "/opt/rootfs.ext4", "is_root_device": true, "is_read_only": false } ], "machine-config": { "vcpu_count": 1, "mem_size_mib": 256 } }这套东西跑起来之后,隔离是真好,但你要维护内核版本、rootfs 构建流水线、网络配置(Firecracker 默认没有网络,得自己配 tap 设备或者走 vsock),工作量不小。所以 Firecracker 适合多租户、强隔离要求、且团队有虚拟化运维能力的场景,小团队慎入。
2.4 seccomp:不是独立方案,而是必备补丁
seccomp 严格来说不算一个独立的沙箱方案,它是 Linux 内核提供的一个系统调用过滤机制。你可以给它一个策略,规定进程只能调用哪些 syscall,其他的直接杀掉或者返回错误。
很多人以为用了 gVisor 或者 Firecracker 就不需要 seccomp 了,这是个误区。纵深防御的原则是每一层都要加。Docker 默认就带了一个 seccomp profile,但那个 profile 比较宽松,允许了大量 syscall。做 AI Sandbox 时,我建议根据实际需要收紧。
一个针对 Python 执行场景的最小 seccomp 策略大概长这样(用 Docker 的 profile 格式):
{ "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"], "syscalls": [ { "names": [ "read", "write", "open", "close", "stat", "fstat", "mmap", "mprotect", "munmap", "brk", "exit", "exit_group", "futex", "clone", "execve", "wait4", "getpid", "gettid" ], "action": "SCMP_ACT_ALLOW" } ] }这个策略默认拒绝所有 syscall,只放行 Python 运行必需的那些。实测下来,一个纯计算的 Python 脚本能正常跑,但任何试图socket、mount、ptrace的操作都会被直接拒绝。
注意:seccomp 策略写太严会导致程序莫名其妙崩溃,排查起来很痛苦。我的经验是先用
SCMP_ACT_LOG模式跑一段时间,收集实际用到的 syscall 列表,再收紧成SCMP_ACT_ERRNO。别一上来就照抄网上的"最小策略",大概率跑不起来。
seccomp 的怪现状是它和 gVisor 有重叠。gVisor 本身就在用户态拦截 syscall,你在外面再套一层 seccomp,有些调用会被拦两次。这时候要小心,别让 seccomp 把 gVisor 自己需要的 syscall 也拦了,否则 Sentry 直接起不来。
3. 方案选型的决策逻辑
3.1 一张表看清四种方案的定位
我把四种方案的关键指标整理成表,方便你对照自己的场景做选择:
| 维度 | Docker | gVisor | Firecracker | seccomp |
|---|---|---|---|---|
| 隔离强度 | 低(共享内核) | 中高(用户态内核) | 高(独立内核) | 辅助层 |
| 启动速度 | 300ms-1s | 400ms-1.3s | 150ms-1s | 无额外开销 |
| syscall 性能 | 原生 | 慢 30%-2x | 接近原生 | 轻微开销 |
| 运维复杂度 | 低 | 中 | 高 | 低 |
| 生态成熟度 | 极高 | 中 | 中 | 高 |
| 适用场景 | 可信代码 | 半可信代码 | 不可信代码 | 所有场景叠加 |
这张表里最值得说的是隔离强度和运维复杂度的正相关。你想要越强的隔离,就得付出越多的运维成本。Firecracker 隔离最强,但你得自己管内核和 rootfs;Docker 最省事,但隔离最弱。gVisor 卡在中间,这也是它这两年越来越受欢迎的原因。
3.2 按场景选型的具体建议
场景一:内部工具,跑自己团队的代码。直接用 Docker,把前面说的那些加固参数加上就够了。别过度设计,gVisor 和 Firecracker 的复杂度对内部工具来说是负担。
场景二:AI Agent 执行用户提交的代码。这是最典型的 AI Sandbox 场景。我的建议是gVisor + seccomp + 严格资源限制。gVisor 挡住内核攻击面,seccomp 再收一层,资源限制防止 DoS。如果预算充足且对隔离有极致要求,再上 Firecracker。
场景三:多租户 SaaS 平台。每个租户的代码都可能恶意,这时候 Firecracker 的独立内核就很有价值了。配合容器编排(比如 Kata Containers 把 Firecracker 包装成 K8s 的 runtime),能兼顾隔离和运维效率。
场景四:在线判题 / 竞赛系统。这类场景的特点是大量短生命周期的执行,启动速度极其关键。gVisor 是比较好的平衡点,或者用 Docker + 预热池。Firecracker 的启动虽然快,但端到端加上用户空间初始化还是偏慢。
3.3 一个容易被忽略的维度:镜像分发
选型时大家盯着隔离和性能,但镜像分发这个维度经常被忽略,实际却非常影响体验。Docker 的镜像生态是碾压级的,docker pull一个基础镜像几秒钟搞定。gVisor 复用 Docker 镜像,所以也享受这个生态。但 Firecracker 用的是 ext4 rootfs 或者类似的磁盘镜像,你得自己构建、自己分发,没有现成的 registry 生态。
我踩过的一个坑:早期用 Firecracker 时,每次更新沙箱环境都要重新构建 rootfs 镜像,然后推到一个对象存储,再让各个节点拉取。这套流程比docker push/pull麻烦太多,而且镜像体积大(几百 MB 到 GB 级),分发慢。后来我们改用firecracker-containerd,它能复用 OCI 镜像,才算缓解了这个问题。
所以如果你团队没有专门的镜像构建和分发能力,Firecracker 的隐性成本会很高。
4. 实操中的核心环节与配置细节
4.1 资源限制的参数计算
资源限制不是随便填个数字就完事,填错了要么浪费资源,要么被用户代码拖垮。我以内存和 CPU 为例讲讲怎么算。
内存:一个 Python 解释器启动大概占 20-30MB,加上你的代码和数据,给 256MB 通常够跑中等复杂度的脚本。但要注意--memory-swap必须和--memory设成一样,否则容器可以用 swap,等于没限制。另外 Python 的某些库(比如 numpy、pandas)加载后内存占用会飙升,如果你的场景涉及数据分析,得给到 512MB 甚至 1GB。
CPU:--cpus 0.5表示限制到半个核。对于计算密集的 AI 代码,这个值要按实际负载调。我的经验是给 0.5 到 1 个核比较合理,太低会导致正常代码超时,太高则失去限制意义。
进程数:--pids-limit 64是个比较安全的默认值。Python 多进程、多线程场景下,64 个 PID 通常够用,同时能挡住 fork 炸弹。如果你的场景需要大量并发,可以调到 128 或 256,但别不设上限。
磁盘:--read-only加--tmpfs /tmp:size=64m是标配。tmpfs 的大小要按需给,太小会导致程序写临时文件失败,太大则失去限制意义。64MB 对大多数脚本够用。
4.2 网络隔离的三种模式
AI Sandbox 的网络策略是个大话题,我按隔离强度从高到低列三种模式:
完全断网:--network none。最安全,但很多 AI 代码需要联网(比如调用 API、下载依赖)。适合纯计算场景。
白名单代理:容器内只能访问一个本地代理,代理再按白名单转发。这是我最推荐的模式。实现方式是在宿主机跑一个 HTTP 代理,容器通过--network连到一个内部网络,环境变量指向代理。代理层做域名白名单和请求审计。
受限网络:给容器一个独立的 bridge 网络,用 iptables 限制出站目标。这种方式配置复杂,容易出错,我一般不用。
提示:无论哪种模式,都要禁止容器访问宿主机内网地址段(比如 169.254.169.254 这个云元数据地址,是 SSRF 攻击的重灾区)。用 iptables 或者代理层直接 drop 掉。
4.3 超时与清理机制
沙箱最怕的是僵尸进程和资源泄漏。用户代码可能死循环、可能挂起、可能创建一堆子进程不退出。所以必须有强制的超时和清理。
我的做法是双保险:容器层面用--stop-timeout配合外部的timeout命令,应用层面再设一个执行超时。
timeout --signal=KILL 30s docker run --rm ... python3 /code/main.pytimeout发 KILL 信号后,Docker 的--rm会自动清理容器。但注意,如果容器里有子进程,KILL 主进程不一定能杀掉所有子进程,所以--pids-limit和 cgroup 的清理机制就很重要。实测下来,配合--init参数(用一个 init 进程做 PID 1,负责回收僵尸进程)能解决大部分清理问题。
docker run --rm --init --pids-limit 64 ...--init这个参数很多人不知道,但它对沙箱场景几乎是必需的。没有它,用户代码 fork 出来的孤儿进程会变成僵尸,累积多了会耗尽 PID。
4.4 gVisor 的调优参数
gVisor 有一些平台相关的调优参数,值得单独说。runsc支持多种平台实现,默认是ptrace,性能较差;推荐用systrap或kvm。
// /etc/docker/daemon.json { "runtimes": { "runsc": { "path": "/usr/local/bin/runsc", "runtimeArgs": [ "--platform=systrap", "--network=none", "--debug=false" ] } } }--platform=systrap比默认的 ptrace 快不少,实测 syscall 密集场景能提升 20%-40%。--network=none直接禁用 gVisor 的网络栈,如果你不需要网络,这个能省不少开销。
另外 gVisor 有个--overlay2参数,能改善文件系统性能,但需要特定的存储配置。如果你的场景涉及大量文件读写,值得研究一下。
5. 常见问题与排查技巧实录
5.1 沙箱启动失败类问题
问题:permission denied while trying to connect to the Docker API
这个报错太常见了,本质是当前用户没有权限访问 Docker 的 socket。解决方法有两个:把用户加入 docker 组(sudo usermod -aG docker $USER,然后重新登录),或者用 sudo。但注意,加入 docker 组等于给了 root 权限,生产环境要谨慎。
问题:docker: Error response from daemon: OCI runtime create failed
这类报错信息通常不完整,得看dmesg或者journalctl -u docker。常见原因包括:seccomp 策略拦了必需的系统调用、cgroup 配置冲突、或者内核版本不支持某个特性。我遇到过一次是 seccomp 把clone3拦了,导致新版本 glibc 的程序起不来,加上clone3到白名单就好了。
问题:gVisor 容器启动后立即退出,没有任何日志
大概率是 Sentry 初始化失败。用runsc --debug模式跑,能看到详细日志。常见原因是宿主机内核版本太老,或者缺少某些内核特性。gVisor 对内核版本有要求,建议 4.14 以上。
5.2 性能异常类问题
问题:沙箱内程序跑得比预期慢很多
先确认是不是 syscall 密集。用strace -c统计一下系统调用分布,如果 syscall 数量巨大,那 gVisor 的开销就体现出来了。解决办法:要么换回 Docker(如果隔离要求不高),要么优化程序减少 syscall(比如批量读写代替逐条读写)。
问题:容器启动慢
检查镜像层数,层数越多启动越慢。用多阶段构建把镜像压到最少层。另外存储驱动也有影响,overlay2 通常比 devicemapper 快。如果用了 gVisor,确认 platform 是不是 systrap。
5.3 隔离失效类问题
问题:容器内能访问到宿主机文件
检查挂载点。-v挂载的目录如果权限没设好,容器内可能读写宿主机文件。原则是:只挂载必需的目录,且尽量只读(:ro)。绝对不要挂载/var/run/docker.sock,那等于把宿主机控制权交出去了。
问题:容器内能提权
检查是否 drop 了所有 capability,是否设了no-new-privileges,是否用了非 root 用户。这三条缺一不可。我见过有人只做了前两条,结果容器内有个 setuid 程序,直接提权成功。
5.4 一张速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动即退出 | seccomp 拦截 / 内核不兼容 | 看 dmesg,用 LOG 模式跑 seccomp |
| 运行超时 | 死循环 / 资源不足 | 加 timeout,调大 memory/cpu |
| 僵尸进程堆积 | 缺 init 进程 | 加--init参数 |
| 内存超限被杀 | 限制太严 / 内存泄漏 | 调大--memory,检查代码 |
| 网络不通 | 网络模式配置 | 确认--network和代理设置 |
| 性能骤降 | syscall 密集 / platform 不对 | strace 统计,换 systrap |
5.5 几个血泪教训
第一个教训:别在生产环境用latest标签的镜像。我有次因为基础镜像更新,导致沙箱行为变化,排查了半天。所有镜像都要固定版本号。
第二个教训:seccomp 策略要版本化管理。不同语言、不同版本的运行时需要的 syscall 不一样,策略要跟着代码走,别一套策略用到底。
第三个教训:监控沙箱的逃逸尝试。即使隔离做得再好,也要假设会被攻破。记录所有被 seccomp 拒绝的 syscall、所有异常的网络请求,这些是发现攻击的信号。
第四个教训:定期做逃逸测试。自己写一些攻击脚本,尝试从沙箱里逃出来,验证隔离是否有效。这比看文档靠谱得多。
6. 我的实际组合方案与后续扩展
讲了这么多方案,最后说说我自己在项目里实际用的组合。对于中等规模、跑 AI 生成代码的场景,我的默认配置是:gVisor(systrap 平台)+ 收紧的 seccomp + 严格资源限制 + 白名单代理网络 + 强制超时 + init 进程。这套组合在隔离强度、性能和运维复杂度之间取得了比较好的平衡,实测能挡住绝大多数常见攻击,同时启动开销在可接受范围内。
如果场景升级到多租户、强合规要求,我会把 gVisor 换成 Firecracker,配合 Kata Containers 做编排,代价是运维复杂度上一个台阶。如果只是内部工具,那就退回纯 Docker 加加固参数,简单省事。
这个领域还在快速演进,我最近在关注的方向包括:基于 eBPF 的运行时行为监控(能在沙箱运行时动态检测异常行为)、以及一些新的轻量级虚拟化方案。但无论技术怎么变,那四个维度的取舍逻辑不会变——想清楚你的场景最看重什么,然后接受其他维度的妥协,这才是做 AI Sandbox 的正确姿势。
最后分享一个我常用的验证方法:搭好沙箱后,别急着上线,先拿几个公开的容器逃逸 PoC 跑一遍,看看能不能逃出来。逃不出来,说明基本配置到位了;逃出来了,那就继续加固。这个过程比任何文档都直观。