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

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

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

<!--more-->

## 先区分 rootfs 和共享 Volume

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

```text
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 只处理当前节点的目录映射，本身不会让两台机器同步：

```text
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**

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

**COSFS**

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

**virtiofs**

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

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

```text
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：

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

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

```text
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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/chrooted/chroot.go#L27-L107)，实际使用的是 bind mount、`pivot_root` 和卸载旧根：

```text
执行前
  /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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/chrooted/mountns.go#L171-L260)把 goroutine 锁定到一个 OS thread，在该线程执行 `unshare(CLONE_NEWNS)`，以后也通过 channel 把这个 Volume 的文件操作送到同一线程执行。

**CubeSandbox 中的 mount namespace**

Cubelet 也运行在独立 mount namespace，但目的不同。[Cubelet 把根挂载设为 `rslave`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/cmd/cubelet/main.go#L145-L205)：宿主机默认 namespace 的新挂载可以单向传播给 Cubelet，Cubelet 为 Sandbox 创建的 bind mount 不会反向出现在宿主机默认挂载表中。

### NFSv3、portmapper 和 file handle

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

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

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

envd 的完整参数见 [`packages/envd/internal/api/init.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/envd/internal/api/init.go#L503-L624)。

这里有两个不同的 `/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 端口流量重定向给它，以兼容标准发现流程。[当前注册表](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/portmap/server.go#L46-L62)把 mountd 和 NFSv3 都指向 2049。

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

guest 内核向 mountd 发送：

```text
MOUNT /workspace
```

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

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

完成授权和 `pivot_root` 后，mountd 返回这个受限根目录的 file handle。Linux mount 命令执行到这里，才拿到后续访问文件所需的根对象。Volume 解析入口见 [`nfsproxy/chroot/nfs.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/nfsproxy/chroot/nfs.go#L139-L188)。

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

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

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

```text
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。挂载完成后的 `LOOKUP`、`GETATTR`、`READ`、`WRITE`、`CREATE` 和 `REMOVE` 由 NFS 程序处理。

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

完整链路是：

```text
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.txt` 和 `b.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=none`，[Terraform 的默认节点到 Filestore mount](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/iac/provider-gcp/main.tf#L219-L253)也使用这两个选项；部署者为 Volume type 提供自定义 mount options 后，节点侧行为会以自定义配置为准。这些设置会减少旧属性和旧目录项的缓存时间，让 A、B 更快看到对方的变化，代价是更多 metadata 请求和更高的后端压力。

**可见性、并发和持久化**

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

## 两种数据路径

```mermaid
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 字符串：

```python
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`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/docs/guide/persistent-storage.md)。

