不可变根目录与原子升级

来自 Alpine Linux

是什么?

本文提供了一个基本指南,用于设置基于只读根目录的 Alpine Linux 系统,该系统具有多个启动环境,并使用现代引导加载程序和 btrfs 进行原子升级。

为什么?

只读根目录和原子升级以及轻松回滚或启动先前配置的能力最近越来越受欢迎。提供和推广此类功能的发行版例如有 Fedora Silverblue, Opensuse MicroOS, NixOSGNU Guix

虽然 Alpine Linux 有其杀手级功能,但默认设置下缺少上述功能。这是一个概念验证,证明在最小化的系统上以最小化的方式实现它们是可能的。

注意: Alpine Linux 也可以从 RAM 在 无盘模式 下启动(参见 Installation),这支持使用 lbu 在重启之间保留更改。

准备工作

您应该拥有可启动的 Alpine 介质。获取它的过程在安装页面中描述。

磁盘分区

在本指南中,假设您有一个全新的 UEFI 系统,没有操作系统,并且刚刚使用 USB 闪存驱动器或 CD 启动到 live Alpine 系统中。第一步是在您的 HDD/SSD 目标设备(此处为 /dev/sda)上创建分区表

# apk add gptfdisk
# gdisk /dev/sda
> o ↵
> y ↵
> w ↵
> y ↵

现在我们可以定义分区

# cgdisk /dev/sda

分区创建过程包括几个步骤

  1. 起始扇区 - 您可以按 ↵ 安全地使用默认值
  2. 大小
  3. 类型(作为十六进制代码)- EFI 是 ef00,Linux 文件系统是 8300,Swap 是 8200。

结果表

Part.     #     Size        Partition Type            Partition Name
----------------------------------------------------------------
1               200.0 MiB   EFI System                EFI
2               200.0 GiB   Linux filesystem          ROOT
3               32.0 GiB    Linux swap                SWAP

ROOT 分区名称稍后将在 rEFInd 配置中用于标识启动卷。下一步是创建文件系统

# mkfs.vfat -F32 /dev/sda1
# mkfs.btrfs /dev/sda2
# mkswap /dev/sda3

现在我们可以挂载我们的根卷

# mount -t btrfs /dev/sda2 /mnt

文件系统结构

现在我们应该创建文件结构,该结构将提供可靠的原子系统升级。
从以下目录开始

# mkdir /mnt/next

存储下一个 current 链接,这对于 busybox mv 如何执行原子链接替换是必要的。

# mkdir /mnt/commons

存储通用的非快照子卷。
我们可以立即填充它

# btrfs subvolume create /mnt/commons/@var
# btrfs subvolume create /mnt/commons/@home

接下来,最重要的目录

# mkdir /mnt/snapshots

存储包含属于一代的快照的目录。

# mkdir /mnt/links

存储包含指向快照世代的链接的目录世代。
让我们创建第一代并用一个操作系统根快照 @ 填充它

# NEWSNAPSHOTS="$(date -u +"%Y%m%d%H%M%S")$(cat /dev/urandom | tr -dc 'a-zA-Z' | fold -w 8 | head -n 1)"
# mkdir "/mnt/snapshots/$NEWSNAPSHOTS"
# btrfs subvolume create /mnt/snapshots/$NEWSNAPSHOTS/@

填充 links

# NEWLINKS="$(date -u +"%Y%m%d%H%M%S")$(cat /dev/urandom | tr -dc 'a-zA-Z' | fold -w 8 | head -n 1)"
# mkdir "/mnt/links/$NEWLINKS"
# ln -s "../../snapshots/$NEWSNAPSHOTS" "/mnt/links/$NEWLINKS/0"
# ln -s "../../snapshots/$NEWSNAPSHOTS" "/mnt/links/$NEWLINKS/1"
# ln -s "../../snapshots/$NEWSNAPSHOTS" "/mnt/links/$NEWLINKS/2"
# ln -s "../../snapshots/$NEWSNAPSHOTS" "/mnt/links/$NEWLINKS/3"

您可以拥有任意数量的链接,只需相应地应用对 rEFInd 配置和下面描述的升级脚本的更改即可。
将指向最新链接世代的链接

