一起制作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-tmpfiles
、systemd-sysusers
、systemd-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-sysext
和systemd-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 | [Validation] |
我希望能生成一个可以做VM的磁盘镜像,因此将“将产品分离为独立的分区镜像”这一配置我们禁用,以导出单个.raw
磁盘镜像:
1 | [Output] |
分区设计
如前所述,在最基本的情况下,我们期望一个独立的、不可变的/usr
文件系统,以及一个可写的/
。此外,我们还希望对/usr
进行A/B更新。那么毫无疑问,我们的系统中就应该存在:
- EFI System Partition,用于保存引导需要的EFI PE程序
- 版本A的USR分区。
- 版本B的USR分区。
- Swap分区
- 根分区
这些分区都是通过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 | [Content] |
mkosi.images/base/mkosi.extra/usr/lib/repart.d/usr-verA.conf
:
1 | [Partition] |
mkosi.images/base/mkosi.extra/usr/lib/repart.d/usr-verA.conf
:
1 | [Partition] |
扩展镜像
为了日后的扩展性,我们需要构建扩展镜像。
如前所述,要构建扩展镜像,我们首先需要确定获取增量文件的基础镜像,也就是最小化的/usr
,然后再获取相对于该镜像的增量,构建另一个Sysext镜像,这意味着我们需要用到mkosi
的子镜像功能mkosi.images/
。
我们将基础系统镜像保存为mkosi.images/base/
,这里包含了最小化、可引导的系统构建配置。同时,我们将这样一个镜像的构建格式设为文件系统目录:
1 | [Build] |
这样,在构建时,该镜像就会被构建为mkosi.output/base/
目录树,我们就可以通过将其设为之后希望构建的Sysext镜像的BaseTrees=
来获取增量了,例如我们的mkosi.images/incus/mkosi.conf
:
1 | [Output] |
系统文件释放
systemd-tmpfiles
会将/usr/share/factory/
目录视为出厂文件目录,并按照配置拷贝或链接到该目录下的文件,从而释放出根文件系统。因此,我们需要在构建时,把非/usr
的文件全部移动到这个目录下,具体来说,这是通过mkosi.finalize
这个钩子脚本实现的:
1 | #!/bin/sh -ue |
于是,我们便可以在mkosi.images/base/mkosi.extra/usr/lib/tmpfiles.d/
中创建各种systemd-tmpfiles
的配置文件了,例如:
1 | L /etc/os-release - - - - ../usr/lib/os-release |
这两个文件是POSIX标准中至关重要的文件,因此务必确保存在。
不过,为了省力,我没有采用完全使用systemd-tmpfiles
生成的思路(因为这样要编写太多配置文件了,而我太懒了),因此,我选择了一种取巧的办法:为第一次启动时进行初始化的systemd-firstboot.service
创建了一条ExecStartPre=
钩子:
mkosi.images/base/mkosi.extra/usr/lib/systemd/system/systemd-firstboot.service.d/etc.conf
1 | [Service] |
这样,在系统首次启动时,便会触发该命令的执行,确保从/usr/share/factory/
中将系统需要的所有配置文件都释放出来,省了不少力气。
系统更新
在systemd V257,我们终于迎来了systemd-sysupdate
与mkosi
的集成,因此,我们只需要为mkosi
编写mkosi.sysupdate
配置,即可将mkosi
用作进行系统A/B更新的工具。
mkosi.sysupdate
下的.transfer
文件的格式与systemd-sysupdate
的标准要求没什么区别,但是特别需要注意的是这两行:
1 | [Source] |
这代表着由mkosi
确定更新源的地址,是systemd-sysupdate
和mkosi
集成的关键。
其它的内容没什么特别的,详情请参考systemd-sysupdate
的手册。
镜像口味
我们会希望为镜像编写不同微调的“口味”,例如让基础镜像带上桌面环境之类的。mkosi
整理这些内容的方式是mkosi.profiles
,每个Profile目录的结构都和项目根目录一致,除了不能嵌套包含mkosi.profiles
目录以外。
一些截图
构建系统镜像:
这个.raw
结尾的就是我们的系统镜像了,可以直接truncate -s
调整大小后用于虚拟机:
引导界面:
在安装好的系统中,安装mkosi
,克隆系统代码,进行自举的A/B更新:
更新完成的引导界面:
导入Incus的系统扩展镜像:
如此一来就可以使用Incus了:
最后留一张分区表的图片: