Frood:一个基于Alpine Initramfs的NAS
摘要
描述了一种完全从Alpine Linux initramfs运行NAS的方法,支持干净启动、A/B部署、声明式git跟踪配置,并相比Alpine的无盘模式降低了复杂性。
暂无内容
查看缓存全文
缓存时间: 2026/06/16 20:35
# frood:一个 Alpine initramfs NAS
来源:https://words.filippo.io/frood/
我的 NAS,frood(https://hitchhikers.fandom.com/wiki/Frood),它的设置有点奇怪。它就是一个巨大的 initramfs,里面包含了整个 Alpine Linux 系统。这非常令人愉快,而且我不明白为什么这种用法不那么常见。
- 只要引导加载程序能找到内核和 initramfs,机器就能干净地启动。
- A/B 部署和回滚只需选择不同的启动选项即可。
- 系统在构建 initramfs 的 git 仓库中声明式地定义。
- 对我重要的是,它不是用某种复杂的 DSL 定义的:如果我想要一个文件存在于 `/etc/example.conf`,我就把它放在 `root/etc/example.conf` 里,剩下的工作由几百行我能(也确实)阅读的脚本来完成。
- 配置它看起来和配置任何普通 Alpine 系统没什么不同。
- 我可以用一条 qemu 命令来测试下一次部署。
- 活动部件非常非常少。
如果这听起来已经吸引你了,你可以直接跳到下面的“工作原理”部分。
## 但为什么
我一直喜欢从内存运行系统:这样速度快,并且能防止对系统存储设备(通常是某种不靠谱的 SD 卡)的磨损,因为好的驱动器都留给了 ZFS 池。
然而,你立即会面临一个问题:如何持久化配置更改。
Alpine 对此的答案是“无盘模式(https://wiki.alpinelinux.org/wiki/Diskless_Mode)”,其中任何定制都保存在一个覆盖文件中。启动后,标准系统会在所有可用文件系统中查找匹配 `*.apkovl` 的文件,应用它,然后从本地缓存安装任何缺失的 apk 包。
第一个问题是复杂性:生成和管理 apkovl 的工具 `lbu(1)`(https://wiki.alpinelinux.org/wiki/Alpine_local_backup)相当不错,但这个过程有**很多**活动部件:找到 apkovl,应用它,挂载新 fstab 中的文件系统,安装缺失的 apk,然后继续启动过程。在过去一年里,我遇到过多次这种情况失败的情况——要么是它找不到文件系统了(https://gitlab.alpinelinux.org/alpine/aports/-/issues/14624),要么是 apk 没有被安装。启动过程依赖于包管理器!
第二个问题是,我真的很希望系统状态能在 git 中追踪。Graham Christensen 在“Erase your darlings(https://grahamc.com/blog/erase-your-darlings/)”中对声明式或不可变系统提出了一个非常好的观点。
> 我每次启动时都擦除我的系统。随着时间的推移,系统会在其根分区上积累状态。这些状态存在于 `/etc` 和 `/var` 等目录中,代表了启动服务过程中每个记录不全或顺序混乱的步骤。“对了,运行 `myapp-init`。”这些微小、无关紧要的“哦,糟了”步骤正是那些丢失并不会出现在你的运行手册中的片段。“只要下载 ca-certificates 到……来修复……”每一次这样的快速修复都会让你在三年后终于进行那可怕的 RHEL 7 到 RHEL 8 升级时重蹈覆辙。“哦,触摸 `/etc/ipsec.secrets`,否则 l2tp 隧道就不工作。”
我以前通过用 Ansible 做(大多数)更改来解决这个问题,但那会导致多层情况:我需要在 Ansible 中做更改,然后部署它,然后用 lbu 将其保存到 apkovl 中。
当然,有很多声明式系统的替代方案:从 NixOS(https://nixos.org/)(但这听起来就不有趣(https://bsky.app/profile/filippo.abyssdomain.expert/post/3l76qq2gwdz2h))到 gokrazy(https://gokrazy.org/)(它还没准备好支持 ZFS(https://abyssdomain.expert/@[email protected]/113338344895999793))到嵌入式工具链如 buildroot(https://buildroot.org/)或更新的 u-root(https://u-root.org/)。
不过,问题是,我真的很喜欢 Alpine:一个简单、打包良好、轻量级、没有 GNU 的 Linux 发行版。我不喜欢的是它的 init 和持久化机制。
> 一段截图,包含四句话:“是的,我认为我对 Alpine 的所有反对意见基本上就是它不稳定的 init 和它的持久化机制”“如果我在构建时运行 apk 来制作一个巨大的 initramfs,写 300 行代码替换 init,我可能就稳了”“所有 mkinitfs 的复杂性和不稳定性在于找到模块、加载它们、找到根、找到 apk 缓存、安装它”“所有这些都消失”
## 工作原理
启动时,Linux 期望一个“initramfs”镜像(https://www.kernel.org/doc/html/latest/filesystems/ramfs-rootfs-initramfs.html)。它就是一个简单的 cpio(https://www.kernel.org/doc/html/latest/filesystems/ramfs-rootfs-initramfs.html#why-cpio-rather-than-tar)归档文件,包含启动时构成第一个根文件系统的那些文件。*通常*,这个系统的工作是加载足够的模块来挂载真正的 rootfs,然后切换到它。但是,没有什么能阻止我们把整个系统放进 initramfs!谁还需要 rootfs 呢?
### 构建 initramfs
起点是 alpine-make-rootfs(https://github.com/alpinelinux/alpine-make-rootfs),这是一个很短的(约 500 行)脚本,用于构建容器镜像。它基本上满足了我们 90% 的需求。
```
#!/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)
# 阻止 mkinitfs 在 apk 安装期间运行。
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 归档。
```
cd "$ROOTFS_DEST"
mv boot "$IMAGE_DEST"
find . | cpio -o -H newc | gzip > "$IMAGE_DEST/initramfs-lts"
```
基本上就是这样了!Alpine 能如此配合,几乎不需要什么 hack,真是令人印象深刻。
### 包
我们安装的包都是你通常在服务器上安装的东西。只有少数几个值得注意。
- alpine-base(https://pkgs.alpinelinux.org/package/edge/main/x86_64/alpine-base)是安装 apk、busybox、openrc 和一些配置文件的元包。
- linux-lts(https://pkgs.alpinelinux.org/package/edge/main/x86_64/linux-lts)是内核,以及它的模块。我考虑过精简模块,只保留需要的,但最终为了节省几百 MB 而做大量 hack 并不值得。注意:没有 modloop!模块始终可用。
- linux-firmware-i915(https://pkgs.alpinelinux.org/package/edge/main/x86_64/linux-firmware-i915)是 Linux firmware 中的 i915 文件夹。需要安装至少一个提供 `linux-firmware-any` 的包(包括 `linux-firmware-none`),否则会安装 `linux-firmware`,而它会安装所有 firmware。
- intel-ucode(https://pkgs.alpinelinux.org/package/edge/main/x86_64/intel-ucode)是微码更新。它在 `/boot` 中安装一个文件,可以用作 pre-initramfs。实际上,这比在更大的系统上配置更容易。
- syslinux(https://pkgs.alpinelinux.org/package/edge/main/x86_64/syslinux)是引导加载程序。比 GRUB 简单得多,它安装在文件系统分区中,然后从该分区引导内核。这样就形成了一个闭环:只要我们引导正确的分区,除了我们的系统之外,没有任何东西能加载。启动过程中不需要发现——**甚至不需要命名**——任何文件系统。
- openrc-init(https://pkgs.alpinelinux.org/package/edge/main/x86_64/openrc-init)是 init。Alpine 实际上并不使用 OpenRC 的 init,它使用 busybox 的 init,但我发现 OpenRC 的更容易设置。不过要注意,它不能与 busybox 的 shutdown/reboot/poweroff 命令配合使用(https://gitlab.alpinelinux.org/alpine/aports/-/issues/16562),所以你需要使用 `openrc-shutdown`。
- agetty(https://pkgs.alpinelinux.org/package/edge/main/x86_64/agetty)如果你打算连接键盘和屏幕的话。
### 设置脚本
`setup.sh` 脚本也没什么特别的。我们只需要链接 `/init`,设置运行级别,并设置 root 密码。(是的,那是我的实际密码哈希。不,你破解不了它。)
```
#!/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/` 中添加一个文件。
骨架中一些值得注意的文件:
```
#!/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`,原因显而易见。
```
sshd_disable_keygen=yes
```
`root/etc/conf.d/sshd` 避免生成非 Ed25519 的主机密钥。
最后,为两件真正无法离开持久化的事物提供一点持久化:RNG 种子(在有硬件随机数发生器的情况下也许不是必需的)和 Tailscale(它确实不知道如何在没有持久化的情况下运行,唉)。严格使用 UUID 挂载。
```
UUID=B61B-19E7 /media/usb vfat noatime,rw,fmask=177 0 0
```
`root/etc/fstab`
```
seed_dir=/media/usb/persist/seedrng
```
`root/etc/conf.d/seedrng`
```
TAILSCALED_OPTS="-state /media/usb/persist/tailscaled.state"
```
`root/etc/conf.d/tailscale`
### qemu 测试
这个设置有一个美妙之处:你可以通过将 qemu 指向内核和 initramfs 来在 qemu 中有意义地测试它。甚至在 arm64 M2 上模拟也能工作。
```
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。1(https://words.filippo.io/frood/#fn:format)由于 Tailscale 配置在里面,qemu 镜像会以另一个 Tailscale 设备出现,我可以单独 SSH 进去。
### 引导加载程序
安装或更新引导加载程序是在系统内部通过 `extlinux` 完成的。
```
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` 选择它作为下一次启动的条目。
```
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
```
如果机器正常启动,那么我们将常规镜像移到旧,并将新镜像移到常规。否则,只需再次重启即可回滚。
### 一个简单的状态服务
我想要一个简单的服务,能一目了然地看到系统的状态。实现这个的方法有上百万种(https://bsky.app/profile/filippo.abyssdomain.expert/post/3lbn65cmodk2w),但我选择写一个小型 Go 服务器。这对于使系统工作并非必需,但我将其包含在内是为了展示添加一个服务有多么容易。
在调用 alpine-make-rootfs 之前,我添加了几行,用于将本地模块中的所有 Go 二进制文件构建到 `/usr/local/bin/`。注意,由于 `GOTOOLCHAIN=auto`,甚至 Go 工具链也是根据 `go.mod` 声明式地选择的。
```
go env -w GOTOOLCHAIN=auto
go build -C bins -o "$ROOTFS_DEST/usr/local/bin/" ./...
```
然后我创建了 `root/etc/init.d/srvmonitor`。
```
#!/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` 中添加了一行。
```
rc-update add srvmonitor default
```
就这样。Go 服务器在 Tailscale IP 上监听端口 80,并输出我放在 `/etc/monitor.d/` 中的脚本的结果。
## frood
整个设置是开源,在我的 mostly-harmless 仓库(https://github.com/FiloSottile/mostly-harmless/tree/main/frood)中。你可能会对我如何让 ZFS 导入工作(https://abyssdomain.expert/@filippo/113382333291761248)感兴趣,这部分没有在上文覆盖到。
我没有把它变成一个可重用的项目,部分原因是它**实在是太简单了**。添加用于配置的钩子可能会使它的体积轻易翻倍。如果你喜欢,我建议你直接 fork 它。
我还没有解决的一个问题是如何注入密钥。目前它们只是被 `.gitignore` 忽略了。也许我会插入一个 YubiKey,使用 `age-plugin-yubikey` 来解密它们,并使用 `yubikey-agent` 来处理主机密钥。或者也许这块板子有一个 TPM,我可以利用这个系统的简单性来获得一个完整的、能够解锁 TPM 密钥的安全启动链。那会很有趣。
如果你读到了这里,你可能也想在 Bluesky 上关注我(@filippo.abyssdomain.expert,https://bsky.app/profile/filippo.abyssdomain.expert)或在 Mastodon 上关注我(@[email protected],https://abyssdomain.expert/@filippo)。
## 图片
马德拉岛波尔图莫尼兹的天然泳池(https://visitmadeira.com/en/where-to-go/madeira/north-coast/porto-moniz/cachalote-natural-swimming-pools/)。它们是公共开放的,由火山岩构成,被壮丽地拍打其上的海洋波浪填满。那天我状态不太好,但这是一个可以状态不好的绝佳地点。
马德拉岛相当酷。2(https://words.filippo.io/frood/#fn:madeira)也是最具挑战的侧风着陆之一。
> 一张图片:一个天然泳池,清澈的蓝色海水,在夕阳的映照下被深色火山岩环绕。背景中可见大海,几朵白云倒映其中。一座岩石岛屿顶部有一座灯塔。
我的维护工作由优秀的 Geomys(https://geomys.org/)客户资助:Interchain(https://interchain.io/)、Smallstep(https://smallstep.com/)、Ava Labs(https://www.avalabs.org/)、Teleport(https://goteleport.com/)。
相似文章
裸启动Linux
本文介绍如何创建一个运行单进程的最小化Linux启动,精简通常的initramfs,使启动时间小于一秒。
在树莓派 Zero 上完全运行于 RAM 中提供网站服务
本教程介绍如何在树莓派 Zero v1.3 上使用 Alpine Linux 搭建无磁盘网站,系统完全启动至其 512MB RAM 中。详细说明了所需硬件、操作系统配置、轻量级 Web 服务器以及将 TLS 终止卸载到外部 VPS 的方法。
将我的 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS
Michael Stapelberg 详细介绍了他将一台 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS 的过程,涵盖了从 Docker 容器逐步过渡到原生 NixOS 模块的步骤,并附有实际示例。
我们通过删除文件系统使其速度提升了47倍
microsandbox将其缓慢的用户空间FUSE文件系统替换为内核挂载的EROFS磁盘映像,在文件系统操作上实现了几何平均47倍的速度提升,并消除了虚拟机/主机往返瓶颈。
我的新家庭服务器软件选择
一篇个人博客文章,详细描述了作者为新家庭服务器选择操作系统和服务管理软件的经历,比较了Synology、TrueNAS、Debian以及使用Runit的Void Linux。