CubeSandbox 存储篇

这是 CubeSandbox 系列的第二篇。架构篇说过,这套系统的核心思路是“冷启动在模板阶段付一次,运行时只做本机克隆和快照恢复”。这个思路能不能落地,关键在存储:创建一台沙箱时,磁盘和内存镜像都不能真的拷一份。

CubeSandbox 的默认做法是把所有大文件都放在本地 XFS 上,用一次 FICLONE ioctl 完成克隆。0.7.0 起又多了一个 S3 后端,把盘放进对象存储,让暂停的沙箱能在另一台机器上恢复。

本篇按这个顺序讲:沙箱的盘怎么拼起来、模板怎么来、reflink 在文件系统里做了什么;CubeCoW 怎么用同一套接口管 XFS 和 S3 两个后端;内存和磁盘的差分具体怎么做;MinIO、S3 Volume 插件和 CubeS3lvol 各管什么;最后拆开 CubeS3lvol,看它怎么切块、记元数据、做本地缓存、在机器之间交换快照。内存快照里“哪些页算脏页”的筛选逻辑放在虚拟化篇,网络放在网络篇。

一台沙箱的盘长什么样

宿主机上的文件 交给 VM 的设备 客户机里看到的 客户机 OS 镜像 所有沙箱共用 · 只读 virtio-pmem0 / 客户机系统盘,DAX 直读宿主机页缓存 cube-agent.ext4 所有沙箱共用 · 只读 virtio-pmem1 /run/support/cube-agent cube-init 挂上后 exec 成 PID 1 <artifact>.ext4 OCI 镜像解包成的 ext4 · 同模板共用 · 只读 virtio-pmem2 sb-<id>-rootfs-gen0 FICLONE 自 tpl-<模板>-rootfs · 每台沙箱私有 virtio-blk 容器 rootfs = overlayfs lower:模板镜像(pmem,只读,DAX) upper / work:可写盘上的 disk/<容器 ID> 读走模板镜像,写只落在可写层 可写盘本身是 ext4,不带日志 /data/cubelet/hostdir/<id>/… 宿主机目录或 Volume 插件挂载点的 bind virtio-fs 用户指定的挂载点 VMM 内置的 virtiofsd 透传,和宿主机实时同步

灰色是只读、全机共享的文件,橙色是每台沙箱私有的可写层,蓝色是穿透到宿主机的共享目录。创建一台沙箱真正新增的只有橙色那一个文件,而它是一次 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 镜像都是已经在盘上的共享文件,直接挂上就行。

模板从哪里来

控制面:只做一次 OCI 镜像 rootfs · 入口 · 环境变量 TemplateCenter 拉取 · 解包 mkfs.ext4 -d 打成镜像 制品存储 S3 / MinIO / 本地 CubeMaster 记录构建任务阶段 选节点下发副本 每台计算节点:各做一次 gRPC 下发模板副本 下载 ext4 落到本机,按制品 ID 缓存,供 pmem 挂载 冷启动一台临时 VM 跑入口命令 等 HTTP 探针返回 2xx 冻结并拍快照 全量内存 → tpl-…-memory 可写层 → tpl-…-rootfs 登记为本地模板 上报给调度器 之后只在本机克隆

冷启动内核、跑初始化、等服务就绪这些慢事,都在模板阶段每台节点各付一次。之后每次创建只做本地 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 把它链进自己的进程,每个后端初始化一个句柄。

一套接口,两个后端

Cubelet(Go,每台节点一个) storage/cubecow_volume_manager.go(XfsCow) · storage/s3_volume_manager.go(S3Cow) cgo,同一进程内调用 libcubecow(Rust,导出 C ABI) dyn Engine:建卷 · 从卷拍快照 · 从快照克隆 · 删除 · 激活成设备 · 扩容 · 导出 · 导入 ReflinkEngine 名字索引在内存里,启动时扫 volumes/ 目录重建 S3Engine 名字索引 + 本地 index.json(种类、激活设备、export_uuid) ioctl(FICLONE) JSON-RPC · /var/run/s3lvol.sock 本地 XFS(reflink=1) /data/cubelet/storage/xfs/objects/volumes/ tpl-x-rootfs/tpl-x-rootfs tpl-x-rootfs/sb-1-rootfs-gen0 卷 = 普通文件;快照、克隆 = 一次 FICLONE 写时复制粒度:文件系统块(4 KiB) s3lvol_tgt(SPDK 进程,每台节点一个) rcow_create_lvol / snapshot / clone / export / import 激活后经 NVMe/TCP 回环导出:/dev/nvmeXnY 本地盘只放日志和缓存,数据在对象存储 卷 = blobstore 里的 blob;快照、克隆 = 改元数据 写时复制粒度:cluster(默认 1 MiB) MinIO / COS:bucket cube-s3lvol

