目录

Volatoo:Gentoo 上的不可变根和 generation 更新

Volatoo 是我(bushi,其实是 AI)今年在写的一个发行版,名字来自 volatile 和 Gentoo。它基于 Gentoo,系统按 generation 组织。仓库在 github.com/Volatoo/Volatoo,现在的 v0.1-dev 预览版能启动 OpenRC 和 systemd 两个目标。

Volatoo 是一个不可变系统:根文件系统来自只读的 SquashFS 镜像,运行时的写操作进 tmpfs,重启就丢掉。

启动时 SquashFS 当 OverlayFS 的 lower,tmpfs 当 upper,叠成 /。你看到的是一个能正常读写的 Gentoo,写到 /usr 的改动只活在内存里。重启时 upper 丢掉,系统回到镜像。

需要留下的路径(/home、/etc、/var/log 等)在启动时从持久分区接回来;持久分区是可选的。

generation 是这套东西的版本。更新就是生成一份新的只读镜像和它的挂载记录,等对象和 manifest 都写完,再原子地把 current 指过去;回滚就是把 previous 指回来。

这篇主要讲它的启动流程、持久化和更新,最后列一下现在还没做好的地方。

文中用到的几个术语:

词 在这里的意思
闭包(closure) 一次 generation 里完整的系统文件集合,包含 base 和所有 FHS 层
generation 指向一个闭包、一份 boot plan 和一份 realization 的不可变记录;current 是选中的,previous 是回滚目标
manifest generation 的内容清单:包含哪些对象、父 generation、PortageState
plan / boot plan 启动时先读的小摘要,绑定 generation digest、目标和 realization
realization 怎么挂这个闭包:SquashFS 对象、dm-verity root hash、salt、层顺序
dm-verity 按块校验的 Merkle 树,root hash 在 realization 里,读块时由内核验证
signify OpenBSD 的 Ed25519 签名工具,用来签 realization plan
slot state 分区里的 A/B 整机镜像槽,和 generation 是两条更新路径

我用 Gentoo 很多年,喜欢它的 USE flag 和可控性。问题是系统的状态就是“当前这一堆文件”:更新到一半断电、某个包升级后配置不兼容、想把系统退回上周的状态,都不好处理。

NixOS 的 generation 思路很吸引我,但 /nix/store 和 FHS 不兼容,很多现成的二进制、脚本、驱动装不进去。我想保留 Gentoo 的包管理和普通 FHS 路径,只把“系统状态”这一层做成 generation。这个方向对不对,我也还在试。

第一版的做法很直接:启动时把整个根文件系统解压到 tmpfs。原型能跑,但数字很难看。QEMU TCG 里完整复制大约 440 秒;stage3 解压后是 2,961,144 KiB(约 2.8 GiB),复制到 tmpfs 后 8 GiB 虚拟机只剩 3.5 GiB 可用,每次重启要展开 134,221 个 inode。

改成 SquashFS 只读下层加 tmpfs overlay 之后,同一个环境里启动到登录大约 20 秒,写层占 64 KiB,可用内存 7.9 GiB。这个对比很直接,我最后把 store-overlay 做成了默认模式:系统闭包留在磁盘上,内核按需读压缩块,tmpfs 只保存运行时改动。

方案 系统单元 更新方式 Volatoo 的不同
NixOS /nix/store 闭包 generation 保留 FHS,闭包按内容寻址
Silverblue OSTree 镜像 rpm-ostree 保留 Portage
MicroOS Btrfs 快照 transactional-update SquashFS 加 dm-verity
Android dm-verity 加 A/B slot 有完整的 Gentoo userspace

Volatoo 把不可变的部分和可写的部分分开。持久化分区可选,文件系统标签是 VOLATOO-STATE,里面是 /volatoo/:

/volatoo/
├── config/          # persist.conf、identity.conf
├── data/            # bind/overlay/sync 的持久数据
├── snapshots/       # 恢复快照、/etc 冲突
└── system/
    ├── objects/sha256/      # 内容寻址的闭包和层
    ├── manifests/           # generation manifest
    ├── plans/               # generation 到 boot plan
    ├── realizations/        # 完整闭包或层栈的绑定
    ├── signatures/          # signify 签名
    ├── current              # 选中的 generation
    └── previous             # 回滚目标

