一起制作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-userdbdsystemd-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 # %i是Image ID,%v是版本字符串,如果使用Verity

(2026补充)在systemd v259以后,你可以使用mount.usr=dissect了,它会使用systemd-gpt-auto-generator的逻辑,自动挂载最新版本的USR分区(不用再和过去一样,只能僵硬地硬编码一一对应的版本了,不过如果你喜欢,也可以继续使用mount.usr=PARTLABEL=):

1
2
[Content]
KernelCommandLine=rw mount.usr=dissect

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

1
2
3
4
5
6
[Partition]
Type=usr
Label=%M_%A # 初始版本号,%M是Image ID,%A是版本字符串
SizeMinBytes=5G
SizeMaxBytes=20G
Weight=2000

mkosi.images/base/mkosi.extra/usr/lib/repart.d/usr-verB.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
[Assert]
# 需要依赖Base镜像
PathExists=%O/base

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

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

注意:MKOSI在构建时,如果发现镜像列表中有至少一个镜像具有BaseTrees=配置,那么就会导致缓存失效,判定元数据需要同步,进而强制清除列表中所有镜像的缓存mkosi.cache/)。因此在构建扩展镜像时,务必只指定最终的目标镜像(如 mkosi --dependency sysext-xxx build)。否则会导致基座镜像的缓存被误删,导致每次都重新构建。

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

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

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

1
systemd-sysext merge

即可接入扩展镜像。

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

1
2
3
systemd-tmpfiles --create

unlock-etc -d cp -rpn /usr/share/factory/ /

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

1
systemctl --no-block default

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

(预计V260上线)现在,Sysext镜像会被Initrd中的systemd-sysext-sysroot.service直接合并到根文件系统中,因此在初始事务中也会加载镜像中新增的服务,需要PR #39410

相似的,对于包含了/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镜像的依赖关系。

(2026补充)你可以创建一个既可以当作Sysext用,又可以当作Confext用的镜像,只需要包含这样一个mkosi.repart/10-root.conf分区配置:

1
2
3
4
5
6
7
[Partition]
Type=root
Format=erofs
CopyFiles=/usr/
CopyFiles=/opt/
CopyFiles=/etc/
Minimize=best

