ᕕ( ᐛ )ᕗ Jimyag's Blog

CubeSandbox 与 E2B 共享存储实现原理

Last modified:

Sandbox 的根文件系统通常是临时的:实例销毁后,写入 rootfs 的数据随之消失。共享存储解决的是另一个问题——让一份独立于 Sandbox 生命周期的数据,同时或先后挂载到多个 MicroVM 中。

CubeSandbox 和 E2B 都提供了 Volume API,也都能让多个 Sandbox 读写同一个 Volume,但它们选择了不同的数据路径:

  • CubeSandbox 把后端差异放进 Volume Plugin,由 Cubelet 将插件返回的宿主机目录通过 virtiofs 暴露给 MicroVM。
  • E2B 在所有计算节点上预先挂载共享 NFS,再由宿主机 NFS proxy 向每个 MicroVM 只导出它被授权使用的子目录。

先区分 rootfs 和共享 Volume

Sandbox rootfs 通常由模板克隆。多个实例可以共享模板的只读块,写入各自的 CoW 层。共享 Volume 则让多个实例访问同一文件命名空间。

1
2
3
4
5
6
7
Template rootfs
├── CoW clone ── Sandbox A rootfs
└── CoW clone ── Sandbox B rootfs

Shared Volume
├── mount ── Sandbox A:/workspace
└── mount ── Sandbox B:/workspace

共享模板块不等于共享文件。Sandbox A 写入自己的 rootfs,不会出现在 Sandbox B;两边挂载同一个 Volume 后,文件变化才会通过共同后端传播。

几个容易混淆的概念

下面统一使用一个例子:有一个名为 workspace 的 Volume,同时挂载到 Sandbox A 和 Sandbox B 的 /workspace。A 写入 /workspace/a.txt 后,B 也要能从相同路径读到它。

mount、bind mount 和共享存储不是同一层

mount

把一个文件系统接到某个目录上。

例如,节点把 NFS 服务端的 10.0.0.8:/store 挂载到本机 /mnt/store。此后访问 /mnt/store/a.txt,实际访问的是 NFS 服务端的数据。

mount 不会把远端文件复制到 /mnt/store。如果这个目录原来有文件,挂载期间它们会被新的文件系统遮住,unmount 后才重新出现。

bind mount

给已有目录再开一个入口。

例如,/mnt/store/team-1/vol-42 已经存在,把它 bind mount 到 /data/sandbox-a/workspace 后,这两个路径指向同一棵目录树。在任一路径写入 a.txt,另一路径都能看到,文件没有被复制一份。

两个入口可以有不同的挂载属性。CubeSandbox 会把只读 Sandbox 对应的 bind mount 单独 remount 为 MS_RDONLY,底层共享目录仍可以供另一个 Sandbox 读写。

共享存储

共享存储描述的是多个节点访问同一份后端数据。bind mount 只处理当前节点的目录映射,本身不会让两台机器同步:

1
2
3
4
5
NFS 服务端 10.0.0.8:/store
  ├─ mount 到节点 1:/mnt/store
  │    └─ bind mount 给 Sandbox A
  └─ mount 到节点 2:/mnt/store
       └─ bind mount 给 Sandbox B

Sandbox A 和 B 能看到同一个 a.txt,是因为两个节点挂载了同一个 NFS 后端,而不是因为执行了 bind mount。CubeSandbox 中的 COS、NFS 等插件负责准备共同后端,bind mount 再把插件返回的宿主机目录交给 virtiofs。E2B 的共同后端是 Filestore。

FUSE、COSFS 和 virtiofs 的关系

FUSE

内核与文件系统实现之间的接口和消息协议,不是某一种存储后端。应用仍然调用 openreadwrite,内核负责把文件操作交给实现文件系统的用户态进程。

COSFS

运行在宿主机上的 FUSE 文件系统。宿主机内核把文件操作交给 COSFS 用户态进程,再由 COSFS 调用 COS object API。它解决的是「怎样把 COS 中的对象呈现为宿主机目录」。

virtiofs

把宿主机已有目录呈现到 MicroVM 内。它复用 FUSE 的请求格式,但 guest 内核通过 virtqueue 把请求送到 host backend,不会直接访问 COS API。

以 CubeSandbox 的 COS 插件为例,Sandbox A 打开 /workspace/a.txt 时,请求经过下面两段:

1
2
3
4
5
Sandbox A: open("/workspace/a.txt")
  -> guest virtiofs client
  -> host virtiofs backend
  -> host 上的 COSFS 挂载目录
  -> COS object API

把插件换成 NFS 后,COSFS 这一层会变成宿主机的 NFS client,virtiofs 仍然负责 host 到 guest 的目录共享。

语义和缓存边界

virtiofs 只转发已有目录的文件操作,不会补齐后端缺少的语义。例如,COSFS 后端不能可靠实现跨客户端文件锁时,在外面再套一层 virtiofs 也不会让锁变得可靠。缓存也分层存在:CubeSandbox 的 CacheNone 约束 guest 与 virtiofs backend 之间的缓存,COSFS、NFS client 或远端存储仍可能有自己的缓存策略。

mount namespace、chroot 和 pivot_root

先看 E2B 要解决的问题。Orchestrator 的宿主机可以看到所有团队的 Volume:

1
2
3
4
5
宿主机的 /
├── etc/
└── mnt/persistent-volume-types/default/
    ├── team-1/vol-42/
    └── team-2/vol-99/

Sandbox A 只被授权使用 team-1/vol-42。为它处理 NFS 请求的代码应该看到下面这棵目录树:

1
2
3
NFS handler 看到的 /
├── docs/
└── a.txt

它不能看到宿主机的 /etc,也不能看到 team-2/vol-99

mount namespace:隔离挂载表

mount namespace 决定当前执行线程能看到哪些 mount,以及 / 下面接着哪些文件系统。

