跳到主要内容

嵌套隔离:容器内再用 bubblewrap+overlay 套一层沙箱

30 秒导读: 沙箱已经是一个容器了,为什么还要再套一层?因为「容器」是给一个用户的一整个环境, 而 agent 常常想让每一次代码执行都有自己独立、可看 diff、可提交或丢弃的草稿空间。本章讲 OpenSandbox 里工程含量最高的一支:在容器内部,用 bubblewrap(轻量命名空间沙箱工具)+ overlayfs(联合挂载,下层只读、上层记改动)为单次运行再造一层可回滚的隔离。对外表现为 /v1/isolated/* 这组 API。

本章与 04-execd-data-plane(数据面:在沙箱里直接跑命令/文件/PTY)并列: 04 讲的是"直接在容器里跑",本章讲的是"在容器里再包一层命名空间跑"。两者共用不了实现,所以本章 不重复普通 command/fs 的内容,只讲这层额外的隔离。协议全景见 index,生命周期见 01-protocol-and-lifecycle


1. 这是什么(零基础也能懂)

一句话定义: 在一个已经是沙箱的容器里,再给单次执行套一层可写、可 diff、可回滚的命名空间隔离。

1.1 为什么容器之上还要再隔离一层

沙箱容器解决的是"把 agent 的活动关进一个盒子"。但一个 agent 会话里,往往要跑很多次代码,你会希望:

  • 让某一次执行在独立的可写层里改文件,不脏到原始工作区;
  • 事后能看这次改了什么(diff),满意就提交(commit)进工作区,不满意就整层丢弃;
  • 给这次执行更严的权限边界(不同 profile:更严 / 更平衡);
  • 这一切都发生在已经在容器里的进程内部——不需要再起一个新容器。

这正是"给容器内的每次运行加一个 overlay 草稿层"的直觉。

1.2 一句话直觉/类比

把它想成 Git 的暂存区,但作用在文件系统上:

概念类比
lower(下层,只读)原始工作区,像 HEAD
upper(上层,可写)这次执行的所有改动,像 working tree 的改动
diffgit diff:这次到底改了啥
commitgit commit:把改动落回工作区
丢弃 uppergit checkout .:整层扔掉,工作区毫发无损

区别在于:这层"草稿"不只是文件差异,还是一个真正的命名空间沙箱——独立的 PID / IPC / UTS / (可选)网络命名空间 + seccomp 系统调用过滤。

1.3 用起来什么样

对外是一组 /v1/isolated/* HTTP 接口(注册见 pkg/web/router.go:95-114)。一次典型使用:

POST /v1/isolated/session 创建隔离会话(选 profile、workspace 挂载模式)→ 得到 session_id
POST /v1/isolated/session/{id}/run 在这层里跑代码(SSE 流式回传 stdout)
GET /v1/isolated/session/{id}/diff 看这次改了什么 ← Phase 2,尚未实现
POST /v1/isolated/session/{id}/commit 把改动提交进工作区 ← Phase 2,尚未实现
DELETE /v1/isolated/session/{id} 销毁会话,连 upper 层一起删
GET /v1/isolated/capabilities 问:这台机器到底支不支持隔离/commit/diff

⚠ 诚实提示(全章通用): 截至本 commit,create / run / delete 和一整套隔离内文件操作 已经能用;而 diff / commit / persist(跨会话持久化)属于 Phase 2,代码里是明确的桩(stub), 调用直接返回"not implemented yet"。见 pkg/web/controller/isolated_session.go:237-244pkg/runtime/isolated_session_ctrl.go:396-403。本章会讲清楚"已铺好的地基"和"还没盖的楼", 不会把桩当成能用的功能。

平台边界: 这层只在 Linux 上真正工作。非 Linux 是编译期桩,Available() 恒为 false (pkg/isolation/bwrap_stub.go:37);Windows 的 runner 同样是桩(pkg/runtime/isolated_session_stub.go)。


2. 顶层全景(它大概怎么转)

2.1 三层结构

从 HTTP 请求到最底层的 bwrap 进程,分三层。怎么读:从上到下是调用方向,每层只依赖它下面一层。

HTTP /v1/isolated/*

┌──────────▼───────────┐ web 层:参数校验 + SSE 流式输出 + 文件操作代理
│ IsolatedSessionCtrl │ pkg/web/controller/isolated_session*.go
└──────────┬───────────┘

┌──────────▼───────────┐ runtime 层:会话生命周期、并发串行化、空闲 GC
│ IsolatedRunner │ pkg/runtime/isolated_session_ctrl.go
│ + isolatedSession │ (每会话 = 一个常驻 bash,活在 bwrap 命名空间里)
└─────┬──────────┬─────┘
│ │
┌──────▼───┐ ┌───▼────────┐ isolation 层:把 exec.Cmd 包进 bwrap;管 upper 层
│ Isolator │ │UpperManager│ pkg/isolation/{bwrap,upper,merged_view}.go
│ (bwrap) │ │ + Merged │
└────┬─────┘ │ View │
│ └────────────┘
┌────▼─────────────────────────┐
│ bwrap 进程(命名空间 + overlay │ 真正的隔离在这里发生
│ + seccomp)→ 里面跑 bash/命令 │
└──────────────────────────────┘

2.2 部件一句话职责

部件干什么在哪个文件
Isolator 接口抽象"把命令包进一层隔离"的能力pkg/isolation/isolator.go:108-114
bwrapImplbubblewrap 的具体实现:构造 bwrap 命令行pkg/isolation/bwrap_linux.go:63-143
buildArgv按固定段序拼出 bwrap 参数(命名空间/挂载/seccomp)pkg/isolation/bwrap.go:42-126
MergedView用户态的联合视图:读上盖下、写只落 upperpkg/isolation/merged_view.go:42-47
UpperManager分配/回收/配额每个会话的 upper 目录pkg/isolation/upper.go:28-93
Probe启动时探测:bwrap 在不在、能不能建命名空间、overlay 行不行pkg/isolation/probe.go:56-85
IsolatedRunner会话增删改查 + 空闲回收 + 并发串行化pkg/runtime/isolated_session_ctrl.go:42-48
isolatedSession一个常驻 bash 进程,活在 bwrap 命名空间里pkg/runtime/isolated_session.go:45-60

2.3 主线走一遍(高层,不进代码)

  1. 启动时(main.go:46-78):加载 TOML 配置 → Probe 探测能力 → 能用就建 bwrapImpl + IsolatedRunner,并注册进 controller。探不出来则整组接口对外返回 503。
  2. create:校验参数 → 建工作区目录 → overlay 模式下由 UpperManager 分配一对 upper/work 目录 → isolatedSession.start()bash --noprofile --norc 包进 bwrap 启动。
  3. run:把用户代码 + 结束标记写进这个常驻 bash 的 stdin,逐行读 stdout 用 SSE 回传,读到标记 就知道跑完了、拿到退出码。
  4. delete:杀掉 bwrap 进程组 → 删掉 upper 层 → 从会话表里移除。

3. 核心原理(逐个机制,由浅入深)

3.1 隔离抽象:一个 Isolator 接口 + 一组值类型

要解决的小问题: runtime 层不该关心"到底是 bwrap 还是别的东西在隔离",它只想说"把这条命令包起来"。

思路: 定义一个窄接口,把"包一层"抽象成 Wrap(cmd, opts)。接口只有四个方法 (pkg/isolation/isolator.go:108-114):

// 真实源码,pkg/isolation/isolator.go:108-114
type Isolator interface {
Name() string
Available() bool
Capabilities() Capabilities
Wrap(cmd *exec.Cmd, opts WrapOptions) error
}

Wrap 的语义很关键:它不新起进程,而是改写传入的 *exec.Cmd——把 cmd.Path 换成 bwrap、 把原命令塞到 bwrap 参数后面。调用方随后照常 cmd.Start() 即可。

配套的三组"档位"枚举,都带 Valid() 自校验:

类型取值含义定义
Profilestrict / balanced隔离档位:严格 vs 平衡isolator.go:20-31
WorkspaceModerw / overlay / ro工作区怎么挂:直接读写 / 上层草稿 / 只读isolator.go:33-46
EnvModedeny / allow宿主环境变量怎么透传:黑名单 / 白名单isolator.go:48-60

两个"数据袋"结构:

  • WrapOptions(isolator.go:94-104):一次执行的全部输入——profile、workspace、ExtraWritable (额外可写路径)、ShareNet(是否共享网络)、EnvPassthroughUid/GidUpperDir/WorkDir
  • Capabilities(isolator.go:76-92):这台机器能干什么——是否可用、版本、支持哪些 profile、 CommitSupported / DiffSupported / PersistAvailable 等开关,以及 SeccompProfileSHA256 (seccomp 配置指纹)。

诚实点: SeccompProfileSHA256 这个字段声明了但当前没有任何代码去填(全库仅出现在结构体 定义 isolator.go:87)。它是为"把 seccomp 策略指纹暴露给客户端"预留的位置,尚未接线。

3.2 bubblewrap 实现:把 exec.Cmd 包进命名空间

要解决的小问题: 怎么让一条普通命令"降生"在一个新的 PID/IPC/UTS/(可选)网络命名空间里, 根文件系统只读、工作区可写、危险系统调用被拦?

思路: 不自己调 clone/unshare,而是复用成熟的 bwrap 二进制——OpenSandbox 只负责把参数拼对。 拼参数的活全在 buildArgv(pkg/isolation/bwrap.go:42-126),它按一个固定的段序产出参数, 顺序不能乱(注释写在 bwrap.go:28-41):

① 命名空间标志 --unshare-pid --unshare-uts --unshare-ipc [--unshare-net]
② 根挂只读 --ro-bind / /
③ /tmp 段 strict: --tmpfs /tmp balanced: --bind /tmp /tmp
④ /run --tmpfs /run
⑤ /dev --dev /dev
⑥ /proc --proc /proc
⑦ 工作区段 rw/ro/overlay 三选一(见 3.3)
⑦b 藏 upper 根 --tmpfs <upperRoot> ← 防跨会话偷看别人的 upper
⑧ 额外可写 --bind <p> <p> ...
⑨ 环境变量段 按 EnvMode 生成 --setenv / --unsetenv / --clearenv
⑩ seccomp --seccomp <fd>
⑪ 分隔 + 降权 -- setpriv --reuid --regid --clear-groups <用户命令>

几个不显然的设计点:

  • 不开 user namespace。 注释明说 no --unshare-user (real setuid instead)(bwrap.go:49)。 它靠真实的 setpriv 从 root 降到目标 UID/GID(bwrap.go:115-123),降权后进程没有 CAP_SETUID,无法再爬回来。
  • 藏起 upper 根目录(bwrap.go:79-84):UpperDir 形如 <root>/<id>/upper,于是把 <root> 整个 --tmpfs 盖掉,namespace 内看不到别的会话的 upper,杜绝横向数据泄露。
  • strict 档更狠:/tmp 用独立 tmpfs(bwrapTmpSegment,bwrap.go:146-154),且默认剥离一批 敏感环境变量(见 3.6)。

改写命令的收尾(wrapWithArgv,bwrap.go:261-271):把 bwrapPath 放最前,接 bwrap 参数, 再接原命令,替换 cmd.Path

真正执行 Wrap 的入口bwrapImpl.Wrap(bwrap_linux.go:113-143):它先把 seccomp BPF 通过 memfd 挂进 cmd.ExtraFiles,算出子进程侧的 fd 号,再调 buildArgv

bwrap 二进制怎么找(findBwrap,bwrap_linux.go:44-60):优先 $PATH,再依次找 /opt/opensandbox/bwrap(init 容器注入)、/usr/bin/bwrap/usr/local/bin/bwrap

能力探测(Probe,probe.go:56-85)分三步,任何一步失败都降级:

  1. probeBwrapVersion:能不能跑 bwrap --version 并解析出版本(probe.go:88-116)。
  2. probeBwrapSmoke:真起一个最小 namespace 跑 true(probe.go:119-136)。
  3. probeOverlayMount:真做一次 overlay 挂载测试(probe.go:139-183)——成功才点亮 CommitSupported/DiffSupported。注意它故意在 upperRoot(通常 tmpfs/emptyDir)上探, 因为 overlayfs 不能嵌套在 Docker 的 overlay2 层上,但在 tmpfs 上没问题(probe.go:145-147)。

clone3 兼容:让老 seccomp 场景不崩

坑: Go 运行时和 glibc 会用 clone3(2);某些旧内核 / 严格 seccomp 环境里 clone3 会被拦成 EPERM 而不是 ENOSYS,导致不回退到 clone(2),进程直接失败。

对策(pkg/clone3compat):可选地装一条 seccomp 规则,让 clone3 返回 ENOSYS,libc/Go 就会 优雅回退到 clone。由环境变量控制(compat_linux.go:39-79):

EXECD_CLONE3_COMPAT行为
空 / 0 / false / off / no不启用
1 / true / yes / on进程启动后装过滤器
reexec装过滤器后 re-exec 自身,让所有 init 代码都在过滤器已生效下跑

装过滤器本身在 loadClone3EnosysFilter(compat_linux.go:81-103),默认放行、只对 clone3 返回 ENOSYSmain.go:40 在最早期调用 clone3compat.MaybeApply()

3.3 overlay 合并视图:下层只读 + 上层草稿

这是本章的核心。它有两副面孔,别混淆:

  • 内核态 overlay(给 namespace 里的进程用):bwrap 用 --overlay-src LOWER --overlay UPPER WORK DEST 真的挂一个 overlayfs,进程写文件时自动落到 upper。
  • 用户态 MergedView(给 host 侧的文件 API 用):一个 Go 结构体,模拟 overlay 语义,让 /v1/isolated/.../files/* 这些接口读到"上盖下"的合并视图。

内核态挂载参数bwrapWorkspaceSegment(bwrap.go:157-182):

// 真实源码节选,pkg/isolation/bwrap.go:167-178 —— overlay 分支
case WorkspaceOverlay:
if opts.UpperDir == "" {
// upper 落在 tmpfs,进程一退就没 —— 临时草稿
return []string{"--overlay-src", ws.Path, "--tmp-overlay", ws.Path}, nil
}
// upper 落在磁盘 —— 会话内跨多次 run 保留
return []string{"--overlay-src", ws.Path, "--overlay", opts.UpperDir, workDir, ws.Path}, nil

用户态 MergedView 的规则(merged_view.go):

操作行为代码
读(Stat/Open/ReadFile)先查 upper,命中即返回;否则落 lowermerged_view.go:135-242
列目录(ReadDir)合并 lower + upper,upper 覆盖同名项merged_view.go:157-198
写(WriteFile)只写 upper,并 chown 到会话 uid/gidmerged_view.go:247-271
删(Remove)upper 有就删;lower 有则建 whiteout 遮住merged_view.go:310-344
改名/改权限lower-only 时先 copy-up 到 upper 再改merged_view.go:395-485

whiteout(白障) 是 overlay 删除下层文件的标准技巧:在 upper 建一个 .wh.<name> 标记文件, 表示"这个名字在合并视图里当作不存在"(createWhiteout,merged_view.go:111-122;读取时 hasWhiteoutReadDir.wh. 前缀处理,merged_view.go:125-132177-181)。

关键坑(代码注释里白纸黑字写着,merged_view.go:29-38): 用户态 MergedView 往 upper 目录的 直接写入,在一个正在运行的内核 overlay 挂载里是看不见的——内核 VFS 在挂载时缓存了目录项, 绕过 overlay 的直改会被无视。所以只有 run→API 方向(进程写→经 overlay 落 upper→host 读 upper) 可靠;要双向交换文件,应该用 rw 模式而不是 overlay

3.4 upper 层的持久化与配额

要解决的小问题: 每个 overlay 会话都要一块可写的 upper 目录,谁来分配、谁来回收、别把磁盘撑爆?

思路: UpperManager(upper.go:28-33)在一个 root 下(默认 /var/lib/execd/isolation, 见 config.go:50)为每个会话开一对目录,并加锁跟踪、做总量配额。

布局: 每次 Allocate()(upper.go:63-93)生成一个随机 16 字节 hex 会话 ID,建 <root>/<id>/upper<root>/<id>/work(overlayfs 要求一个同文件系统的 work 目录)。

配额: Allocate 前先算当前总用量,>= maxBytes 就返回 ErrUpperLimitExceeded(upper.go:67-72), maxBytes 即配置里的 UpperMaxBytes(默认 8 GiB,config.go:51)。用量靠 dirSize 遍历累加 文件大小(usageLocked + dirSize,upper.go:146-192)。

回收有三档:

方法语义代码
Release只标记"可回收",不立即删upper.go:96-103
Remove立即删整个 <root>/<id>upper.go:106-118
Collect一轮 GC:删掉所有已 Release 的条目upper.go:121-136

诚实点: upper 目录在一次会话内跨多次 run 是保留的(常驻 bash + 同一个 overlay 挂载)。 但"persist"(让 upper 跨会话、跨重启存活)是另一回事,当前未实现:CapabilitiesPersistAvailable: false(bwrap_linux.go:106),PersistMaxBytes* 只是预告的默认值。 会话一旦 delete,DeleteIsolatedSession 会顺手 upperMgr.Remove 把这层删掉 (isolated_session_ctrl.go:384-388)。

3.5 seccomp:按 profile 生成系统调用过滤

要解决的小问题: 命名空间挡不住所有坏事,得再拦一层危险系统调用(挂载、ptrace、加载内核模块……)。

思路: 生成一段 默认放行、黑名单拒绝 的 BPF 字节码,通过 memfd 交给 bwrap 的 --seccomp

黑名单(denylistSyscalls,seccomp_gen.go:30-71)覆盖几类:文件系统操纵(mount/umount2/ chroot/pivot_root)、进程窥探(ptrace/process_vm_readv)、内核模块(init_module 等)、 BPF/seccomp 自身、namespace 操纵(setns/unshare)、kexecreboot 等。注释特意说明没有setresuid/setresgid——因为 setpriv 降权要用(seccomp_gen.go:47-49)。

生成过程(generateSeccompDenyBPF,seccomp_gen.go:79-132):

// 真实源码节选,pkg/isolation/seccomp_gen.go:96-104 —— 默认放行、命中黑名单返回 EACCES
policy := seccomp.Policy{
DefaultAction: seccomp.ActionAllow,
Syscalls: []seccomp.SyscallGroup{{
Names: names, // 已按当前架构过滤掉不存在的 syscall
Action: seccomp.ActionErrno | seccomp.Action(syscall.EACCES),
}},
}

生成后序列化成 struct sock_filter 字节流(每条 8 字节,seccomp_gen.go:116-129),启动时预生成一次 (NewBwrapseccompBPF,bwrap_linux.go:69-74),每次 Wrap 用 memfd 把它喂给 bwrap (createMemfdWithData,bwrap_linux.go:148-163)。

可覆盖: 配置里 [seccomp] deny = [...](SeccompOverride,config.go:42-44)会完全替换 内置黑名单(不是合并,config.go:36-37)。不同架构上不存在的 syscall 会被 filterKnownSyscalls 静默跳过(seccomp_gen.go:135-143)。

注意: profile(strict/balanced)当前主要影响 /tmp 和环境变量的处理;seccomp 黑名单本身 不随 profile 分档——两档用的是同一份内置黑名单(除非配置覆盖)。这点从 generateSeccompDenyBPF 只吃 override 不吃 Profile 可以看出(seccomp_gen.go:79)。

3.6 环境变量透传:别把容器的密钥漏进沙箱

要解决的小问题: 容器里常带 *_API_KEYAWS_*KUBE_* 这类敏感变量,不该无脑透传给沙箱里 跑的代码。

思路(bwrapEnvSegment,bwrap.go:199-228):按 EnvMode 分三种情形——

  • 未指定 mode:剥离一批敏感变量(unsetBlacklistedEnv,按 strictEnvBlacklist glob 匹配, bwrap.go:231-234:*_API_KEY/*_TOKEN/*_SECRET/*_PASSWORD/AWS_*/ALI_*/ALIYUN_*/ K8S_*/KUBE_*)。
  • deny:显式 --unsetenv 指定 key(空 list 时退回上面的黑名单)。
  • allow:--clearenv 清空,再只 --setenv 白名单里的 key。

