一起制作USR不可变系统

本文是我在制作自己的USR不可变系统时的一些记录。你可以访问MoltenArmor/not-so-immutable-os查看我的系统源码。

什么是USR不可变系统?

顾名思义,就是围绕/usr文件系统不可变这一核心设计的操作系统。

为什么要这么做?

看到标题,大部分人肯定会首先想到这一问题:为什么要这么干?这有什么意义?但是先等等,我们先不说“为什么”,让我们先来看现在的Linux发行版在管理时的困境。

设想你希望在VM中使用一个Debian,应该怎么做呢?你首先要去官方网站下载一个Debian installer ISO镜像,然后需要将镜像接入VM,在其中运行Debian installer程序,在安装完成后,你还需要修改系统配置,需要安装自己需要的软件包,需要调整系统服务的运行状态。

在日后的管理中,你还需要反复地使用apt安装软件包,这些软件包都有着复杂的postinst脚本和相互依赖关系,如果稍有不慎破坏了依赖链,你的整个系统可能都会受到影响。除此之外,如果之后你移除了软件包,那么包管理器并不会移除/etc/下你编写的配置文件,这些文件日积月累会变成不计其数的脏数据。而且,在系统更新后,/etc/下还会出现两份,甚至更多的旧版本配置归档(.dpkg-old),更是让情况火上浇油。尤其是在日后系统有大型更新,涉及到大量软件包变化的情况下,除了重新安装,似乎并没有什么更好的办法了。

这套模式已经出现并且被使用了数十年,但是这并不代表它没有问题,恰恰相反,它有很多毛病。

为什么要使用Installer程序呢?很早之前就有人使用块设备镜像的方式进行系统备份了,那么它既然可以用于备份,难道不可以用于安装吗?现在不就有这样的系统构建工具mkosi吗?我们只要构建出一个磁盘镜像,直接烧录(二进制写入)到磁盘中,不就得到了一个完整的操作系统吗?

为什么要我们手动进行配置呢?systemd不是已经提供了systemd-tmpfilessystemd-sysuserssystemd-firstboot等组件,来让系统在启动时达到合适的状态吗?我们不仅不需要手动修改任何文件,而且甚至不需要编写复杂的Shell脚本,只需要提供合适的这些systemd组件的配置,就可以让系统在启动时就达到我们期望的状态。而所有这些配置文件都可以被封装在/usr中,在第一次启动时被systemd读取。没有听懂吗?换句话说:只要我们在/usr中封装好这些组件的配置和一些必要的文件,那么/下的其他层次结构,例如/etc/,完全可以在我们第一次启动时,从/usr中被“释放”或者说“生成”出来。

  • 同时,这也意味着,系统存在“出厂状态”这一概念了:其他层次结构尚未从/usr中被释放出来的状态。如此一来,我们就完全可以在任何时候对操作系统进行“恢复出厂状态”了。

为什么我们要用包管理器安装软件包呢?系统之所以会变得复杂,归根结底是因为我们“能”让它变得复杂,在系统层面缺乏强硬的限制。我们已经知道,通过Usr-merge计划和上一段提到的“生成”逻辑,系统的所有关键资源已经被保存在了/usr。因此我们完全可以将系统理解为/usr下所有资源的集合——那么,系统的版本,不就是/usr的版本吗?那么,我们不就可以把整个系统以一份/usr镜像的形式发布了吗?那么,我们不就可以通过统一/usr(使其不可变)的方式,来确保全世界所有用户的系统环境都一致了吗?

  • 同时,这也意味着,我们在更新时可以采用A/B更新的方式,即:在一个地方创建新版本的/usr文件系统,然后通过某种方式让系统在启动时加载这个新版本的/usr文件系统(而不是旧版本的)。这同样有systemd提供的组件:systemd-sysupdate
  • 你可能会疑惑:不能在/usr上使用包管理器,我怎么安装应用程序呢?这样的可扩展性不是太差劲了吗?实际上是存在解决方案的:设想一下,如果/usr文件系统的内容已经是确定的,那么安装软件包这个行为的含义,不就是在/usr中添加新的文件吗?那么,我们完全可以获取出安装你所需要的软件包时,相对于/usr文件系统产生的增量的文件,然后构建出一个包含该软件包的镜像,接着通过Linux内核提供的Overlayfs功能,挂载到/usr,不就相当于安装了这些软件包吗?这就是systemd-sysextsystemd-confext的功能。