新建 mount namespace 后,在里面执行 mount、unmount 或 pivot_root,不会直接改掉其他 namespace 的挂载表。E2B 为每个受限 Volume 创建独立 mount namespace,让不同 Volume 的 NFS handler 可以拥有不同的根挂载。

mount namespace 只解决「看到哪棵挂载树」,不负责判断 Sandbox 是否有权使用这个 Volume。

chroot:改变路径解析的根

执行 chroot("/data/vol-42") 后,进程再访问 /docs/a.txt,会从 /data/vol-42 开始解析。

chroot 改变的是进程使用的路径根。它没有替换当前 mount namespace 的根挂载,也没有自动卸载旧根文件系统。

pivot_root:替换 mount namespace 的根挂载

pivot_root(newRoot, oldRoot)newRoot 变成当前 mount namespace 的 /,再把原来的根临时放到 oldRoot。调用方随后可以卸载 oldRoot,彻底移除返回宿主机旧根的入口。

E2B 的类型名是 chrooted.Builder,实际使用的是 bind mount、pivot_root 和卸载旧根:

1
2
3
4
5
6
7
8
执行前
  /mnt/persistent-volume-types/default/team-1/vol-42

执行 pivot_root 并卸载旧根后
  NFS handler 的 / = team-1/vol-42

客户端请求 /docs/a.txt
  -> 实际访问 team-1/vol-42/docs/a.txt

即使客户端路径里出现 ..,路径解析也只能回到这个 Volume 的 /,不能回到宿主机的 /

E2B 的完整顺序

  1. 根据 NFS 连接的来源地址找到 Sandbox。
  2. 在 Sandbox 配置中查找 guest 请求的 Volume 名。
  3. 从控制面配置取得 teamID、Volume ID 和 Volume type。
  4. 拼出宿主机真实目录 team-<teamID>/vol-<volumeID>
  5. 创建独立 mount namespace。
  6. bind mount 目标目录,执行 pivot_root,卸载旧根。
  7. 把这个受限文件系统交给 NFS handler。

前四步完成授权和 Volume 选择,后三步限制文件访问范围。mount namespace 和 pivot_root 不能代替前面的授权检查。

为什么 E2B 要锁定 OS thread

Linux 的 mount namespace 与执行线程关联,而 Go goroutine 可能在不同 OS thread 之间迁移。E2B 的 tempMountNS把 goroutine 锁定到一个 OS thread,在该线程执行 unshare(CLONE_NEWNS),以后也通过 channel 把这个 Volume 的文件操作送到同一线程执行。

CubeSandbox 中的 mount namespace

Cubelet 也运行在独立 mount namespace,但目的不同。Cubelet 把根挂载设为 rslave:宿主机默认 namespace 的新挂载可以单向传播给 Cubelet,Cubelet 为 Sandbox 创建的 bind mount 不会反向出现在宿主机默认挂载表中。

NFSv3、portmapper 和 file handle

NFSv3 的挂载和文件操作由多组 RPC 程序配合完成。portmapper、mountd 和 NFS 程序分别承担端口发现、取得挂载根对象和文件读写。

先用 E2B guest 中的挂载请求说明:

1
2
3
4
mount -t nfs \
  -o nfsvers=3,mountport=2049,port=2049 \
  <orchestrator-ip>:/workspace \
  /workspace

envd 的完整参数见 packages/envd/internal/api/init.go

这里有两个不同的 /workspace

  • <orchestrator-ip>:/workspace 是 guest 发给 NFS proxy 的逻辑 Volume 名。
  • 最后的 /workspace 是这个 Volume 在 guest 内的本地挂载点。

portmapper:查询 RPC 程序监听在哪个端口

标准 NFSv3 客户端可以先连接 111 端口上的 portmapper,按 RPC program number 查询 mountd 和 NFS 程序的端口。

portmapper 不处理文件读写,也不转发文件内容。它只返回「某个 RPC 程序监听在哪个端口」。

E2B 的 envd 已显式传入 mountport=2049,port=2049,所以当前挂载链路不需要通过 111 端口动态查询。Orchestrator 仍然启动 portmapper,并把 guest 的 111 端口流量重定向给它,以兼容标准发现流程。当前注册表把 mountd 和 NFSv3 都指向 2049。

mountd:把逻辑挂载目标换成根 file handle

guest 内核向 mountd 发送:

1
MOUNT /workspace

E2B NFS proxy 不会把 /workspace 当成宿主机路径。它先根据连接来源找到 Sandbox,再在 Sandbox.Config.VolumeMounts 中查找名为 workspace 的配置,最后定位到:

1
/mnt/persistent-volume-types/<type>/team-<teamID>/vol-<volumeID>

完成授权和 pivot_root 后,mountd 返回这个受限根目录的 file handle。Linux mount 命令执行到这里,才拿到后续访问文件所需的根对象。Volume 解析入口见 nfsproxy/chroot/nfs.go

file handle:NFS 服务端对象的标识符

file handle 不是宿主机路径,也不是文件内容。它是 NFS 服务端用来标识文件或目录的不透明值。

NFSv3 的后续请求通过「父目录 handle + 文件名」逐级查找:

1
2
3
4
5
MOUNT "/workspace"      -> Volume 根目录 handle H1
LOOKUP H1, "docs"       -> docs 目录 handle H2
LOOKUP H2, "a.txt"      -> 文件 handle H3
READ H3                  -> 读取文件内容
WRITE H3                 -> 写入文件内容

guest 只知道 H3 表示目标文件,不知道它在宿主机上对应 team-<teamID>/vol-<volumeID>/docs/a.txt

NFS 程序:使用 file handle 执行文件操作

mountd 只负责取得 export 的根 handle。挂载完成后的 LOOKUPGETATTRREADWRITECREATEREMOVE 由 NFS 程序处理。

