Xidorn的博客

给Ubuntu做个“恢复”系统

每个操作系统难免不时会有一些在系统内部无法解决的问题,无论是由于什么改动而无法启动系统了需要修复,还是想要对系统分区做一些调整。对于使用Linux桌面的,喜欢自己折腾电脑的人来说由其如此。这种时候就需要有一个独立于主要操作系统的操作系统来帮忙。

虽然有很多现成的方法,如双系统、LiveUSB,但它们都有自己的问题和局限性。在这个问题上我倒是很喜欢macOS的“恢复”系统这套方案——一个安装在同一块硬盘上但独立于主系统的只读系统,自带了一系列工具,可以在启动时切换过去。因而我想给我的Ubuntu桌面也做一套这样的“恢复”系统,并且更易于自定义以及更方便使用。

问题

对于使用Linux桌面作为主要系统的人来说,常见的“恢复”系统方案主要有两种:

但这些方案都有自己的问题。对于我来说,第一种方案的问题就是我并没有双系统。从很早开始我就是Linux桌面单系统的环境,不得不用Windows的时候就开虚拟机。我觉得这样的配置更简单方便,也避免了双系统下两个系统相互干扰潜在造成问题。

LiveUSB虽然也不错,但有几个主要的缺点:

我也尝试过使用Cubic创建自定义ISO再将其写入U盘作为“恢复”系统。这虽然部分解决了便捷性和兼容性,但如果想要调整什么东西都要走一遍它的流程再写入U盘也是十分麻烦。不过它创建工作区并且把文件系统留在工作区中的做法对我启发很大。

参考了许多资料以后,我决定自己从头构建一套基于Ubuntu的”恢复“系统给自己的电脑用。

目标和思路

我为这套“恢复”系统设想的目标包括:

基于这些需求和现有的工具,我的大体计划是:

工作区和脚本

首先创建了一个新的Btrfs子卷/recovery作为工作区:

sudo btrfs subvolume create /recovery

这个位置是随意选的,因为大多数操作都需要root权限,所以放在用户目录之外会比较省事。创建一个单独的子卷的好处是每次调整以后可以建立一个新的快照保存当前的状态,如果后续调整把它搞坏了,也可以随时通过快照找回之前好的版本,类似于有一个文件系统级的版本管理。

在这个工作区里,我放了三个文件夹:

基础文件系统

接下来需要初始化文件系统的内容。可以从官方镜像站下载Ubuntu Base的每日构建版,比如我想让我的“恢复”系统基于Ubuntu 24.04 LTS,我就在Ubuntu Base 24.04 (Noble Numbat) Daily Build下载对应构架的压缩包,如noble-base-amd64.tar.gz,作为我的基础文件系统。进入fs目录并将其内容解压进去:

cd /recovery/fs
sudo tar xvf path/to/noble-base-amd64.tar.gz

使用每日构建版的好处是里面的软件包是对应大版本里最新的,这样抓下来以后就不必再次升级这些软件包了。

chroot脚本

Ubuntu Base只包含了最基础的系统,显然我们需要安装更多的软件包,之后也需要做很多调整,为此我们需要一个脚本能“进入”这套“恢复”系统的文件系统,方便对它进行修改。这里我写了一个chroot脚本start-chroot.sh来做这件事:

#!/bin/sh

set -fv

# 确保是在root下运行的
if [ "$(id -u)" != "0" ]; then
    echo "Require root!"
    exit 1
fi

cd /recovery

# 使用主系统的DNS配置以保证网络可用
mv fs/etc/resolv.conf fs/etc/resolv.conf.1
cp /etc/resolv.conf fs/etc/resolv.conf

# 挂载必要的系统挂载点
mount --bind /dev fs/dev
mount none -t proc fs/proc
mount none -t sysfs fs/sys
mount none -t devpts fs/dev/pts

# 进入chroot
chroot fs

# 清理临时文件
rm -f fs/var/lib/dbus/machine-id
rm -f fs/root/.bash_history
rm -rf fs/tmp/*

# 卸载系统挂载点
umount fs/dev/pts
umount fs/dev
umount fs/proc
umount fs/sys

# 恢复DNS配置
mv fs/etc/resolv.conf.1 fs/etc/resolv.conf

有了这个脚本就可以随时进入“恢复”系统将使用的文件系统,对它的内容进行修改。

脚本里修改了resolv.conf。这个文件本身是一个由systemd管理的符号链接,我们并不想在“恢复”系统中改变这一结构。然而因为chroot进入这个文件系统时,这个系统中的systemd并没有运行,所以resolv.conf是空的,这会导致需要网络的程序因为没有DNS配置而无法正常工作,所以这里需要先暂时使用主系统的文件替换它。

构建脚本

接下去需要的另一个脚本来把将文件系统打包成只读的,并且构建需要的EFI执行文件来启动“恢复”系统。下面是我的build-image.sh脚本:

#!/bin/sh

set -efv

# 确保是在root下运行的
if [ "$(id -u)" != "0" ]; then
    echo "Require root!"
    exit 1
fi

cd /recovery

# 移除旧的镜像
if [ -L image ]; then
  old_image=$(readlink image)
  if [ -d "$old_image" ]; then
    rm -rf "$old_image"
  fi
  rm image
fi

# 创建临时目录以存放构建结果
tmp_dir=$(mktemp -d)
ln -s "$tmp_dir" image
mkdir image/casper

# 打包文件系统
mksquashfs fs image/casper/filesystem.squashfs -comp zstd

# 构建内核镜像
ukify build \
  --linux=fs/boot/vmlinuz \
  --initrd=fs/boot/initrd.img \
  --cmdline="boot=casper noprompt libata.allow_tpm=1" \
  --output=image/recovery.efi

这里将构建的结果放进一个临时文件夹,并将这个临时文件夹用符号链链接到image子文件夹下。这么做不是必须的。我选择这么做的原因是

镜像文件是一个已经压缩过的大型二进制块,让其被文件系统追踪并没有什么好处。况且通过这个脚本可以随时从fs快速构建新的镜像1,没必要保留旧的结果。

这里用ukify构建统一内核镜像,使用了几个简单的内核参数:

快照

前面提到将工作区放在一个独立的Btrfs子卷里可以方便创建快照。我使用了snapper来管理快照,为此首先要创建配置文件/etc/snapper/configs/recovery

SUBVOLUME="/recovery"
FSTYPE="btrfs"
QGROUP=""
NUMBER_CLEANUP="yes"
NUMBER_MIN_AGE="1800"
NUMBER_LIMIT="5"
NUMBER_LIMIT_IMPORTANT="1"

然后就可以创建一个起始快照

sudo snapper -c recovery create

未来可以用同样的命令创建更多快照。

配置系统

创建了工作区并放入对应的脚本之后,需要对“恢复”系统进行配置以让它变得真正可以使用。

这一节中的命令如果没有特殊说明,都是在由start-chroot.sh建立的chroot环境里执行的。

安装基本软件包

首先要安装基础系统

apt update
apt install linux-generic
apt install --no-install-recommends ubuntu-minimal
apt install casper discover laptop-detect os-prober
apt install ubuntu-desktop

Casper默认会在开机时做MD5校验,这对我们没有太大的用处,所以我们把它禁用掉:

systemctl disable casper-md5check.service

接着可以清理掉一些”恢复“系统不太可能需要的软件包:

apt autoremove --purge \
    snapd \
    rhythmbox \
    libreoffice-common \
    totem \
    gnome-calendar \
    gnome-clocks \
    gnome-characters \
    gnome-startup-applications \
    gnome-online-accounts \
    transmission-gtk \
    cloud-init \
    unattended-upgrades \
    firefox \
    thunderbird \
    ubuntu-docs \
    ubuntu-report

这里列出的是我删除的软件包,主要是一些我觉得在“恢复”系统里不太需要用到的东西,比如媒体播放、办公文档编辑、系统升级等。也许有更多包可以卸载而不影响作为“恢复”系统的功能,但我还没有细究。我把snap也卸载了,因为基于snap的软件中我唯一需要的是Firefox,但我更喜欢Mozilla官方源的版本。之后的小节会对此有更详细的叙述。

除此之外可以安装需要的工具,如编辑器、一些常用的命令行工具、维护用的工具等:

apt install vim-gtk3 ripgrep curl bash-completion
apt install gparted mtools dmraid \
    efibootmgr btrfs-progs \
    nvme-cli smartmontools \
    cryptsetup lvm2 \
    snapper-gui \
    systemd-ukify systemd-boot-efi

具体的工具当然就根据个人使用习惯和实际需要来调整了,比如如果没有软加密分区的话,或许就不需要cryptsetup,如果惯用Emacs的话或许就不需要VIM,诸如此类。

如果有其他APT源里没有的工具,也可以自行复制进fs里,比如我需要sedutil,就从它的发布页面里下载之后把二进制文件sedutil-cli复制到/usr/sbin里再加上执行权限。

安装Firefox

虽然在上面的步骤里删除了Firefox,但“恢复”系统里浏览器还是需要的。现在这个时代即使电脑不能用了,在手机上也可以查资料,但毕竟能直接在电脑上查还是更方便的。

这里我按照Mozilla官方的文档Install Firefox on Linux来安装:

# 安装签名密钥
install -d -m 0755 /etc/apt/keyrings
wget -q https://packages.mozilla.org/apt/repo-signing-key.gpg -O- | \
	tee /etc/apt/keyrings/packages.mozilla.org.asc > /dev/null
# 添加APT源
cat <<EOF | tee /etc/apt/sources.list.d/mozilla.sources
Types: deb
URIs: https://packages.mozilla.org/apt
Suites: mozilla
Components: main
Signed-By: /etc/apt/keyrings/packages.mozilla.org.asc
EOF
# 配置优先级
cat <<EOF | tee /etc/apt/preferences.d/mozilla
Package: *
Pin: origin packages.mozilla.org
Pin-Priority: 1000
EOF
# 安装软件包
apt update
apt install firefox-esr

因为“恢复”系统不常更新,所以我选择了支持周期更长更稳定的ESR版本,但其实这也不是什么重要的事情。

Firefox本身有很多对“恢复”系统不必要的功能,如自动升级检测、Firefox帐号、档案导入等等,对于Casper创建的新用户,它也会显示许多在“恢复”系统下没有实际意义的提示信息,我们可以使用Firefox的策略文件将它们全部禁掉。这里我在工作区里添加了files/firefox-policies.json

{
  "policies": {
    "DisableAppUpdate": true,
    "DisableFirefoxAccounts": true,
    "DisableFirefoxStudies": true,
    "DisablePocket": true,
    "DisableProfileImport": true,
    "DisableProfileRefresh": true,
    "DisableSystemAddonUpdate": true,
    "DontCheckDefaultBrowser": true,
    "DisplayBookmarksToolbar": "never",
    "NoDefaultBookmarks": true,
    "OfferToSaveLogins": false,
    "OverrideFirstRunPage": "",
    "UserMessaging": {
      "ExtensionRecommendations": false,
      "FeatureRecommendations": false,
      "SkipOnboarding": true,
      "MoreFromMozilla": false,
      "FirefoxLabs": false
    }
  }
}

具体选项可以参考策略文件的文档,有许多策略可以选择。

为了将这个策略文件应用于“恢复”系统中的Firefox,我们需要在chroot__之外__执行:

mkdir -p fs/etc/firefox/policies
ln files/firefox-policies.json \
	fs/etc/firefox/policies/policies.json

这让工作区里的files和文件系统里的对应文件链接在一起,既可以在外面直接修改,当需要从头重做“恢复”系统的时候,这些配置文件也可以直接重复使用。

配置WiFi

在“恢复”系统里可以使用网络是十分重要的,不仅可以查资料,需要的话还可以下载安装没放进去的工具。如果联网是通过WiFi,可能就需要把WiFi密码也配进“恢复”系统:

nmcli --offline connection add type wifi \               
  autoconnect yes \
  ssid "<SSID>" \
  wifi-sec.key-mgmt wpa-psk \
  wifi-sec.psk "<password>" \
  > /etc/NetworkManager/system-connections/wifi.nmconnection
chmod 0600 /etc/NetworkManager/system-connections/wifi.nmconnection

把这里的<SSID><password>替换成真正的WiFi配置即可,这样打开“恢复”系统就会自动连上指定的WiFi,不需要进行额外的操作。

配置显示器

有时候系统不能正确地检测显示器配置,特别是当显示器被旋转了,或者希望使用不同的缩放比例时。

这时可以将~/.config/monitors.xml复制到fs/root/monitors.xml里,然后创建一个Casper的initramfs脚本放在fs/usr/share/initramfs-tools/scripts/casper-bottom/99gnome_monitors

#!/bin/sh

PREREQ=""

prereqs()
{
    echo "$PREREQ"
}

case $1 in
# get pre-requisites
prereqs)
    prereqs
    exit 0
    ;;
esac

chroot /root install \
    -o $USERNAME -g $USERNAME \
    /root/monitors.xml \
    /home/$USERNAME/.config/monitors.xml

然后执行

chmod +x /usr/share/initramfs-tools/scripts/casper-bottom/99gnome_monitors
update-initramfs -u

这样这个显示器配置文件就会在启动的时候被自动安装到临时用户的.config里以确保显示配置是正确的。

覆盖GNOME桌面设定

Ubuntu的docker上默认显示的应用可能不是我们所需要的,但这也是可以修改的。

和之前Firefox的策略文件类似,首先创建一个配置文件files/gnome-settings.schema.override

[org.gnome.shell]
favorite-apps = [ 'org.gnome.Nautilus.desktop', 'firefox-esr.desktop', 'org.gnome.Terminal.desktop', 'gparted.desktop' ]

[org.gnome.shell:ubuntu]
favorite-apps = [ 'org.gnome.Nautilus.desktop', 'firefox-esr.desktop', 'org.gnome.Terminal.desktop', 'gparted.desktop' ]

然后在chroot__之外__执行:

ln files/gnome-settings.schema.override \
	fs/usr/share/glib-2.0/schemas/99_settings.gschema.override

以将这个配置文件链接进文件系统,最后在chroot里执行:

cd /usr/share/glib-2.0/schemas
rm gschemas.compiled
glib-compile-schemas .

以保证GNOME的数据库里的信息是正确的。

这里放置了文件浏览、浏览器、终端和GParted,但具体怎么排放同样可以根据个人需要来调整。至于为什么这里需要写两次,其中一次是:ubuntu的,我也不是非常确定。但这些数据是否正确可以通过这条命令来检查:

gsettings --schemadir . get org.gnome.shell favorite-apps

构建和部署

完成了系统的配置以后,可以使用之前提到的构建脚本来构建“恢复”系统:

sudo ./scripts/build-image.sh

等待打包完成以后,就可以在工作区的image文件夹里找到recovery.eficasper/filesystem.squashfs两个文件。接下来只要让EFI可以使用这个文件就可以了。

我自己是把它们直接复制到了EFI系统分区里2,但通常的建议似乎是这个分区只需要200MB这个级别,而基于Ubuntu的“恢复”系统很容易就有上GB的大小。如果EFI系统分区没有足够的空间,也可以考虑另外创建FAT32分区来存放。需要注意的是filesystem.squashfs要放在分区根目录的casper子目录下,但recovery.efi可以放在任意位置。

最后只要让EFI能够找到“恢复”系统即可,假设recovery.efi/dev/nvmeXnYpZ分区的根目录,可以执行:

sudo efibootmgr --create \
	--disk /dev/nvmeXnY --part Z \
	--label "Ubuntu Recovery" \
	--loader recovery.efi

完成后即可用efibootmgr查看这个“恢复”系统所在的启动编号,然后用

sudo efibootmgr --bootnext XXXX

来在下一次启动时进入“恢复”系统,此外有的BIOS也支持从界面里选择EFI里的启动项启动。

重启测试以后如果没有问题,就可以用snapper为工作区的当前状态创建一个新的快照保存当前的工作区了。

参考

脚注

  1. 在我自己的机器上,构建一次仅仅花费半分钟左右,这个时间换空间还是十分值得的。

  2. 我在上一次换硬盘的时候特地为这个分区预留了10GB。