两个后端实现同一个 Engine 接口,Cubelet 上层的创建、快照、回滚、克隆流程不用关心底下是哪个。模板创建时选定后端,之后整条血缘都留在这个后端里。

CubeCoW 对上只暴露几类操作:建卷、从卷拍快照、从快照克隆出卷、删除、激活成设备、导出、导入。对下是一个 Engine trait,目前有两个实现。

同一个操作在两个后端各做了什么:

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 出一个稀疏文件,不预分配空间。

在此之上,它定了三条规矩。

快照是扁平的。

链式:每层只记差异,依赖父层 base snap1 snap2 删掉中间的 snap1,要先把它的差异合并进 snap2, 否则 snap2 读不全。读一个块也可能要沿链往回找。 箭头指向父层。 CubeCoW:同一目录里的平级文件 volumes/tpl-x-rootfs/ tpl-x-rootfs 主文件 snap-a 独立映射 snap-b 由 snap-a 克隆 … 血缘只记在内存索引里 snap-b 的 origin 仍记为 tpl-x-rootfs,文件也放在这个目录下。 删掉 snap-a 只是 unlink 一个文件,snap-b 不受影响。

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 后端一脉相承:

创建、快照、回滚、克隆在盘上各做了什么

有了上面这些,几种操作在存储层面都很直接。

内存和磁盘是怎么做差分的

“快照很便宜”的前提是:每次只存和上一份相比变了的部分。CubeSandbox 里磁盘和内存用的是同一个套路:先把基线克隆成目标,再只往目标里写变了的部分。写出来的目标在逻辑上是一份完整的盘或完整的内存镜像,没变的部分在物理上仍然和基线共享。