E2B 把 mountd 和 NFSv3 程序都注册到 2049 端口。guest 对 2049 的连接被宿主机重定向到对应节点的 userspace NFS proxy。

完整链路是:

1
2
3
4
5
envd 执行 mount
  -> mountd: MOUNT /workspace
  -> 根据来源 Sandbox 解析并授权 Volume
  -> 返回受限 Volume 根目录的 file handle
  -> guest 内核使用 NFS RPC + file handle 读写文件

E2B 中存在两段 NFS 连接

第一段是 guest 到 Orchestrator NFS proxy,固定使用 NFSv3。

第二段是计算节点到 Filestore。它在节点启动时已经挂载,协议版本由 persistent_volume_types 配置决定,可以使用后端支持的 NFSv3 或 NFSv4.1。

因此,即使节点到 Filestore 使用 NFSv4.1,guest 面向 NFS proxy 的协议仍然是 NFSv3。

POSIX 语义与缓存一致性

文件可见性

「Sandbox A 写入后,Sandbox B 能看到文件」只验证了最基本的文件可见性。应用还可能依赖下面这些行为:

语义 workspace 中的具体例子
原子 rename A 先写 result.tmp,再 rename 为 result.json;B 应该看到旧文件或完整的新文件,而不是改名到一半的状态
hard link a.txtb.txt 指向同一个 inode;修改其中一个名字对应的内容,另一个名字也能读到
文件锁 A 锁住 job.lock 后,B 的加锁请求应该等待或失败,而不是同时获得同一把锁
权限 A 创建 mode 为 0600 的文件后,B 是否能读取取决于双方的 UID、GID 和后端权限语义
fsync A 调用 fsync 后,后端是否已经把数据持久化到能承诺的故障边界,而不只是留在某层缓存中

文件锁和并发写入

这些能力不是一个非黑即白的「POSIX 兼容」开关。比如 E2B 能正常使用 NFSv3 读写文件,但当前用户态服务没有注册 NLM,不能据此认为 Sandbox 之间的 flock 或 POSIX record lock 一定可用。缺少可靠锁时,A 和 B 同时覆盖同一个文件也不会因为它是共享 Volume 就自动串行,应用仍要使用后端实际支持的协调机制。

缓存一致性

缓存还会影响 A 的修改何时对 B 可见。例如,B 第一次查找 new.txt 时文件不存在,它可能缓存「这个名字不存在」;A 随后创建 new.txt,B 在缓存过期前仍可能得到不存在的结果。属性缓存也可能让 B 暂时看到旧的文件大小和修改时间。

CubeSandbox 和 E2B 的缓存设置

CubeSandbox 的 virtiofs 使用 CacheNone。E2B guest 使用 noac,lookupcache=noneTerraform 的默认节点到 Filestore mount也使用这两个选项;部署者为 Volume type 提供自定义 mount options 后,节点侧行为会以自定义配置为准。这些设置会减少旧属性和旧目录项的缓存时间,让 A、B 更快看到对方的变化,代价是更多 metadata 请求和更高的后端压力。

可见性、并发和持久化

文件可见性、并发正确性和持久化是三个问题。关闭缓存主要缩短 B 看到 A 更新的时间;原子 rename 和文件锁决定并发操作是否互相破坏;fsync 以及后端的持久化承诺决定节点或存储故障后数据是否还在。任何一项都不能替代另外两项。

两种数据路径

  flowchart LR
    subgraph CubeSandbox
        CA["Sandbox 进程"] --> CV["guest virtiofs"]
        CV --> CH["宿主机 virtiofs backend"]
        CH --> CB["每个 Sandbox 的 bind mount"]
        CB --> CP["Volume Plugin hostPath"]
        CP --> CS["COS / S3 / NFS / 其他后端"]
    end

    subgraph E2B
        EA["Sandbox 进程"] --> EN["guest NFSv3 client"]
        EN --> EP["宿主机 NFS proxy"]
        EP --> EC["team/volume chroot"]
        EC --> EH["节点预挂载 NFS"]
        EH --> EF["Google Filestore"]
    end

CubeSandbox 的统一边界是 hostPath:插件只需要让后端数据在 Cubelet 节点上表现为一个目录,后续交给 bind mount 和 virtiofs。E2B 的统一边界是节点上的共享 NFS:Orchestrator 直接在 Filestore 目录上创建 Volume,MicroVM 再通过本机 NFS proxy 访问该目录。

CubeSandbox:Volume Plugin 加 virtiofs

两种入口:Host Mount 与托管 Volume

CubeSandbox 有两种持久目录入口:

入口 数据从哪里来 生命周期由谁管理 跨节点条件
metadata["host-mount"] Cubelet 节点上已经存在的目录 集群管理员或外部系统 每个候选节点必须有语义相同的目录,例如都预挂同一 NFS
POST /volumes + volume_mounts Volume Plugin 创建并 Attach 的后端 CubeMaster、Cubelet 与插件共同管理 插件必须在不同节点 Attach 到同一后端命名空间

Host Mount 的请求是一个 JSON 字符串:

1
2
3
4
5
6
7
mounts = json.dumps([{
    "hostPath": "/data/shared/models",
    "mountPath": "/models",
    "readOnly": True,
}])

Sandbox.create(metadata={"host-mount": mounts})

CubeMaster 默认只允许 /data/shared/ 下的路径,也可以通过 extra_conf.allowed_host_mount_prefixes 配置更窄的可信前缀。它会清理 .. 并阻止 / 成为允许前缀,但目录权限仍沿用宿主机的 UID、GID 和 mode;readOnly 不会替用户修正权限。

Host Mount 没有 Create/Destroy 后端生命周期,也没有 Volume ID 和引用计数。它适合管理员预先准备的数据集或模型目录;若目录只存在于某一节点,调度到其他节点后就无法挂载。完整行为见 persistent-storage.md