objects/sha256 放真实文件(SquashFS、层、verity tree),manifests 记录一个 generation 由哪些对象组成,plans 是启动时先读的小摘要。

realizations 记录怎么挂载(root hash、salt、层顺序),signatures 是对 realization plan 的签名。

对象按内容寻址,多个 generation 可以共享同一份 base 或层;更新只发布新的层和 hash tree,不复制整个系统。没有这个分区时,系统完全 volatile。

系统闭包可以是一份完整的 SquashFS(release 和 compaction),也可以是 base 加一串小的 FHS 层(日常更新)。一次开发 Gate 的完整闭包是 574,824,448 字节,dm-verity hash tree 4,534,272 字节,大约 0.79% 的开销。

启动时闭包作为只读 lower,tmpfs 作为 OverlayFS upper,/ 可写,但改动只活在内存里。

根模式有三个:

模式 lower upper 用在哪
store-overlay 磁盘上的 SquashFS 或层栈 tmpfs 默认
ram-overlay tmpfs 里的压缩镜像(只读 loop) 另一块 tmpfs PXE、U 盘、无盘
copy 无,完整文件树展开到 tmpfs tmpfs 兼容和调试

旧的 overlay 是 store-overlay 的兼容别名,不算单独模式。

更新有两条路。generation 是包级别的增量更新,一次包事务生成一个小的 FHS 层(就是这次更新产生的文件差异,保持 /usr、/etc 这些普通路径),base 和层各自有 dm-verity,启动时按顺序叠成 lower。

slot 是整机更新,新 release 的 kernel、initramfs、root.squashfs 写进 state 分区的另一个槽,重启进新槽。两条路共用一个 state 分区,但一次启动只走一条。

Volatoo 的普通启动分两种情况。前面的步骤一样,到“选哪个根”时分开:

  • release 盘 / 直接镜像:没有 generation,initramfs 找配置的镜像。release 盘上是 VOLATOO-SYSTEM 分区里的 root.squashfs;找到后校验 SHA-256,挂成只读 lower。
  • 安装后的系统:state 里有 current,initramfs 读 generation,验签 realization plan,用 dm-verity 打开只读 lower。

slot 启动是第三种,需要 state:cmdline 设 volatoo.root=slot,initramfs 验签 slot manifest、验 root 的 SHA-256,再挂对应槽的 root.squashfs。

不管走哪条,最后都叠上 tmpfs upper,应用 persist 和 identity,再 switch_root 到真正的 init。

flowchart TD
    A["固件 / bootloader"] --> B["kernel + initramfs"]
    B --> C["/init:挂 proc/sys/dev,读配置"]
    C --> D{"state 里有 current?"}
    D -->|有| E["读 manifest / plan / realization"]
    E --> F["signify 验签 realization plan"]
    F --> G["dm-verity 打开只读 lower"]
    D -->|没有| H["找配置的镜像"]
    H --> I["校验 SHA-256,挂 SquashFS"]
    G --> J["tmpfs upper + OverlayFS"]
    I --> J
    J --> K["应用 persist 和 identity"]
    K --> L["挂载移到 /.volatoo"]
    L --> M["switch_root → persist-early → init"]
    M --> N["OpenRC / systemd"]

以 release 盘为例,整个过程大概是:

  1. UEFI 或 BIOS 加载 bootloader。预览镜像用 GRUB;签名 release 的 UEFI 路径可能是 UKI。bootloader 把 kernel 和 initramfs 读进内存,kernel 启动后执行 initramfs 里的 /init。
  2. /init 挂好 /proc、/sys、/dev,读 default.conf 和内核命令行里的 volatoo.* 参数。
  3. 找到 VOLATOO-SYSTEM 分区,把里面的 root.squashfs 只读挂上,校验它的 SHA-256。
  4. 建 tmpfs,作为 OverlayFS 的 upper;SquashFS 作为 lower,两者叠成新的根。
  5. 找到 VOLATOO-STATE 分区,应用 persist.conf 里的 bind / overlay 策略。
  6. 把 lower、tmpfs、state 的挂载移到 /.volatoo,switch_root 到真实 init;有 state 时先经过 volatoo-persist-early,它恢复 sync 策略、应用机器身份,再 exec 真实 init。

