Cloudflare Computer,装在 Durable Object 里的文件系统
CloudflareDurable ObjectsFUSECloudflare 最近放出了一个叫 Cloudflare Computer 的预览仓库,把「给 agent 一台电脑」拆成了两半:文件系统和执行环境。
文件系统住进 Durable Object 的 SQLite,执行环境退化成可插拔的外设,容器只是三种外设之一。
这个拆法跟我对沙箱的直觉正好相反,翻完二十篇规格文档,值得记一篇。
它是什么
@cloudflare/computer 是一个跑在 Durable Object 里的虚拟文件系统:整棵树落在 DO 自带的 SQLite 里,
对外是 workspace.fs(用起来像 node:fs/promises)加 workspace.runtime.exec(source, { backend }) 一个执行入口。
今天有三种后端:container(真容器加 FUSE 挂载)、isolate shell(Dynamic Worker 里跑 just-bash)、isolate JavaScript(Dynamic Worker 里求值 ES module)。
也可以一个后端都不配,纯当持久文件系统用——fs 照常工作,exec 才抛错。
丑话在前:README 顶上就是 PREVIEW ONLY,API 不稳定,不适合生产;docs/ 下二十篇规格自称 forward-looking,要当设计意图读,不能当代码现状读。
下文尽量把两者分开标:比如 R2 只读挂载在 schema 里留好了座位(_vfs_mounts 表、stub_size 列),但运行时还没接线,就属于「规格意图」。
为什么不是沙箱
给 agent 配环境,常规做法是发一个容器:文件在容器盘上,跑完要么整包丢弃,要么自己往对象存储打包搬运。
状态的权威在容器里,而容器天生要被回收,这两件事是拧着的。isolate 倒是轻,但 Workers 里压根没有像样的持久文件系统。
这个项目把关系倒过来:权威态只有一份,住在 DO 的 SQLite 里(DO 本来就是带事务的有状态单点),
执行环境全部变成无状态外设,注册在稳定 ID 下、首次使用才懒连接,用完随便死。容器重启、isolate 回收,树还在。
权威态怎么存
schema 是经典 inode 设计,三个机制点值得说:
vfs_dirents (parent_inode, name) → child_inode加一张vfs_nodes节点表,路径解析走间接层:
本地 rename 是一条UPDATE,O(1);硬链接就是两条 dirent 指同一个 inode,白送。
文件内容切成 512 KiB 的 chunk,按哈希落进内容寻址的vfs_blobs;节点行上冗余一个size列,stat不用每次去SUM。vfs_meta里一行单调递增的rev计数器,每次变更原子自增并盖到节点上——整套增量同步就悬在这一个数上。
计数器是刻意的单写者设计:所有变更先过 Workspace 的 FIFO 串行化,单行永不争用;哪天要加并发写者,得先动它。vfs_nodes 上按 rev 建了索引,同步端枚举「上次游标之后动过的东西」就是一次索引扫描,不用全表比对。
同步协议
只有 container 后端需要同步——另外两个后端根本没有第二份存储。同步双向增量,各自带单调游标。
push(DO 到容器)发生在每次 exec 之前,把容器没见过的 rev 全推过去。同一路径中间改写五次,线上只有终态一条;
条目不带字节,只带 chunk 哈希,发送方先 hasObjects 探测,缺的块才 pushObjects。
pull(容器回 DO)发生在 exec 返回之后,fetchChanges({ after: 游标 }),游标是 (rev, path) 二元组。
DO 按 256 条一批收流:查本地缺哪些块、fetchObjects 补齐、applyChanges 落库,再把游标推进到这一批最后一条。
中途崩溃从上个批次续传,重复工作的上界是一批而不是整条流;接收侧 alreadyApplied 把重复条目原地丢弃,重放廉价且幂等。
线协议里没有 rename opcode:只有新路径的活条目加旧路径的墓碑,apply 因此不需要按操作顺序重放。
代价是目录改名要给整棵子树逐个盖新 rev,O(子树) 条条上线;类型冲突走 last-writer-wins,上游节点直接顶掉本地整棵子树。
还有两处取舍写得很直白:硬链接跨线不保身份,一个 inode 挂几个名字就每个名字发一条,落到对端是内容相同的独立文件;
目录条目的幂等判定只看 mode 不看 mtime,两边目录 mtime 有漂移,不会变成同步流量。
容器后端
容器里跑的守护进程叫 computerd,是个 Node SEA 单二进制:Node 运行时、fuse-native 预编译件、libfuse 全部内嵌,
宿主镜像不需要装 Node,Debian slim 加个 fuse3 就能起。
它把容器内 VFS 用 FUSE 挂到 /workspace,容器里任何工具——shell、npm、编译器——看到的就是 DO 里那棵树,路径一致。
建连是反向拨号:后端调 computerd 的 POST /connect,让它主动拨一条 WebSocket 出来,capnweb 会话的 bootstrap stub 是 WorkspaceRPC(sync 与 shell 两个子 stub)。
容器侧 VFS 放在内存里,容器重启就丢,下一次 push 重新基线——权威态从来不在这一边。
命令执行的括号是固定的:push、spawn、流事件或 result、pull,把 result() 或事件流耗尽,收尾的 pull 才算完成。computerd 自己的默认端口是 45678,Cloudflare 后端把镜像内监听钉在 8080;
它保留进程日志,断线后 getExec 能重新挂回去看回放、补信号——三个后端里生命周期最全的一个。
isolate shell
第二种后端不要容器:just-bash 解释器跑在 env.LOADER 拉起的 Dynamic Worker 里,
shell 发出的每个文件系统调用走 Workers RPC 打回宿主 DO,直接读写权威态。零第二存储、零同步回路,result 里 pushed 和 pulled 恒为 0。
有个实现细节挺有意思:DurableObjectNamespace 过不了 Worker Loader env 的 structured clone,
所以塞进 isolate 的是一个叫 WorkspaceServiceProxy 的回环入口,每次 env.HOST.getWorkspace() 由宿主侧代查命名空间。
isolate 的 globalOutbound 置 null,fetch 和 connect 全封死,唯一出口就是这个回环;git 和 assets publish 是仅有的两个内建转发命令,R2 桶绑定和签名密钥永远不进 isolate。
命令集是 just-bash 的子集:cat、grep、awk、sed、jq 这类文本活够用;要编译、装包、跑浏览器,回容器。
隔离粒度是每个 workspace 一个 isolate,Worker Loader 按 workspace-shell:${workspace.id} 缓存:
同一工作区的并发 exec 共享热 isolate,一段跑飞的脚本最多把自己工作区的 isolate 撑爆,宿主 DO 和别的工作区不受影响。
这一版是一次调用、缓冲结果的语义,不保留执行记录供事后重挂;timeoutMs 和并发的 killExec 只在语句边界协作式中止。
isolate JavaScript
第三种后端把 source 当真正的 ES module 求值,也在 Dynamic Worker 里:
const handle = await workspace.runtime.exec(
`
import fs from "node:fs/promises";
export default async (input) => {
await fs.writeFile("/workspace/result.txt", String(input.value));
return { persisted: await fs.readFile("/workspace/result.txt", "utf8") };
}
`,
{ backend: "worker-javascript", input: { value: 21 } },
);
const result = await handle.result();默认导出函数收到 options.input,返回值原样回到 result().value——这是结构化的值通道,不是 stdout 文本协议。
相对导入从 cwd 出发经权威文件系统解析,而且是先静态解析完整个模块图再起 Worker:拒绝符号链接穿越,
深度、模块数、总字节都有上限,动态 import 只认字符串字面量。node:fs/promises 是 Workspace 后端的,isolate 里写文件就是写 DO 的 SQLite;ws:git、ws:artifacts 两个受信模块管提交与发布。
process 是个 shim:env 只是调用方传入的快照,DO 自己的绑定和密钥永不合并进来;stdin 是一次性异步迭代;argv、platform 给的是占位假值。
限额给得很足:默认并发 24,超了直接 EEXEC_BUSY;执行记录保留 100 条或 60 分钟,可回放;
取消先停新的宿主能力调用、等已受理的调用排空,然后才发布 exit 130——正常完成同样排空,不存在 exit 0 之后还有未落账的写。
有个容易踩的点:runtime.exec() 在 Dynamic Worker 跑完之前就返回,靠悬着的宿主调用把 DO 顶在内存里;
handle 拿了不读,DO 一闲置执行就可能被驱逐——要么把事件流排干,要么给必须活过驱逐的工作配 ctx.storage.setAlarm()。
怎么用
npm install @cloudflare/computerimport { Workspace } from "@cloudflare/computer";
import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript";
const workspace = new Workspace({
storage: ctx.storage,
backends: [
new WorkerJavaScriptBackend({ loader: env.LOADER, root: "/workspace" }),
],
});后端按子路径导入,不用的后端连带 just-bash 载荷一起被 tree-shake 掉。能做的事:
workspace.fs:mkdir、readFile、writeFile、rm、symlink、stat,全异步、绝对路径、跨 DO 重启持久;workspace.runtime:exec、getExec、killExec、disposeExec,handle 既是事件流又能result();
命令后端的 result 带pushed、pulled和sync状态,命令成功而 pull 失败时可配SyncRetryScheduler只重试同步、不重跑命令;@cloudflare/computer/tools:现成的 AI SDK 工具面(read、write、edit、ls,可选 exec 与 publish);
R2 只读挂载:规格已写、schema 留座,运行时未接线(规格意图,非今日代码)。
省略 backend 就用第一个配置项。文档特意提醒:路由不是鉴权,公网网关要自己校验 backend 参数。
仓库的 8 个 examples 里,think-compare-runtimes 把同一个 agent 任务在容器和 worker 两种运行时上并排跑,最能看出取舍。
边界与限制
工作区约 10 GB 上限,跟 DO 存储共享;容器侧整棵树在内存里,放 agent 级工作区合适,放 monorepo 不行。
FUSE 在大文件顺序 IO 上吃亏。官方 fs-bench 对cloudflare/sandbox-sdk做全量npm install(854 个包、36675 个文件):computerd 124.7 秒,容器 ext4 63.9 秒,tmpfs 34.3 秒,比真盘慢约一倍。
反直觉的是元数据操作赢真盘:stat0.91x、rm0.66x、mkdir树 0.74x、find0.72x、git init加提交 0.72x——内存 inode 表的功劳。
大文件才是重灾区:纯读 64 MiB 比真盘慢 30 倍、copy慢 40 倍;但npm init加小安装 0.95x 追平真盘,日常负载的大头本来就是元数据。
慢的根源在写路径每 512 KiB 就哈希一次进内容寻址库,换来的是按块增量同步和内容去重。
PREVIEW ONLY,不收 unsolicited PR,反馈走 issue 和 discussion。
最后说一个印象:4900 行规格配 5.7 万行实现,连「考虑过又放弃的编码方案」都写进了文档,这个密度在预览期项目里少见。
写完有点手痒,想把自己博客的构建态也塞进 DO 里,转念一想静态站要什么权威态,睡了。