这就是/usr不可变的意义所在,它通过新的结构性设计解决了传统Linux发行版随时间变化不可避免地变得复杂和混乱的问题。回顾前面提到的内容,我们不难看出,这一设计的核心理念就是“操作系统即代码”(System As Code)。通过精心编写的代码,使得我们可以构建出自定义的、可更新的、可共享的操作系统。

更多的细节,请参考ParticleOS

工具

毫无疑问使用mkosi,事实上这就是systemd在提出该设想时推荐的构建工具,mkosi同时可以构建磁盘镜像、Initrd、UKI镜像和Sysext系统扩展镜像。

mkosi目前正在紧张地开发V25,这是一个大版本更新,所以我建议你使用开发中的版本,你可以使用Pipx安装,也可以使用其他任何你喜欢的工具安装:

1
pipx install git+https://github.com/systemd/mkosi.git

整体设计

A/B更新模式依赖“封装内核命令行参数”的功能(我们之后会提到原因),因此我们需要使用UKI,这也是mkosi的默认策略,所以我们不需要特别配置。

我比较懒,所以不希望使用安全启动(Secure Boot),因此,在mkosi.conf中禁用:

1
2
3
4
[Validation]
SecureBoot=no
SignExpectedPcr=no
SecureBootAutoEnroll=no

我希望能生成一个可以做VM的磁盘镜像,因此将“将产品分离为独立的分区镜像”这一配置我们禁用,以导出单个.raw磁盘镜像:

1
2
3
[Output]
SplitArtifacts=no
Format=disk

分区设计

如前所述,在最基本的情况下,我们期望一个独立的、不可变的/usr文件系统,以及一个可写的/。此外,我们还希望对/usr进行A/B更新。那么毫无疑问,我们的系统中就应该存在:

  1. EFI System Partition,用于保存引导需要的EFI PE程序
  2. 版本A的USR分区。
  3. 版本B的USR分区。
  4. Swap分区
  5. 根分区

这些分区都是通过systemd-repart进行创建的,但是,创建的时机有所不同,可以是在构建时创建的,也可以是在系统启动时创建。具体来说,为了确保构建好的产品最小化,我们会在构建时创建尽可能少的分区,其他分区在启动时按照环境创建。在以上列表中,只有1(用于在构建时保存引导必备的EFI PE程序)、2(用于在构建时保存所有的资源文件)是必要的,因此应当保存在mkosi.repart/,在构建时读取。其它的只需要保存在mkosi.images/base/mkosi.extra/usr/lib/repart.d/目录下,在系统启动时读取。

我们之前已经提到过了A/B更新的思路,但是这具体是怎么实现的呢?我们知道,GPT分区表是支持保存分区PARTLABEL,并通过PARTLABEL定位分区的。因此,我们完全可以将版本号作为分区的PARTLABEL,以此确定分区版本,同时,我们将root=PARTLABEL=XXX的内核命令行参数封装到版本对应的UKI中,这样,系统在启动时,只要使用某个版本的UKI,那么启动时,就一定会使用对应版本的/usr分区:

mkosi.conf中需要:

1
2
[Content]
KernelCommandLine=rw mount.usr=PARTLABEL=%i_%v

mkosi.images/base/mkosi.extra/usr/lib/repart.d/usr-verA.conf

1
2
3
4
5
6
[Partition]
Type=usr
Label=%M_%A # 初始版本号
SizeMinBytes=5G
SizeMaxBytes=20G
Weight=2000

mkosi.images/base/mkosi.extra/usr/lib/repart.d/usr-verA.conf

1
2
3
4
5
6
[Partition]
Type=usr
Label=_empty # 该分区初始情况下不存储任何版本的USR数据,所以初始情况下Label设为_empty就好
SizeMinBytes=5G
SizeMaxBytes=20G
Weight=2000

扩展镜像

为了日后的扩展性,我们需要构建扩展镜像。