安装后的系统在第 3 步换成 generation:读 current,验签 realization plan,用 dm-verity 打开 lower,再进入第 4 步。这些逻辑都在 initramfs/init 里。

UEFI 从 ESP 加载 bootloader;BIOS 从磁盘开头的 BIOS boot 分区 加载 GRUB。

签名 release media 在 UEFI 下可以用签名的 UKI,把 kernel、initramfs、cmdline 和 OS identity 打成一个 PE,Secure Boot 先验这个 PE。开发者预览里 secure_boot=no 的镜像走普通 GRUB UEFI,BIOS 下也是 GRUB。

bootloader 把 kernel 和 initramfs 放进内存,跳到 kernel。

kernel 解压 initramfs,执行 /init。这个 init 是一个静态 BusyBox 脚本,先挂 /proc、/sys、/dev(devtmpfs)。

然后读两份配置:编译进 initramfs 的 default.conf,和内核命令行里的 volatoo.* 参数。命令行能覆盖镜像位置、state、generation、root 模式、tmpfs 大小和签名策略。state 里的 persist.conf 在这里不会被 source,它只按数据解析。

VOLATOO_IMAGE 是镜像所在的位置,可以是 /dev/vda、LABEL=... 或 UUID=...。

如果镜像放在文件系统或 ISO 里,再用 VOLATOO_IMAGE_FILE 指向里面的 root.squashfs。initramfs 会把容器只读挂上,再用只读 loop 设备挂 SquashFS。裸 SquashFS 分区只给 VOLATOO_IMAGE。

设备最多等 10 秒,U 盘和内置盘用同一套配置。

VOLATOO_STATE 默认是 LABEL=VOLATOO-STATE。没有这个分区时,系统完全 volatile;VOLATOO_STATE_REQUIRED=yes 会等 10 秒,然后进 rescue。

找到 state 后,initramfs 会先读写挂载,再校验 /volatoo/layout-version;不认识的 layout 会进 rescue。它不会创建或格式化 state 分区,安装器负责这件事。

state 里有 /volatoo/system 时,VOLATOO_GENERATION=auto 会读 current 里的 generation digest。

一个 generation 有三份东西:

  • manifest:完整清单,记录这个 generation 包含哪些对象、父 generation 是谁。
  • plan:启动时先读的一页摘要,绑定 generation digest、目标和 realization。
  • realization:挂载参数,记录 root hash、salt 和层顺序。

启动时会先校验 manifest 的 digest(但不解析整份 JSON),再读 plan;需要挂载时再看 realization。这样启动路径不用解析可能很大的清单。

current 校验失败会试 previous,previous 也要独立通过同样的校验。也可以指定 none 或某个 digest 做恢复。

release 镜像和没有 current 的机器用 generation=none,initramfs 打印 generation selection disabled for this boot 或 no current generation; using configured image,直接挂配置的镜像。

默认 VOLATOO_SIGNATURE_POLICY=required。initramfs 用嵌入的公钥验 realization plan 的 signify 签名,验签在解析 root hash 之前。

dm-verity 只能证明数据块和 root hash 一致,证明不了 root hash 是谁给的;签名补的是这一环。allow-unsigned 是开发或恢复用的降级,会明确警告。

dm-verity 在 SquashFS 下面加一层按块校验的设备。内核读某个块时才校验它,不会在启动时把整个镜像 hash 一遍。

  • v2 的 realization 是一整份 SquashFS 加一份 hash tree,映射成 /dev/mapper/volatoo-root。
  • v3 的 realization 是 base 加一串 FHS 层,每层一个 dm-verity 设备,映射成 /dev/mapper/volatoo-lower-N,按顺序叠成 lower。

改一个数据块或一个用到的 hash-tree 块,挂载会失败。

