3277 words
16 minutes
Bubblewrap 详解:用 Linux Namespace 构建轻量级沙箱

Bubblewrap 详解:用 Linux Namespace 构建轻量级沙箱#

Bubblewrap,命令行工具叫 bwrap,是 Flatpak、toolbox 等 Linux 工具背后常见的一层沙箱原语。它不像 Docker 那样管理镜像、网络、daemon 和完整容器生命周期,也不像虚拟机那样启动一个独立内核。它做的事情很窄:创建一个隔离的进程执行环境,然后让调用者自己决定哪些路径、设备、环境变量和 namespace 要开放。

这正是它有意思的地方。Bubblewrap 不是“策略”,而是“执行策略的工具”。你给它一组参数,它就按这组参数搭出一个新的文件系统视图和进程环境。安全边界到底强不强,取决于你挂了什么、没挂什么、是否隔离网络、是否给了 /proc、是否创建新 session。

这篇文章把它拆开讲:先看它依赖的 Linux 内核机制,再看文件系统是怎么从空白根目录搭出来的,最后讲它适合什么场景,以及容易误用在哪里。

Bubblewrap 是什么#

可以把 Bubblewrap 理解成一个“低层容器启动器”。它不提供镜像格式,不负责拉取依赖,也不帮你定义默认安全策略。它只把 Linux 内核已经提供的隔离能力包装成一组命令行参数:

  • 用 mount namespace 构建独立的文件系统视图
  • 用 user namespace 让普通用户也能执行必要的挂载操作
  • 用 PID、IPC、UTS、network namespace 隔离对应资源
  • 用 bind mount 明确声明哪些宿主机路径可见
  • --clearenv--setenv 控制环境变量
  • 用最小 /dev/proc、tmpfs 等特殊挂载补齐程序运行环境

换句话说,Bubblewrap 的默认状态不是“给你一个完整 Linux 系统”,而更接近“给你一个空盒子”。你要自己往盒子里放 /usr/bin、工作目录、临时目录、设备节点和环境变量。

这和 Docker 的心智模型很不一样。Docker 默认是从镜像启动一个相对完整的根文件系统,再把宿主机目录作为 volume 挂进去;Bubblewrap 则是从一个几乎空白的根开始,只把你指定的东西挂进去。

核心机制:Namespace#

Linux namespace 的作用是让进程看到一套局部化的系统资源。不同 namespace 隔离不同维度:

Namespace常见参数隔离内容
Mount默认使用文件系统挂载表
User--unshare-userUID/GID 与 capabilities 视图
PID--unshare-pid进程号和进程列表
Network--unshare-net网卡、路由表、端口
IPC--unshare-ipcSysV IPC、共享内存等
UTS--unshare-utshostname、domainname
Cgroup--unshare-cgroupcgroup 视图

其中最关键的是 mount namespace 和 user namespace。

mount namespace 让沙箱内进程拥有一张独立的挂载表。你在里面 bind mount /usr/tmp/workspace,不会改变宿主机的挂载表。沙箱外也看不到这个局部挂载布局。

user namespace 解决的是“普通用户如何做 mount”这个问题。传统上,挂载文件系统需要 CAP_SYS_ADMIN,也就是接近 root 的权限。user namespace 允许普通用户在一个新的用户命名空间里拥有“局部 root”能力:在 namespace 内可以做一些挂载和 namespace 操作,但映射回宿主机仍然是原来的普通用户。

一个典型映射大概是这样:

namespace 内 UID 0 -> 宿主机 UID 1000
namespace 内 GID 0 -> 宿主机 GID 1000

这意味着沙箱里的进程可以认为自己是 root,但它对宿主机文件的访问能力不会超过原用户。原用户读不了 /etc/shadow,沙箱里的“root”也读不了。

从空白根目录搭文件系统#

Bubblewrap 最有价值的部分,是它让你用参数显式搭文件系统。一个简化例子:

Terminal window
bwrap \
--ro-bind /usr /usr \
--ro-bind /bin /bin \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /etc /etc \
--bind "$PWD" /workspace \
--tmpfs /tmp \
--dev /dev \
--proc /proc \
--clearenv \
--setenv PATH /usr/bin:/bin \
--setenv HOME /workspace \
--chdir /workspace \
bash

这个沙箱里,进程能看到:

  • 只读的系统程序和动态库
  • 可写的 /workspace
  • 临时的 /tmp
  • 最小设备目录 /dev
  • procfs /proc
  • 只有 PATHHOME 两个环境变量

它看不到没有挂进去的路径,比如宿主机的 ~/.ssh~/.gnupg、其他项目目录、私有配置目录。不是权限拒绝,而是路径在这个 mount namespace 里根本不存在。

这就是 Bubblewrap 的核心安全模型:不是在一个完整文件系统上到处 chmod,而是从一张空挂载表开始,只把必要路径放进去。

Bind Mount:可见性的白名单#

Bubblewrap 的文件系统策略主要通过 bind mount 表达。

参数含义
--bind SRC DEST把宿主机路径可读写挂到沙箱内
--ro-bind SRC DEST把宿主机路径只读挂到沙箱内
--tmpfs DEST在沙箱内创建新的内存文件系统
--dev DEST创建最小设备树
--proc DEST挂载 procfs
--dir DEST在沙箱根里创建目录
--symlink SRC DEST创建符号链接

--bind--ro-bind 的区别非常实际:如果只是让程序加载系统库,应该用只读;如果是工作区、缓存、输出目录,才用可写。

安全上最容易犯的错,是把太大的目录挂进去。比如为了方便直接挂 $HOME,那沙箱就能看到用户下几乎所有私有文件;为了让工具链跑起来挂 /,那就基本失去了文件系统隔离的意义。

更好的方式是按需挂载:

--ro-bind /usr /usr
--ro-bind /etc /etc
--bind /home/user/project /workspace
--bind /tmp /tmp

这种白名单模式让边界很清楚:不在参数里的路径不可见。

/dev 为什么重要#

很多程序即使只是启动一个子进程,也会依赖 /dev/null。比如 Node.js 的 child_process.spawn() 如果使用 stdio: "ignore",底层需要打开 /dev/null。如果沙箱里没有 /dev/null,你可能看到一个很迷惑的错误:

spawn /bin/bash ENOENT

表面看像是 /bin/bash 不存在,但真正的缺口可能是子进程准备 stdio 时找不到 /dev/null

Bubblewrap 的 --dev /dev 会创建一个最小设备树,通常包含:

  • /dev/null
  • /dev/zero
  • /dev/full
  • /dev/random
  • /dev/urandom
  • /dev/tty
  • /dev/fd
  • /dev/stdin
  • /dev/stdout
  • /dev/stderr

它不是把宿主机整个 /dev 暴露进去。完整 /dev 里可能有磁盘、TTY、GPU、USB 等设备节点,把它整个挂进去会扩大攻击面。最小 /dev 通常足够支撑普通 CLI 和语言运行时。

环境变量:--clearenv 比黑名单可靠#

文件系统不是唯一需要隔离的东西。很多服务启动后,API key、token、数据库密码都在环境变量里。子进程默认继承父进程 env,一旦被提示词注入或脚本执行劫持,就能直接读出来。

Bubblewrap 提供两类参数:

--clearenv
--setenv PATH /usr/bin:/bin
--setenv HOME /workspace

更可靠的模式是先 --clearenv,再逐个 --setenv 白名单变量。不要维护“危险变量黑名单”,因为你永远不知道以后会多出什么新 secret。白名单虽然麻烦一点,但安全边界更稳定。

对 agent 子进程来说,常见可传变量包括:

  • PATH
  • HOME
  • USER
  • SHELL
  • LANG
  • TZ
  • TERM
  • HTTP_PROXY
  • HTTPS_PROXY

具体要不要传代理变量,取决于你是否允许沙箱联网,以及 LLM/API 调用是否需要代理。

pivot_root 与旧根卸载#

一个沙箱如果只是 chroot,历史上有不少逃逸技巧。Bubblewrap 更接近容器运行时的做法:搭好新根之后,用 pivot_root 切换根目录,然后卸载旧根。

概念流程是:

创建新的 tmpfs 根
-> 创建目录和符号链接
-> 执行 bind mount / ro-bind
-> 挂载 /dev、/proc、/tmp
-> pivot_root 到新根
-> umount 旧根
-> exec 目标程序

旧根卸载之后,沙箱内进程不能再通过普通路径访问宿主机原根目录。这比单纯改变路径解析的 chroot 更干净。

进程模型:monitor、init、目标程序#

Bubblewrap 内部不是简单地 fork 一下然后 exec。为了建立 namespace、处理挂载和回收子进程,它会有一个更细的进程协作模型。

可以粗略理解为三层:

宿主机 monitor 进程
-> 沙箱 init 进程
-> 目标程序

monitor 进程保留在宿主机视角,负责协调一些建立阶段的操作,并等待沙箱退出。沙箱内如果启用了 PID namespace,第一个进程需要承担 PID 1 的职责:回收僵尸进程、转发信号、等待目标程序结束。

这个细节解释了为什么容器/沙箱里 PID 1 很重要。普通应用如果直接作为 PID 1 运行,可能不会正确处理 SIGCHLD,导致僵尸进程堆积。Bubblewrap 会用一个极简 init 处理这类生命周期问题。

