Immutable root with atomic upgrades
This page is a work in progress ... This page is still being developed. |
What?
This article provides a basic guide to setting up a read-only-root-based Alpine Linux system with several boot environments and atomic upgrades using rEFInd and btrfs.
Why?
Read-only root and atomic upgrades with ability to easily rollback or boot previous configurations is a concept that got some popularity recently. Distributions providing and promoting such features, for example, are Fedora Silverblue, Opensuse MicroOS, NixOS and GNU Guix.
While Alpine Linux has it's killer features it lacks mentioned above ones on default setup. This is a proof of concept that it's possible to implement them in a minimal way on a minimal system.
lbu
, that is used to save modified states to .apkovl files, supports to keep older versions (by configuring BACKUP_LIMIT) and to revert to booting older .apkovl versions. (The partitions are only temporarily re-mounted writable during the lbu operations, and are mounted read-only otherwise. (Manually jumpering the storage device read-only wouldn't interfere with the regular operation, either.)
Partitioning disks
In this guide it is assumed that you have a fresh UEFI system without OS and just managed to boot into live Alpine using USB flash drive or CD.
The first step is creating partition table on your HDD/SSD target device (/dev/sda
here):
# apk add gptfdisk # gdisk /dev/sda > o ↵ > y ↵ > w ↵ > y ↵
Now we can define the partitions:
# cgdisk /dev/sda
Partition creation process consists of several steps:
- Start sector - you can safely use default value by pressing ↵
- Size
- Type (as hex code) - EFI is ef00, Linux filesystem is 8300, Swap is 8200.
Result table:
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
partition name will later be used in rEFInd configuration to identify boot volume.
Next step is creating filesystems:
# mkfs.vfat -F32 /dev/sda1 # mkfs.btrfs /dev/sda2 # mkswap /dev/sda3
Now we can mount our root volume:
# mount -t btrfs /dev/sda2 /mnt
File system structure
Now we should create file structure that would provide reliable atomic system upgrades.
Start with following directories:
# mkdir /mnt/next
Stores next current
link.
# mkdir /mnt/commons
Stores common non-snapshotting subvolumes, is necessary due to how busybox mv
does atomic link replacement.
We may populate it right away:
# btrfs subvolume create /mnt/commons/@var@tmp # btrfs subvolume create /mnt/commons/@var@cache # btrfs subvolume create /mnt/commons/@var@log # btrfs subvolume create /mnt/commons/@home
If you use flatpak, you may also want to keep it's directory separate:
# btrfs subvolume create /mnt/commons/@var@lib@flatpak
Next, most important directories:
# mkdir /mnt/snapshots
Stores directories containing snapshots belonging to one generation.
# mkdir /mnt/links
Stores generations of directories containing links to snapshot generations.
Let's create first generation and populate it with one OS root snapshot @
:
# 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/$NEWSNAPSHOTS" # btrfs subvolume create /mnt/$NEWSNAPSHOTS/@
Populate 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"
You can have as many links as you like, just apply changes to rEFInd config and upgrade scripts described below accordingly.
Link that will point to latest links generation:
# ln -s "./links/$NEWLINKS" /mnt/current
This setup allows us to just have static rEFInd config that points to to /current/0/@
, /current/1/@
, etc. while the actual underlying boot environment will change with each upgrade.
But how will fs mounting services know which snapshot generation is currently loaded?
The answer is common fstab
in the btrfs root.
Get UUIDs of the partitions first:
# blkid > /mnt/fstab
Now edit fstab accordingly:
# vi /mnt/fstab
Example:
UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 / btrfs subvol=CURRENT_SNAPSHOTS_PATH/@,ro,noatime 0 0 UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 /var/tmp btrfs subvol=/commons/@var@tmp,rw,noatime 0 0 UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 /var/cache btrfs subvol=/commons/@var@cache,rw,noatime 0 0 UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 /var/log btrfs subvol=/commons/@var@log,rw,noatime 0 0 UUID=b9ff5e7b-e128-4e64-861a-2fdd794a9828 /var/lib/flatpak btrfs subvol=/commons/@var@lib@flatpak,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
will be replaced by scripts with, for example, /snapshots/20210411212549sdBXyLxg
, and the result will be piped into /etc/fstab
of a created @
snapshot during new generation preparations.
Root btrfs volume structure mounted on /mnt
:
|--mnt | |--commons | | |--@var@tmp | | |--@var@cache | | |--@var@log | | |--@var@lib@flatpak | | |--@home | |--current | |--fstab | |--links | | |--20210411213742qwrXAJBz | | | |--0 | | | |--1 | | | |--2 | | | |--3 | |--next | |--snapshots | | |--20210411212549sdBXyLxg | | | |--@
Base system install
With the directory strtucture prepared we can start installation of a basic Alpine Linux system.
Considering that installation is done from Alpine system, we only need following parts of the process:
# apk -X https://dl-cdn.alpinelinux.org/alpine/latest-stable/main -U --allow-untrusted -p /mnt/snapshots/20210411212549sdBXyLxg/@ --initdb add alpine-base
Now we can setup basic chroot to complete the installation process:
# export SNP="/mnt/snapshots/20210411212549sdBXyLxg/@" # mount -o bind /dev $SNP/dev # mount -t proc none $SNP/proc # mount -o bind /sys $SNP/sys # mkdir -p $SNP/var/tmp # mount -o bind /mnt/commons/@var@tmp $SNP/var/tmp # mkdir -p $SNP/var/cache # mount -o bind /mnt/commons/@var@cache $SNP/var/cache # mkdir -p $SNP/var/log # mount -o bind /mnt/commons/@var@log $SNP/var/log # mkdir -p $SNP/var/lib/flatpak # mount -o bind /mnt/commons/@var@lib@flatpak $SNP/var/lib/flatpak # mkdir -p $SNP/home # mount -o bind /mnt/commons/@home $SNP/home # cp -L /etc/resolv.conf /mnt/etc/ # chroot /mnt /bin/sh
As soon as you in chroot, define repositories:
# mkdir -p /etc/apk # echo "https://dl-cdn.alpinelinux.org/alpine/latest-stable/main" > /etc/apk/repositories
Example shows only main
, but you should also add testing
and community
if you need any packages in those.
Now it's time for firmware, kernel and btrfs packages:
# apk add -U linux-firmware linux-lts btrfs-progs
You may want to change linux-firmware
to a custom set of firmware packages suitable for you system, for example linux-firmware-amd linux-firmware-amd-ucode linux-firmware-amdgpu linux-firmware-ath10k linux-firmware-qca
for typical AMD laptop.
It is also important to add btrfs
feature to mkinitfs.conf
and run mkinitfs
manually:
# vi /etc/mkinitfs/mkinitfs.conf # mkinitfs
These steps prepare kernel and generate initramfs
which later will be used to boot from our first snapshot.
After that you should install any package you may need on first boot.
iwd
, so you won't end up severed from network on your first boot.
openresolv
. Then /etc/resolvconf.conf
should have resolv_conf=/tmp/resolv.conf
, /etc/resolv.conf
should be moved to /tmp/resolv.conf
and a link should be created ln -sfn /tmp/resolv.conf /etc/resolv.conf
.
(The use of central DNS servers would transmit network activity from your machine directly identifyable to the DNS server's provider.)
With the snapshot prepared we can chroot out of it and unmount everything:
# exit # umount $SNP/dev # umount $SNP/proc # umount $SNP/sys # umount $SNP/var/tmp # umount $SNP/var/cache # umount $SNP/var/log # umount $SNP/var/lib/flatpak # umount $SNP/home
...and inject fstab:
# sed "s#CURRENT_SNAPSHOTS_PATH#/snapshots/20210411212549sdBXyLxg#g" /mnt/fstab > "/mnt/snapshots/20210411212549sdBXyLxg/@/etc/fstab"
Finish editing snapshot by setting ro
flag:
# btrfs property set -ts "/mnt/snapshots/20210411212549sdBXyLxg/@" ro true