# ln -s "./links/$NEWLINKS" /mnt/current

此设置允许我们只拥有指向 /current/0/@/current/1/@ 等的静态 rEFInd 配置,而实际的底层启动环境将随着每次升级而更改。
但是文件系统挂载服务如何知道当前加载的是哪个快照世代?
答案是 btrfs 根目录中的通用 fstab
首先获取分区 UUID

# blkid > /mnt/fstab

现在相应地编辑 fstab

# vi /mnt/fstab

示例

UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 / btrfs subvol=CURRENT_SNAPSHOTS_PATH/@,ro,noatime 0 0
UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 /var btrfs subvol=/commons/@var,rw,noatime 0 0
UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 /home btrfs subvol=/commons/@home,rw,noatime 0 0
# UUID=2FE6-837A /boot/efi vfat rw,noatime,discard 0 2
tmpfs /tmp tmpfs mode=1777,noatime,nosuid,nodev,size=2G 0 0
UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 swap swap rw,noatime,discard 0 0

CURRENT_SNAPSHOTS_PATH 将被脚本替换,例如 /snapshots/20210411212549sdBXyLxg,结果将被管道传输到创建的 @ 快照的 /etc/fstab 中,在新的世代准备期间。
btrfs 卷结构挂载在 /mnt

|--mnt
| |--commons
| | |--@var
| | |--@home
| |--current
| |--fstab
| |--links
| | |--20210411213742qwrXAJBz
| | | |--0
| | | |--1
| | | |--2
| | | |--3
| |--next
| |--snapshots
| | |--20210411212549sdBXyLxg
| | | |--@

基础系统安装

准备好目录结构后,我们可以开始安装基本的 Alpine Linux 系统。
考虑到安装是从 Alpine 系统完成的,我们只需要 该过程的以下部分

# apk -X https://dl-cdn.alpinelinux.org/alpine/latest-stable/main -U --allow-untrusted -p /mnt/snapshots/20210411212549sdBXyLxg/@ --initdb add alpine-base

现在我们可以设置基本的 chroot 以完成安装过程

# export SNP="/mnt/snapshots/20210411212549sdBXyLxg/@"

# mount -o bind /dev $SNP/dev
# mount -t proc none $SNP/proc
# mount -t sysfs sys $SNP/sys

# sed "s#CURRENT_SNAPSHOTS_PATH#/snapshots/20210411212549sdBXyLxg#g" /mnt/fstab > "$SNP/etc/fstab"

# cp -L /etc/resolv.conf "$SNP/etc/"
# chroot "$SNP" /bin/sh

# mount -a

# mv /etc/resolv.conf /tmp/
# ln -s /tmp/resolv.conf /etc/resolv.conf

一旦您进入 chroot,请定义存储库

# echo "https://dl-cdn.alpinelinux.org/alpine/latest-stable/main" > /etc/apk/repositories

此示例仅显示 main,但如果您需要其中的任何软件包,您还应该添加 testingcommunity
现在是固件、内核和 btrfs 软件包的时候了

# apk add -U linux-firmware linux-lts btrfs-progs

您可能希望将 linux-firmware 更改为适合您系统的自定义固件软件包集,例如,对于典型的 AMD 笔记本电脑,可以使用 linux-firmware-amd linux-firmware-amd-ucode linux-firmware-amdgpu linux-firmware-ath10k linux-firmware-qca
btrfs 功能添加到 mkinitfs.conf 并手动运行 mkinitfs 也很重要

# vi /etc/mkinitfs/mkinitfs.conf
# mkinitfs

这些步骤准备内核并生成 initramfs,稍后将用于从我们的第一个快照启动。
之后,您应该安装您可能需要在首次启动时需要的任何软件包。

警告: 如果您的 PC 只有无线连接,您还应该安装任何合适的网络软件,例如本例中的 iwd,这样您在首次启动时就不会与网络断开连接。


注意: 由于根目录在操作期间是不可变的,因此建议安装 openresolv 软件包以支持更改网络连接。在这种情况下,/etc/resolvconf.conf 应该有 resolv_conf=/tmp/resolv.conf/etc/resolv.conf 应该移动到 /tmp/resolv.conf,并且应该创建指向新 resolv.conf 位置的链接:ln -sfn /tmp/resolv.conf /etc/resolv.conf

