Tines 3B 如何在 gVisor 沙箱上实现可 fork 的写时复制文件系统
Forkable copy-on-write filesystem on gVisor sandboxes
Tines 3B 为每个步骤的 gVisor 沙箱提供版本化文件系统,步骤运行与文件变更像数据库事务一样要么全部提交、要么全部丢弃,崩溃或退出 1 的沙箱不会发布任何改动。系统由沙箱外的文件服务器 Nest 和存储引擎 Spongeblob 组成,卷按快照版本管理,可 fork 出草稿分支而不复制数据,典型 128 KB 写入可在 2–4 ms 内持久化,且不依赖 S3 也能运行。
在 Tines,我们围绕一个简单的理念构建了 3B,即工作流中的每一步都是一台计算机。一个步骤的代码,无论是人还是智能体编写的,都在一个全新的 Linux 沙箱中运行,该沙箱通过代理获取凭据,完成工作,然后被丢弃,这样就不会有残留状态在之后引发安全问题。
但随着你开始用这些步骤做更多事情,你很快意识到你需要一个地方来保存状态,这一点我们从一开始就很清楚。如今,3B 客户会克隆仓库来运行分析,保存其 Salesforce 线索的 SQLite 数据库,并将入站 webhook 存储在 DuckDB 中,以便其他工作流之后可以搜索它们。
因此,每个步骤都会获得一个比其沙箱更长寿的文件系统,而我们让这个文件系统具备版本控制,事实证明这比我们预想的要重要得多。换句话说,一次沙箱运行及其对文件所做的更改,其行为就像一次数据库事务,要么全部落地,要么全都不落地。因此,你可以在草稿分支上用一个智能体构建工作流,而不触碰 live 所依赖的数据,并且在某个步骤成功完成之前,其他任何人都看不到它的更改。
在这篇文章中,step 和 sandbox 含义相同,因为每个步骤都在自己的沙箱中运行。
在深入之前,了解一下本文经常提到的三个部分会有所帮助。步骤的代码在沙箱中运行,它对文件所做的任何操作都会交给 Nest,这是一个小型 gVisor gofer,即我们编写的文件服务器扩展,它正好位于沙箱之外。Nest 之下是 Spongeblob(又名 Blobstore),这是我们编写的存储引擎,用于保存每个卷,并在 S3 可用时将其复制到那里以进行恢复和驱逐。
愿望清单
在编写任何代码之前,我们列出了一份简短清单,说明这个系统必须做对的事情,本文后面的每一个决定都可追溯到其中一项。
它必须表现得像一个真正的 POSIX 文件系统。 Git、SQLite、编译器和 pip 都期望一个目录,并依赖 mmap、fsync 和重命名,它们都不会采用我们的 SDK。
任何东西都不能留在运行该步骤的机器上,因为下一步可能会在几天后在其他地方运行。
热路径必须快速且本地化。我们希望一次 128 KB 写入在典型情况下能在 2–4 毫秒内持久化,而由于 SQLite 会进行大量小写入,在每次写入之下放置一次网络往返(这正是基于 S3 的网络文件系统会做的事)会让这个预算很难达到。
步骤会并行运行,两个步骤写入同一个卷时,绝不应损坏它或悄悄覆盖彼此的工作。
它必须在没有 S3 的情况下也能工作,因为 3B 也支持自托管运行,而在自托管环境中 S3 并不总是可用。
而且它必须保持简单。每一个额外的活动部件都是崩溃后需要保持一致性的又一项负担,因此我们希望只有一个持久日志、每块磁盘只有一个所有者,并且不运行单独的数据库或共识系统,我们只在上述某项要求迫使下才增加复杂性,因为得承认,保持数据同步从来都不简单,无论是单写入者、S3 上的 LSM,还是——上帝保佑——基于法定人数的系统。
一个步骤看到的内容
一个步骤在其 Dockerfile 中用一行 VOLUME 声明一个卷,该卷会作为目录挂载到沙箱中的 /storage/<name>。它可以是只读或读写,作用域可以是某个分支或单次运行,可以在多个并发写入者之间共享,也可以轮流使用。接口除此之外没有别的东西,而且当步骤启动时,它会在整个运行期间被固定到该卷的某一个版本上。
打开文件
沙箱是 gVisor,它会拦截每一个系统调用,任何触及 /storage 的操作都会被交给 Nest。我们本可以改用 FUSE 文件系统,但 gVisor 已经把文件操作发送给一个 gofer,所以扩展我们自己的实现意味着中间不会多出一层,而且它让 Nest 能把一个真正的文件描述符交给沙箱,这样读写会直接命中本地文件,而不是每次调用都经过 FUSE 守护进程。
惰性是系统的核心特性(也是我的,开个玩笑)。
打开一个卷并不会下载它,因为 Nest 会在程序遍历目录树时惰性地查找内容。当程序打开一个文件时,Nest 会把它复制到宿主机内存中的一个临时文件里,并把该文件的文件描述符交给沙箱。从那时起,读取、写入和 mmap 都以内存速度命中那个本地文件,只有像 open、rename 和 list 这样的控制操作才会跨越沙箱边界。
这确实意味着打开一个文件会复制整个文件。我们曾尝试改为按需获取每个文件,但内核的页缓存总是打败我们的代理,因为真实程序会不断重读相同的页。所以我们现在正在试验一种拆分方案:小文件仍然预先复制进来并保留快速的本地路径,而大文件则跳过复制,只获取程序实际触及的块。
Nest 还会跟踪发生了什么变化,包括创建、重命名、删除以及每个文件被写入的部分,并且它在打开文件时对文件块做指纹,以便捕获通过共享 mmap 所做的更改。步骤内部的 fsync 只会刷新本地副本,因为持久性是在步骤完成时才实现的。换句话说,当沙箱崩溃、OOM 或以 1 退出时,它的更改会被丢弃,卷不会移动。丢弃这些更改是目前默认的做法,如果将来需要,我们可以添加专用挂载,让步骤的更改在它仍在运行时就可被看到。
每个步骤都是一次提交
一个卷是一系列快照加上一个小指针,指针指明哪个快照是当前快照,而快照一旦写入就永不改变。当一个步骤成功时,Nest 会上传所有发生更改的内容,Spongeblob 会在当前快照之上构建一个新快照,并把指针向前移动。
如果你眯着眼看,它有点像 Git,其中一次提交记录整棵目录树的状态,只不过每个成功的步骤都会自动产生一次提交。它还借鉴了多版本并发控制,所以如果有人在你还读着 42 的时候发布了快照 43,你会继续读 42,读者和写者永远不会互相等待。
哞——。我是说你好,COW。
如果每个快照都是完整副本,那么保留每个版本都会很昂贵,所以我们使用写时复制(COW,你懂的)。新快照只重写树中发生变化的部分,因此在一个巨大卷中更新一个文件只会写入几个小节点,其余全部共享。
当两个步骤同时写入时
发布是对指针执行比较并交换,大致意思是“把它从 42 移到 43,但前提是它仍然写着 42。”当别人抢先一步时,我们会比较两个步骤各自改了什么。对不同文件的更改会在新快照之上重放,而对同一文件的更改则会失败,并产生一个指明路径的冲突。我们仍在研究正确的语义,很快会有更多内容。
这是否意味着你可以随意分叉一个卷?
差不多。由于快照永不改变,分叉只是第二个指针指向同一个快照,因此一个拥有数 GB 数据的卷和一个空卷分叉起来一样快。如今,新的草稿分支可以从空开始,也可以从 live 开始,很快它就能从它来源的分支分叉,从那时起两个指针就各自独立移动。
我们不在分支之间合并文件,因为每个分支都在自己的副本上工作,而且我们不想为文件系统发明三方合并。同样的指针也让在工作流运行时查看分支文件变得容易,因为 UI 和 agent 读取分支指向的任何快照,永远不会看到写了一半的文件。
认识 Spongeblob
Nest 之下的一切都是 Spongeblob,一个我们用 Rust 编写的存储引擎。它分为一个嵌入式数据库和一个服务,前者只知道不可变对象和带指针的版本化树,后者将它们转化为产品概念,比如“这棵树是一个文件系统”和“这两个写入冲突”。
Spongeblob 把所有内容都保存在自己的磁盘上,并且同一时间只有一个进程拥有该磁盘,因此写入者之间永远不需要协调。代价是部署期间会有短暂交接,旧进程让位,新进程重新打开同一块磁盘。
我们也不会运行一个巨大的 Spongeblob。每个分片的 3B 栈都运行自己的 Spongeblob,拥有自己的磁盘,因此一个繁忙的工作负载不会拖慢其他所有人的读写。在今天的生产环境中,典型读取大约需要一毫秒,典型写入略低于两毫秒。
一次写入如何变得持久
当 Nest 发布一个步骤的更改时,Spongeblob 会把新数据追加到其磁盘上一个大型文件的末尾,我们称之为 pack,调用 fsync,然后写入就完成了。来自不同步骤、大约同时到达的写入会共享那个 fsync,这就是组提交,因此持久化的成本取决于时间,而不是写入次数。
更新索引、清理旧数据以及上传到 S3 都是异步进行的。也没有单独的日志文件。每次写入都会作为数据加上一条小的日志记录落到 pack 中,位于同一个 fsync 下,因此 pack 本身就是日志,字节会留在它们落地的位置,直到清理过程把幸存者从一个几乎全是死数据的 pack 中复制出去。
我的数据在哪里?问索引
Pack 保存字节,但无法回答诸如文件的最新版本是什么或它位于哪个 pack 中这样的问题,因此 Spongeblob 将这些答案保存在一个索引中,而这个索引是一棵 LSM 树。新条目进入内存中的有序表,写满的表被写出为永不改变的有序文件,后台进程会随时间合并这些文件。
我们在这里是站在巨人的肩膀上。如果你用过 LSM 树或 RocksDB,这看起来会很熟悉,我们知道的很多东西都来自 RocksDB 和 WiscKey 论文。就像 RocksDB 的 blob 文件一样,我们把大字节挡在树外,因此索引只保存指向 pack 的小条目,合并永远不会移动文件数据。
我们划定的界线也很重要,而且两边都不是没有代价。在读这一侧,一个文件就是一次索引查找加上对保存其块的任意 pack 的范围读取,而由于一个步骤中发生变化的块会被存放在一起,未触及的块则留在原处,一个被更新过很多次的文件最终会分散到更多的 pack 中,复制进来时需要更多的读取。在写这一侧,将它们分开只是转移了部分写入成本,而不是消除它,成本变成了从几乎全是死数据的 pack 中复制幸存者,而且因为我们只在 pack 至少有半数已死时才搬迁它,文件数据在其生命周期内最多会被写入大约两遍。
那么为什么不直接嵌入 RocksDB 呢?它很可能本来能行,而 compaction filter 和共享写缓冲区预算之类的想法正是我们学到的重要部分。我们写了一个自己的小引擎,是因为我们希望它围绕我们的数据来塑造,使用我们可以就地读取的格式,并由一个进程统一为它的日志、索引和后台工作做预算。即使底层是 RocksDB 或类似系统,我们仍然会自己构建快照树、支撑 fork、租约、冲突检查和镜像分块存储的原语,因为没有键值存储会把这些直接交给你。我们也不想要一个像 FoundationDB 那样的独立数据库集群来保存元数据,因为那会在每次文件查找下放一次网络跳转,并给每个技术栈、每个自托管安装增加一个需要运行并与 pack 保持一致的额外系统。
热数据在磁盘上,冷数据在 S3 中
步骤读取的内容大多通常是近期的。一个工作流往往会重新打开它最近几次运行中写入的文件,而旧快照和几周没人碰过的文件大多就放在那里。因此 Spongeblob 把自己的磁盘当作热、持久数据的存放处,并在可用时将 S3 作为冷层。
一个 pack 会持续追加,直到写满或存在 30 秒(如果你想要更紧的恢复点,这个时间可以配置),然后它就被封存,之后不再改变。当配置了 S3 时,已封存的 pack 会在后台上传,并记录在一个恢复检查点中,该检查点包含重建存储所需的一切。一旦一个 pack 安全地进入 S3,它就有资格被驱逐,因此当磁盘开始填满时,超过高水位线后,Spongeblob 会丢弃那些一段时间内无人读取的 pack 的本地副本。如果之后有人确实读取了一个已被驱逐的 pack,Spongeblob 会按需从 S3 取回它,进行检查,然后提供读取,因此冷数据在再次被读取时第一次需要访问 S3,而在使用期间则保留在本地。
随着文件被覆盖和旧快照过期,pack 中也会填满死数据。大部分已死的 pack 会将其幸存者复制到一个新的 pack 中,而完全已死的 pack 会在没有检查点需要它们后被删除。在没有 S3 的自托管安装中,没有地方可以驱逐,因此每个 pack 都留在磁盘上。回收死 pack 可以防止磁盘填满,当这还不够时,我们会调整磁盘大小。
为什么不直接写入 S3?
你可能想知道为什么我们不直接写入 S3,就像许多较新的基于 S3 的 LSM 系统那样。部分原因是自托管要求,部分原因是延迟。Nest 吸收了一个步骤的页级读写,因此 Spongeblob 主要看到的是打开时读入一个文件,结束时发布一个文件,两者都从其磁盘上以一两毫秒的速度完成,而 AWS 自己的指导将小型 S3 请求定为几十毫秒,远超我们为写入设定的预算。其余是成本,因为将每次写入都通过 S3 发送,即使批量处理,成本也会比我们今天在 S3 请求上的花费高出几倍,并随流量增长。这些单独都不是决定性问题,但加在一起,它们使本地磁盘优先、S3 其次成为更合适的选择。不过,底层设计并没有把我们锁定在这个选择上,将日志末尾移到 S3 Express One Zone 是我们正在积极考虑的事情,我们将在下面讨论。
持久性、一致性和可用性
综合来看,Spongeblob 目前做出三个承诺,就像任何存储系统一样,每个承诺都有权衡。
持久性。 一旦其组的 fsync 返回到 Spongeblob 的磁盘,写入就是持久的。在我们的云中,该磁盘是一个 EBS 卷,AWS 已经在可用区内复制了它,因此崩溃、重启或部署永远不会丢失已确认的写入。丢失卷本身是 EBS 不覆盖的唯一情况,而 S3 是我们的后备。已封存的 pack 和恢复检查点会在后台复制到那里,因此如果卷丢失,我们会从最后一个检查点重建,我们将其保持在最新写入后约一分钟内。在没有 S3 的自托管安装中,持久性取决于磁盘提供的保障。
一致性。 每个步骤从头到尾读取一个快照,无论期间发布了什么。发布是有序的,因为一个进程拥有每个磁盘,并且每次发布都是对指针的比较并交换,因此任何读取分支的人,无论是另一个步骤、UI 还是代理,都会看到一个步骤的完整更改或没有更改,而绝不会看到半写入的文件。
可用性。让每个磁盘由一个进程独占可以保持简单,因为写入者永远不需要协调,一切都直接从本地磁盘提供,冷数据则从 S3 回迁。唯一会暴露问题的时刻是部署时,旧进程排空并把磁盘交给新进程,这通常需要一两秒。Spongeblob 前面的一个小型路由器会尽职地确认哪个进程拥有该磁盘,因此一旦新进程就绪,流量就会切换过去,任何落在间隙中的请求都会得到快速响应,告诉客户端一秒后重试。
如今这两个窗口都很小,尤其是丢失窗口,它源于将日志末尾保存在单个磁盘上,而这也是设计中最容易更改的部分。日志是唯一决定写入是否发生的依据,其上的一切,包括索引、快照和分支,都派生自它,因此我们可以在不重新设计其余部分的情况下移动日志末尾的存放位置。仅将日志尾部同步写入 S3 Express One Zone 每次提交大约需要 7–10 毫秒,这超出了我们的写入预算,但远不及 S3 Standard 那么离谱,作为回报,它将关闭丢失卷的窗口,并阻止新所有者依赖旧磁盘,而数据本身仍留在本地磁盘上。不过 S3 Express 位于单个可用区,因此与 EBS 一样,它无法覆盖整个可用区丢失的情况。这距离将整个存储放到对象存储上还有很长的路要走,因为读取、打包、索引和快照都将保留在本地,只有日志末尾会移动。我们正在权衡这一点与在几台机器上构建基于法定人数的日志,后者可以保持快速提交,但意味着要运行我们自己的共识系统(谁想管理那个?!)。
这让我们处于什么位置
我们对最终的结果感到满意,而这主要归功于底层的几个原语。快照永不改变,指针只通过比较并交换移动,分支只需一个指针,数据以其内容命名,所有这些都建立在单个持久日志之上。许多产品功能自然而然地从中产生,而无需我们逐一构建。草稿无法触及线上版本,因为它是不同的指针;一个步骤要么提交,要么不留下任何痕迹,因为发布是最后的一次交换。两个步骤无法相互覆盖,因为同一次交换会捕获它;你可以在工作流运行时查看分支的文件,因为读者只需跟随指针。
分支是我最兴奋的一点。由于分支只是一个指针,一个工作分支可以获得整个卷及其内部所有内容的完整副本,无论是 SQLite 文件、JSONL、DuckDB 文件还是其他内容,而无需复制一个字节。
同样的设计在卷之外也被证明是有用的。沙箱的基础镜像,即其根文件系统,也可以由 Spongeblob 支持,这为我们提供了快速启动并让镜像共享数据块,我们稍后会有更多分享。
回顾过去,简单的选择完成了大部分工作。每个磁盘一个所有者意味着写入者永远不需要协调,包兼作日志意味着只有一个持久性故事,不合并分支意味着没有合并逻辑会出错。我们只在需求推动时才承担复杂性,比如编写自己的引擎而不是改造另一个 LSM 存储来适应,在磁盘和 S3 之间移动包,以及在彼此之上重放非冲突写入。
我们仍在为保持简单而付出代价的地方,正是我们接下来要着手解决的。我们想要确定哪些文件在打开时被复制、哪些按需获取,这样即使是大文件也能低成本打开。我们还希望索引合并在相同变更量下做更少的工作,因为它是我们最大的后台开销。而对于 v2,我们正在决定日志的末尾应该放在 S3 Express 上,还是放在一个机器法定人数上,这样丢失一块磁盘就不再丢失最近的写入,新的所有者也不再需要在交接期间依赖旧磁盘。
来源:Hacker News · shayon.dev