然后建 tmpfs,放 upper 和 work,默认大小是内存的 80%;OverlayFS 把 lower 和 tmpfs upper 合到 /newroot。

v3 的删除直接编码在层里(whiteout 是 OverlayFS 的删除标记,opaque 目录表示整个目录被替换),不需要把删除记录重放到 upper。

persist.conf 里的 bind 和 overlay 由 initramfs 在 switch_root 前挂好;sync 和机器身份由 persist-early 在 switch_root 后处理。每种策略的细节在下面的“持久化”一节。

/etc 不用快照直接盖,做三路合并:上一份镜像的 /etc 是 base,上次同步的本机 /etc 是 local,新镜像的 /etc 是 new;冲突保留本机版本,把镜像版本按 ._cfgNNNN_ 放旁边。

机器身份和日志默认持久化:machine-id、SSH host key、/var/log。没有 state 时这些都不存在,每次启动都是新身份。

switch_root 会丢掉 initramfs,所以 initramfs 在切换前把 lower、writable、state、source 这些挂载移到 /newroot/.volatoo/ 下,/proc、/sys、/dev 也 move 过去。

有 state 时,switch_root 执行的是 /usr/libexec/volatoo-persist-early;没有 state 时才直接进 /sbin/init。

volatoo-persist-early 恢复 sync 策略,应用机器身份(machine identity)和首次启动的管理员访问配置,最后 exec 真实 init。

这一步必须在服务启动前做完,否则服务会看到旧的 /etc,或者每台机器不同的 machine-id。

OpenRC 或 systemd 看到的是一个普通 Gentoo 根:

/                          overlay(lower: SquashFS,upper: tmpfs)
/.volatoo/lower     只读 SquashFS 或层栈
/.volatoo/writable  tmpfs(upper/work)
/.volatoo/state     ext4,LABEL=VOLATOO-STATE

服务正常启动,写操作进 tmpfs upper。关机时 persist 服务再跑一次 sync。

下一次启动还是从选中的 generation 开始,/usr 里的改动、临时装的包、没 sync 的 /etc 改动都消失;state 里的 bind/overlay/sync 数据和 identity 保留。

失败时 initramfs 用稳定的错误码,比如 image.not-found、image.integrity、state.layout-version、persist.config、generation.verity、root.init-missing。

进 rescue 前会打印命令行、有效配置、/proc/partitions、blkid 和已经建立的挂载。rescue 是同一个 BusyBox,不加载完整系统。current 校验失败会回退 previous。

我最后把 lower 留在磁盘上,原因很实际:完整复制到 tmpfs,运行时不再依赖镜像设备,但每次启动要展开几 GB 和十几万 inode。

SquashFS 按块读,启动只碰工作集,内存留给应用。代价是运行时读会回到存储设备,设备不能拔。

ram-overlay 是为必须拔盘或无盘的场景准备的:它复制压缩镜像,不展开文件树,复制完把源设备卸掉。

SquashFS 的块被改了,内核不一定知道。dm-verity 像一棵 Merkle 树:每个数据块有 hash,hash 再汇总成 root hash,读块时顺便验。root hash 在 realization plan 里。

plan 很小,用 release 私钥签,公钥嵌在 initramfs 里。能改 state 的攻击者可以同时换掉 plan、root hash、SquashFS 和 hash tree;签名把 plan 钉在 release key 上,dm-verity 再把数据钉在 plan 上。

如果 bootloader 加载的 kernel 和 initramfs 被换掉,内嵌公钥也会被换掉。release media 用签名 UKI 加 Secure Boot 锚定这一层;BIOS 下没有这个锚,信任边界就是启动介质。

没有 state 时,所有东西都 volatile。有 state 时,machine-id、SSH host key、日志默认持久化,保证一台机器不会每次启动换身份;这三项可以单独关掉。

其它路径要显式声明,写在 /volatoo/config/persist.conf,一行一条:

bind     /home      home
overlay  /var/lib   var-lib
sync     /etc       etc

bind 第一次启动把镜像目录复制到 /volatoo/data/bind/<key>/root,之后每次 bind mount 这个目录。写直接落到 state,下一次启动还在。

