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 盘的启动
以 release 盘为例,整个过程大概是:
- UEFI 或 BIOS 加载 bootloader。预览镜像用 GRUB;签名 release 的 UEFI 路径可能是 UKI。bootloader 把 kernel 和 initramfs 读进内存,kernel 启动后执行 initramfs 里的
/init。 /init挂好/proc、/sys、/dev,读default.conf和内核命令行里的volatoo.*参数。- 找到
VOLATOO-SYSTEM分区,把里面的root.squashfs只读挂上,校验它的 SHA-256。 - 建 tmpfs,作为 OverlayFS 的 upper;SquashFS 作为 lower,两者叠成新的根。
- 找到
VOLATOO-STATE分区,应用persist.conf里的bind/overlay策略。 - 把 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 里。
从固件到 initramfs
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,它只按数据解析。
找镜像和 state
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 分区,安装器负责这件事。
选 generation,验签
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,组装 lower 和 upper
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。
持久化和 switch_root
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。
init 之后
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 留在磁盘上
我最后把 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 验一遍。
相关阅读
参考
如果你觉得这篇文章对你有所帮助,欢迎赞赏~
赞赏