Pause 允许使用 Host Mount,Resume 会以同一个 Sandbox ID 重新 bind 原来的宿主机路径;CommitSandbox 创建的用户快照则拒绝依赖 Host Mount 的 Sandbox。检查入口在 template_ops.go

Volume API 与生命周期

CubeSandbox 提供与 E2B 形状接近的 Volume API:

1
2
3
4
POST   /volumes
GET    /volumes
GET    /volumes/{volumeID}
DELETE /volumes/{volumeID}

SDK 的基本使用方式是先创建 Volume,再把它挂到 Sandbox 路径:

1
2
3
4
5
6
from cubesandbox import Sandbox, Volume

volume = Volume.create("workspace", driver="cos")

with Sandbox.create(volume_mounts={"/workspace": volume}) as sandbox:
    sandbox.files.write("/workspace/result.txt", "hello")

退出 with 会销毁 Sandbox,但不会删除 Volume;Volume.destroy() 才会删除后端数据。

版本和客户端兼容边界

这套 Volume 能力要求 CubeMaster、CubeAPI、Cubelet 和 Python cubesandbox SDK 都不低于 0.6.0。滚动升级期间,新 CubeMaster 配旧 Cubelet 时创建 Volume 可以成功,但 Sandbox 的 volumeMounts 不会真正挂载,因此不能只根据控制面 API 成功判断数据面可用。

CubeAPI 的 /volumes REST 形状兼容 E2B 协议,不代表官方 E2B Python SDK 可以直连 CubeSandbox;当前官方 SDK 将后端固定为 e2b.cloud,应使用 cubesandbox SDK 或原始 HTTP。版本矩阵和限制见 volume-plugin.md

Controller 和 Node 两类 Hook

CubeSandbox 的 Volume Plugin 模型接近 CSI 的职责拆分,但不是直接实现 Kubernetes CSI 协议:

角色 调用者 Hook 作用
Controller CubeMaster CreateDestroy 创建或删除后端资源
Node Cubelet AttachDetach 在计算节点挂载后端,返回 hostPath

Controller 接口定义在 CubeMaster/pkg/volume/plugin/plugin.go

1
2
3
4
5
type ControllerPlugin interface {
    Name() string
    Create(ctx context.Context, volumeID, name string) (*VolumeInfo, error)
    Destroy(ctx context.Context, volumeID string) error
}

Node Hook 支持外部二进制和 gRPC 服务。Attach 返回宿主机目录,而不是块设备:

1
2
3
4
message AttachResponse {
  string host_path = 2;
  map<string, string> metadata = 4;
}

完整协议见 volumeplugin.protometadata 由插件产生,Cubelet 原样保存并在 Detach 时传回,适合存放挂载点等清理所需状态;Controller 返回的 private_data 则由 CubeMaster 持久化,再传给 Node Attach

插件负责把后端挂成宿主机目录:

  • COS 插件可以运行 cosfs
  • S3 插件可以运行 s3fs
  • NFS 插件可以执行 NFS mount。
  • 本地或分布式文件系统插件也可以直接返回已有目录。

插件注册也是数据面的信任配置

同一个 driver 必须在 CubeMaster 和 Cubelet 两侧注册。Controller 与 Node 都支持按次启动的 binary Hook,或连接 Unix socket 的 RPC Hook:

1
2
3
4
5
# CubeMaster conf.yaml
volume_plugins:
  - name: cos
    type: binary
    binary_path: /opt/cube/plugins/cube-volume-cos
1
2
3
4
5
6
7
8
# Cubelet config.toml
[plugins."io.cubelet.internal.v1.storage"]
  volume_plugin_base_dir = "/data/cube-shared/volume"

[[plugins."io.cubelet.internal.v1.storage".volume_plugins]]
  name = "cos"
  type = "binary"
  binary_path = "/opt/cube/plugins/cube-volume-cos"

API 未指定 driver 时,CubeMaster 使用配置列表的第一项。两侧名称不一致会出现「控制面创建成功、Cubelet 找不到 driver」;binary 路径或 RPC socket 错误则在对应 Hook 执行时失败。

volume_plugin_base_dir 决定插件 hostPath 的允许范围。对该目录的写权限属于节点特权:写入者可用 symlink 或替换 mount 改变 Sandbox 最终看到的目录。binary 插件由 Cubelet fork,继承 Cubelet 的 mount namespace。rslave 只把宿主机默认 namespace 的挂载事件单向传入;其他 namespace 中的私有挂载保持隔离,插件创建的挂载也不会反向传播到宿主机默认 namespace。

从 API 到 Cubelet

一次挂载请求经过以下调用链:

  sequenceDiagram
    participant SDK
    participant API as CubeAPI
    participant M as CubeMaster
    participant L as Cubelet
    participant P as Node Plugin
    participant V as virtiofs
    participant VM as MicroVM

    SDK->>API: Sandbox.create(volume_mounts)
    API->>M: VolumeSpec + VolumeMount
    M->>M: 查询 VolumeRecord
    M->>L: driver + private_data + mount path
    L->>P: Attach(volumeID, refCount, volumeBaseDir)
    P-->>L: hostPath + metadata
    L->>L: hostPath bind mount 到 Sandbox 专用目录
    L->>V: 配置 SharedDir、AllowedDirs、ReadOnly
    V-->>VM: 在目标路径挂载

CubeMaster 根据 Volume 名称读取 t_cube_volume,把 driverprivate_data 写入传给 Cubelet 的 annotation。对应转换在 CubeMaster/pkg/service/sandbox/util.go

Cubelet 收到请求后先调用插件:

1
res, err := mgr.Attach(ctx, req)

随后检查插件返回的 hostPath 是否位于 volume_plugin_base_dir 内。默认根目录是 /data/cube-shared/volume,直接返回 /etc 等目录会被拒绝。检查使用 filepath.Cleanfilepath.Rel,不解析 symlink,因此只约束词法路径。volume_plugin_base_dir 只能由受信任的节点管理员和插件写入。

验证通过后,Cubelet 建立每个 Sandbox 独立的 bind mount:

1
2
flags := uintptr(unix.MS_BIND | unix.MS_REC)
err := unix.Mount(res.HostPath, bindDest, "", flags, "")

这段逻辑在 Cubelet/storage/pluginvolume.go。共享后端 mount 和 Sandbox bind mount 被分成两层:前者可以被同一节点上的多个 Sandbox 复用,后者负责为每个 Sandbox 建立单独的暴露边界。

virtiofs 如何把目录送进 MicroVM

Linux virtiofs 文档将 virtiofs 定义为 host 与 guest 之间共享文件系统的机制。guest 内核作为 FUSE client,文件系统请求通过 virtqueue 交给 host 侧处理,不需要给 guest 暴露后端存储网络。

Cubelet 按只读属性聚合共享目录,生成 virtiofs 配置:

1
2
3
4
5
6
VirtioBackendFsConfig{
    SharedDir:   shareDir,
    AllowedDirs: bindPaths,
    ReadOnly:    readOnly,
    Cache:       constants.VirtiofsCacheNone,
}

源码见 Cubelet/plugins/cbri/cubeboxcbri/virtiofs.goAllowedDirs 限制 virtiofs backend 可以暴露的 bind 路径,CacheNone 则避免 guest 长时间持有旧缓存。

只读属性属于单次 Sandbox 挂载。同一 Volume 可以在 Sandbox A 中读写,在 Sandbox B 中只读。Cubelet 同时执行两层限制:

  1. 将 B 的 bind mount 重新挂载为 MS_RDONLY
  2. 将 B 所属的 virtiofs 通道标记为 ReadOnly

这样即使底层 COSFS 或 NFS 是读写挂载,也能在 Sandbox 边界收窄权限。

COS 插件如何实现共享

仓库提供的 COS 参考插件把一个 Volume 映射为 Bucket 中的一个 prefix:

1
2
COS bucket
└── volumes/<volumeID>/

Controller Create 上传一个 .keep 对象,Destroy 删除整个 prefix。Node 第一次 Attach 时运行 cosfs,将该 prefix 挂到:

1
/data/cube-shared/volume/cos-<volumeID>

同一节点后续 Sandbox 复用这个 mount;其他节点也用相同 Volume ID 挂载相同 COS prefix。因此「共享」来自共同对象存储命名空间,而不是来自某个 Cubelet 的本地目录。参考实现和部署要求见 examples/volume/cos/README.md

COSFS/S3FS 把对象存储模拟成文件系统,但其 rename、锁、权限和小文件性能不一定等价于 NFS。Plugin API 只统一生命周期和挂载入口,不统一后端的 POSIX 语义。

两级引用计数

Volume 可以被多个 Sandbox、多个节点同时使用。如果只统计 Sandbox 数量,节点上的共享 FUSE mount 很难安全拆除;如果只统计节点数量,又不能判断本节点最后一个 Sandbox 何时退出。CubeSandbox 因此维护两级引用:

  1. Cubelet 用持久化 RefCountStore 统计本节点的 Sandbox 引用。
  2. CubeMaster 的 t_cube_volume.refcount 统计正在引用该 Volume 的节点数。

同一 Volume 的本地 AttachDetach 还由 per-volume mutex 串行化,避免两个首个 Attach 同时 mount,或最后一个 Detach 与新 Attach 交错。核心逻辑见 Cubelet/plugins/volume/manager.go

1
2
3
4
本节点引用:0 -> 1    Cubelet 调用插件挂载,并向 CubeMaster 报告 referenced=1
本节点引用:1 -> 2    复用已有宿主机 mount,不报告跨节点变化
本节点引用:2 -> 1    保留宿主机 mount
本节点引用:1 -> 0    插件卸载,并向 CubeMaster 报告 referenced=0

CubeMaster 收到节点级 0 -> 11 -> 0 事件后,原子增减数据库计数。删除 Volume 时只要计数非零就返回冲突,不会在运行中的 Sandbox 脚下删除后端。实现分别在 refcount.govolume.go

Attach 和 Detach 的失败回滚

Cubelet 在调用插件前增加本地引用。插件 Attach 失败时,Manager 立即释放刚增加的引用,并取消准备上报给 CubeMaster 的 0 -> 1 事件。插件返回的 hostPath 校验失败时,Cubelet 还会调用 Detach,清理插件已经建立的宿主机 mount。

Attach 成功后,Cubelet 先把 driver、hostPath 和插件 metadata 写入 PluginVolumeBackendInfos,再创建 bind mount 和只读 remount。后续步骤失败时,Sandbox 创建回滚可以用这份信息调用 Detach 并卸载已经建立的 bind mount。对应代码在 pluginvolume.golocal.go

Detach 的顺序相反:先释放本地引用,再调用插件。插件卸载失败时,Manager 重新增加引用,并取消 1 -> 0 事件,避免 CubeMaster 误以为该节点已经不再使用 Volume。Cubelet 启动时还会用实际存活的 Sandbox ID 清理本地 RefCountStore 中的过期记录,见 manager.gostore.go

回滚本身也可能失败。非法 hostPath 后的 Detach 失败只记录 warning;销毁阶段会汇总各插件的 Detach 错误,但仍继续清理其他 Volume 和 bind mount。失败的插件保留本地引用,不会上报节点级卸载完成。

