基于Alpine initramfs的NAS系统构建指南

本文详细介绍了如何利用Alpine Linux构建基于initramfs的NAS系统,包含完整的系统架构设计、软件包选择、启动流程配置以及QEMU测试方法,展示了如何通过声明式配置实现轻量级可移植系统。

frood:一个Alpine initramfs NAS

我的NAS设备frood采用了有点特殊的配置。它只是一个包含完整Alpine Linux系统的大型initramfs。这种配置非常优雅,我不明白为什么它不更常见。

只要引导加载程序能找到内核和initramfs,机器就能正常启动。 A/B部署和回滚只需选择不同的启动选项即可。 系统通过构建initramfs的git仓库进行声明式定义。 对我来说重要的是,它不是用复杂的DSL定义的:如果我想在/etc/example.conf创建文件,只需将其放在root/etc/example.conf中,其余工作由几百行我可读(且已读过)的脚本完成。 配置它看起来与配置任何常规Alpine系统没什么不同。 我可以用一行qemu命令测试下一个部署。 系统的移动部件非常少。

如果这听起来已经很有吸引力,你可以直接跳到下面的"工作原理"部分。

为什么选择这种方案

我一直喜欢从内存运行系统:这样速度快,并且能防止系统存储设备(通常是某些劣质SD卡)磨损,因为好的驱动器都专用于ZFS池。

然而,你立即面临如何持久保存配置更改的问题。 Alpine的解决方案是"无盘模式",任何自定义都保存在覆盖文件中。启动后,标准系统在所有可用文件系统中查找匹配*.apkovl的文件,应用它,然后从本地缓存安装任何缺失的apk包。

第一个问题是复杂性:生成和管理apkovl的工具lbu(1)相当不错,但该过程有很多移动部件。找到apkovl,应用它,在新的fstab中挂载文件系统,安装缺失的apk,恢复启动过程。在过去的一年里,这个过程多次出现问题,要么是因为找不到文件系统,要么是因为apk没有安装。启动过程依赖于包管理器!

第二个问题是我真的希望系统状态能在git中跟踪。Graham Christensen在"Erase your darlings"中对声明式或不可变系统有很好的阐述。

我每次启动都会擦除系统。 随着时间的推移,系统会在其根分区上收集状态。这些状态存在于/etc和/var等各种目录中,代表了启动服务时每个未充分记录或顺序错误的步骤。 “对了,运行myapp-init。” 这些小的、无关紧要的"哦,糟糕"步骤是会丢失的部分,不会出现在你的运行手册中。 “只需下载ca-certificates到…来修复…” 每个快速修复都让你注定在三年后最终进行那个令人畏惧的RHEL 7到RHEL 8升级时重蹈覆辙。 “哦,touch /etc/ipsec.secrets,否则l2tp隧道无法工作。”

我以前通过Ansible进行(大部分)更改来解决这个问题,但那时我面临多层情况:需要在Ansible中进行更改,然后部署它,再使用lbu保存到apkovl。

当然,声明式系统有很多替代方案:从NixOS(听起来就不有趣)到gokrazy(尚未准备好支持ZFS)再到嵌入式工具链如buildroot或更新的u-root。 但问题是,我真的很喜欢Alpine:一个简单、打包良好、轻量级、无GNU的Linux发行版。我不喜欢的是它的init和持久化机制。

工作原理

启动时,Linux期望有一个"initramfs"镜像。这是一个简单的cpio归档,包含启动时组成第一个根文件系统的文件。通常这个系统的任务是加载足够的模块来挂载真正的根文件系统并切换到其中。但没有什么能阻止我们把整个系统放在里面!谁需要根文件系统?

构建initramfs

起点是alpine-make-rootfs,这是一个用于构建容器镜像的简短(约500行)脚本。它确实满足了我们90%的需求。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/sh
set -e

wget https://raw.githubusercontent.com/alpinelinux/alpine-make-rootfs/v0.7.0/alpine-make-rootfs \
    && echo 'e09b623054d06ea389f3a901fd85e64aa154ab3a  alpine-make-rootfs' | sha1sum -c && \
    chmod +x alpine-make-rootfs

ROOTFS_DEST=$(mktemp -d)

# 在apk安装期间阻止mkinitfs运行。
mkdir -p "$ROOTFS_DEST/etc/mkinitfs"
echo "disable_trigger=yes" > "$ROOTFS_DEST/etc/mkinitfs/mkinitfs.conf"