overlay 把镜像目录当 lower,upper/work 放在 /volatoo/data/overlay/<key>/。镜像更新换 lower,本机改的东西留在 upper,删除用 whiteout。

sync 在启动早期把最近一次完整快照恢复到目标,volatoo-persist sync 或关机时写回。/etc 走三路合并,不直接盖:

本机相对 base 新镜像相对 base 结果
没改 没改 用新镜像
没改 改了 用新镜像
改了 没改 用本机
改了 改得一样 用任意一个
改了 改得不一样 保留本机,镜像版本按 ._cfgNNNN_ 放旁边,记冲突

冲突不会往正在用的配置文件里写 merge marker。结构性冲突,也就是没法放在旁边的,进 state 的快照目录,启动继续但会警告。

恢复失败(base 缺失、元数据坏了、路径不安全)会进 emergency shell,不会带着一半恢复的 state 启动真实 init。

state 目前是明文 ext4,LUKS 还在 roadmap 上。persist /etc 意味着里面的密钥也会以明文留在 state 分区,这一点要自己权衡。

拿一次包更新举例。走 Volatoo 的 planner/engine 流水线,开发期通过 volatoo-update-view 访问私有可写的 store。

planner 生成 BuildSpec(这次要装哪些包、目标是什么),从本地或 binhost 拿 binpkg,在 staging 里只做二进制安装,把差异打成 FHS 层,合成 realization,签名,切 current。

重启后新的 generation 生效;不满意就 volatoo-generation rollback,把 current 指回 previous。

flowchart LR
    G1["G1: base"] --> G2["G2: G1 + layer A"]
    G2 --> G3["G3: G2 + layer B"]
    C["current"] -.-> G3
    P["previous"] -.-> G2

generation v2 把 PortageState 也绑进 generation,包括 Portage 配置、world、repository 快照、profile 和 toolchain。PortageState 是这次 generation 对应的 Portage 输入的完整快照。

每个层记录父 generation,最多 256 层,超过就 compaction。compaction 把一长串层合成一份完整闭包,防止链太长。

所有对象按内容寻址。发布时先写完对象和 manifest,再原子改 current。activation 带 expected current digest,基于旧 generation 的发布会被拒绝。

回滚就是把 previous 指回去(第一次更新后才有 previous),previous 也要通过 generation 和 realization 校验。

host 看到的 system store 是只读的,只有 update view 在私有 mount namespace 里有写权限。update view 是一个临时带写权限的挂载视图,命令退出就消失,普通进程碰不到可写的 store。

slot 是整机更新。新 release 的 kernel、initramfs、root.squashfs 写进 state 分区的另一个槽,slot manifest 是签名的 VOLATOO_SLOT_V1,绑定 channel、init system、对象 digest 和大小。

bootloader 按 pending/active 标记选槽;initramfs 验签 manifest、验 root 的 SHA-256 和大小,失败就减 tries、清 pending、回 active。state 分区跨槽共享,identity、persist、generation store 不受影响。

generation 和 slot 是两条不同的路:generation 用于日常包更新,slot 用于整机 release、内核或 init system 变更。一次启动只走一条,slot 启动时设 volatoo.generation=none。

dm-verity 只保证读到的块和 root hash 一致,root hash 本身是谁给的,它不知道;签名解决的是后一个问题。

generation 的信任链是:initramfs 里的公钥 → signify 签名 → realization plan → generation、boot plan、context → 各镜像记录 → dm-verity root hash → SquashFS 块。slot 的链是:公钥 → 签名的 slot manifest → kernel、initramfs、root 的 SHA-256 和大小。两条链都没有“签名整个 root”,大文件靠 dm-verity 或 SHA-256 校验。

签名的回滚是有意的:任何仍然有有效签名的旧 generation 都可以选。防止重放旧 release 需要 TPM 或受保护的 UEFI 单调状态,这块还没做。

下面的数据来自 QEMU TCG,跨架构模拟,不是原生性能。它们用来比较方案,不用来预测你的机器。