您也可以使用静态 DNS,但这会使您的网络活动直接可识别给 DNS 服务器提供商,因此不建议这样做。

现在,配置系统,首先为 root 用户设置密码

# passwd root

不要忘记将必要的服务添加到它们各自的运行级别

rc-update add devfs sysinit
rc-update add dmesg sysinit
rc-update add mdev sysinit
rc-update add hwdrivers sysinit

rc-update add hwclock boot
rc-update add modules boot
rc-update add sysctl boot
rc-update add hostname boot
rc-update add bootmisc boot
rc-update add syslog boot

rc-update add mount-ro shutdown
rc-update add killprocs shutdown
rc-update add savecache shutdown

准备并配置好快照后,我们可以退出 chroot 并卸载所有内容

# umount -a
# exit

通过设置 ro 标志并卸载根卷来完成快照编辑

# btrfs property set -ts "/mnt/snapshots/20210411212549sdBXyLxg/@" ro true
# umount /mnt

引导加载程序安装

挂载 EFI 分区

# mount -t vfat /dev/sda1 /mnt
# mkdir /mnt/EFI

引导加载程序配置

这里有两个引导加载程序安装示例选项:rEFIndGRUB。有时其中一个会无缘无故地拒绝在系统上工作,在这种情况下,请尝试另一个。

rEFInd

检查 refind 软件包的最新版本号

# apk info -X https://dl-cdn.alpinelinux.org/alpine/edge/testing -U refind

下载最新版本(替换下面示例中的 0.13.2-r3)的 refind 软件包

# wget https://dl-cdn.alpinelinux.org/alpine/edge/testing/x86_64/refind-0.13.2-r3.apk

解压准备好的 rEFInd 存档并将相关文件复制到 /mnt/EFI/

# tar -xzf refind-0.13.2-r3.apk
# cp -r usr/share/refind /mnt/EFI/
# cd /mnt/EFI/refind

重命名配置文件并编辑它

# mv refind.conf-sample refind.conf
# vi refind.conf

并将以下内容附加到文件末尾,记住将示例 UUID 替换为您自己的 root (btrfs 分区) 和 resume (交换分区) 的 UUID。请记住,如果您在“磁盘分区”阶段将 btrfs 卷命名为 ROOT 以外的名称,则必须相应地更改下面的 volume 字段。

menuentry "Alpine Linux" {
    icon /EFI/refind/icons/os_linux.png
    volume "ROOT"
    loader /current/0/@/boot/vmlinuz-lts
    initrd /current/0/@/boot/initramfs-lts
    options "root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/0/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash"

    submenuentry "Boot fallback 1" {
        loader /current/1/@/boot/vmlinuz-lts
        initrd /current/1/@/boot/initramfs-lts
        options "root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/1/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash"
    }

    submenuentry "Boot fallback 2" {
        loader /current/2/@/boot/vmlinuz-lts
        initrd /current/2/@/boot/initramfs-lts
        options "root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/2/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash"
    }

    submenuentry "Boot fallback 3" {
        loader /current/3/@/boot/vmlinuz-lts
        initrd /current/3/@/boot/initramfs-lts
        options "root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/3/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash"
    }
}
注意: "ROOT"btrfs 分区的 PARTLABEL。您也可以使用 PARTUUID 代替。要获取两者,可以使用来自 blkid 软件包的 blkid。busybox 中包含的 blkid 不提供此信息。

GRUB

# apk add grub-efi

GRUB 这次需要两个配置文件,因为我们将使用 grub-mkstandalone。第一个配置文件是内部的,应该只指向第二个文件,我们在其中存储菜单

# cd /tmp
# vi grub_internal.cfg

将内容设置为以下内容,但请确保将 2FE6-837A 替换为您自己的 EFI 分区 UUID

insmod part_gpt
insmod fat
search --set efi --fs-uuid 2FE6-837A
configfile (${efi})/EFI/grub/grub.cfg

第二个配置文件是主配置文件,我们在其中描述整个启动菜单。

# vi grub.cfg

设置为包含,但替换 UUID