以及这样一个``mkosi.postinst.chroot`脚本:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#!/bin/sh
set -eu

EXT_IMAGE_ID="${IMAGE_ID}"

if [ -f /usr/lib/os-release ]; then
. /usr/lib/os-release
else
ID="_any"
fi

is_empty() {
for d in "$@"; do
if [ -d "${d}" ] && [ -n "$(ls -A "${d}" 2> /dev/null)" ]; then
return 1
fi
done
return 0
}

SYSEXT_RELOAD=
! is_empty /usr/lib/systemd/system /usr/lib/systemd/user \
&& SYSEXT_RELOAD="EXTENSION_RELOAD_MANAGER=1"

CONFEXT_RELOAD=
! is_empty /etc/systemd/system /etc/systemd/user \
&& CONFEXT_RELOAD="EXTENSION_RELOAD_MANAGER=1"

mkdir -p /usr/lib/extension-release.d

cat > "/usr/lib/extension-release.d/extension-release.${EXT_IMAGE_ID}" << EOF
ID=${ID}
SYSEXT_ID=${IMAGE_ID}
SYSEXT_SCOPE=${SYSEXT_SCOPE:-initrd system portable}
${SYSEXT_RELOAD:+"${SYSEXT_RELOAD}"
}ARCHITECTURE=${ARCHITECTURE}
EOF

mkdir -p /etc/extension-release.d

cat > "/etc/extension-release.d/extension-release.${EXT_IMAGE_ID}" << EOF
ID=${ID}
${VERSION_ID:+VERSION_ID="${VERSION_ID}"
}CONFEXT_ID=${IMAGE_ID}
CONFEXT_SCOPE=${CONFEXT_SCOPE:-initrd system portable}
${CONFEXT_RELOAD:+"${CONFEXT_RELOAD}"
}ARCHITECTURE=${ARCHITECTURE}
EOF

SYSTEMD_TMPFILES_FORCE_SUBVOL=0 \
systemd-tmpfiles \
--boot \
--create \
--remove \
--prefix=/etc || {
RET="${?}"
test "${RET}" -eq 65 ||
test "${RET}" -eq 73
}

systemctl preset-all

systemctl --global preset-all

并在配置中这样写:

1
2
3
4
5
6
7
8
9
10
[Config]
Dependencies=base

[Output]
Format=disk
Overlay=yes

[Content]
Bootable=no
BaseTrees=%O/base

如果希望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-allsystemctl --global 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/),否则无法在初始事务中读取到单元。(systemd V260已修复问题,Sysext和Confext的加载会提前到Initrd中)

有的Sysext镜像会安装额外的单元依赖关系,这些关系会被体现为/etc/systemd/system/目录下的符号链接,如果日后我们不想再使用某个提供了依赖关系的Sysext镜像了,/etc/systemd/system/目录下就会留下残留的悬置符号链接(Dangling Symlink),要清理这些符号链接,可以执行:

1
find -L /etc/systemd/system/ -type l -print -delete

(已废弃)可写的/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

然后为了确保systemd-confext.service服务总是能够启动,我们还需要创建一个空的Confext:

1
2
3
4
5
6
7
8
9
10
11
12
# mkosi.images/confext-empty/mkosi.conf
[Config]
Dependencies=base

[Output]
ImageVersion=
Format=confext
Overlay=yes

[Content]
Bootable=no
BaseTrees=%O/base

以及Sysext:

1
2
3
4
5
6
7
8
9
10
11
12
# mkosi.images/sysext-empty/mkosi.conf
[Config]
Dependencies=base

[Output]
ImageVersion=
Format=sysext
Overlay=yes

[Content]
Bootable=no
BaseTrees=%O/base

然后在mkosi.postoutput中写入:

1
2
3
mkdir -p "${OUTPUTDIR}/var/lib/extensions/" "${OUTPUTDIR}/var/lib/confexts/" && \
cp -af --update=all "${OUTPUTDIR}/sysext-empty.raw" "${OUTPUTDIR}/var/lib/extensions/" && \
cp -af --update=all "${OUTPUTDIR}/confext-empty.raw" "${OUTPUTDIR}/var/lib/confexts/"

这样,我们对/etc所做的任何修改都会被保存在/var/lib/extensions.mutable/目录下。

2025-04:这样仍然会导致/var/lib/extensions.mutable/etc/目录下出现大量的配置文件,根本无法管理。我们应该使用某种方式明确地区分开这几种行为:

  1. 预设的,内置的,编写在构建配置里的/etc/下的文件或是修改操作。
  2. 临时的手动/etc/修改。
  3. 持久化的,加载式的/etc/修改。(现在完全没有也无法区分2和3。)
  4. 本机特定的、持久化的,手动的/etc/修改。

动态解锁的/etc/

现在,系统的/etc/重新被设为不可变的,它的配置是这样的mkosi.images/base/mkosi.extra/usr/lib/systemd/system/systemd-confext.service.d/exec.conf(以及mkosi.images/default-initrd/mkosi.extra/usr/lib/systemd/system/systemd-confext-sysroot.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-sysext.servicesystemd-confext.service单元并发启动(例如ldconfig.servicesystemd-networkd.servicesystemd-resolved.service),但却又依赖Sysext或Confext中添加的配置,那么就会存在竞态条件,可以创建/usr/lib/systemd/单元名.service.d/tmpfiles.conf,内容如下:

1
2
3
[Unit]
# systemd-sysext.service和systemd-confext.service都有Before=systemd-tmpfiles-setup.service
After=systemd-tmpfiles-setup.service

测试环境

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 || :

Drop-in式新增用户

很多软件包都提供自己的新用户,而我们的/etc/是不可变的(而且/etc/passwd是单体的),所以没有办法直接添加用户。纯粹增量的、Drop-in方式管理用户的systemd组件是systemd-userdbd,具体方式为,在扩展镜像中添加mkosi.extra/usr/lib/userdbd/目录,这个目录下保存以下几个文件:

/etc/userdb/GID.group -> /etc/userdb/组名.group

1
2
3
4
{
"groupName": "组名",
"gid": GID
}

UID.user -> 用户名.user

1
2
3
4
5
6
7
8
9
{
"userName": "用户名",
"uid": UID,
"gid": GID,
"realName": "GECOS信息",
"homeDirectory": "/家目录",
"shell": "/shell",
"storage": "classic"
}

/etc/userdb/用户名:组名.membership

1
{}

这样,在插入镜像之后,systemd-userdbd就会自动识别到这些用户,连创建都不需要。

保留软件包元数据

我希望在镜像中保留软件包元数据,因此配置了:

1
2
[Content]
CleanPackageMetadata=no

并且在mkosi.finalize.chroot脚本中写入:

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

mkdir -p /usr/share/factory/var/lib/apt /usr/share/factory/var/lib/dpkg && \
# 目的是保留/var/lib/apt/extended_states中的Auto-installed信息
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服务挂钩实现,具体来说,就是编写这样一个update-etc.service服务以更新/etc(v260更新:需要迁移到Initrd中):

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-confext.service
ConditionNeedsUpdate=/etc

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

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

编写这样一个服务update-package-metadata.service以更新/var

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

[Service]
Type=oneshot
# Copy preserved package metadata from /usr/share/factory.
ExecStart=cp -rpf --update=all /usr/share/factory/var /
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
5
6
7
8
9
#!/bin/sh
set -ue

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

# MKOSI 26以后会自动执行
# locale-gen

(V26更新)注意,这个脚本必须放在Main镜像中,否则每次构建扩展镜像时,由于/etc/locale.gen不为空,总是会触发执行locale-gen重新编译语言,浪费时间。

测试环境

我们的系统镜像完全符合Hermetic USR,因此可以100%使用systemd-nspawn的Volatile模式启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
systemd-nspawn [-b] -D / \
--volatile=yes \
--overlay=/usr::/usr \
--overlay=/var::/var \
--bind-ro=/etc/passwd \
--bind-ro=/etc/group \
--bind-ro=/etc/apt \
[--bind-ro=/etc/配置] \
--private-users=no \
--capability=all \
--resolv-conf=bind-host \
--timezone=bind \
--private-network=no

病毒式安装

要实现病毒式安装(即把运行中的系统原封不动克隆/分裂到另一个块设备),关键在于在00-esp.conf中添加:

1
2
3
[Partition]
CopyFiles=/boot:/
CopyFiles=/efi:/

10-usr-verA.conf20-usr-verB.conf中添加:

1
2
[Partition]
CopyBlocks=auto

然后执行:

1
systemd-repart --empty=force --dry-run=no /dev/块设备

即可。

结合这一思路,你还可以通过Netboot实现自动化安装。你只需要参考以下配置构建一个UKI,在UEFI中启动它,并把自己的系统镜像上传到一个HTTP服务器上,便会在启动时自动拉取镜像并在内存中启动系统,然后你就可以使用命令安装:

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
[Include]
Include=mkosi-initrd

[Distribution]
# 发行版
Distribution=debian

[Output]
Format=uki
Output=镜像名

[Content]
Bootable=no
# 构建UKI的三要素
Packages=
# 提供systemd-stub
systemd-boot-efi
# 提供importd
systemd-container
# 提供内核
linux-image-generic
# UKI中封装的内核命令行参数
KernelCommandline=
# 设置URL
rd.systemd.pull=raw,machine,verify=no,blockdev:usrdisk:http://URL/XXXOS.raw
mount.usr=/dev/disk/by-loop-ref/usrdisk.raw-part2
root=tmpfs
ip=dhcp
# systemd-repart没办法对Tmpfs或Loop设备进行重新分区,总是会失败
rd.systemd.mask=systemd-repart.service
systemd.mask=systemd-repart.service
# UKI内已经包含了内核,Initrd中不需要再包含
# 日后也许可以换成RemovePackages=
RemoveFiles=
/boot/config-*
/boot/System.map-*
/boot/vmlinuz-*

启动后便可执行命令进行安装。

(TODO:systemd-sysinstall开发完成后,更新。)

杂记

不支持扩展镜像的组件

参考Issue #76,部分组件仅支持单例配置文件,因此无法通过扩展镜像进行扩展(会导致覆盖问题),必须尽可能保留在基座镜像,需要特别注意,包括:

核心组件 阻塞文件 影响范围 详细说明
(致命)VFS、Veritysetup、Cryptsetup /etc/*tab 所有文件系统挂载、Verity、Crypt配置 这些配置全部属于本机特定配置,不要保存在镜像中,尤其不要保存在基座镜像中
(严重)Shadow-utils /etc/passwd/etc/group/etc/shadow 所有需要自定义用户的组件(如sshdlightdm 所有UNIX用户数据库文件都是单例的,如果你的扩展镜像包含了新的用户,你必须创建systemd-userdbd的JSON用户记录。
(严重)GlibC /etc/nsswitch.conf 所有NSS插件/模块 NSS配置文件是单例的且每行都涉及复杂的优先级顺序,如果你在扩展镜像安里装了nss-mdns,你无法通过Drop-in将mdns4_minimal插入到hosts行中。这导致这些NSS插件装了也无法生效。因此,所有涉及身份验证源、主机名解析源的组件最好在基座中规划好。
(严重)PAM /etc/pam.d/common-*/etc/pam.d/system-* 所有PAM模块,例如:libpam-google-authenticatorlibpam-u2flibpam-ldapfprintd 全局通用的认证栈(如common-auth)是单文件维护的,且严重依赖每行规则的先后顺序。如果你想通过扩展增加“指纹解锁”功能,你需要直接修改common-auth,不支持Drop-in插入。
(中度)PAM_Shell /etc/shells 所有非标准Shell,例如zshfishtcshksh 这是一个纯文本列表。如果你把zsh放在扩展镜像里却没有修改/etc/shells,用户会被某些认证(例如Polkit)拒绝导致无法登录(被PAM拒绝)。不过这个文件的格式简单(一行一条数据,不涉及复杂逻辑),因此可以通过systemd-tmpfiles管理。
(中度)RPC /etc/rpc 使用RPC的服务,例如:nfs-utilsnisquota RPC程序号映射表。默认情况下通常包含了大部分常见的RPC,但是如果扩展镜像中包含了自定义的RPC服务,无法注册其程序号。不过这个文件的格式简单(一行一条数据,不涉及复杂逻辑),因此可以通过systemd-tmpfiles管理。
(轻微)Alternatives /etc/alternatives/ 存在多实现/多版本的工具,例如:javaeditorpython Alternatives机制依赖/etc下的单个符号链接来指向不同的/usr下的二进制。如果符号链接被覆盖可能会指向意料之外的二进制。
(中度)GSettings Schema /usr/share/glib-2.0/schemas/gschemas.compiled 所有GTK的桌面环境和软件 GLib应用程序启动时只会读取预编译的缓存,必须通过GSETTINGS_SCHEMA_DIR配置到别的路径,并执行glib-compile-schemas更新,Qt直接读取.qrc文本文件,不受影响
(中度)GIO缓存 /usr/lib/x86_64-linux-gnu/gio/modules/giomodule.cache 所有使用GVFS的应用程序 删除/usr/lib/x86_64-linux-gnu/gio/modules/giomodule.cache可以触发回退逻辑,直接读取源文件,或者修改GIO_MODULE_DIR环境变量,执行gio-querymodules
(中度)MIME类型缓存 /usr/share/mime/mime.cache 所有GUI应用程序,包括GTK和Qt 依赖XDG_DATA_DIRS环境变量,执行update-mime-database更新
(中度)MIME关联缓存 /usr/share/applications/mimeinfo.cache 所有GUI应用程序,包括GTK和Qt 删除/usr/share/applications/mimeinfo.cache可以触发回退逻辑,直接读取源文件,或者修改XDG_DATA_DIRS,执行update-desktop-database更新
(中度)GDK Pixbuf缓存 /usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/.../loaders/loaders.cache 所有需要缩略图的应用程序,包括GTK和Qt 修改GDK_PIXBUF_MODULE_FILE环境变量,然后执行/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders >更新,Qt不受影响
(轻微)图标缓存 /usr/share/icons/*/icon-theme.cache 所有GUI应用程序,包括GTK和Qt 删除/usr/share/icons/*/icon-theme.cache可以触发回退逻辑,直接读取源文件,或者修改XDG_DATA_DIRS
(无影响,仅记录)字体缓存 /var/cache/fontconfig/* 所有GUI应用程序,包括GTK和Qt 无影响,仅记录,通过/etc/fonts/fonts.conf配置

修复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

修复移动系统

在使用U盘移动系统时由于Initrd中没有USB驱动导致无法正常启动。解决方案是在Initrd中包含所有的存储驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Content]
KernelInitrdModules=
default
ata/
scsi/
nvme/
usb/
mmc/
firewire/
gpio/
virtio/
md/
mtd/
block/
input/
hid/

图标丢失

在安装Sysext形式的X应用程序后,会发现其图标经常丢失。其原因在于X应用程序的的图标缓存是单体的(/usr/share/icons/*/icon-theme.cache),不支持增量扩展,插入新的Sysext会导致其被覆盖。

解决方案是,直接删掉所有的icon-theme.cache,这样X在获取图标时就会放弃使用缓存,直接查找源文件。

1
2
[Content]
RemoveFiles=/usr/share/icons/*/icon-theme.cache

镜像口味

我们会希望为镜像编写不同微调的“口味”,例如让基础镜像带上桌面环境之类的。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