export ALPINE_BRANCH=edge
export SCRIPT_CHROOT=yes
export FS_SKEL_DIR=root
export FS_SKEL_CHOWN=root:root
PACKAGES="$(cat packages)"
export PACKAGES
./alpine-make-rootfs "$ROOTFS_DEST" setup.sh

alpine-make-rootfs将从root目录复制文件,从packages文件安装包,并在chroot中运行setup.sh脚本。

然后,我们提取boot目录并将其余部分打包成initramfs归档。

1
2
3
cd "$ROOTFS_DEST"
mv boot "$IMAGE_DEST"
find . | cpio -o -H newc | gzip > "$IMAGE_DEST/initramfs-lts"

这真的差不多就完成了!Alpine几乎不需要任何hack就能做到这一点,令人印象深刻。

软件包

我们安装的软件包是通常在服务器上安装的常规内容。只有几个值得注意。

  • alpine-base是安装apk、busybox、openrc和一些配置文件的元包。
  • linux-lts是内核及其模块。我考虑过精简模块只保留我需要的,但最终为了节省几百MB而进行大量hack不值得。注意没有modloop!模块始终可用。
  • linux-firmware-i915是Linux固件的i915文件夹。需要安装至少一个提供linux-firmware-any(包括linux-firmware-none)的包,否则会安装linux-firmware,这会安装所有固件。
  • intel-ucode是微码更新。它在/boot中安装一个文件,可用作pre-initramfs。实际上这比在更大的系统上设置更容易。
  • syslinux是引导加载程序。比GRUB简单得多,它安装在文件系统分区中,然后从该分区启动内核。这完成了循环:只要我们启动正确的分区,除了我们的系统之外,没有任何东西可以加载。启动过程中不需要发现文件系统甚至为其命名。
  • openrc-init是init。Alpine实际上不使用OpenRC的init,它使用busybox的init,但我发现OpenRC的更容易设置。但请注意,它不适用于busybox的shutdown/reboot/poweroff命令,因此你需要使用openrc-shutdown。
  • agetty如果你计划连接键盘和屏幕。

设置脚本

setup.sh脚本也没什么特别的。我们只需要链接/init,设置运行级别,并设置root密码。(是的,那是我的实际密码哈希。不,你无法破解它。)

 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
#!/bin/sh
set -e

ln -s /sbin/openrc-init /init

rc-update add devfs sysinit
rc-update add dmesg 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 klogd boot
rc-update add networking boot
rc-update add seedrng boot

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

ln -s /etc/init.d/agetty /etc/init.d/agetty.ttyS0
ln -s /etc/init.d/agetty /etc/init.d/agetty.tty1

rc-update add agetty.ttyS0 default
rc-update add agetty.tty1 default

rc-update add acpid default
rc-update add crond default
rc-update add local default
rc-update add openntpd default
rc-update add sshd default
rc-update add tailscale default

chpasswd -e <<'EOF'
root:$6$twsDxnP.TG2M8J4l$7lte7E/ImK4UwoursD7qQCC7XMUothIDb9FTH1MncxYbGQDUQPkC/9pxleTwPxEs3nbatApszxuwc4yj6ucdX1
EOF

在实践中,我在这里设置了更多服务,但它们不是运行系统所必需的。这只是你声明式指定系统配置的地方。

根骨架

根骨架同样特定于系统,能够通过创建文件将文件放入镜像中真是太棒了。例如,如果我想在启动时运行某些东西,我只需向root/etc/local.d/添加一个文件。

骨架中有几个值得注意的文件。

1
2
#!/bin/sh
openrc-shutdown -p now

root/etc/acpi/PWRF/00000080使电源按钮与openrc-init一起工作。 root/etc/network/interfaces和root/etc/hostname和root/etc/hosts使网络工作。 root/etc/ssh/ssh_host_ed25519_key和root/etc/ssh/ssh_host_ed25519_key.pub和root/root/.ssh/authorized_keys出于明显原因。

1
sshd_disable_keygen=yes

root/etc/conf.d/sshd避免生成非Ed25519主机密钥。

最后,对两个真正无法没有持久化的事物进行一点持久化:RNG种子(有硬件随机性可能不需要)和Tailscale(唉,真的不知道如何在没有持久化的情况下运行)。严格使用UUID挂载。

1
UUID=B61B-19E7   /media/usb   vfat   noatime,rw,fmask=177 0 0

