一起制作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目前正在紧张地进行着开发,所以我建议你使用开发中的版本,你可以使用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/目录下,在系统启动时读取。

systemd在基本的根文件系统层次结构(即/bin -> /usr/bin/lib -> /usr/lib等)不存在时,会自动创建。

我们之前已经提到过了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
8
9
10
11
12
[Config]
# 需要依赖Base镜像
Dependencies=base

[Output]
Format=sysext
Overlay=yes # 该配置启用增量获取

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

在使用时,我们只需要先执行:

1
importctl import-raw --class=sysext xxx.raw

倒入扩展镜像,然后执行:

1
systemd-sysext merge

即可接入扩展镜像。

值得注意的是,systemd-sysext在接入扩展镜像后,并不会触发执行systemd-tmpfilessystemd-sysuserssystemd-update-done.service,因此,你需要手动执行:

1
2
3
4
5
systemd-tmpfiles --create

systemd-sysusers

cp -rpn /usr/share/factory/ /

此外,在系统初始化时,systemd-sysext接入的镜像中的服务并不会被启动,因此你需要额外在启动后执行:

1
systemctl --no-block default

你可以将这条命令写入/etc/rc.local,以确保在系统进入默认Target时自动执行。

相似的,对于包含了/etc/目录下的配置文件的软件包,我们还需要构建一个Confext镜像:

1
2
3
4
5
6
7
8
9
10
11
12
[Config]
# 需要依赖Base镜像
Dependencies=base

[Output]
Format=confext
Overlay=yes # 该配置启用增量获取

[Content]
Bootable=no # 该镜像是扩展镜像,不需要可引导
BaseTrees=%O/base # 获取增量的相对目录树
Packages=... # 安装的软件包,和Sysext相同
  • 可以考虑为Sysext镜像添加对该Confext镜像的依赖关系。

如果希望systemd-sysext.service在启动时自动导入/var/lib/extensions.mutable/目录下的替换文件(我们不希望USR可变),创建mkosi.images/base/mkosi.extra/usr/lib/systemd/system/systemd-sysext.service.d/import.conf

1
2
3
4
5
[Service]
ExecStart=
ExecStart=systemd-sysext --mutable=import
ExecReload=
ExecReload=systemd-sysext --mutable=import

值得一提的是,安装Sysext/Confext镜像时,虽然默认会触发systemctl daemon-reload,但是并不会触发systemctl preset-allsystemd-tmpfiles --createsystemd-sysusers的执行(有时候还需要重载dbus.service,例如rtkit.service这类需要在D-Bus上注册的服务),需要手动执行对应操作。

  • 启用(enable)Sysext镜像中提供的systemd单元的操作必须在systemd-confext.service没有启动时执行,确保/etc/systemd/system/目录下的软链接是在基础/etc中创建的,而不是合并Confext后的可变目录下(/var/lib/extensions.mutable/)。这一问题也可以通过在/etc/rc.local中手动添加systemctl daemon-reload解决。

(已废弃)可写的/etc/

我的系统的/etc/是可变的,所以还需要为systemd-confext.service进行修改,主要是启用--mutable=yes可变选项和禁用--noexec=no不可执行脚本选项。创建mkosi.images/base/mkosi.extra/usr/lib/systemd/system/systemd-confext.service.d/mutable.conf

1
2
3
4
5
[Service]
ExecStart=
ExecStart=systemd-confext --mutable=yes --noexec=no
ExecReload=
ExecReload=systemd-confext --mutable=yes --noexec=no

动态解锁的/etc/

现在系统的/etc/也是不可变的,它的配置是这样的mkosi.images/base/mkosi.extra/usr/lib/systemd/system/systemd-confext.service.d/exec.conf

1
2
3
4
5
[Service]
ExecStart=
ExecStart=systemd-confext refresh --noexec=no --mutable=import
ExecReload=
ExecReload=systemd-confext refresh --noexec=no --mutable=import

这意味着我们有两种动态修改/etc/的方式:

  1. 临时执行systemd-confext unmerge,直接修改下层的/etc/目录。
  2. 创建/var/lib/extensions.mutable/etc/目录,在里面保存修改的文件后执行systemd-confext refresh --noexec=no --mutable=import

unlock-etc脚本,用于方便地修改下层的/etc/目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/bin/sh
set -ue