跨节点计数没有独立事务:Cubelet 把节点级变化放进 Sandbox create/destroy 响应的 ext_info,CubeMaster 收到响应后再更新 t_cube_volume.refcount。响应丢失会造成暂时漂移,所以这个计数是删除保护,不是后端挂载状态的强一致事实。

暂停和恢复

共享 Volume 不属于 Sandbox 快照。暂停时 Cubelet 会 Detach 插件 Volume,并把本节点引用变化返回 CubeMaster;CubeMaster 在 pause metadata 中记住 Volume ID。恢复前,它会重新确认这些 Volume 记录仍然存在,再让目标节点重新 Attach。

  • rootfs 和内存状态由快照恢复。
  • Volume 内容始终以外部后端当前状态为准。
  • 暂停期间如果 Volume 已不可用,恢复会失败,而不是得到一份旧的 Volume 副本。

相关检查在 sandbox_resume_pause.go

多租户边界

CubeAPI 路由声明了 API Key 认证,但当前 VolumeRecord 只有 volume_idnamedriver、token、插件私有数据和引用计数,没有 team 或 tenant 字段。数据模型中的名称也是全局唯一。

这套数据模型提供集群级 Volume 生命周期和 Sandbox 挂载隔离,没有 SaaS 多租户的数据归属字段。多租户部署还需要在 API、数据模型和查询链路中加入租户 scope。

E2B:Filestore 加 Sandbox NFS proxy

启用条件

E2B 的 Volume 创建和 Sandbox 挂载都受 persistent-volumes Feature Flag 控制。创建 Sandbox 时还会检查模板中的 envd 版本,最低要求为 0.5.14;旧模板即使 API 和 Orchestrator 已升级,也不能完成 guest 内挂载。检查入口见 sandbox_create.go

Volume API 还依赖内容访问 token 的签名配置。VOLUME_TOKEN_ENABLED=true 时,issuer、算法、私钥和 key name 缺一都会导致 API 启动校验或 Volume 创建失败;可通过 VOLUME_TOKEN_DURATION 控制默认一小时的 token 生命周期。配置模型见 cfg/model.go

remote/BYOC cluster resource provider 返回空 Volume type 列表,当前不支持 Persistent Volume。证据见 resources_remote.go

API 中已经有按 BYOC cluster domain 生成 volume-content token audience 的代码,但 remote provider 没有可用的 Volume type。domain 路由代码存在,不代表 BYOC Persistent Volume 已经可用。

Filestore 是所有节点的共同底座

E2B 的 Terraform 会按 persistent_volume_types 创建 Google Filestore。每种类型对应一个独立 file share,可配置容量、tier、位置和 NFS 版本:

1
2
3
4
5
6
7
8
resource "google_filestore_instance" "persistent-volumes" {
  protocol = format("NFS_V%s", replace(local.nfs_version, ".", "_"))

  file_shares {
    capacity_gb = var.capacity_gb
    name        = var.key
  }
}

源码见 iac/provider-gcp/persistent-volume-types/main.tf。计算节点启动时,将每个 share 挂到统一路径:

1
/mnt/persistent-volume-types/<volume-type>

启动脚本把 NFS 配置写入 /etc/fstab 后执行 mount,见 start-client.sh。Google Filestore 当前支持 NFSv3,并在部分 tier 支持 NFSv4.1,具体范围以 Filestore 官方协议说明为准。

节点先挂载 Filestore;MicroVM 内还会发起一次面向本机 NFS proxy 的 NFSv3 mount。

Volume API 创建的只是目录和元数据

E2B 的 volumes 表用 team_id 建立租户归属,并以 (team_id, name) 保证团队内名称唯一:

1
2
3
4
5
6
7
8
9
CREATE TABLE volumes (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    team_id     UUID NOT NULL,
    name        VARCHAR(250) NOT NULL,
    volume_type VARCHAR(250) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (team_id) REFERENCES teams(id),
    UNIQUE (team_id, name)
);

迁移文件见 20260304120000_volumes.sql

实际目录由 volume_type、team UUID 和 volume UUID 共同决定:

1
2
3
4
5
filepath.Join(
    volumeTypeRoot,
    fmt.Sprintf("team-%s", teamID),
    fmt.Sprintf("vol-%s", volumeID),
)

路径构造在 chrooted/builder.goCreateVolume 只对这个路径执行 os.MkdirAll(fullPath, 0700),再由 API 将记录写入 PostgreSQL。

Filestore 是容量池,Volume 是其中按 team 和 UUID 隔离的目录,不需要单独创建 Filestore 或 NFS export。

创建顺序是「先目录,后数据库」:API 先选择一个支持该 Volume type 的 Orchestrator,调用 CreateVolume 创建目录,再插入 volumes 记录。如果名称冲突或数据库写入失败,API 会异步调用 Orchestrator DeleteVolume 清理目录。这个补偿是 best-effort,清理失败只记录错误。实现见 volume_create.gocleanupOrchestratorVolume

NFS proxy 隔开 guest 与 Filestore

E2B 在每个 Orchestrator 节点启动一个 userspace NFS proxy 和 portmapper。Sandbox 以为自己在访问 Orchestrator 的 NFS 服务,实际流量会在宿主机重定向:

1
2
Sandbox -> orchestrator-in-sandbox-IP:111  -> host portmapper
Sandbox -> orchestrator-in-sandbox-IP:2049 -> host NFS proxy:5011

iptables 规则见 network.go,服务启动入口见 factories/run.go

NFS proxy 如何做授权和目录隔离

Orchestrator 传给 envd 的目标形如:

1
<orchestrator-in-sandbox-IP>:/<volume-name>