Pause 允许使用 Host Mount，Resume 会以同一个 Sandbox ID 重新 bind 原来的宿主机路径；CommitSandbox 创建的用户快照则拒绝依赖 Host Mount 的 Sandbox。检查入口在 [`template_ops.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/services/cubebox/template_ops.go#L285-L305)。

### Volume API 与生命周期

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

```http
POST   /volumes
GET    /volumes
GET    /volumes/{volumeID}
DELETE /volumes/{volumeID}
```

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

```python
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`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/docs/guide/volume-plugin.md)。

### Controller 和 Node 两类 Hook

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

| 角色 | 调用者 | Hook | 作用 |
| --- | --- | --- | --- |
| Controller | CubeMaster | `Create`、`Destroy` | 创建或删除后端资源 |
| Node | Cubelet | `Attach`、`Detach` | 在计算节点挂载后端，返回 `hostPath` |

Controller 接口定义在 [`CubeMaster/pkg/volume/plugin/plugin.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/CubeMaster/pkg/volume/plugin/plugin.go#L169-L182)：

```go
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` 返回宿主机目录，而不是块设备：

```protobuf
message AttachResponse {
  string host_path = 2;
  map<string, string> metadata = 4;
}
```

完整协议见 [`volumeplugin.proto`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/api/services/volumeplugin/v1/volumeplugin.proto)。`metadata` 由插件产生，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：

```yaml
# CubeMaster conf.yaml
volume_plugins:
  - name: cos
    type: binary
    binary_path: /opt/cube/plugins/cube-volume-cos
```

```toml
# 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

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

```mermaid
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`，把 `driver` 和 `private_data` 写入传给 Cubelet 的 annotation。对应转换在 [`CubeMaster/pkg/service/sandbox/util.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/CubeMaster/pkg/service/sandbox/util.go#L752-L818)。

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

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

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

验证通过后，Cubelet 建立每个 Sandbox 独立的 bind mount：

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

这段逻辑在 [`Cubelet/storage/pluginvolume.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/storage/pluginvolume.go#L155-L264)。共享后端 mount 和 Sandbox bind mount 被分成两层：前者可以被同一节点上的多个 Sandbox 复用，后者负责为每个 Sandbox 建立单独的暴露边界。

### virtiofs 如何把目录送进 MicroVM

[Linux virtiofs 文档](https://docs.kernel.org/filesystems/virtiofs.html)将 virtiofs 定义为 host 与 guest 之间共享文件系统的机制。guest 内核作为 FUSE client，文件系统请求通过 virtqueue 交给 host 侧处理，不需要给 guest 暴露后端存储网络。

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

```go
VirtioBackendFsConfig{
    SharedDir:   shareDir,
    AllowedDirs: bindPaths,
    ReadOnly:    readOnly,
    Cache:       constants.VirtiofsCacheNone,
}
```

源码见 [`Cubelet/plugins/cbri/cubeboxcbri/virtiofs.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/plugins/cbri/cubeboxcbri/virtiofs.go#L29-L95)。`AllowedDirs` 限制 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：

```text
COS bucket
└── volumes/<volumeID>/
```

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

```text
/data/cube-shared/volume/cos-<volumeID>
```

同一节点后续 Sandbox 复用这个 mount；其他节点也用相同 Volume ID 挂载相同 COS prefix。因此「共享」来自共同对象存储命名空间，而不是来自某个 Cubelet 的本地目录。参考实现和部署要求见 [`examples/volume/cos/README.md`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/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 的本地 `Attach` 和 `Detach` 还由 per-volume mutex 串行化，避免两个首个 Attach 同时 mount，或最后一个 Detach 与新 Attach 交错。核心逻辑见 [`Cubelet/plugins/volume/manager.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/plugins/volume/manager.go#L114-L211)。

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

CubeMaster 收到节点级 `0 -> 1` 或 `1 -> 0` 事件后，原子增减数据库计数。删除 Volume 时只要计数非零就返回冲突，不会在运行中的 Sandbox 脚下删除后端。实现分别在 [`refcount.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/CubeMaster/pkg/volume/refcount/refcount.go) 和 [`volume.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/CubeMaster/pkg/service/httpservice/cube/volume.go#L265-L321)。

### 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.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/storage/pluginvolume.go#L172-L253)和 [`local.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/storage/local.go#L878-L900)。

`Detach` 的顺序相反：先释放本地引用，再调用插件。插件卸载失败时，Manager 重新增加引用，并取消 `1 -> 0` 事件，避免 CubeMaster 误以为该节点已经不再使用 Volume。Cubelet 启动时还会用实际存活的 Sandbox ID 清理本地 RefCountStore 中的过期记录，见 [`manager.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/plugins/volume/manager.go#L114-L211)和 [`store.go`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/plugins/volume/refcount/store.go#L303-L355)。

回滚本身也可能失败。非法 `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`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/CubeMaster/pkg/service/sandbox/sandbox_resume_pause.go#L263-L385)。

### 多租户边界

CubeAPI 路由声明了 API Key 认证，但当前 `VolumeRecord` 只有 `volume_id`、`name`、`driver`、token、插件私有数据和引用计数，没有 team 或 tenant 字段。[数据模型](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/CubeMaster/pkg/base/db/models/volume.go#L23-L50)中的名称也是全局唯一。

这套数据模型提供集群级 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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/handlers/sandbox_create.go#L462-L500)。

Volume API 还依赖内容访问 token 的签名配置。`VOLUME_TOKEN_ENABLED=true` 时，issuer、算法、私钥和 key name 缺一都会导致 API 启动校验或 Volume 创建失败；可通过 `VOLUME_TOKEN_DURATION` 控制默认一小时的 token 生命周期。配置模型见 [`cfg/model.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/cfg/model.go#L164-L213)。

remote/BYOC cluster resource provider 返回空 Volume type 列表，当前不支持 Persistent Volume。证据见 [`resources_remote.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/clusters/resources_remote.go#L31-L37)。

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 版本：

```hcl
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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/iac/provider-gcp/persistent-volume-types/main.tf)。计算节点启动时，将每个 share 挂到统一路径：

```text
/mnt/persistent-volume-types/<volume-type>
```

启动脚本把 NFS 配置写入 `/etc/fstab` 后执行 mount，见 [`start-client.sh`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/iac/provider-gcp/nomad-cluster/scripts/start-client.sh#L103-L107)。Google Filestore 当前支持 NFSv3，并在部分 tier 支持 NFSv4.1，具体范围以 [Filestore 官方协议说明](https://cloud.google.com/filestore/docs/about-supported-protocols)为准。

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

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

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

```sql
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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/db/migrations/20260304120000_volumes.sql)。

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

```go
filepath.Join(
    volumeTypeRoot,
    fmt.Sprintf("team-%s", teamID),
    fmt.Sprintf("vol-%s", volumeID),
)
```

路径构造在 [`chrooted/builder.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/chrooted/builder.go#L40-L50)。`CreateVolume` 只对这个路径执行 `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.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/handlers/volume_create.go#L105-L175)和 [`cleanupOrchestratorVolume`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/handlers/volume_create.go#L287-L308)。

### NFS proxy 隔开 guest 与 Filestore

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

```text
Sandbox -> orchestrator-in-sandbox-IP:111  -> host portmapper
Sandbox -> orchestrator-in-sandbox-IP:2049 -> host NFS proxy:5011
```

iptables 规则见 [`network.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/sandbox/network/network.go#L334-L352)，服务启动入口见 [`factories/run.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/factories/run.go#L1115-L1167)。

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

Orchestrator 传给 envd 的目标形如：

```text
<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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/nfsproxy/chroot/nfs.go#L139-L188)。

```go
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 配置：

```go
VolumeMount{
    NfsTarget: orchestratorIP + ":/" + mount.Name,
    Path:      mount.Path,
}
```

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

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

实现见 [`packages/envd/internal/api/init.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/envd/internal/api/init.go#L510-L624)。关键 mount 选项包括：

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

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

NFSv3 的文件锁由独立的 NLM/lockd 协议处理，不属于 NFS 文件读写 RPC。当前 Orchestrator portmapper 只注册 NFSv3 和 mountd，没有注册 NLM；因此不能把 Filestore 本身的锁能力直接等同于 Sandbox 内可用的跨实例锁。注册项见 [`portmap/server.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/portmap/server.go#L46-L62)。

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

### 跨节点共享路径

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

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

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

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

```text
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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/docs/ARCHITECTURE.md#volume-content)。

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

```text
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`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/handlers/volume_token.go#L20-L66)，domain 选择见 [`volume_util.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/handlers/volume_util.go#L82-L113)。

### 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.proto`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/volume.proto#L176-L193)。`CreateFile` 收到 start message 后建立文件，持续接收 content message，收到 finish 后执行 `fsync`、`chown` 和 `chmod`；`GetFile` 则先返回文件大小，再流式发送内容。实现分别在 [`file_create.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/volumes/file_create.go#L24-L124)和 [`file_get.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/volumes/file_get.go#L16-L100)。

所有路径操作先解析 `volume_type`、`teamID` 和 `volumeID`，用 `chrooted.Builder` 切换到对应 Volume 根目录，再把请求路径转成绝对路径并执行 `filepath.Clean`。即使请求包含 `..`，路径也只能在这个 chroot 内解析。公共入口在 [`service.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/volumes/service.go#L51-L135)。JWT 限制内容请求属于哪个 team 和 Volume，VolumeService 则限制文件操作落在哪个目录。

### 删除和只读边界

E2B 当前公开 `SandboxVolumeMount` 只有 `name` 和 `path`，Orchestrator gRPC 结构只有 `id`、`path`、`type`、`name`，没有 per-mount `readOnly` 字段。[OpenAPI](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/spec/openapi.yml#L591-L602)和 [Proto](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/orchestrator.proto#L93-L98)都体现了这个边界，所以 Sandbox 内挂载按读写处理。

删除路径也与 CubeSandbox 不同。API 先删除 PostgreSQL 记录，返回 `204` 后异步调用 Orchestrator；Orchestrator 对共享目录执行 `os.RemoveAll`。当前这条路径没有检查运行中 Sandbox 的挂载引用。异步删除失败时只记录错误，数据库记录已经不存在，后端目录会成为孤立数据。相关实现见 [`volume_delete.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/api/internal/handlers/volume_delete.go#L20-L62)和 [`packages/orchestrator/pkg/volumes/volume_delete.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/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`](https://github.com/TencentCloud/CubeSandbox/blob/981ccda4279f7934e7919bac6482dd86e7c52c48/Cubelet/storage/pluginvolume.go#L172-L327)。排障时还应进入 Cubelet 的 mount namespace 查看真实挂载，而不是只看宿主机根 namespace：

```bash
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`：按 `operation`、`result` 统计调用数。
- `orchestrator.nfsproxy.call.duration`：按相同维度记录毫秒级耗时。
- `nfs.chroot.mounts`、`nfs.chroot.unmounts` 和 `nfs.chroots.gauge`：观察受限文件系统视图的创建、释放和当前数量。

指标定义分别在 [`nfsproxy/metrics/util.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/nfsproxy/metrics/util.go)和 [`nfsproxy/chroot/nfs.go`](https://github.com/e2b-dev/infra/blob/54693ff353d26c5fe1951db5467e751f3e2f984e/packages/orchestrator/pkg/nfsproxy/chroot/nfs.go#L38-L83)。调用数上升且延迟恶化时，继续检查 host NFS client 与 Filestore；chroot gauge 持续增长而 unmount 不增长时，检查 Sandbox 网络释放和 lifecycle 清理。