start_confext() {
if ! systemctl --no-block start systemd-confext.service; then
printf '%s\n' 'Error: Failed to queue start job for systemd-confext.service.' >&2
fi
}

trap start_confext HUP INT QUIT ABRT TERM

usage() {
printf '%s\n' 'Unlock /etc and run command.'
exit 0
}

if [ -z "${1:-}" ]; then
usage
fi

if ! [ -x "$(which "$1")" ]; then
printf '%s\n' "Error: $1 is not executable." >&2
exit 1
fi

if systemctl stop systemd-confext.service; then
"$@" || { printf '%s\n' "Error: Failed to run $1" >&2 && exit 1; }
systemctl --no-block start systemd-confext.service
else
printf '%s\n' 'Error: Failed to stop systemd-confext.service.' >&2
fi

测试环境

systemd-sysextsystemd-confext原生就支持通过--mutable=ephemeral选项在Overlayfs的最上层再挂载一层可写的Tmpfs,从而实现可回滚的测试操作。

究其原理其实不难,也就是在Tmpfs的/run目录下创建两个临时目录,分别用于Upperdir和Workdir,然后执行:

1
mount -t overlay overlay -o lowerdir=下层目录,upperdir=/run中的Upperdir,workdir=/run中的Workdir 下层目录

一个PoC程序为tmpify.sh

快速进入可回滚环境enter-rw

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/bin/sh
set -ue

reload_or_restart() {
systemctl --no-block reload-or-restart systemd-sysext.service systemd-confext.service
tmpify -r /var/lib/apt || :
tmpify -r /var/lib/dpkg || :
}

trap reload_or_restart HUP INT QUIT ABRT TERM

usage() {
printf '%s\n' 'Enter testing mode with ephemeral /etc and /usr.'
exit 0
}

check_user() {
if [ "$(id -u)" -ne 0 ]; then
echo "Error: This script must be run as root." >&2
exit 1
fi
}

if [ "$#" -ne 0 ]; then
usage
fi

check_user

if systemd-confext refresh --noexec=no --mutable=ephemeral-import && systemd-sysext refresh --mutable=ephemeral; then
if tmpify /var/lib/apt && tmpify /var/lib/dpkg; then
:
else
printf '%s\n' 'Error: Failed to tmpify /var/lib/apt or /var/lib/dpkg.' >&2
fi
else
printf '%s\n' 'Error: Failed to refresh systemd-confext or systemd-sysext.' >&2
fi

退出exit-rw

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/sh
set -ue

usage() {
printf '%s\n' 'Exit testing mode and restore /etc and /usr.'
exit 0
}

check_user() {
if [ "$(id -u)" -ne 0 ]; then
echo "Error: This script must be run as root." >&2
exit 1
fi
}

if [ "$#" -ne 0 ]; then
usage
fi

check_user

systemctl --no-block reload-or-restart systemd-confext.service systemd-sysext.service

tmpify -r /var/lib/apt || :
tmpify -r /var/lib/dpkg || :

保留软件包元数据

我希望在镜像中保留软件包元数据,因此在mkosi.finalize.chroot脚本中写入:

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

mkdir -p /usr/share/factory/var/lib/apt /usr/share/factory/var/lib/dpkg && \
cp --archive --no-target-directory --update=none /var/lib/apt /usr/share/factory/var/lib/apt && \
cp --archive --no-target-directory --update=none /var/lib/dpkg /usr/share/factory/var/lib/dpkg

然后写入/etc/rc.local

1
cp -rpf --update=all /usr/share/factory/var / &
  • 我不认为/var/lib/apt/extended_states/var/lib/dpkg/是会变化的,因为安装软件包在我的系统上是不可能的,因此cp使用了--update=all选项。

系统文件释放

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

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

# --update=none是必要的,因为有些发行版本身就会在这里提供出厂文件,我们不必覆盖
# 这里只写了/etc,你也可以考虑额外移动/var
mkdir -p "${BUILDROOT}/usr/share/factory/etc" && \
cp -aT --update=none "${BUILDROOT}/etc" "${BUILDROOT}/usr/share/factory/etc"

这等价于mkosi.finalize.chroot

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