TIOCSTI 与 --new-session#

如果沙箱内进程仍然和宿主机 shell 共享同一个终端 session,理论上可能通过 TIOCSTI 这类 ioctl 往终端输入缓冲区注入字符,让外部 shell 执行命令。

防护方式是创建新的 session:

--new-session

在无人值守的服务里,子进程通常不是直接挂在交互式 shell 上,这个风险不一定是首要矛盾。但如果你在终端里手动跑不可信程序,--new-session 是应该考虑的安全参数。

网络隔离:可选但不能忘#

Bubblewrap 可以用 --unshare-net 让沙箱只有 loopback 网络。这样沙箱内程序不能访问公网,也不能访问云厂商元数据端点。

但网络隔离会带来现实代价。比如 agent 子进程需要调用 LLM API、下载依赖、访问搜索接口,那么完全断网会让功能失效。此时有三种常见选择:

  • 不隔离网络,接受 SSRF/元数据端点风险
  • --unshare-net,只允许离线任务
  • --unshare-net 加本地代理,把代理作为唯一出口,由代理做域名/IP 白名单

第三种最接近严肃生产环境:沙箱内只能访问代理,代理拒绝 RFC1918、链路本地地址、云元数据端点,只放行必要 API 域名。代价是复杂度明显上升。

什么时候适合用 Bubblewrap#

Bubblewrap 特别适合这些场景:

  • 给单个 CLI 工具套一层文件系统和 env 边界
  • 运行不完全可信的代码生成/代码执行子进程
  • 不想引入 Docker daemon 和镜像构建流程
  • 每次执行都是短生命周期,不需要容器常驻
  • 需要精确控制哪些宿主机路径可见

它不适合这些场景:

  • 多租户强隔离
  • 需要完整服务编排和网络管理
  • 需要稳定跨平台运行,尤其是 macOS/Windows
  • 需要内核级强隔离,那应该看 VM、microVM 或 gVisor

Bubblewrap 是共享宿主机内核的进程沙箱。它的边界比“普通子进程”强很多,但不能等同于虚拟机。

和 Docker、Firecracker 的取舍#

维度BubblewrapDockerFirecracker / microVM
隔离对象单进程/进程树容器虚拟机
启动开销很低中等较高,需快照优化
依赖一个 bwrap 二进制dockerd/containerdKVM、镜像、网络、快照
文件系统策略参数级白名单镜像 + volumeVM 磁盘/共享目录
内核隔离共享内核共享内核独立 guest kernel
适合场景本地工具、agent 子进程服务部署、开发环境多租户/高风险执行

如果目标是“给一个本地 agent 的子进程减少可见文件和凭据”,Bubblewrap 的性价比很高。它不需要 daemon,不需要镜像构建,也不需要把整套项目容器化。

如果目标是“运行陌生人的任意代码”,Bubblewrap 就不够了。那时你要的不是轻量沙箱,而是更强的租户隔离和生命周期治理。

一个实用的设计原则#

使用 Bubblewrap 时,可以按这个顺序思考:

  1. 子进程必须写哪里?
  2. 子进程必须读哪些系统路径?
  3. 子进程是否真的需要看到 $HOME
  4. 子进程需要哪些环境变量?
  5. 是否需要网络?如果需要,是否需要代理限制?
  6. 是否需要 /proc?如果需要,是完整 /proc 还是受限 /proc
  7. 是否在交互式终端里运行?是否需要 --new-session

答案越具体,沙箱越干净。最差的参数组合通常长这样:

--bind "$HOME" "$HOME"
--bind / /

这样当然最省事,但也最接近没有沙箱。

好的 Bubblewrap 配置应该像一份“可见性清单”:每一条 bind 都能解释为什么需要,不需要的东西就不要挂。

小结#

Bubblewrap 的价值不在于它替你设计了一套万能安全策略,而在于它把 Linux namespace 和 mount 操作变成了足够轻量、足够可组合的工具。

它适合给单进程工具加边界:清掉环境变量、只挂必要目录、把系统路径改成只读、给最小 /dev,必要时再隔离 PID、IPC、网络和终端 session。对于个人 agent、本地自动化、代码执行子进程来说,这种粒度刚好够用。

但也要记住:Bubblewrap 是原语,不是魔法。它的安全性等于你的参数设计。挂载越多,边界越薄;白名单越小,沙箱越像沙箱。

Bubblewrap 详解:用 Linux Namespace 构建轻量级沙箱
https://sgjki547.top/posts/2026-07-29-bubblewrap详解/
Author
SGJki
Published at
2026-07-29
License
CC BY-NC-SA 4.0