匹配是大小写不敏感的简单 glob(前缀/后缀/包含/精确,matchEnvPattern,bwrap.go:237-258)。


4. 会话生命周期(深入实现)

本节把"一个隔离会话从生到死"走一遍代码。会话 = 一个常驻 bash 进程,活在 bwrap 命名空间里, 多次 run 复用同一个 bash(所以 upper 层在会话内累积)。

4.1 create:分配 upper + 启动常驻 bash

CreateIsolatedSession(isolated_session_ctrl.go:149-182):

  1. 校验 ExtraWritable 必须在 allowlist 内(validateExtraWritable,:454-476)。
  2. os.MkdirAll 建工作区。
  3. overlay 模式(或未指定 mode,默认 overlay)→ upperMgr.Allocate() 拿到 upperID/upperDir/workDir (:162-170)。
  4. session.start() 启动;失败则回滚 upper(:172-177)。

start()(isolated_session.go:74-163)的要点:

// 真实源码节选,pkg/runtime/isolated_session.go:75-76
cmd := exec.Command("bash", "--noprofile", "--norc")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // 独立进程组,便于整组 kill
  • 组装 WrapOptions:profile(默认 strict,:83-90)、workspace 模式(默认 overlay,:93-100)、 env 透传(默认 deny,:105-110)、uid/gid、UpperDir/WorkDir
  • isolator.Wrap(cmd, wrapOpts) 把 cmd 改写成 bwrap 调用。
  • 建 stdin/stdout 管道,cmd.Start(),起一个死亡守望 goroutine 关 doneCh(:148-151)。
  • 启动自检:等 100ms,若 bwrap 立刻退出就判定失败(:156-160)——早失败早报错,不拖到首个 run。