NFS proxy 收到 mount 请求后不是直接把 <volume-name> 当宿主机路径。它按以下顺序解析:

  1. 根据连接来源地址查到发起请求的 Sandbox。
  2. 要求 NFS mount path 只能是单层的 /<volume-name>
  3. 在该 Sandbox 的 Config.VolumeMounts 中查找同名项。
  4. 从 Sandbox metadata 读取权威 teamID
  5. volume_type + teamID + volumeID 构造真实目录。
  6. 在独立 mount namespace 中 pivot root 到该目录,只把 chroot 后的文件系统交给 NFS handler。

核心代码在 nfsproxy/chroot/nfs.go

1
2
3
sbx, err := h.sandboxes.GetByHostPort(remoteAddr.String())
// 校验 volumeName 属于 sbx.Config.VolumeMounts
fs, err := h.builder.Chroot(ctx, volumeMount.Type, teamID, volumeMount.ID)

隔离边界不依赖 guest 自报 team ID 或 volume ID:guest 只能请求一个逻辑名称,NFS proxy 再结合来源 Sandbox 的控制面配置解析真实目录。即使 guest 猜到其他团队的 UUID,也不能通过 mount path 直接指定它。

envd 在 guest 内执行 NFS mount

Orchestrator 初始化 Sandbox 时,把 Volume 列表转换成 envd 配置:

1
2
3
4
VolumeMount{
    NfsTarget: orchestratorIP + ":/" + mount.Name,
    Path:      mount.Path,
}

envd 收到 /init 后并行挂载各个 Volume:

1
{"mount", "-v", "-t", "nfs", "-o", "fg,hard," + nfsOptions, nfsTarget, path}

实现见 packages/envd/internal/api/init.go。关键 mount 选项包括:

  • nfsvers=3:guest 面向的是 Orchestrator 自带的 NFSv3 proxy。
  • hard:I/O 失败时持续重试,而不是把短暂网络问题转成应用层错误。
  • noaclookupcache=none:禁用属性和目录项缓存,减少多个 Sandbox 之间看到旧状态的时间窗口。
  • noacl:不使用 NFS ACL。

禁用缓存有利于共享一致性和 pause/resume,但会增加 metadata 请求和延迟。它是明确的正确性优先选择,不代表 NFS 本身没有缓存。

NFSv3 的文件锁由独立的 NLM/lockd 协议处理,不属于 NFS 文件读写 RPC。当前 Orchestrator portmapper 只注册 NFSv3 和 mountd,没有注册 NLM;因此不能把 Filestore 本身的锁能力直接等同于 Sandbox 内可用的跨实例锁。注册项见 portmap/server.go

envd 还用 lifecycle ID 记录每个目标路径对应哪次 Sandbox 生命周期。恢复时 lifecycle 改变,它会先卸载旧 NFS mount,再建立新 mount;相同 lifecycle 的重复 /init 则跳过,避免反复挂载。

跨节点共享路径

Sandbox A 和 B 挂载同一个 workspace Volume 时,请求分别进入所在节点的 NFS proxy,最终路径相同:

1
/mnt/persistent-volume-types/<type>/team-<teamID>/vol-<volumeID>

如果 A 和 B 位于不同计算节点,两台节点也都挂载同一个 Filestore share。因此共享来自 Filestore,而 NFS proxy 只负责导出授权后的视图。

这条链路中有两层文件系统转发:

1
2
3
4
5
6
guest VFS
  -> guest NFSv3 client
  -> Orchestrator userspace NFS proxy
  -> host VFS/chroot
  -> host NFS client
  -> Filestore

这条路径比 virtiofs 多一层 NFS 协议处理,授权集中在 guest 与宿主机之间的 NFS proxy。

Volume content 是另一条数据入口

E2B 还允许 SDK 在 Sandbox 外直接管理 Volume 文件。控制面 API 为 Volume 签发短期 JWT,SDK 再访问 e2b-dev/belt 提供的 volume-content API。

E2B 有两条数据面:

  • Sandbox 内:envd 建立 NFS mount,经 Orchestrator NFS proxy 访问。
  • Sandbox 外:SDK 携带 JWT 访问 volume-content API。

控制面 API 只负责签发 token 和返回目标 domain,不代理文件内容。调用链见 docs/ARCHITECTURE.md

创建或查询 Volume 时,API 生成 JWT,核心 claim 包括:

1
2
3
4
5
sub/teamid = team UUID
volid      = volume UUID
voltype    = persistent volume type
aud        = https://api.<目标 domain>
exp        = 签发时间 + VOLUME_TOKEN_DURATION

token 同时带 kid 和兼容旧验证端的 tokid header。aud 将凭据绑定到内容服务 origin:默认集群使用部署的 DOMAIN_NAME,BYOC 路由代码则选择 cluster domain,避免一个 origin 的 token 被拿到另一个 origin 使用。签发实现见 volume_token.go,domain 选择见 volume_util.go

Orchestrator 的 VolumeService

Orchestrator 注册了独立的 gRPC VolumeService,直接操作节点已经挂载的共享目录:

RPC 行为
CreateDir 创建目录,可选递归创建父目录
ListDir 递归列目录,depth 范围为 1~10
CreateFile client streaming 写文件,支持拒绝覆盖或强制覆盖
GetFile server streaming 读文件,每块最多 1 MiB
DeletePath 删除文件或目录,拒绝删除 Volume 根目录
StatPath 返回类型、大小、mode、UID、GID 和时间信息
UpdatePath 修改 mode、UID 和 GID

接口定义见 volume.protoCreateFile 收到 start message 后建立文件,持续接收 content message,收到 finish 后执行 fsyncchownchmodGetFile 则先返回文件大小,再流式发送内容。实现分别在 file_create.gofile_get.go

所有路径操作先解析 volume_typeteamIDvolumeID,用 chrooted.Builder 切换到对应 Volume 根目录,再把请求路径转成绝对路径并执行 filepath.Clean。即使请求包含 ..,路径也只能在这个 chroot 内解析。公共入口在 service.go。JWT 限制内容请求属于哪个 team 和 Volume,VolumeService 则限制文件操作落在哪个目录。