set timeout=3
menuentry "Alpine Linux Current" {
	search --set root --fs-uuid b9ff5e7b-e128-4e64-861a-2fdd794a9828
	linux /current/0/@/boot/vmlinuz-edge root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/0/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash
	initrd /current/0/@/boot/initramfs-edge
}
menuentry "Alpine Linux Snapshot 1" {
	search --set root --fs-uuid b9ff5e7b-e128-4e64-861a-2fdd794a9828
	linux /current/1/@/boot/vmlinuz-edge root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/1/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash
	initrd /current/1/@/boot/initramfs-edge
}
menuentry "Alpine Linux Snapshot 2" {
	search --set root --fs-uuid b9ff5e7b-e128-4e64-861a-2fdd794a9828
	linux /current/2/@/boot/vmlinuz-edge root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/2/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash
	initrd /current/2/@/boot/initramfs-edge
}
menuentry "Alpine Linux Snapshot 3" {
	search --set root --fs-uuid b9ff5e7b-e128-4e64-861a-2fdd794a9828
	linux /current/3/@/boot/vmlinuz-edge root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/3/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 quiet splash
	initrd /current/3/@/boot/initramfs-edge
}

生成 grubx64.efi 二进制文件

# grub-mkstandalone -O x86_64-efi -o grubx64.efi "boot/grub/grub.cfg=/tmp/grub_internal.cfg"
# mkdir /mnt/EFI/grub
# mv grubx64.efi /mnt/EFI/grub/
# mv grub.cfg /mnt/EFI/grub/

添加 EFI 启动项

要将选择的引导加载程序添加到 UEFI,efibootmgr 是一个合适的工具。以下示例适用于 rEFInd,但可以轻松地针对 GRUB 进行调整

# apk add efibootmgr
# efibootmgr --create --disk /dev/sda --part 1 --loader /EFI/refind/refind_x64.efi --label "rEFInd" --verbose

/dev/sda 是我们的磁盘设备,1 是包含引导加载程序数据的 FAT32 分区的编号。

更新或更改系统

警告: 如果没有以下步骤或替代方案,您将无法轻松地修改已安装的系统。


警告: 这些示例是使用 execline 实现的,并且需要在系统中安装 execline 软件包。


注意: 这些当然可以用 POSIX shell 实现,但是,execline 提供了许多运行时优势,并且生成的脚本更具可读性。
# touch /usr/sbin/sysmut
# chmod +x /usr/sbin/sysmut
# vi /usr/sbin/sysmut

修改系统的示例脚本

#!/bin/execlineb -W
unshare --mount
importas -D 0 source 1
define mnt /media/root
if { mkdir -p ${mnt} }
if { mount -t btrfs -o rw,noatime UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 ${mnt} }
foreground {
	backtick -E dt {
		date -u +%Y%m%d%H%M%S
	}
	backtick -E rnd {
		pipeline { cat /dev/urandom }
		pipeline { tr -dc a-zA-Z }
		pipeline { fold -w 8 }
		head -n 1
	}
	define newsnap ${dt}${rnd}
	if { mkdir -p ${mnt}/snapshots/${newsnap} }
	if { btrfs subvolume snapshot ${mnt}/current/${source}/@ ${mnt}/snapshots/${newsnap}/@ }
	if {
		redirfd -w 1 ${mnt}/snapshots/${newsnap}/@/etc/fstab
			sed s#CURRENT_SNAPSHOTS_PATH#/snapshots/${newsnap}#g ${mnt}/fstab
	}
	if { mount -t proc none ${mnt}/snapshots/${newsnap}/@/proc }
	if { mount -t sysfs sys ${mnt}/snapshots/${newsnap}/@/sys }
	if { mount -o bind,ro /dev ${mnt}/snapshots/${newsnap}/@/dev }
	foreground {
		foreground { mount -o bind,ro /etc/resolv.conf ${mnt}/snapshots/${newsnap}/@/etc/resolv.conf }
		foreground {
			chroot ${mnt}/snapshots/${newsnap}/@
			foreground { mount -a }
			foreground { sh }
			importas apply ?
			foreground { umount -a }
			exit ${apply}
		}
		importas apply ?
		foreground { redirfd -w 2 /dev/null umount ${mnt}/snapshots/${newsnap}/@/etc/resolv.conf }
		ifelse { exit ${apply} } {
			if { btrfs property set -ts ${mnt}/snapshots/${newsnap}/@ ro true }
			define newlink ${dt}${rnd}
			if { mkdir -p ${mnt}/links/${newlink} }
			if { ln -s ../../snapshots/${newsnap} ${mnt}/links/${newlink}/0 }
			if { cp -P ${mnt}/current/0 ${mnt}/links/${newlink}/1 }
			if { cp -P ${mnt}/current/1 ${mnt}/links/${newlink}/2 }
			if { cp -P ${mnt}/current/2 ${mnt}/links/${newlink}/3 }
			if { mkdir -p ${mnt}/next }
			if { ln -sfn ./links/${newlink} ${mnt}/next/current }
			if { mv ${mnt}/next/current ${mnt}/ }
			echo "Changes applied"
		}
		echo "Changes discarded"
	}
	foreground { redirfd -w 2 /dev/null umount ${mnt}/snapshots/${newsnap}/@/proc }
	foreground { redirfd -w 2 /dev/null umount ${mnt}/snapshots/${newsnap}/@/sys }
	redirfd -w 2 /dev/null umount ${mnt}/snapshots/${newsnap}/@/dev
}
umount ${mnt}