如前所述,要构建扩展镜像,我们首先需要确定获取增量文件的基础镜像,也就是最小化的/usr,然后再获取相对于该镜像的增量,构建另一个Sysext镜像,这意味着我们需要用到mkosi的子镜像功能mkosi.images/

我们将基础系统镜像保存为mkosi.images/base/,这里包含了最小化、可引导的系统构建配置。同时,我们将这样一个镜像的构建格式设为文件系统目录:

1
2
[Build]
Format=directory

这样,在构建时,该镜像就会被构建为mkosi.output/base/目录树,我们就可以通过将其设为之后希望构建的Sysext镜像的BaseTrees=来获取增量了,例如我们的mkosi.images/incus/mkosi.conf

1
2
3
4
5
6
7
[Output]
Format=sysext
Overlay=yes # 该配置启用增量获取

[Content]
Bootable=no # 该镜像是扩展镜像,不需要可引导
BaseTrees=%O/base # 获取增量的相对目录树

系统文件释放

systemd-tmpfiles会将/usr/share/factory/目录视为出厂文件目录,并按照配置拷贝或链接到该目录下的文件,从而释放出根文件系统。因此,我们需要在构建时,把非/usr的文件全部移动到这个目录下,具体来说,这是通过mkosi.finalize这个钩子脚本实现的:

1
2
3
4
5
#!/bin/sh -ue

# 这里只写了/etc,你也可以考虑额外移动/var
mkdir -p "$BUILDROOT/usr/share/factory/etc" && \
cp -aT "$BUILDROOT/etc" "$BUILDROOT/usr/share/factory/etc"

于是,我们便可以在mkosi.images/base/mkosi.extra/usr/lib/tmpfiles.d/中创建各种systemd-tmpfiles的配置文件了,例如:

1
2
L /etc/os-release - - - - ../usr/lib/os-release
L+ /etc/mtab - - - - ../proc/self/mounts

这两个文件是POSIX标准中至关重要的文件,因此务必确保存在。

不过,为了省力,我没有采用完全使用systemd-tmpfiles生成的思路(因为这样要编写太多配置文件了,而我太懒了),因此,我选择了一种取巧的办法:为第一次启动时进行初始化的systemd-firstboot.service创建了一条ExecStartPre=钩子:

mkosi.images/base/mkosi.extra/usr/lib/systemd/system/systemd-firstboot.service.d/etc.conf

1
2
[Service]
ExecStartPre=cp -rpn /usr/share/factory/etc /

这样,在系统首次启动时,便会触发该命令的执行,确保从/usr/share/factory/中将系统需要的所有配置文件都释放出来,省了不少力气。

系统更新

在systemd V257,我们终于迎来了systemd-sysupdatemkosi的集成,因此,我们只需要为mkosi编写mkosi.sysupdate配置,即可将mkosi用作进行系统A/B更新的工具。

mkosi.sysupdate下的.transfer文件的格式与systemd-sysupdate的标准要求没什么区别,但是特别需要注意的是这两行:

1
2
3
[Source]
Path=/
PathRelativeTo=explicit

这代表着由mkosi确定更新源的地址,是systemd-sysupdatemkosi集成的关键。

其它的内容没什么特别的,详情请参考systemd-sysupdate的手册。

镜像口味

我们会希望为镜像编写不同微调的“口味”,例如让基础镜像带上桌面环境之类的。mkosi整理这些内容的方式是mkosi.profiles,每个Profile目录的结构都和项目根目录一致,除了不能嵌套包含mkosi.profiles目录以外。

一些截图

构建系统镜像:

not-so-immutable-os1.png

not-so-immutable-os2.png

这个.raw结尾的就是我们的系统镜像了,可以直接truncate -s调整大小后用于虚拟机:

[not-so-immutable-os3.png]

引导界面:

[not-so-immutable-os4.png]

在安装好的系统中,安装mkosi,克隆系统代码,进行自举的A/B更新:

not-so-immutable-os5.png

not-so-immutable-os6.png

not-so-immutable-os7.png

更新完成的引导界面:

not-so-immutable-os8.png

导入Incus的系统扩展镜像:

not-so-immutable-os9.png

not-so-immutable-os10.png

如此一来就可以使用Incus了:

not-so-immutable-os11.png

最后留一张分区表的图片:

not-so-immutable-os12.png