# --update=none是必要的,因为有些发行版本身就会在这里提供出厂文件,我们不必覆盖
# 这里只写了/etc,你也可以考虑额外移动/var
mkdir -p "/usr/share/factory/etc" && \
cp -aT --update=none "/etc" "/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/中将系统需要的所有配置文件都释放出来,省了不少力气。

此外,在系统A/B更新后,也需要再次从/usr/share/factory/释放文件,这可以通过为systemd-update-done.service服务挂钩实现,具体来说,就是编写这样一个服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Unit]
DefaultDependencies=no
Conflicts=shutdown.target
Before=shutdown.target
Before=systemd-update-done.service
ConditionNeedsUpdate=/etc

[Service]
Type=oneshot
ExecStart=cp -rpn /usr/share/factory/etc /
RemainAfterExit=yes

[Install]
WantedBy=systemd-update-done.service

系统更新

在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的手册。

汉化

要使用中文Locale,可以直接安装预编译好的locales-all包,也可以选择创建mkosi.postinst.chroot脚本,写入编译逻辑:

1
2
3
4
sed -i '/^# C.UTF-8.*/c\C.UTF-8 UTF-8' /etc/locale.gen && \
sed -i '/^# en_US.UTF-8.*/c\en_US.UTF-8 UTF-8' /etc/locale.gen && \
sed -i '/^# zh_CN.UTF-8.*/c\zh_CN.UTF-8 UTF-8' /etc/locale.gen && \
locale-gen

杂记

修复regulatory.db

发现明明安装了wireless-regdb包,还是会报告failed to load regulatory.db

猜想原因可能是/usr/etc不在同一个分区。创建mkosi.postinst.chroot脚本,写入逻辑:

1
2
ln -sf regulatory.db-debian /usr/lib/firmware/regulatory.db
ln -sf regulatory.db-debian.p7s /usr/lib/firmware/regulatory.db.p7s

镜像口味

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

Live系统

要构建Live系统,只需要修改内核命令行参数,当然,我们可以通过UKI Profile在UKI中封装额外的内核命令行参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[UKIProfile]
Profile=
ID=live
TITLE=Live System

Cmdline=
root=tmpfs
rd.systemd.mask=systemd-repart.service
systemd.mask=systemd-repart.service
systemd.firstboot=no
# Agetty的凭据,启用Root的自动登录
systemd.set_credential=agetty.autologin:root
# 跳过身份验证
systemd.set_credential=login.noauth:yes
systemd.journald.forward_to_console=1
systemd.journald.max_level_console=warning
SYSTEMD_SULOGIN_FORCE=1

SignExpectedPcr=no

Debug模式

这会导致超多输出:

1
2
3
4
5
6
7
8
9
10
11
[UKIProfile]
Profile=
ID=debug
TITLE=Boot with debug mode

Cmdline=
debug
systemd.log_level=debug
systemd.journald.forward_to_console=1

SignExpectedPcr=no

STM服务端

通过NVME-TCP实现将根文件系统服务出去:

1
2
3
4
5
6
7
8
9
10
[UKIProfile]
Profile=
ID=storagetm
TITLE=Storage Target Mode with Public Access

Cmdline=
rd.systemd.unit=storage-target-mode.target
ip=any

SignExpectedPcr=no

救援模式

不必多说:

1
2
3
4
5
6
7
8
9
[UKIProfile]
Profile=
ID=emergency
TITLE=Boot into Emergency Mode

Cmdline=
systemd.unit=emergency.target

SignExpectedPcr=no

Initrd断点

在Initrd打断启动:

1
2
3
4
5
6
7
8
9
[UKIProfile]
Profile=
ID=break
TITLE=Boot into Initrd

Cmdline=
rd.systemd.break=pre-switch-root

SignExpectedPcr=no

出厂化

systemd-repart分区配置FactoryReset=yes,然后:

1
2
3
4
5
6
7
8
9
[UKIProfile]
Profile=
ID=factory-reset
TITLE=Reset System to Factory Defaults [CAUTION!]

Cmdline=
systemd.factory_reset=1

SignExpectedPcr=no

彻底出厂化:

1
2
3
4
5
6
7
8
9
[UKIProfile]
Profile=
ID=factory-reset-tpm2-clear
TITLE=Reset System to Factory Defaults + TPM2 Clear [CAUTION!]

Cmdline=
rd.systemd.unit=factory-reset.target

SignExpectedPcr=no

一些截图

构建系统镜像:

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