4.2 run:把代码喂给常驻 bash,读到标记为止

RunInIsolatedSession(isolated_session_ctrl.go:236-314)的机制很巧:

  1. 每会话串行:s.runMu.Lock(),同一会话的多个 run 排队(:243-244)。
  2. 组装脚本:可选地在子 shell 里 export 环境变量 + 用户代码 + 一行 echo __ISOLATED_RUN_END__<uuid> $?(:260-279)。这个唯一结束标记用来切分本次输出、并带回退出码。
  3. 取消/超时用 SIGINT 而非关 stdin(:286-294):关 stdin 会杀死整个常驻 bash,而发 SIGINT 只 中断当前命令,bash 还活着可供下次 run。
  4. scanUntilMarker(:318-368)逐行扫 stdout,遇到标记就解析退出码返回;标记可能出现在行中间 (上条命令输出没有换行结尾时),所以用 strings.Index 定位而非整行相等(:332-337)。

web 层 Run(controller/isolated_session.go:140-214)把每行 stdout 包成 SSE 事件流回,末尾发 IsolatedCompleteIsolatedError

4.3 delete 与空闲 GC

  • DeleteIsolatedSession(isolated_session_ctrl.go:371-393):stop() 杀进程组 (syscall.Kill(-pid, SIGKILL),isolated_session.go:166-179)→ upperMgr.Remove 删 upper → 从 isolatedSessionMap 移除。
  • 后台 GC(gcLoop + CollectIdle,:86-136):每 60s 一轮,清掉已死的会话,以及空闲超过 IdleTimeoutSeconds 的会话(用 runMu.TryLock 避开正在跑的,:125-128)。

4.4 隔离内的文件操作

/v1/isolated/.../files/*.../directories/* 这一整套(controller/isolated_session_files.go) 不进 bash,而是直接对 MergedView 操作:先 GetMergedView(isolated_session_ctrl.go:406-435) 拿到该会话的合并视图,再做 info/list/search/download/upload/rename/chmod/replace/mkdir/remove。

GetMergedView 里有个细节:rw 模式下 upper 直接指向工作区本身(写落原地),overlay 模式才用 会话的 upperDir(:426-432)。这解释了 3.3 里"要双向就用 rw"的建议。

4.5 diff / commit:地基已铺,楼还没盖(Phase 2)

这是全章最需要诚实的地方。相关表面都在,但实现是桩:

表面现状代码
GET .../diff返回 503 not implemented yet (phase 2)controller/isolated_session.go:237-239
POST .../commit返回 503 not implemented yet (phase 2)controller/isolated_session.go:242-244
IsolatedRunner.DiffUpper返回 "diff not implemented yet"isolated_session_ctrl.go:396-398
IsolatedRunner.CommitUpper返回 "commit not implemented yet"isolated_session_ctrl.go:401-403
capabilities 里 commit/diff强制置 false,即便 overlay 探测成功controller/isolated_session.go:270-271

换句话说:upper 层(diff/commit 的数据基础)已经真实存在并在累积改动,Probe 也能探出 overlay 可用,但"把 upper 算成一份 diff""把 upper 合并回 lower"这两步逻辑尚未落地。config.go 里的 DiffMaxBytes(默认 4 GiB,config.go:52)是为 diff 预留的配额,当前也没有任何代码消费它。


5. 巧妙之处(可借鉴的技术)

  • Wrap(cmd) 改写而非新起进程。 隔离对 runtime 层几乎透明:照常建 exec.Cmd,Wrap 一下 再 Start。接口窄到只有四个方法(isolator.go:108-114)。
  • 藏 upper 根防横向泄露。 把整个 upper root --tmpfs 盖掉,namespace 内看不到别人的草稿层 (bwrap.go:79-84)——一行挂载解决多租户隔离。
  • 不靠 user namespace,靠真 setpriv 降权。 规避了 userns 的兼容/权限坑,降权后无 CAP_SETUID 无法回爬(bwrap.go:49115-123)。
  • seccomp 走 memfd。 BPF 字节码放匿名内存文件、经 ExtraFiles 传 fd,不落磁盘、无临时文件清理 (bwrap_linux.go:121-131148-163)。
  • 结束标记切分流式输出。 常驻 bash 复用,靠 echo <marker> $? 精确定位每次 run 的结束与退出码, 还能容忍标记出现在行中间(isolated_session_ctrl.go:260332-337)。
  • 取消用 SIGINT 保命。 中断当前命令但不杀常驻 bash,会话得以复用(:286-294)。
  • overlay 探测挑对文件系统。 明知 overlayfs 不能嵌在 Docker overlay2 上,就故意去 tmpfs 上探 (probe.go:145-147)——一个很实战的兼容判断。
  • clone3→ENOSYS 兼容垫片。 用一条 seccomp 规则让运行时优雅回退到 clone,还能 re-exec 让全程 生效(compat_linux.go)。

6. 边界与局限(诚实)

  • diff / commit / persist 未实现(Phase 2)。 见 §4.5。API 在、能力字段在、配额配置在,但逻辑是桩。
  • SeccompProfileSHA256 声明未填。 预留字段,无代码写入(isolator.go:87)。
  • 只在 Linux 生效。 非 Linux / Windows 全是编译期桩,Available() 恒 false (bwrap_stub.goisolated_session_stub.go)。
  • overlay 模式下 host 写 upper 对运行中进程不可见。 内核 VFS 目录项缓存所致;双向交换要用 rw 模式(merged_view.go:29-38)。
  • seccomp 不随 profile 分档。 strict / balanced 共用同一份内置黑名单(除非配置覆盖), profile 目前只影响 /tmp 与环境变量处理(seccomp_gen.go:79bwrap.go:146-154)。
  • 依赖外部 bwrap 二进制。 找不到就整组隔离能力降级为 503;能力探测三步任一失败即不可用 (probe.go:56-85)。
  • 配额是软的、事后的。 UpperManager 用量靠遍历目录累加,Allocate 前检查一次;单次会话跑飞 的写入不会被实时拦截(upper.go:63-93146-156)。

7. 横向对比

  • 与本项目 04-execd-data-plane:04 是"容器内直接跑",本章是"容器内再套一层 可回滚命名空间"。两者数据面能力(命令/文件)对等,但本章多了 upper 草稿层、seccomp 与更细的挂载控制。
  • 03-runtime-backends 的容器级隔离:03 是外层(Docker/K8s 起容器), 本章是内层(容器里再隔离)。两层正交,合起来是"外容器 + 内命名空间"的双层沙箱。
  • 与外层网络策略见 06-networking-and-vault:本章的 ShareNet 决定 隔离层是否 --unshare-net,与出站策略是不同层次的控制。

8. 代码地图(导航索引)

主题文件关键符号
隔离接口与值类型pkg/isolation/isolator.goIsolatorProfileWorkspaceModeEnvModeCapabilitiesWrapOptions
bwrap 参数拼装pkg/isolation/bwrap.gobuildArgvbwrapWorkspaceSegmentbwrapEnvSegmentstrictEnvBlacklistwrapWithArgv
bwrap 实现与 seccomp 挂载pkg/isolation/bwrap_linux.gobwrapImplNewBwrapWrapfindBwrapcreateMemfdWithData
非 Linux 桩pkg/isolation/bwrap_stub.gobwrapStub
启动能力探测pkg/isolation/probe.goProbeprobeBwrapVersionprobeBwrapSmokeprobeOverlayMount
用户态合并视图pkg/isolation/merged_view.goMergedViewcreateWhiteouthasWhiteoutRenameChmodSearch
upper 层管理与配额pkg/isolation/upper.goUpperManagerAllocateReleaseRemoveCollectErrUpperLimitExceeded
seccomp BPF 生成pkg/isolation/seccomp_gen.gogenerateSeccompDenyBPFdenylistSyscallsfilterKnownSyscalls
隔离配置pkg/isolation/config.goConfigDefaultConfigLoadConfigSeccompOverride
clone3 兼容垫片pkg/clone3compat/compat_linux.goMaybeApplyloadClone3EnosysFilter
常驻 bash 会话pkg/runtime/isolated_session.goisolatedSessionstartstopdead
会话增删改查 + GCpkg/runtime/isolated_session_ctrl.goIsolatedRunnerCreateIsolatedSessionRunInIsolatedSessionscanUntilMarkerGetMergedViewDiffUpper(桩)、CommitUpper(桩)
Windows 桩pkg/runtime/isolated_session_stub.goIsolatedRunner
HTTP 控制器(会话)pkg/web/controller/isolated_session.goIsolatedSessionControllerCreateRunDeleteDiff(桩)、Commit(桩)、Capabilities
HTTP 控制器(文件)pkg/web/controller/isolated_session_files.gogetMergedViewUploadFileDownloadFileListDirectory
请求/响应模型pkg/web/model/isolated_session.goCreateIsolatedSessionRequestIsolatedRunRequestCapabilitiesResponse
路由注册pkg/web/router.go/v1/isolated group(:95-114)
启动接线main.goLoadConfigProbeNewBwrapNewIsolatedRunner(:46-78)