项目 数据
完整复制到 tmpfs 约 440 秒启动,2.8 GiB root,8 GiB 虚拟机剩约 3.5 GiB
SquashFS + tmpfs overlay 约 20 秒启动,写层 64 KiB,可用约 7.9 GiB
签名 realization v3(开发期 fixture) root 认证和 switch_root 4 秒 BIOS / 7 秒 UEFI;systemd PID 1 12/13 秒,OpenRC 10/12 秒;登录 38/40 秒 systemd,41/43 秒 OpenRC
闭包和 verity 574,824,448 / 4,534,272 字节,约 0.79%
Phase 0 stage3 镜像(项目早期的 OCI stage3,含 Rust 工具链) zstd 19,574,746,624 字节,构建 50 秒

表里的数字来自 docs/design/boot.md 的开发期记录和 TCG 环境,重复跑会波动。

Phase 0 那一行用的是项目早期的 gentoo/stage3 OCI 镜像;2026-11 的 stage3 小了很多。下面这组是我这次用当前 stage3 复现的:SquashFS 304 MiB,构建约 20 秒;BIOS 直接镜像启动到 shell 10 秒,带 state 的 OpenRC 启动 26 秒,UEFI 43 秒。

原生 x86_64 加 KVM 的基准脚本是 scripts/measure-native-root-modes.sh,reference hardware 的数据还没录。

v0.1-dev 还是预览版,v0.1.0 还没 tag。下面这些是我现在知道的坑,有些是没做完,有些是故意留到后面。

  • state 明文:machine-id、SSH host keys、authorized_keys、/etc 里的密钥和日志都在 ext4 里,拿到盘就能读改。LUKS 还在 roadmap。
  • 启动链没有根:BIOS 和 secure_boot=no 的预览镜像里,image-sha256 写在 GRUB cmdline 里,能改镜像的人也能改这个值。签名 UKI 加 Secure Boot 目前只覆盖签名 release media。
  • 重放保护没做:current/previous 和 slot 的 active/pending 都不是单调状态,旧但签名有效的 generation 或 slot 可以重新选。预览版的 SHA256SUMS 没有离线生产密钥签名,PXE 也没有 release key 验证。
  • 更新还没有一键命令:planner/engine 是开发期流水线,volatoo-generation 是底层工具。generation 和 slot 的 rollback 语义也不一样;slot 的 tries 只覆盖 initramfs 能观察到的失败,previous 要第二次选择后才有。
  • state 启动不做 fsck:initramfs 直接读写挂载,依赖 ext4 journal replay;journal 之外有错误就只能进 rescue。我这次组装 release disk 时,poweroff -f 写脏的 state 镜像让 e2fsck 直接失败。
  • persist-early 失败就失联:identity、firstboot-access、persist restore 任一失败都会进 emergency shell,没有回退到 snapshot 或跳过本次 persist 的选择。无键盘、无 SSH key 的机器没法操作。
  • tmpfs upper 没有磁盘后备:默认是内存的 80%,大写入可能把内存打满;store-overlay 又依赖镜像设备。/etc 三路合并冲突也要手工处理。
  • 构建输入没有 pinned:fetch-gentoo-inputs.sh 取的是 latest stage3 和最新 snapshot,下一次构建会拿新的输入。prototype 和 Catalyst 已经漂移,这次缺 volatoo-firstboot-access 就是例子。
  • systemd 路径没验证完:Catalyst systemd root 卡在 builder sandbox,官方镜像下载太慢,没启动;journald 持久化仍是推断。arm64 和原生 KVM 数据也还没做。

仓库是 github.com/Volatoo/Volatoo,预览版在 v0.1.0-dev.20260814,安装、持久化和构建的细节在 handbook。

安装需要显式指定目标磁盘。默认要提供 SSH 公钥;也可以显式选择 --no-provision-access,但那意味着你得另外安排访问方式。没有默认密码。OpenRC 和 systemd 是两个独立镜像,装完不能直接转。

自己构建需要 Docker:拉 Gentoo 输入、构建 Catalyst root、构建 kernel 和 initramfs、组装 release disk,最后用 QEMU gate 验一遍。