它将使您进入 chroot 到新快照中的 root shell,您可以在其中应用您喜欢的任何更改。新快照的来源由第一个也是唯一的参数定义,形式为数字。如果未提供参数,则将 0(当前最新)作为来源。
如果 chroot shell 以错误退出,则不会切换到新快照。这意味着您可以在 chroot 中手动放弃更改,方法是

# exit 1

删除未使用的快照

未使用的快照可以通过以下方式进行垃圾回收

# touch /usr/sbin/syscln
# chmod +x /usr/sbin/syscln
# vi /usr/sbin/syscln
#!/bin/execlineb -W
unshare --mount
define mnt /media/root
if { mkdir -p ${mnt} }
if { mount -t btrfs -o rw,noatime,compress=zstd:3 UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 ${mnt} }
foreground {
	foreground {
		pipeline {
			foreground {
				pipeline {
					find -H ${mnt}/snapshots/ -maxdepth 1 -mindepth 1 -print0
				}
				xargs -0 -r realpath
			}
			pipeline {
				find -H ${mnt}/current/ -maxdepth 1 -mindepth 1 -print0
			}
			xargs -0 -r realpath
		}
		pipeline { tr \\n \\0 }
		pipeline { sort -z }
		pipeline { uniq -u -z }
		pipeline { xargs -0 -r -n 1 -I [] find -H [] -maxdepth 1 -mindepth 1 -print0 }
		xargs -0 -r btrfs subvolume delete
	}
	foreground { find -H ${mnt}/snapshots/ -maxdepth 1 -mindepth 1 -empty -type d -delete }
	foreground {
		pipeline {
			foreground {
				pipeline {
					find -H ${mnt}/links/ -maxdepth 1 -mindepth 1 -print0
				}
				xargs -0 -r realpath
			}
			realpath ${mnt}/current
		}
		pipeline { tr \\n \\0 }
		pipeline { sort -z }
		pipeline { uniq -u -z }
		pipeline { xargs -0 -r -n 1 -I [] find -H [] -maxdepth 1 -mindepth 1 -print0 }
		xargs -0 -r -n 1 unlink
	}
	find -H ${mnt}/links/ -maxdepth 1 -mindepth 1 -empty -type d -delete
}
umount ${mnt}

允许临时的运行时更改

您可以使用带有内置于 Alpine init 脚本的 tmpfsoverlayfs,以允许在 rootfs 中进行更改,这些更改将在重新启动时自动还原。
要使用此功能,请将 overlaytmpfs 添加到 refind.conf 中的内核启动选项,例如

...
    initrd /current/0/@/boot/initramfs-lts
    options "root=UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 rootfstype=btrfs rootflags=subvol=/current/0/@,ro,noatime resume=UUID=f0239163-9d46-47c1-67a4-3ee1d63d0676 overlaytmpfs quiet splash"

    submenuentry "Boot fallback 1" {
...