root/etc/fstab

1
seed_dir=/media/usb/persist/seedrng

root/etc/conf.d/seedrng

1
TAILSCALED_OPTS="-state /media/usb/persist/tailscaled.state"

root/etc/conf.d/tailscale

QEMU测试

这种设置的一个优点是:你可以通过指向内核和initramfs在qemu中有意义地测试它。甚至在我的arm64 M2上模拟也能工作。

1
2
3
4
qemu-system-x86_64 -m 4G -kernel "images/$image/vmlinuz-lts" \
    -initrd "images/$image/initramfs-lts" -append "console=ttyS0" \
    -nographic -device qemu-xhci -device usb-storage,drive=usbstick \
    -drive if=none,id=usbstick,file=usb_disk.img,format=raw

这包括一个持久化设备,我使用与生产设备相同的UUID进行了格式化。由于Tailscale配置在其中,qemu镜像作为不同的Tailscale设备启动,我可以单独SSH进入其中。

引导加载程序

安装或更新引导加载程序是从系统本身使用extlinux完成的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
rm -rf /media/usb/boot/syslinux
mkdir -p /media/usb/boot/syslinux

cp /usr/share/syslinux/*.c32 /media/usb/boot/syslinux/

extlinux --install /media/usb/boot/syslinux

cat > /media/usb/boot/syslinux/syslinux.cfg <<EOF
PROMPT 0
DEFAULT lts

LABEL lts
KERNEL /boot/vmlinuz-lts
INITRD /boot/intel-ucode.img,/boot/initramfs-lts

LABEL old
KERNEL /boot/vmlinuz-lts-old
INITRD /boot/intel-ucode.img-old,/boot/initramfs-lts-old

LABEL new
KERNEL /boot/vmlinuz-lts-new
INITRD /boot/intel-ucode.img-new,/boot/initramfs-lts-new
EOF

我们有三个启动条目:常规、旧的和新的。部署新版本系统时,我们通过rsync同步,然后使用extlinux –once选择它用于下一次启动。

1
2
3
4
rsync -Pv "$image/vmlinuz-lts" root@frood:/media/usb/boot/vmlinuz-lts-new
rsync -Pv "$image/initramfs-lts" root@frood:/media/usb/boot/initramfs-lts-new
rsync -Pv "$image/intel-ucode.img" root@frood:/media/usb/boot/intel-ucode.img-new
echo "extlinux --once=new /media/usb/boot/syslinux" | ssh root@frood sh

如果机器正常启动,那么我们将常规镜像移动到old,将new移动到常规。否则,另一次重启将回滚它。

简单的状态服务

我想要一个简单的服务来一目了然地获取系统状态。有无数种方法可以做到这一点,但我选择编写一个小的Go服务器。这不是使这个系统工作所必需的,但我包含它是为了展示添加服务是多么容易。

在alpine-make-rootfs调用之前,我添加了几行代码,将本地模块中的所有Go二进制文件构建到/usr/local/bin/。请注意,即使是Go工具链也通过GOTOOLCHAIN=auto从go.mod中声明式选择。

1
2
go env -w GOTOOLCHAIN=auto
go build -C bins -o "$ROOTFS_DEST/usr/local/bin/" ./...

然后我创建了root/etc/init.d/srvmonitor。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/sbin/openrc-run
# shellcheck shell=sh

description="Serve scripts from /etc/monitor.d"
command=/usr/local/bin/srvmonitor
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"

depend() {
    need net localmount
    after firewall
}

最后我向setup.sh添加了一行。

1
rc-update add srvmonitor default

就是这样。Go服务器在Tailscale IP的端口80上监听,并服务我放在/etc/monitor.d/中的脚本的输出。

frood

整个设置是开源的,在我的mostly-harmless仓库中。你可能对我如何使ZFS导入工作感兴趣,这在上文没有涉及。

我还没有将其制作成可重用的项目,部分原因是它内容太少了。添加钩子来配置东西很容易使其大小翻倍。如果你喜欢,我鼓励你直接fork它。

我还没有解决的一个问题是如何注入密钥。目前它们只是被.gitignore忽略。也许我会插入一个YubiKey并使用age-plugin-yubikey解密它们,使用yubikey-agent作为主机密钥。或者也许这个主板有TPM,我可以利用这个系统的简单性获得完整的Secure Boot链来解锁TPM密钥。那会很有趣。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计