删除和只读边界

E2B 当前公开 SandboxVolumeMount 只有 namepath,Orchestrator gRPC 结构只有 idpathtypename,没有 per-mount readOnly 字段。OpenAPIProto都体现了这个边界,所以 Sandbox 内挂载按读写处理。

删除路径也与 CubeSandbox 不同。API 先删除 PostgreSQL 记录,返回 204 后异步调用 Orchestrator;Orchestrator 对共享目录执行 os.RemoveAll。当前这条路径没有检查运行中 Sandbox 的挂载引用。异步删除失败时只记录错误,数据库记录已经不存在,后端目录会成为孤立数据。相关实现见 volume_delete.gopackages/orchestrator/pkg/volumes/volume_delete.go

这不是说所有删除都会立刻破坏现有文件句柄:具体表现还受 NFS 和已打开 inode 状态影响。但从控制面语义看,它没有 CubeSandbox 那种「引用非零时拒绝删除」的明确保护,调用方应先销毁或卸载使用该 Volume 的 Sandbox。

功能对比

维度 CubeSandbox E2B infra
持久目录入口 Host Mount;托管 Volume Plugin 托管 Persistent Volume
后端抽象 Controller/Node Volume Plugin persistent_volume_types 对应 Filestore/NFS
VM 暴露协议 virtiofs NFSv3
共享来源 插件连接的共同后端 所有节点挂载同一 Filestore share
每个 Volume 的物理形态 由插件决定,常见为对象存储 prefix 或 NFS 子目录 team-UUID/vol-UUID 目录
多节点 各节点插件 Attach 同一后端 各节点预挂载同一 Filestore
每次挂载只读 支持,bind mount 和 virtiofs 双重限制 当前 API/Proto 不支持
删除保护 本地与跨节点两级引用计数,使用中返回 409;跨节点事件可能随响应丢失而漂移 当前删除路径无活跃挂载引用检查
租户归属 当前 VolumeRecord 无 tenant/team 字段 PostgreSQL team_id + NFS proxy chroot
Sandbox 外文件 API 取决于插件 token 和外部服务 独立 volume-content API + JWT
一致性 取决于插件后端;virtiofs CacheNone 只覆盖 guest 到 host 这一层 guest 和默认 host mount 都使用 noac,lookupcache=none;自定义 host mount options 可以改变行为
POSIX 语义 NFS 类插件较完整;COSFS/S3FS 取决于实现 经过 userspace NFS proxy;当前未注册 NLM,不能直接继承 Filestore 的锁语义
创建失败 Controller/Node Hook 分别返回错误;Cubelet 回滚 Attach 和本地引用 目录创建成功、数据库写入失败时异步清理目录
启用条件 平台与 SDK ≥ 0.6.0,Master/Node driver 对齐 Feature Flag、envd ≥ 0.5.14、token signer、Volume type
当前 BYOC 由部署方提供插件和共同后端 remote cluster provider 暂不提供 Persistent Volume

两种实现的取舍

CubeSandbox 优先后端可插拔

CubeSandbox 把稳定接口放在 hostPath,适合需要接入 COS、S3、NFS 或已有分布式文件系统的私有化部署。后端凭据留在节点插件,单次 Sandbox 挂载可以独立设置只读。

插件需要处理挂载幂等、并发、失败回滚和后端文件系统兼容性。平台统一 Hook,但不会补齐对象存储的 POSIX 语义。

E2B 优先固定共享文件系统上的隔离

E2B 把后端收敛为节点已经挂载的 Filestore,将 team 目录归属和 guest mount 授权集中在 NFS proxy。Volume 创建和删除是目录操作,所有 Sandbox 使用同一种 guest 协议。

这套实现依赖 NFS 基础设施,guest 文件请求还会经过 userspace NFS proxy;禁用缓存会增加 metadata 成本。若要接入对象存储,节点侧还需要提供兼容的 VFS 语义。

日志和指标

共享存储故障至少要区分控制面、节点 Attach、guest mount 和后端 I/O 四层。只看 POST /volumes 返回 200,无法证明 Sandbox 已经能读写。

CubeSandbox 在 Cubelet 的关键日志统一带 [plugin_volume],可看到 driver、Volume ID、hostPath、bind path、只读标记及 Attach/Detach 前后的 refcount,日志点见 pluginvolume.go。排障时还应进入 Cubelet 的 mount namespace 查看真实挂载,而不是只看宿主机根 namespace:

1
2
CPID=$(pgrep -f "cubelet --config" | head -1)
nsenter -t "$CPID" -m -- mount | grep -E 'volume|fuse'

排查顺序是 CubeMaster 的 Volume 记录与跨节点 refcount、Cubelet [plugin_volume] 日志、本地持久化 refcount、Cubelet namespace 内的后端 mount 和 bind mount,最后是 guest 内的目标路径。API 成功但 guest 没有目录时,先检查 driver 名称、节点版本、插件执行错误和 virtiofs 配置。

E2B 的 NFS proxy 已注册以下 OpenTelemetry 指标:

  • orchestrator.nfsproxy.calls.total:按 operationresult 统计调用数。
  • orchestrator.nfsproxy.call.duration:按相同维度记录毫秒级耗时。
  • nfs.chroot.mountsnfs.chroot.unmountsnfs.chroots.gauge:观察受限文件系统视图的创建、释放和当前数量。

指标定义分别在 nfsproxy/metrics/util.gonfsproxy/chroot/nfs.go。调用数上升且延迟恶化时,继续检查 host NFS client 与 Filestore;chroot gauge 持续增长而 unmount 不增长时,检查 Sandbox 网络释放和 lifecycle 清理。

#Sandbox #MicroVM #存储 #Virtiofs #NFS #Firecracker