CubeSandbox 存储篇
这是 CubeSandbox 系列的第二篇。架构篇说过,这套系统的核心思路是“冷启动在模板阶段付一次,运行时只做本机克隆和快照恢复”。这个思路能不能落地,关键在存储:创建一台沙箱时,磁盘和内存镜像都不能真的拷一份。
CubeSandbox 的默认做法是把所有大文件都放在本地 XFS 上,用一次 FICLONE ioctl 完成克隆。0.7.0 起又多了一个 S3 后端,把盘放进对象存储,让暂停的沙箱能在另一台机器上恢复。
本篇按这个顺序讲:沙箱的盘怎么拼起来、模板怎么来、reflink 在文件系统里做了什么;CubeCoW 怎么用同一套接口管 XFS 和 S3 两个后端;内存和磁盘的差分具体怎么做;MinIO、S3 Volume 插件和 CubeS3lvol 各管什么;最后拆开 CubeS3lvol,看它怎么切块、记元数据、做本地缓存、在机器之间交换快照。内存快照里“哪些页算脏页”的筛选逻辑放在虚拟化篇,网络放在网络篇。
一台沙箱的盘长什么样
灰色是只读、全机共享的文件,橙色是每台沙箱私有的可写层,蓝色是穿透到宿主机的共享目录。创建一台沙箱真正新增的只有橙色那一个文件,而它是一次 reflink 克隆出来的。
一台沙箱的存储由五样东西拼成,分成三类。
只读、全机共享的:客户机操作系统镜像、guest agent 镜像,以及模板对应的 OCI 镜像。三者都是 ext4 镜像文件,用 virtio-pmem 交给虚拟机,并标记 discard_writes,客户机写不回宿主机。客户机里以 DAX 方式挂载:读文件时直接访问宿主机页缓存里的那一页,不在客户机里再缓存一份。一台机器上跑几百台同模板的沙箱,这些只读内容在物理内存里也只有一份。
每台沙箱私有、可写的:一个由 CubeCoW 管理的 ext4 文件,名字形如 sb-<沙箱ID>-rootfs-gen0,用 virtio-blk 交给虚拟机。它是从模板的可写层 reflink 克隆来的,格式化时关掉了 ext4 的日志。
穿透到宿主机的:用户声明的宿主机目录或数据卷,先 bind 到 /data/cubelet/hostdir/<沙箱ID>/ 下,再经 virtio-fs 共享进去。
客户机里,agent 把容器的根文件系统组装成一个 overlayfs:下层是只读的模板镜像,上层和工作目录放在可写盘里的 disk/<容器ID> 下。读文件先走模板镜像,写入只落在可写层。
这样拆开有一个直接后果:创建一台沙箱,真正新增的只有那个可写层文件,而它是一次 reflink 克隆出来的。模板镜像、系统镜像、agent 镜像都是已经在盘上的共享文件,直接挂上就行。
模板从哪里来
冷启动内核、跑初始化、等服务就绪这些慢事,都在模板阶段每台节点各付一次。之后每次创建只做本地 reflink 和一次快照恢复,关键路径上不访问网络。
模板的构建分两段。
第一段在控制面,只做一次。TemplateCenter 拉取 OCI 镜像、解包出根文件系统,用 mkfs.ext4 -d 把整个目录直接打成一个 ext4 镜像,上传到制品存储(S3、MinIO 或本地目录)。镜像格式是 ext4,不是 erofs;开源版本里 erofs 镜像卷的代码路径会直接报“不支持”。
第二段在每台计算节点上各做一次。CubeMaster 选好节点,用 gRPC 下发模板副本。节点把 ext4 镜像下载到本地,按制品 ID 缓存。然后它用 CubeCoW 新建一个空的构建可写层 tpl-<模板ID>-build-rootfs 并格式化,冷启动一台临时虚拟机,跑镜像的入口命令,等配置好的 HTTP 探针返回 2xx。
探针一通,节点就把虚拟机冻结,做两件事:把整份客户机内存写成 tpl-<模板ID>-memory,把构建可写层 reflink 一份成 tpl-<模板ID>-rootfs。之后构建用的可写层就可以删了。这份模板快照里,服务已经启动完毕、正在监听端口。由它创建出来的沙箱,在创建调用返回时,被探测的那个端口已经能接请求。
探针选得不好会出问题:返回得太早,快照里的服务可能还在初始化;一直不成功,构建会超时。只暴露、没被探测的端口,没有这层保证。
部署约束也由此而来。模板要在每台节点的本地盘上各存一份,所以每台节点都需要足够的本地盘;创建依赖 reflink,所以 /data/cubelet 必须是打开 reflink 的 XFS。
reflink 在 XFS 里做了什么
XFS 用每个 inode 里的一棵 B+ 树(BMBT)记录“文件的第几块对应磁盘上哪个物理块”,每条记录是一段连续区间,叫 extent。打开 reflink 后,文件系统另外维护一棵全局的引用计数树,记录每个物理块被几个文件引用。
ioctl(dst, FICLONE, src) 做的事很简单:把 src 的 extent 映射复制给 dst,再把这些物理块的引用计数各加一。全程只改元数据,不读也不写数据块,耗时和文件里有多少数据基本无关。
之后谁往一个共享块里写,XFS 就新分配一个物理块,把写入落在新块上,并把写入者的映射改指过去;旧块的引用计数减一,降回 1 时它又变成独占块,下次写入不必再分裂。另一个文件的映射和数据完全不受影响。这就是写时复制,粒度是文件系统块,而不是整个文件。
对沙箱来说,这意味着:从模板克隆可写层是一次元数据操作;沙箱运行时写到哪一块,才为哪一块付出复制的代价;几百台沙箱共享模板里没被改过的那些块。
CubeCoW:在 reflink 上加规矩
裸 reflink 只解决了“快”,没解决“管”:卷怎么命名、快照放哪、崩溃后怎么知道哪些文件是半成品。CubeCoW 是专门干这件事的 Rust 库。
它不是一个独立服务。CubeCoW 编译成带 C ABI 的动态库,Cubelet 通过 cgo 把它链进自己的进程,每个后端初始化一个句柄。
一套接口,两个后端
两个后端实现同一个 Engine 接口,Cubelet 上层的创建、快照、回滚、克隆流程不用关心底下是哪个。模板创建时选定后端,之后整条血缘都留在这个后端里。
CubeCoW 对上只暴露几类操作:建卷、从卷拍快照、从快照克隆出卷、删除、激活成设备、导出、导入。对下是一个 Engine trait,目前有两个实现。
- ReflinkEngine:卷就是 XFS 上的一个文件,快照和克隆都是一次
FICLONE。数据只在本机。
- S3Engine:自己不碰数据,把每个操作翻译成一条 JSON-RPC,经 Unix socket
/var/run/s3lvol.sock发给本机的s3lvol_tgt进程(也就是 CubeS3lvol)。卷是 SPDK blobstore 里的一个 blob,数据最终在对象存储里。
同一个操作在两个后端各做了什么:
| CubeCoW 操作 | reflink 后端 | S3 后端(发给 s3lvol 的 RPC) |
|---|---|---|
| 建卷 | set_len 出一个稀疏文件 | rcow_create_lvol |
| 从卷拍快照 | FICLONE + 目录 fsync | rcow_create_snapshot(源必须是卷) |
| 从快照克隆 | FICLONE | rcow_create_clone(源必须是快照) |
| 激活成设备 | 不需要,文件路径直接给虚拟机 | rcow_active_bdev,得到 /dev/nvmeXnY |
| 扩容 | set_len | rcow_resize_lvol,只能变大 |
| 跨节点导出、导入 | 不支持 | rcow_export_snapshot、rcow_import_lvol |
Cubelet 那边,XFS 和 S3 各有一个 volume manager,暂停、恢复、提交模板的流程大体一样,只在“要不要激活设备”“要不要上传”这几步分叉。
reflink 后端的三条规矩
reflink 后端的根目录由 Cubelet 固定在 /data/cubelet/storage/xfs/objects,每个卷是 volumes/<卷名>/<卷名> 这样一个普通文件,同卷的快照放在同一个目录下。新建卷只是 set_len 出一个稀疏文件,不预分配空间。
在此之上,它定了三条规矩。
快照是扁平的。
reflink 本身允许“快照的快照”。CubeCoW 不让这种关系变成磁盘上的链:每个快照都是一个拥有完整 extent 映射的普通文件,共享关系交给 XFS 的引用计数。
reflink 本身允许给快照再拍快照。CubeCoW 让每个快照的 origin_volume 都记为最终的祖先卷,文件也放在祖先卷的目录下。从文件系统看,主卷和它所有的快照都是同一目录里平级的普通文件,各自拥有完整的 extent 映射。删除中间某一份就是 unlink 一个文件,物理块的引用计数由 XFS 去减,其他快照不用跟着改。血缘关系只记在内存索引里,用来回答“这份快照是从哪来的”。
为了配合模板构建,删除卷时如果目录里还有快照,CubeCoW 只删主文件、保留目录;最后一个快照删掉时目录跟着回收。这正是“提交模板后删除构建可写层”需要的语义。
磁盘本身就是账本。卷的列表等于列一遍 volumes/,快照的列表等于列卷目录再去掉主文件,大小和时间来自 stat。CubeCoW 不另写一份元数据文件。引擎启动时扫一遍目录重建内存索引,并按几条规则清理崩溃残留:
| 磁盘上看到的 | 怎么处理 |
|---|---|
| 卷目录里有主文件 | 登记为卷 |
| 目录为空 | 删掉孤儿目录 |
| 主文件没了,还有快照文件 | 只恢复快照,打警告 |
| 零字节的快照文件 | 删掉,那是 FICLONE 没做完就崩了 |
| 重名 | 跳过并警告 |
一次快照落到磁盘上只有两步:一次 FICLONE,一次目录 fsync。中途失败就回滚索引、删掉半成品。没有独立账本,就不存在账本和磁盘对不上的问题。
卷和快照共用一个命名空间。内存里是一张受读写锁保护的表,卷名和快照名在同一个空间里。新建任何对象都在写锁下先占名再做 FICLONE,并发创建不会撞名,也不用沿血缘递归查重。
启动时还有一道检查:CubeCoW 会在根目录里建个小文件真的做一次 FICLONE,失败就拒绝启动。这比运行到一半才发现文件系统不支持要好得多。
S3 后端多做的几件事
“磁盘本身就是账本”在 S3 后端不成立:对象存储里只有一堆按 uuid 命名的数据块,列不出“有哪些卷”。所以 S3Engine 在本地维护一个 index.json,每个名字记一条:是卷还是快照、快照来自哪个卷、大小和创建时间、当前激活成了哪个 NVMe 设备(设备名、uuid、NQN、namespace 号),以及导出过的话 export uuid 是多少。
其余规矩和 reflink 后端一脉相承:
- 名字仍然先在写锁下占住,再发 RPC;RPC 失败就把名字退回去。
- 种类检查放在 CubeCoW 这层做:只能从卷拍快照、只能从快照克隆。想复制一个正在写的卷,必须先拍快照再克隆,RPC 的意图和实际行为保持一致。克隆出来的是卷,可以接着拍快照,沙箱暂停时封存可写层就是这么做的。
__cbc_tmpclone_、__cbc_probe_开头的名字留给内部使用,用户建不了。
- 每个 RPC 调用有 10 秒超时,socket 上一行一条 JSON。
创建、快照、回滚、克隆在盘上各做了什么
有了上面这些,几种操作在存储层面都很直接。
- 创建:从
tpl-<模板ID>-rootfsreflink 出sb-<沙箱ID>-rootfs-gen0。内存镜像不用克隆,虚拟机以只读的私有映射直接打开模板的内存文件,写入只发生在进程自己的内存里。
- 快照:冻结客户机,把当前可写层 reflink 一份,再写一份内存镜像。内存镜像同样先 reflink 上一份作为基线,然后只覆盖改过的页,这样写出来的仍是一份完整布局的镜像。下一节专门展开。
- 回滚:CubeCoW 没有专门的回滚接口。Cubelet 从目标快照派生一份新一代的可写层(文件名里的
gen加一),再从目标内存快照恢复虚拟机。
- 克隆:先给源沙箱拍一份临时快照,再从它创建 N 台,最后尽力删掉临时快照。N 台的可写层共享同一批 extent,内存镜像也共享同一份页缓存,谁先写谁分裂。任何一台创建失败,已经建出来的会被销毁,调用方要么拿到 N 台,要么拿到异常。
内存和磁盘是怎么做差分的
“快照很便宜”的前提是:每次只存和上一份相比变了的部分。CubeSandbox 里磁盘和内存用的是同一个套路:先把基线克隆成目标,再只往目标里写变了的部分。写出来的目标在逻辑上是一份完整的盘或完整的内存镜像,没变的部分在物理上仍然和基线共享。
磁盘:可写层本身就是差分
磁盘这边几乎不用额外做事。沙箱的可写层从一开始就是模板 rootfs 快照的克隆:XFS 上是一个 reflink 文件,S3 上是一个 s3lvol 克隆卷。沙箱运行时写到哪,底层就为哪一块做写时复制。暂停或拍快照时,Cubelet 把这个可写层再封存成一份快照,新快照里“独有”的块就是这段时间写过的块,其余块继续和模板共享。
所以磁盘差分的粒度就是底层写时复制的粒度:XFS 是 4 KiB 的文件系统块,s3lvol 是 1 MiB 的 cluster。
内存:VMM 只写选中的页
内存这边要 VMM 配合。Cube Hypervisor 写内存快照时,不是自己新建文件从头写,而是接收一个目标(一个文件路径或一个块设备),只把选中的页按原来的偏移写进去,其余偏移一个字节都不碰。前提是目标里已经有一份有效的基线,否则没写的页恢复出来就是 0。
Cubelet 负责准备这个目标:
- XFS 后端:reflink 一份基线内存文件,路径交给 VMM。
- S3 后端:从基线的内存快照克隆出一个 s3lvol 卷,激活成
/dev/nvmeXnY交给 VMM。
“选中哪些页”有三档,由 Cubelet 传给 VMM:
| 类型 | 写哪些页 | 依赖 |
|---|---|---|
full | 全部 | 不需要基线,目标可以是空卷 |
incremental | 进程里的匿名页 | 虚拟机内存是模板内存文件的私有映射,来宾写过的页会变成进程自己的匿名页;查 /proc/<pid>/pagemap 就知道是哪些 |
soft-dirty | 上一次清零之后写过的页 | 内核要开 CONFIG_MEM_SOFT_DIRTY;没开时 VMM 自动退回 incremental |
incremental 是“相对模板”的差分,沙箱跑得越久,写过的匿名页越多,每次暂停写的量只增不减。soft-dirty 才是“相对上一次快照”的差分:每轮只写这一轮弄脏的页,配合“基线 = 上一份快照的内存”,一条暂停、恢复、再暂停的链上每次都只写增量。
基线从哪里来
soft-dirty 只有在基线选对时才正确:基线必须正好是“上次清零那一刻”的内存。Cubelet 暂停时按下面的顺序找基线,找到第一个能用的就停:
- 沙箱自己的内存卷:S3 后端同机恢复时克隆出来的,或者跨节点导入进来的那一份。
- 创建沙箱用的模板内存:只在“从模板创建后第一次暂停”时成立。
- 上一次恢复用的暂停包:恢复时特意留着,这次暂停用完就回收。
- 都找不到:退回
full,往空卷里写一份完整的内存。
基线找错比没有基线更糟,恢复出来的内存会是两个时刻拼起来的,所以拿不准时宁可写全量。更细的页面筛选和 UFFD 恢复,见虚拟化篇。
粒度带来的代价
同样是来宾写了 3 个分散的 4 KiB 页:XFS 后端新分配 3 个块,共 12 KiB;S3 后端要分配 3 个 cluster,最多 3 MiB,每个 cluster 先从父层整块复制过来再覆盖。这些 cluster 之后会变成对象存储里的 1 MiB 对象。
这个放大是有意的取舍。对象存储按请求计费、按请求算延迟,4 KiB 一个对象的话,一块 10 GiB 的盘就是 260 万个对象;1 MiB 一个对象只有一万个左右。内存页的写入通常有局部性,实际放大远没有最坏情况那么大。
MinIO、S3 Volume 插件和 CubeS3lvol 各自负责什么
一提到 S3,CubeSandbox 里有好几样东西都沾边,名字也像。先把它们分开:
MinIO 只是个对象存储,自己不懂“沙箱”。四类使用者各占一个 bucket(或前缀),互不读对方的数据。和沙箱跨机恢复有关的只有最右边一列,用户在沙箱之间共享数据靠的是绿色那一列。
| 组件 | 是什么 | 存什么 | 谁用 |
|---|---|---|---|
| MinIO | S3 兼容的对象存储服务。一键部署时起一个 cube-sandbox-minio 容器,API 端口 9000,控制台 9001 | 自己不理解内容,只按 bucket 和 key 存对象 | 下面所有组件 |
| 模板制品存储 | pkgs/blobstore,后端可选 s3(默认)或 fs | 打好的 ext4 模板镜像,一个制品一个对象 | CubeMaster 上传,Cubelet 下载 |
S3 Volume 插件(cube-volume-s3,常说的 s3vol) | 一个 Go 写的 Volume 插件:控制面钩子建删 bucket 前缀,节点钩子用 s3fs 挂载 | 用户的持久数据,一个文件就是一个对象 | 用户通过 /volumes API 建卷、挂进沙箱 |
| CubeS3lvol(s3lvol) | 每台节点上的 SPDK 进程 s3lvol_tgt,把对象存储包装成块设备 | 沙箱根盘、内存镜像、元数据盘的块数据,1 MiB 一个对象 | CubeCoW 的 S3 后端 |
最容易混的是后两个,它们的区别在于“对象存储里的一个对象对应什么”:
- S3 Volume 插件是文件级的。来宾里看到的是一个目录,写一个文件,bucket 里就多一个同名对象;用
aws s3 ls能直接看到用户的文件。它解决的是“用户数据活得比沙箱长、能挂到别的沙箱上”。
- CubeS3lvol 是块级的。来宾里看到的是一块盘,盘上跑 ext4;bucket 里只有一堆 uuid 命名的 1 MiB 块,脱离它的元数据看不出任何文件。它解决的是“整台沙箱的状态(盘加内存)能跨机器恢复”。
两者走完全不同的数据路径:前者是 s3fs(FUSE)挂在宿主机,再经 virtio-fs 共享进来宾;后者是 NVMe 块设备经 virtio-blk 交给来宾。它们可以指向同一个 MinIO,但各用各的 bucket(默认 cube-volumes 和 cube-s3lvol),互不读对方的数据。
CubeS3lvol:把对象存储变成一块盘
reflink 是本机文件系统的能力,克隆出来的文件和源文件必须在同一个 XFS 上。要让暂停的沙箱在另一台节点恢复,盘的数据得放在两台机器都能读到的地方,又不能每次恢复都把几十 GiB 拷过去。CubeS3lvol 的做法是:数据放对象存储,块设备语义和快照、克隆能力交给 SPDK 现成的 lvol/blobstore,自己只写中间那层“把对象存储伪装成一块盘”的设备。
分层
从上往下看:虚拟机以为自己有块盘,宿主机以为自己连着一个 NVMe 设备,blobstore 以为自己管着一块裸盘。到最底下 s3_bs_dev 才知道,“盘”其实是本地日志、缓存和对象存储拼起来的。
从上往下一共五层:
- 来宾看到
/dev/vda,由 Cube Hypervisor 的 virtio-blk 提供。
- 宿主机内核看到
/dev/nvmeXnY,是 nvme-tcp 驱动连到本机127.0.0.1:4420的一个 NVMe-oF 设备。激活一个卷就是给它建一个 subsystem 并连上。走网络协议但不出本机,好处是 s3lvol 可以完全跑在用户态,不用写内核驱动,宿主机只要加载内核自带的 nvme-tcp 模块。
- SPDK lvol/blobstore 提供卷、快照、克隆和 esnap 克隆(以外部设备为只读父层的克隆),按 1 MiB 的 cluster 分配空间和做写时复制。SPDK 的 reactor 线程忙轮询,默认绑在允许使用的最后两个 CPU 上。
- s3_bs_dev 是 CubeS3lvol 自己写的部分。对 blobstore 来说它就是一块裸盘,blobstore 的 super block、元数据页也像写普通盘一样写在它上面,CubeS3lvol 不另外解析 blobstore 的结构。块大小固定 4 KiB。
- 本地盘和对象存储:本地盘只放日志和缓存,数据的正本在对象存储。
下面几节都发生在第 4 层。
切块和写路径
注意 s3_bs_dev 是给整个 lvstore 用的一块“盘”,一个 lvstore 里的所有卷、快照都分配在它上面。它把这块盘的地址空间按 chunk 切开,chunk 默认 1 MiB,和 blobstore 的 cluster 一样大,所以一个 cluster 正好落在一个 chunk 里。从一个读写请求到对象存储里的 key,要过三步:
- 字节偏移除以 chunk 大小,得到 chunk 号;
- 用 chunk 号查 chunk map,得到这个 chunk 当前对应的对象 uuid;
- 拼成
<lvs>/data/<uuid>,就是对象的 key。
chunk map 是内存里一个按 chunk 号排的稠密数组,每项记 uuid、有效字节数、代数和几个标志,约 56 字节。uuid 全零表示空洞,读出来就是 0。blobstore 的卷是精简配置的,cluster 第一次被写才分配,所以新建一个 512 GiB 的卷不会产生任何数据对象,对象存储里的数据量只和真正写过的量有关。
最关键的规矩是 对象只写一次。改一个 chunk 不是覆盖原来的对象,而是 PUT 一个新 uuid 的对象,chunk map 改指过去,旧对象删掉,而且只删一次。这样同一个 key 永远对应同一份内容,后面的缓存和跨机共享都建立在这一点上。
代价是对象不能局部改。来宾只写了 16 KiB,也得把整个 1 MiB 的旧 chunk GET 回来,合并后 PUT 成新对象。如果每次写都这么做,延迟和请求数都受不了,所以写入分成了前后两段:
前台:WAL + overlay,只等本地盘。写请求先追加到本地盘上的 WAL(预写日志),凑满 128 KiB、64 个请求或 200 µs 就提交一批,write 加 flush 都完成才算持久。然后在内存里的 overlay 记下“这个 chunk 的这几个 4 KiB 块有新数据”,给来宾回成功。overlay 上限 4 GiB。WAL 按 16 MiB 一段环形复用,用到 85% 开始给写入限流,降到 65% 解除,用到 50% 就催 flusher 加快。
后台:flusher,按 chunk 合并上传。一个 chunk 满足下面任一条件就被刷:整块都被写满了、最老的脏数据超过 45 秒、overlay 用到 75%。刷一个 chunk 的步骤是:
- 读改写:GET 旧对象(整块写满的跳过这步),和 overlay 里的新块合并;
- PUT 成一个新 uuid 的对象;
- 往本地 journal 追加一条“chunk i:旧 uuid → 新 uuid”并落盘;
- 改内存里的 chunk map;
- 删旧对象;
- 把这块数据顺手放进本地缓存;
- WAL 里已经全部刷完的段可以截掉了。
源码里也有一个不经 WAL、直接读改写到 S3 的模式,注释写明并发下会丢更新:两个写同时 GET 同一个旧 chunk,各自合并后 PUT,后一个会盖掉前一个的修改。WAL 加 overlay 把同一个 chunk 的写入在本地串起来,再交给 flusher 一次合并,这个问题就没了。
元数据:journal 和 checkpoint
chunk map 是整个卷最重要的元数据:丢了它,对象存储里那一堆 uuid 就没法还原成一块盘。它平时只在内存里,持久化靠两样东西:
- journal,在本地盘上。每次修改 chunk map 之前,先追加一条 64 字节的记录并落盘,才能改内存。记录按 4 KiB 一块写,同一时间只有一个写在路上,LSN(日志序号)在追加时分配,所以 journal 里的顺序就是修改发生的顺序。
- checkpoint,在对象存储上。每 60 秒、journal 涨了 128 MiB 或累计 10 万次修改,就把整张 chunk map PUT 成
<lvs>/meta/checkpoint,里面记着它包含了截止到哪个 LSN 的修改。
做 checkpoint 的三步顺序是固定的:先 PUT checkpoint 对象,成功后把这个 LSN 写进本地 super block 的 checkpoint_lsn,最后截断 journal 里不再需要的部分。任意两步之间崩溃都没关系:super block 还是旧值的话,恢复时只是多重放几条已经包含在 checkpoint 里的记录,结果一样。
崩溃或重启后的恢复是三段:
- GET
meta/checkpoint,得到某个 LSN 时刻的整张 chunk map;
- 重放 journal 里 LSN 大于它的记录;
- 重放 WAL 里还没被 flush 的写,overlay 回到崩溃前的样子。
本地盘的开头是一个 4 KiB 的 super block,魔数 S3LVOLSB,记着 journal、WAL、缓存各自在盘上的起止位置和 checkpoint_lsn。对象存储的 <lvs>/meta/ 下还有几个小对象:owner 记录当前是哪台机器在写这个 lvstore(按主机名),防止两台机器同时写;exports.json、imports.json 记录导出导入关系;exports/<uuid>.lease 是远端读者的租约;pending-deletes.json 记推迟的删除。被替换下来的旧对象是在 flush 时当场删的,独立的垃圾回收目前还是个空壳(s3_gc.c 里留着 TODO)。
本地盘和缓存
部署脚本在 /data/cubelet/rcow/wal_bdev.img 建一个 512 GiB 的稀疏文件当本地盘,按顺序切成四段:
| 区域 | 部署默认大小 | 存什么 |
|---|---|---|
| super block | 4 KiB | 各区域位置、checkpoint_lsn |
| journal | 1 GiB | chunk map 的修改记录 |
| WAL | 32 GiB | 还没上传的写入 |
| chunk 缓存 | ≈479 GiB(剩下的全部) | 对象的本地副本 |
这个文件里有还没上传的数据和还没 checkpoint 的元数据,部署脚本明确要求不能删掉重建。配置上也支持把日志和缓存拆到两块盘上。
读一个块时,按这个顺序查:
- overlay:刚写过、还没上传的块在这里,必须最先查,否则会读到旧版本。
- chunk map:是空洞就直接返回 0;否则拿到对象 uuid。
- 热缓存:内存里 1024 个 1 MiB 的缓冲区。
- 盘缓存:本地盘上的缓存区,每个槽和 chunk 一样大。
- 对象存储:按范围 GET,读回来的数据写进盘缓存。
缓存有几个特点,都来自“对象只写一次”:
- 按 uuid 记账,没有失效问题。缓存里存的是“对象
data/91aa…的内容”,而这个对象永远不会变。chunk 被改写后换了新 uuid,旧槽自然没人再找,等着被淘汰就行,不需要任何失效协议。
- 只缓存干净数据。还没上传的数据在 WAL 和 overlay 里,缓存里的都是对象存储里已有的副本,掉电丢了也无所谓。所以缓存索引只放内存,重启后缓存是冷的。
- 按块记录驻留。每个槽带一张位图,记录哪些 4 KiB 块已经在盘上。来宾 ext4 一次预读只要 128 KiB,没必要为此把整个 1 MiB 拉下来;范围 GET 读多少就填多少。
- 写入顺带填缓存。flusher 上传时手里本来就有整块数据,直接放进缓存,写过的数据马上能从本地读回。
- 远端数据优先级低。从别的节点导入的快照也走这套缓存,但用一条更低优先级的通道,避免一次大恢复把本机的热数据挤掉。
跨节点交换:零拷贝导出、esnap 导入、后台解耦
到这里,单机上的卷已经能用了。跨节点要回答两个问题:B 怎么知道该读哪些对象;A 什么时候可以删自己的快照。
导出:只写一份清单。rcow_export_snapshot 立刻返回一个 export uuid,然后在后台做事,调用方用 rcow_get_snapshot_status 轮询,直到 DONE。默认的布局是零拷贝引用:先把 lvstore 刷空(drain),让还在 WAL 和 overlay 里的数据都变成对象;然后沿着快照的克隆链一路往下,为卷的每个 cluster 找到它当前所在的 data/<uuid> 对象,写成一份清单 <lvs>/exports/<uuid>.json。整个导出只多一个 PUT,不复制任何数据块。清单格式有过几个版本,新版本允许每个 chunk 各自指向不同的源。
代价是 A 必须保留这份快照,否则清单引用的对象会被删掉。所以导出关系会记进 A 的 meta/exports.json。少数情况下不能引用,就退回老办法,把数据复制一份到 exports/<uuid>/<chunk 号>:块的几何不匹配、克隆链上碰到了 esnap,或者链深超过 32 层。
导入:建一个 esnap 克隆。rcow_import_lvol 在 B 上先把清单拿下来,记进 B 自己的 meta/imports.json(blobstore 重启加载这个卷时会同步要它的父层,必须本地就能找到清单),然后建一个 esnap 克隆:B 上的新卷,父层是一个只读的外部设备 s3_export_bs_dev。这个设备没有 WAL、overlay 和 chunk map,只会按清单找到 A 的对象、整个读进内存,多个读者共用一份;对它写会返回 EROFS。导入只动元数据,完成后就能激活、启动虚拟机。
之后 B 上的卷就是一个普通的克隆:写过的 cluster 归 B 自己,走 B 的 WAL 和 flusher,最后变成 lvs-B/data/ 下的新对象;没写过的部分读父层,也就是读 A 的对象。
租约:A 别删我在读的东西。B 只要还依赖 A 的对象,就每 20 秒续一次 meta/exports/<uuid>.lease,过期后还有 60 秒宽限。A 想删一个有活跃读者的快照时,删除会被拒绝并记进 pending-deletes.json,后台大约每分钟检查一次,读者走了再补删。
解耦:后台把数据变成自己的。导入后默认在后台启动 decouple:只处理清单里列出的 chunk,把它们一个个变成 B 自己的。两边在同一个 bucket 时用 S3 CopyObject,服务端复制、数据不经过节点,复制完直接登记进 B 的 chunk map;否则就 GET 下来走 B 的 WAL 写一遍。全部完成后最后一步才摘掉父层,此后 B 和 A 再无关系,租约停止续期。过程可以中断后接着做,为此给 SPDK blobstore 打了两个补丁。解耦期间,对这个卷做快照、克隆、删除都会被拒绝。实测一整卷解耦花了 3 分 43 秒,但沙箱在导入完成时就已经跑起来了,不用等。
在同一个 lvstore 里导入自己导出的快照,不需要这一套,直接做本地克隆。
落到 Cubelet 上:一次跨节点暂停和恢复
把上面这些串起来,一次 S3 后端的暂停、跨节点恢复是这样的:
- 暂停(节点 A):冻结虚拟机,按上一节的规则把内存差分写进内存工作卷。Cubelet 把元数据盘刷盘、卸载,然后把内存卷和元数据盘这两个读写卷各封成一份只读的
-snap快照,摘掉设备,删掉工作卷。根盘的快照在提交时就已经封好了。
- 导出(节点 A):对根盘、内存、元数据三份快照并行调
export,把三个 export uuid 报给 CubeMaster。导出状态变成DONE才算可以在别处恢复。暂停加销毁共用 120 秒的时间预算。
- 调度:CubeMaster 选定节点 B,把这次恢复标成
cross_node。
- 导入(节点 B):Cubelet B 直接用沙箱自己的卷名(根盘、内存)调
import_lvol,元数据盘导入后挂到宿主机上读配置。B 上不需要重新拼一个暂停包。
- 恢复(节点 B):激活设备,从内存卷恢复虚拟机。之后的读按需从对象存储拉,后台慢慢解耦。
同一台节点上恢复时没有导出导入这两步,但 S3 后端仍然要把暂停包里的内存快照克隆成一份沙箱私有的卷再交给虚拟机,因为暂停包对应的设备随时可能被摘掉,虚拟机不能直接映射它。这份私有卷也就是下次暂停时基线阶梯的第一级。reflink 后端则没有上传这一步,暂停包始终留在本机盘上。
模板的后端在创建时就定下,整条血缘都跟着它。毫秒级的创建仍然靠本地克隆;S3 后端解决的是“状态跨过单机磁盘”。这项能力目前标为预览,默认搭配内置的 MinIO。
数据卷和宿主机目录
上面讲的都是虚拟机根文件系统和内存,生命周期跟着沙箱走。用户数据另有两条通道,都和 CubeS3lvol 无关。
- 宿主机目录挂载:把宿主机上的一个目录 bind 到
/data/cubelet/hostdir/<沙箱ID>/下,再经 virtio-fs 共享进客户机。这种挂载绑定节点,跨节点恢复时只能回到原节点。
- Volume 插件(0.6.0):生命周期独立于沙箱,可以挂到不同沙箱上。插件实现控制面的创建/删除钩子和节点上的挂载/卸载钩子,仓库里有 s3fs、COS、JuiceFS 的示例。插件把存储挂到宿主机后返回一个路径,Cubelet 复用宿主机目录那套 bind 加 virtio-fs 的机制把它送进客户机。前面说的 S3 Volume 插件就是其中 s3fs 那个示例,Helm chart 里默认给它配了
cube-volumesbucket。
和文档对照时要注意的几处
- CubeCoW 的 README 还写着只有 reflink 一个后端,源码里 S3 后端已经接入 Cubelet。
- 它的 API 文档暗示有快照时删不掉卷;reflink 实现已经允许只删主文件、保留快照。
- 一些文档把 reflink 根目录写成
/var/lib/cubecow/reflink,生产部署由 Cubelet 固定在/data/cubelet/storage/xfs/objects。
- XFS 后端把沙箱的可写层登记成“快照”类型的对象,这只是 CubeCoW 内部的对象种类,不代表它在客户机里是只读的。
- CubeCoW 的
docs/s3lvol-rpc.md说导入时的解耦要在 RPC 返回前做完;CubeS3lvol 现在的行为是导入一完成就返回,解耦在后台跑。
s3_export.h开头的注释还把导出描述成“复制一份数据”,现在默认是零拷贝引用,复制只是退路。
- CubeS3lvol 头文件里的默认值(journal 256 MiB、WAL 1 GiB)比部署脚本实际用的小得多,以部署脚本为准。
参考
- 腾讯云,CubeSandbox 仓库。本文依据
cubecow(src/engine/reflink.rs、src/engine/s3.rs)、Cubelet/storage、Cubelet/services/cubebox、CubeTemplateCenter、CubeS3lvol(lib/s3bsdev、module/bdev/s3lvol)、examples/volume/s3、agent目录的源码,2026-09-30 的 master。
- SPDK,Blobstore Programmer's Guide、Logical Volumes。cluster、精简配置、快照与克隆、esnap 克隆。
- sionli,几十 GiB 快照秒回、克隆“零拷贝”,2026-06-25。reflink、扁平快照、崩溃恢复规则。
- 模板概览。探针与就绪。
- 跨机快照。S3 后端的暂停与恢复。
- v0.6.0 更新说明。Volume 插件。
- 云知声,RL rollout 场景下的密度边界压测,2026-09-01。模板分发到节点本地、XFS reflink 的部署约束。