NixOS Config 重构之路:从混乱到优雅的声明式管理

0.动机:当”可复现”遇到”不可维护”

一年前,我的系统环境经历了一次彻底的崩溃。听闻 NixOS 拥有”可复现系统”的特性,我便毅然入坑。起初,我把 Nix 仅仅当作 JSON 的替代品,结合 AI 快速生成了一套配置。虽然这套配置支撑我使用了一段很长的时间,但随着系统复杂度的增加,它的弊端逐渐显现。

我发现,尽管系统确实做到了一定程度的reproducible,但可维护性却随着时间的推移呈指数级下降。

痛点

1.配置强耦合(Coupling)

功能配置散落在 home-managernixos-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 配置也踩过类似的坑。

重构目标

针对以上痛点,我制定了新的架构目标:

  1. 解耦合:合理拆分模块,相关配置(OS 层 + HM 层)聚合在一起,通过统一参数开关。
  2. Option 模式:封装 options,实现参数化配置。
  3. Impermanence:利用临时根文件系统,保证每次重启环境如初。
  4. Disko:实现磁盘分区的声明式管理。

1. 架构设计

这是一个在在感冒+睡梦中设计出来的架构,诸多地方具有问题(比如homeConfig和osConfig写在一起)

1
2
3
4
5
6
7
8
9
10
11
.
├── flake.nix
├── outputs/ # flake-parts 拆分文件(nixosConfigurations 等)
├── hosts/ # 具体的机器定义(miLaptop, LTG 等)
├── modules/
│ ├── options.nix # 协议层:定义功能开关选项
│ └── profiles/ # 特性层:具体的软件逻辑(系统 + HM)
│ ├── neovim/
│ └── hyprland/ # 包含脚本和配置的自包含文件夹
└── users/
└── seeker/ # 用户层:账号定义与私有 HM 配置

设计理念

  1. 模块化:通用配置全部进入 /modules
  2. host差异化/hosts 仅存放机器特有的 osConfig(如 CPU 类型、显卡驱动、硬件特性),并通过 config 全局读取。
  3. 用户配置:用户特定的配置放在 /users,通用部分通过 sharedModules 对所有用户生效。
  4. 脚本打包
    拒绝 ~/scripts 这种硬编码路径。使用 pkgs.writeShellApplication 将脚本与其依赖打包,通过 ${pkg}/bin/cmd 引用绝对路径,环境零污染,脚本随配置回滚而回滚。
  5. 智能关联:利用 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" ];
# 必须确保 TPM/加密设备已就绪
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 KernelInitrd加载到内存)->内核启动->启动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
# subvolid=5 代表 Btrfs 的物理根,能看到所有子卷
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.

解决方案

  1. 设置 users.mutableUsers = false;
  2. 使用 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
# Sops.nix
{
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.nix
{ ... }:
{
config.seeker = {
#...
secrets = {
ageKeyPath = "/persist/home/seeker/age/keys";
};
};
}

3. 声明式磁盘管理:Disko

Disko 让我告别了繁琐的 fdiskmkfs。配置好后,分区操作只需一行命令。

配置示例 (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 .#miLaptop
# 验证挂载
findmnt /mnt
# 安装系统
nixos-install --flake .#miLaptop

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;

# 过滤出 .nix 文件,并排除 _ 开头的文件和特定目录
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;

# 检查是否有任何用户启用了 zed
anyUserEnabled = lib.any (
userConfig: lib.attrByPath (cfgPath ++ [ "enable" ]) false userConfig
) hmUsers;
in
{
config = lib.mkMerge [
# 定义 Home Manager 侧的 Option
{
home-manager.sharedModules = [{
options.seeker.home.zed.enable = lib.mkEnableOption "Enable zed-editor";
config = lib.mkIf config.seeker.home.zed.enable { /* zed config */ };
}];
}

# 如果任意用户开启了 zed,系统层自动注入配置
(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)

目前的架构虽然已经能够稳定运行,但仍有一些方向值得探索:

  • Walletd 自动解锁: 优化加密钱包的解锁体验。
  • 休眠 (Hibernate) 支持: 目前Hibrnate还有Bug,怀疑是service 启动顺序的问题。
  • 更多模块解耦: 继续拆分尚未完全独立的配置。
  • 添加更多机器: 把其他linux机器也换上nixos。

NixOS Config 重构之路:从混乱到优雅的声明式管理
https://20040702.xyz/2026/01/04/nixos/
作者
Seeker
发布于
2026年1月4日
许可协议