XFS 后端:reflink 克隆 S3 后端:s3lvol 克隆 虚线框 = 一个 cluster 基线(上一份内存镜像,或模板的) 基线快照(只读) 基线的数据 仍指向基线 VMM 写入的脏页 随 cluster 一起从父层复制的页

    磁盘:可写层本身就是差分

    磁盘这边几乎不用额外做事。沙箱的可写层从一开始就是模板 rootfs 快照的克隆:XFS 上是一个 reflink 文件,S3 上是一个 s3lvol 克隆卷。沙箱运行时写到哪,底层就为哪一块做写时复制。暂停或拍快照时,Cubelet 把这个可写层再封存成一份快照,新快照里“独有”的块就是这段时间写过的块,其余块继续和模板共享。

    所以磁盘差分的粒度就是底层写时复制的粒度:XFS 是 4 KiB 的文件系统块,s3lvol 是 1 MiB 的 cluster。

    内存:VMM 只写选中的页

    内存这边要 VMM 配合。Cube Hypervisor 写内存快照时,不是自己新建文件从头写,而是接收一个目标(一个文件路径或一个块设备),只把选中的页按原来的偏移写进去,其余偏移一个字节都不碰。前提是目标里已经有一份有效的基线,否则没写的页恢复出来就是 0。

    Cubelet 负责准备这个目标:

    “选中哪些页”有三档,由 Cubelet 传给 VMM:

    类型写哪些页依赖
    full 全部 不需要基线,目标可以是空卷
    incremental 进程里的匿名页 虚拟机内存是模板内存文件的私有映射,来宾写过的页会变成进程自己的匿名页;查 /proc/<pid>/pagemap 就知道是哪些
    soft-dirty 上一次清零之后写过的页 内核要开 CONFIG_MEM_SOFT_DIRTY;没开时 VMM 自动退回 incremental

    incremental 是“相对模板”的差分,沙箱跑得越久,写过的匿名页越多,每次暂停写的量只增不减。soft-dirty 才是“相对上一次快照”的差分:每轮只写这一轮弄脏的页,配合“基线 = 上一份快照的内存”,一条暂停、恢复、再暂停的链上每次都只写增量。

    基线从哪里来

    soft-dirty 只有在基线选对时才正确:基线必须正好是“上次清零那一刻”的内存。Cubelet 暂停时按下面的顺序找基线,找到第一个能用的就停:

    1. 沙箱自己的内存卷:S3 后端同机恢复时克隆出来的,或者跨节点导入进来的那一份。
    1. 创建沙箱用的模板内存:只在“从模板创建后第一次暂停”时成立。
    1. 上一次恢复用的暂停包:恢复时特意留着,这次暂停用完就回收。
    1. 都找不到:退回 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 里有好几样东西都沾边,名字也像。先把它们分开:

    模板制品 CubeMaster 存,Cubelet 拉 pkgs/blobstore XFS 后端时整个 ext4 镜像 拉到本机,再 reflink CubeOps 组件仓库 部署、升级用的安装包 和沙箱数据无关 S3 Volume 插件(s3vol) Master:create / destroy Cubelet:s3fs 挂载 → virtio-fs 进沙箱 用户的持久数据卷 CubeS3lvol 每台节点的 s3lvol_tgt CubeCoW S3 后端的块设备 沙箱根盘、内存镜像、 元数据盘 HTTP PUT / GET HTTP s3fs(FUSE) 按 chunk PUT 按范围 GET MinIO(一键部署的容器 cube-sandbox-minio,API :9000)· 生产可换 COS 或任意 S3 兼容存储 CUBE_S3_BUCKET <prefix>/… 一个制品一个对象 按需整份下载 cube-ops 安装包 cube-volumes <volume>/a.txt 沙箱里的一个文件 = bucket 里的一个对象 cube-s3lvol <lvs>/data/<uuid> <lvs>/meta/… <lvs>/exports/<uuid>.json 1 MiB 一个对象,写一次不改 粒度:整个文件 粒度:安装包 粒度:用户看到的文件 粒度:磁盘上的 1 MiB 块

    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 后端

    最容易混的是后两个,它们的区别在于“对象存储里的一个对象对应什么”:

    两者走完全不同的数据路径:前者是 s3fs(FUSE)挂在宿主机,再经 virtio-fs 共享进来宾;后者是 NVMe 块设备经 virtio-blk 交给来宾。它们可以指向同一个 MinIO,但各用各的 bucket(默认 cube-volumes 和 cube-s3lvol),互不读对方的数据。

    CubeS3lvol:把对象存储变成一块盘

    reflink 是本机文件系统的能力,克隆出来的文件和源文件必须在同一个 XFS 上。要让暂停的沙箱在另一台节点恢复,盘的数据得放在两台机器都能读到的地方,又不能每次恢复都把几十 GiB 拷过去。CubeS3lvol 的做法是:数据放对象存储,块设备语义和快照、克隆能力交给 SPDK 现成的 lvol/blobstore,自己只写中间那层“把对象存储伪装成一块盘”的设备。

    分层

    沙箱虚拟机 Cube Hypervisor 用 virtio-blk 把块设备交给来宾,来宾里看到的是 /dev/vda 宿主机内核 /dev/nvme1n1 ← nvme-tcp 驱动,连 127.0.0.1:4420(每个激活的卷一个 subsystem) NVMe/TCP 回环,不出本机 s3lvol_tgt(用户态 SPDK 进程,reactor 忙轮询最后 2 个 CPU) NVMe-oF target 把一个 lvol 当成一个 namespace 导出 SPDK lvol / blobstore 卷、快照、克隆、esnap 克隆;按 cluster(1 MiB)分配、写时复制 它以为自己面对一块裸盘:super block 和元数据页也写在下面这块“盘”上 s3_bs_dev:把对象存储伪装成一块盘 LBA → chunk → 对象;块大小固定 4 KiB WAL写先落本地盘 chunk map + journalchunk → 对象 uuid chunk cache读过、写过的干净块 overlay(内存)还没上传的块 flusher 按 chunk 上传 追加 追加 缓存槽 本地盘:/data/cubelet/rcow/wal_bdev.img super 4 KiB | journal 1 GiB | WAL 32 GiB | 缓存 ≈479 GiB (512 GiB 稀疏文件,部署默认值) 对象存储:bucket cube-s3lvol <lvs>/data/<uuid> <lvs>/meta/checkpoint <lvs>/exports/<uuid>.json …

    从上往下看:虚拟机以为自己有块盘,宿主机以为自己连着一个 NVMe 设备,blobstore 以为自己管着一块裸盘。到最底下 s3_bs_dev 才知道,“盘”其实是本地日志、缓存和对象存储拼起来的。

    从上往下一共五层:

    1. 来宾看到 /dev/vda,由 Cube Hypervisor 的 virtio-blk 提供。
    1. 宿主机内核看到 /dev/nvmeXnY,是 nvme-tcp 驱动连到本机 127.0.0.1:4420 的一个 NVMe-oF 设备。激活一个卷就是给它建一个 subsystem 并连上。走网络协议但不出本机,好处是 s3lvol 可以完全跑在用户态,不用写内核驱动,宿主机只要加载内核自带的 nvme-tcp 模块。
    1. SPDK lvol/blobstore 提供卷、快照、克隆和 esnap 克隆(以外部设备为只读父层的克隆),按 1 MiB 的 cluster 分配空间和做写时复制。SPDK 的 reactor 线程忙轮询,默认绑在允许使用的最后两个 CPU 上。
    1. s3_bs_dev 是 CubeS3lvol 自己写的部分。对 blobstore 来说它就是一块裸盘,blobstore 的 super block、元数据页也像写普通盘一样写在它上面,CubeS3lvol 不另外解析 blobstore 的结构。块大小固定 4 KiB。
    1. 本地盘和对象存储:本地盘只放日志和缓存,数据的正本在对象存储。

    下面几节都发生在第 4 层。

    切块和写路径

    lvstore 那块“盘”的地址空间(按 1 MiB 切成 chunk) chunk 号 = 字节偏移 ÷ 1 MiB chunk map(内存里一个按 chunk 号排的数组,每项约 56 字节):chunk 号 → 对象 uuid,全零 uuid 表示空洞,读出来是 0 WAL(本地盘) 16 MiB 一段,环形复用 overlay(内存) 上限 4 GiB flusher journal(本地盘) 对象存储 来宾写入只等 WAL 和 overlay, 不等对象存储

      注意 s3_bs_dev 是给整个 lvstore 用的一块“盘”,一个 lvstore 里的所有卷、快照都分配在它上面。它把这块盘的地址空间按 chunk 切开,chunk 默认 1 MiB,和 blobstore 的 cluster 一样大,所以一个 cluster 正好落在一个 chunk 里。从一个读写请求到对象存储里的 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 的步骤是:

      1. 读改写:GET 旧对象(整块写满的跳过这步),和 overlay 里的新块合并;
      1. PUT 成一个新 uuid 的对象;
      1. 往本地 journal 追加一条“chunk i:旧 uuid → 新 uuid”并落盘;
      1. 改内存里的 chunk map;
      1. 删旧对象;
      1. 把这块数据顺手放进本地缓存;
      1. WAL 里已经全部刷完的段可以截掉了。

      源码里也有一个不经 WAL、直接读改写到 S3 的模式,注释写明并发下会丢更新:两个写同时 GET 同一个旧 chunk,各自合并后 PUT,后一个会盖掉前一个的修改。WAL 加 overlay 把同一个 chunk 的写入在本地串起来,再交给 flusher 一次合并,这个问题就没了。

      元数据:journal 和 checkpoint

      内存(进程一退就没了) chunk map overlay 本地盘(wal_bdev.img) super block “S3LVOLSB” journal(64 B 一条,按 LSN 递增) 对象存储(<lvs>/meta/) meta/checkpoint 同目录下的其他元数据: meta/owner 哪台机器在写 meta/exports.json 导出过的快照 meta/imports.json 导入的清单 meta/exports/*.lease 读者的租约 meta/pending-deletes 推迟的删除

        chunk map 是整个卷最重要的元数据:丢了它,对象存储里那一堆 uuid 就没法还原成一块盘。它平时只在内存里,持久化靠两样东西:

        做 checkpoint 的三步顺序是固定的:先 PUT checkpoint 对象,成功后把这个 LSN 写进本地 super block 的 checkpoint_lsn,最后截断 journal 里不再需要的部分。任意两步之间崩溃都没关系:super block 还是旧值的话,恢复时只是多重放几条已经包含在 checkpoint 里的记录,结果一样。

        崩溃或重启后的恢复是三段:

        1. GET meta/checkpoint,得到某个 LSN 时刻的整张 chunk map;
        1. 重放 journal 里 LSN 大于它的记录;
        1. 重放 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 的元数据,部署脚本明确要求不能删掉重建。配置上也支持把日志和缓存拆到两块盘上。

        读一个 4 KiB 块时的查找顺序 ① overlay 还没上传的新数据 内存 ② chunk map 空洞直接返回 0 否则拿到对象 uuid ③ 热缓存 1024 个 1 MiB 缓冲 内存 ④ 盘缓存 本地盘上的缓存区 ≈479 GiB ⑤ 对象存储 按范围 GET 几毫秒到几十毫秒 盘缓存里的一个槽(大小 = 一个 chunk) 每格 64 KiB(示意)。缓存按对象 uuid 记账,按 4 KiB 块记录哪些在盘上: 来宾 ext4 预读一次只要 128 KiB,没必要把整个 1 MiB 都拉下来。 只缓存干净数据(已经在对象存储里的),索引只在内存,重启后缓存是冷的。 在盘上 不在 这次刚填进来

          读一个块时,按这个顺序查:

          1. overlay:刚写过、还没上传的块在这里,必须最先查,否则会读到旧版本。
          1. chunk map:是空洞就直接返回 0;否则拿到对象 uuid。
          1. 热缓存:内存里 1024 个 1 MiB 的缓冲区。
          1. 盘缓存:本地盘上的缓存区,每个槽和 chunk 一样大。
          1. 对象存储:按范围 GET,读回来的数据写进盘缓存。

          缓存有几个特点,都来自“对象只写一次”:

          跨节点交换:零拷贝导出、esnap 导入、后台解耦

          节点 A(lvstore lvs-A) 沙箱 sb-1 在跑 只读快照 sb-1-rootfs-snap sb-1-memory-snap … 对象存储(同一个 bucket) lvs-A/data/a1f0… lvs-A/data/e93a… 快照的数据(暂停前就在) lvs-A/exports/7c1d.json 清单:chunk i → 哪个对象 lvs-A/meta/exports/7c1d.lease lvs-B/data/… 节点 B(lvstore lvs-B) sb-1-rootfs(esnap 克隆) 只读父层 s3_export_bs_dev 按清单直接读 lvs-A 的对象 没有 WAL、overlay;写返回 EROFS

            到这里,单机上的卷已经能用了。跨节点要回答两个问题: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 后端的暂停、跨节点恢复是这样的:

            1. 暂停(节点 A):冻结虚拟机,按上一节的规则把内存差分写进内存工作卷。Cubelet 把元数据盘刷盘、卸载,然后把内存卷和元数据盘这两个读写卷各封成一份只读的 -snap 快照,摘掉设备,删掉工作卷。根盘的快照在提交时就已经封好了。
            1. 导出(节点 A):对根盘、内存、元数据三份快照并行调 export,把三个 export uuid 报给 CubeMaster。导出状态变成 DONE 才算可以在别处恢复。暂停加销毁共用 120 秒的时间预算。
            1. 调度:CubeMaster 选定节点 B,把这次恢复标成 cross_node。
            1. 导入(节点 B):Cubelet B 直接用沙箱自己的卷名(根盘、内存)调 import_lvol,元数据盘导入后挂到宿主机上读配置。B 上不需要重新拼一个暂停包。
            1. 恢复(节点 B):激活设备,从内存卷恢复虚拟机。之后的读按需从对象存储拉,后台慢慢解耦。

            同一台节点上恢复时没有导出导入这两步,但 S3 后端仍然要把暂停包里的内存快照克隆成一份沙箱私有的卷再交给虚拟机,因为暂停包对应的设备随时可能被摘掉,虚拟机不能直接映射它。这份私有卷也就是下次暂停时基线阶梯的第一级。reflink 后端则没有上传这一步,暂停包始终留在本机盘上。

            模板的后端在创建时就定下,整条血缘都跟着它。毫秒级的创建仍然靠本地克隆;S3 后端解决的是“状态跨过单机磁盘”。这项能力目前标为预览,默认搭配内置的 MinIO。

            数据卷和宿主机目录

            上面讲的都是虚拟机根文件系统和内存,生命周期跟着沙箱走。用户数据另有两条通道,都和 CubeS3lvol 无关。

            和文档对照时要注意的几处

            参考