0.动机:当”可复现”遇到”不可维护”
一年前,我的系统环境经历了一次彻底的崩溃。听闻 NixOS 拥有”可复现系统”的特性,我便毅然入坑。起初,我把 Nix 仅仅当作 JSON 的替代品,结合 AI 快速生成了一套配置。虽然这套配置支撑我使用了一段很长的时间,但随着系统复杂度的增加,它的弊端逐渐显现。
我发现,尽管系统确实做到了一定程度的reproducible,但可维护性却随着时间的推移呈指数级下降。
痛点
1.配置强耦合(Coupling)
功能配置散落在 home-manager 和 nixos-configuration 各处,牵一发而动全身。
- 案例 A:文件管理器
如果我启用 Thunar,不仅要在 Home-Manager 中配置 programs.thunar.enable = true;,还需要在系统层面对服务进行配置:1 2 3 4
| programs.xfconf.enable = true; services.gvfs.enable = true; services.tumbler.enable = true;
|
这种割裂感导致维护极其困难。
- 案例 B:窗口管理器适配
如果启用某个需要 Hyprland 特殊适配的程序(如 windowrule 或 special workspace),我必须修改 Hyprland 的主配置。一旦我日后删除了这个程序,遗留在 Hyprland 配置中的代码就成了难以察觉的冗余垃圾。
3.如果需要加机器,这种强耦合的配置带来了极高的维护成本
2. 代码冗余与 API 变更
由于没有将通用模块(Modules)提取出来,不仅代码不够美观,更增加了维护成本。如果我在一台机器上升级了 nixpkgs 导致某个 API 变动,我不得不手动修改所有机器配置中用到该 API 的地方。
3. “不纯”的环境污染
作为一个强迫症患者,我无法忍受以下情况:
- 通过图形界面设置的改动没有被写入 Nix 配置。
- 软件在
~/.config 或其他地方随意生成状态文件。
- 之前的 Docker 配置也踩过类似的坑。
重构目标
针对以上痛点,我制定了新的架构目标:
- 解耦合:合理拆分模块,相关配置(OS 层 + HM 层)聚合在一起,通过统一参数开关。
- Option 模式:封装
options,实现参数化配置。
- Impermanence:利用临时根文件系统,保证每次重启环境如初。
- Disko:实现磁盘分区的声明式管理。
1. 架构设计
这是一个在在感冒+睡梦中设计出来的架构,诸多地方具有问题(比如homeConfig和osConfig写在一起)
1 2 3 4 5 6 7 8 9 10 11
| . ├── flake.nix ├── outputs/ ├── hosts/ ├── modules/ │ ├── options.nix │ └── profiles/ │ ├── neovim/ │ └── hyprland/ └── users/ └── seeker/
|
设计理念
- 模块化:通用配置全部进入
/modules。
- host差异化:
/hosts 仅存放机器特有的 osConfig(如 CPU 类型、显卡驱动、硬件特性),并通过 config 全局读取。
- 用户配置:用户特定的配置放在
/users,通用部分通过 sharedModules 对所有用户生效。
- 脚本打包:
拒绝 ~/scripts 这种硬编码路径。使用 pkgs.writeShellApplication 将脚本与其依赖打包,通过 ${pkg}/bin/cmd 引用绝对路径,环境零污染,脚本随配置回滚而回滚。
- 智能关联:利用
lib.mkDefault 实现自动关联(如开启 Hyprland 自动开启 Waybar),同时允许在 hosts/*.nix 中轻松覆写默认行为。
2. 核心特性:Impermanence (易失性)
参考文档:impermanence 官方文档
为了实现”每次开机都是新系统”,我们需要超越传统文件系统的能力。在 ZFS 和 Btrfs 之间,我选择了 Btrfs。
原理
Btrfs 是一个自带很多高级功能的现代文件系统,它打破了传统物理分区的僵硬限制,允许像创建文件夹一样创建”子卷(Subvolumes)”,这些子卷不仅共享同一块硬盘空间、动态伸缩,还能通过”写时复制(CoW)”技术瞬间创建快照或回滚状态——这正是实现”每次开机瞬间抹除根目录”而不影响其他数据的核心黑科技。
Btrfs 支持子卷 (Subvolumes) 和 写时复制 CoW。我们可以像创建文件夹一样创建子卷,并利用快照瞬间回滚。
我的实现逻辑是:不直接删除根目录,而是将其移动到备份区。 这比网吧的”还原卡”和高中一体机的”冰点还原”更高级,因为它保留了”后悔药”。
实现脚本
在 boot.initrd 阶段(挂载真正的根目录之前)执行回滚操作:
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 38 39 40 41 42 43 44 45 46 47
| config = mkIf cfg.enable { boot.initrd.systemd.enable = true; boot.initrd.supportedFilesystems = [ "btrfs" ]; boot.initrd.luks.devices."${cfg.luksName}".crypttabExtraOpts = [ "tpm2-device=auto" ];
boot.initrd.systemd.services.rollback = { description = "Rollback BTRFS root subvolume to a pristine state"; wantedBy = [ "initrd.target" ]; after = [ "systemd-cryptsetup@${cfg.luksName}.service" ]; before = [ "sysroot.mount" ]; unitConfig.DefaultDependencies = "no"; serviceConfig.Type = "oneshot"; script = '' mkdir -p /btrfs_tmp mount /dev/mapper/${cfg.luksName} /btrfs_tmp
# 1. 如果存在旧的 root,将其移动到 old_roots 并打上时间戳 if [[ -e /btrfs_tmp/root ]]; then mkdir -p /btrfs_tmp/old_roots timestamp=$(date --date="@$(stat -c %Y /btrfs_tmp/root)" "+%Y-%m-%-d_%H:%M:%S") mv /btrfs_tmp/root "/btrfs_tmp/old_roots/$timestamp" fi
# 2. 递归删除逻辑 delete_subvolume_recursively() { IFS=$'\n' for i in $(btrfs subvolume list -o "$1" | cut -f 9- -d ' '); do delete_subvolume_recursively "/btrfs_tmp/$i" done btrfs subvolume delete "$1" }
# 3. 清理超过保留天数的旧快照 for i in $(find /btrfs_tmp/old_roots/ -maxdepth 1 -mtime +${toString cfg.retentionDays}); do delete_subvolume_recursively "$i" done
# 4. 创建全新的空白 root btrfs subvolume create /btrfs_tmp/root umount /btrfs_tmp ''; }; };
|
所以,启动的大致流程是这样的
UEFI->systemd-boot(读取 /boot 分区,Linux Kernel和Initrd加载到内存)->内核启动->启动Initrd
Initrd中的systemd执行initrd.target->解密磁盘(等待/dev/mapper/crypted设备出现)->执行我写的boot.initrd.systemd.services.rollback脚本->挂载真正的根 (Sysroot Mount)->switch root->控制权交给systemd->系统启动->bind-mount
btrfs 常见操作
- 列出所有子卷
sudo btrfs subvolume list /
- 创建子卷
sudo btrfs subvolume create /path/to/subvolume
- 删除子卷
sudo btrfs subvolume delete /path/to/subvolume(当然也可以用rm -rf)
在我的脚本中,每次开机时(initrd阶段),把上一次关机前的整个根目录移动到了 old_roots里面,并不是直接删除(所以比中学一体机的”冰点还原”高级(bushi))
数据找回
由于旧系统只是被移动到了 old_roots,你可以随时找回丢失的文件:
1 2 3 4 5
| sudo mkdir -p /mnt/btrfs_root
sudo mount -o subvolid=5 /dev/mapper/crypted /mnt/btrfs_root ls /mnt/btrfs_root/old_roots/
|
如果在livecd里面挂载要先解密磁盘
1
| sudo cryptsetup open /dev/nvme0n1p2 crypted
|
遇到的坑
1. Mutable Users 与 Shadow 文件
由于根目录 / 每次重启都被重置,/etc/shadow(存储密码哈希的文件)也会丢失,导致开机后 Root 账户被锁:
Cannot open access to console, the root account is locked.
解决方案:
- 设置
users.mutableUsers = false;
- 使用
mkpasswd -m sha-512 生成哈希,并显式写入 hashedPassword。
2. Sops-nix 密钥路径
Sops 需要在非常早期的阶段解密,而此时 /home 可能还没挂载好(或 key 文件随回滚丢失)。
解决方案:将 Key 存放在持久化分区 /persist 中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| { config, pkgs, lib, ... }: { options.seeker.secrets = { ageKeyPath = lib.mkOption { type = lib.types.path; default = "/home/${config.users.users.seeker.home}/age/keys"; description = "Path to the age key file"; }; }; config.sops.age.keyFile = config.seeker.secrets.ageKeyPath; }
|
1 2 3 4 5 6 7 8 9 10
| { ... }: { config.seeker = { secrets = { ageKeyPath = "/persist/home/seeker/age/keys"; }; }; }
|
3. 声明式磁盘管理:Disko
Disko 让我告别了繁琐的 fdisk 和 mkfs。配置好后,分区操作只需一行命令。
配置示例 (Btrfs + LUKS)
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 38 39 40
| disko.devices.disk.main = { type = "disk"; device = cfg.device; content = { type = "gpt"; partitions = { ESP = { size = "512M"; type = "EF00"; content = { type = "filesystem"; format = "vfat"; mountpoint = "/boot"; }; }; luks = { size = "100%"; content = { type = "luks"; name = cfg.luksName; keyFile = "/tmp/secret.key"; content = { type = "btrfs"; extraArgs = [ "-f" ]; subvolumes = { "/root" = { mountpoint = "/"; mountOptions = [ "compress=zstd" "noatime" ]; }; "/nix" = { mountpoint = "/nix"; mountOptions = [ "compress=zstd" "noatime" ]; }; "${config.seeker.btrfs.impermanence.persistdir}" = { mountpoint = "${config.seeker.btrfs.impermanence.persistdir}"; mountOptions = [ "compress=zstd" "noatime" ]; }; "/swap" = { mountpoint = "/.swapvol"; swap.swapfile.size = "32G"; }; }; }; }; }; }; }; };
|
安装流程
1 2 3 4 5 6 7
| echo -n "你的强密码" > /tmp/secret.key
nix --experimental-features 'nix-command flakes' run github:nix-community/disko -- --mode disko --flake .
findmnt /mnt
nixos-install --flake .
|
4. 提升体验的小技巧 (Nix Tricks)
1. 递归导入 (Recursive Imports)
不想在每个文件夹里写只包含 imports 的 default.nix?使用 lib.filesystem.listFilesRecursive 自动扫描。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { lib, ... }: let allFiles = lib.filesystem.listFilesRecursive ./profiles; allProfiles = builtins.filter (file: let name = toString file; in (lib.hasSuffix ".nix" name) && !(lib.hasPrefix "_" (builtins.baseNameOf name)) && !(lib.hasInfix "/programs/home/" name) ) allFiles; in { imports = allProfiles; }
|
2. OS Config 依赖 Home Config
这是一个很酷的技巧:根据用户是否启用了某个软件,反向触发系统级的配置。
例如:如果用户在 home-manager 中启用了 zed 编辑器,系统层自动启用 gnome-keyring 以支持 Copilot 登录。
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
| { config, lib, ... }: let cfgPath = [ "seeker" "home" "zed" ]; hmUsers = lib.attrValues config.home-manager.users; anyUserEnabled = lib.any ( userConfig: lib.attrByPath (cfgPath ++ [ "enable" ]) false userConfig ) hmUsers; in { config = lib.mkMerge [ { home-manager.sharedModules = [{ options.seeker.home.zed.enable = lib.mkEnableOption "Enable zed-editor"; config = lib.mkIf config.seeker.home.zed.enable { }; }]; } (lib.mkIf anyUserEnabled { services.gnome.gnome-keyring.enable = true; security.pam.services.login.enableGnomeKeyring = true; }) ]; }
|
5. 开发体验优化
- Flake-parts: 将几千行的
flake.nix 拆解为优雅的模块,自动处理多架构(System)循环,消除样板代码。
- 代码格式化: 引入
pre-commit-hooks.nix,在 Git commit 时自动运行 nixfmt-rfc-style,确保代码风格统一。
未完待续 (Roadmap)
目前的架构虽然已经能够稳定运行,但仍有一些方向值得探索: