引言
装一台 NixOS 机器时,真正「不可复现」的部分往往不是 configuration.nix,而是装之前那十几条命令:sgdisk 划区、cryptsetup luksFormat、mkfs.btrfs 建子卷、mount 到正确位置。这些命令只存在于安装那一刻的终端历史里,半年后要重建同一台机器,只能靠记忆和截图。disko 把这一段变成声明式配置:磁盘上有什么分区、每个分区装什么文件系统、子卷如何划分、挂载到哪里,全部写成 Nix 表达式,由工具生成并执行。
本文给出一份可直接用的单盘方案(GPT + LUKS + Btrfs 子卷 + swapfile),讲清 disko 的执行模式与安全边界、它如何与 NixOS 的 fileSystems 衔接、密钥从哪来,以及在没有真机时怎样用虚拟机验证分区方案。
前置:NixOS 存储与文件系统 、NixOS 系统配置 。
目录
- 1. 手工分区为什么不可复现
- 2. disko 的数据模型
- 3. 一个完整的单盘方案
- 4. 执行模式与安全边界
- 5. 与 NixOS 配置的衔接
- 6. LUKS 加密与密钥注入
- 7. ZFS 与 swap 的处理
- 8. 在虚拟机里验证分区方案
- 9. 常见坑与排错
- 10. 速查表与一句话记忆
1. 手工分区为什么不可复现
1.1 安装脚本的三个失效点
① 设备名漂移:/dev/sda 与 /dev/nvme0n1 在不同机型上指的不是同一块盘
② 顺序耦合:必须严格按"分区→加密→建文件系统→建子卷→挂载"执行
③ 无幂等:重复执行 mkfs 会清空数据,无法"再跑一次看看"
1.2 与 NixOS 的断裂
configuration.nix 里写的是 fileSystems."/home".device = "/dev/mapper/cryptroot"——这是结果,不是过程。NixOS 关心「挂载点指向哪个设备」,不关心「这个设备是怎么来的」。于是安装过程与系统配置之间出现了一条只能靠文档传递的缝隙。disko 要补的就是这条缝隙:让分区方案成为可 review、可 diff、可进 git 的表达式,并且危险操作(清盘)必须显式声明。
记忆:手工分区的三个失效点——设备名漂移、顺序耦合、无幂等;NixOS 的 fileSystems 只描述结果不描述过程,disko 补的就是这段「过程即代码」。
2. disko 的数据模型
2.1 三类节点
disko 的配置全部挂在 disko.devices 下,核心是三种节点:
| 节点类型 | 作用 | 典型取值 |
|---|---|---|
disk | 物理块设备 | /dev/disk/by-id/... |
nodev | 不绑定设备的虚拟层 | swapfile、tmpfs、ZFS dataset |
lvm_vg / mdraid | 逻辑卷组与软 RAID | 需要多盘时使用 |
2.2 content 是递归结构
每个 disk 有一个 content,content 里可以再嵌 content——这就是「分区 → 加密 → 文件系统 → 子卷」的表达方式:
disk
└── content: gpt
├── partitions.ESP → content: filesystem(vfat) → mountpoint /boot
└── partitions.root → content: luks
└── content: btrfs
├── subvolumes./root → mountpoint /
└── subvolumes./nix → mountpoint /nix
支持的 content 类型包括 gpt、filesystem(ext4/vfat/btrfs/xfs)、luks、btrfs(含 subvolumes)、lvm_pv/lvm_vg/lvm_lv、zfs、mdraid、swap。每个 content 的 type 决定动作,mountpoint 决定挂载,mountOptions 直接透传给 mount 并写进生成的 fileSystems。
记忆:disko 模型 =
disko.devices下的 disk/nodev 节点 + 递归的 content 链(gpt → luks → btrfs → subvolumes);type 决定动作,mountpoint 决定挂载。
3. 一个完整的单盘方案
3.1 配置全文
# disko-config.nix —— 单盘 GPT + LUKS + Btrfs 子卷
{
disko.devices.disk.main = {
type = "disk";
# 关键:用 by-id 而不是 /dev/sda,避免设备名漂移
device = "/dev/disk/by-id/nvme-Samsung_SSD_990_PRO_0000";
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00"; # EFI System Partition
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "luks";
name = "cryptroot";
settings.allowDiscards = true; # SSD TRIM 穿透
passwordFile = "/tmp/luks.key"; # 安装期临时密钥
content = {
type = "btrfs";
extraArgs = [ "-f" ];
subvolumes = {
"/root" = { mountpoint = "/"; mountOptions = [ "compress=zstd:3" "noatime" ]; };
"/nix" = { mountpoint = "/nix"; mountOptions = [ "compress=zstd:3" "noatime" ]; };
"/home" = { mountpoint = "/home"; mountOptions = [ "compress=zstd:3" "noatime" ]; };
"/swap" = { mountpoint = "/.swap"; swap.swapfile.size = "16G"; };
};
};
};
};
};
};
};
}
3.2 设计要点
- ESP 单独 1G:足够容纳多个内核与引导项,避免 /boot 满导致升级失败
- 根分区 100%:单盘场景下不留空洞,扩容留给 LVM 或多盘
- 子卷分离 /nix 与 /home:/nix 可单独快照与回滚,不影响用户数据
- swapfile 放独立子卷:便于单独设置 nodatacow,也避免快照把 swap 内容带进去
- allowDiscards:SSD 上必须开,否则长期使用后写入放大明显
- 子卷的键与 mountpoint 写成同一个字符串,最不容易写反
3.3 多盘与 LVM
# 两块盘做 PV,卷组上切 root 与 home
disko.devices = {
disk.a = { type = "disk"; device = "/dev/disk/by-id/disk-a"; content = { type = "gpt";
partitions.data = { size = "100%"; content = { type = "lvm_pv"; vg = "pool"; }; }; }; };
disk.b = { type = "disk"; device = "/dev/disk/by-id/disk-b"; content = { type = "gpt";
partitions.data = { size = "100%"; content = { type = "lvm_pv"; vg = "pool"; }; }; }; };
lvm_vg.pool = { type = "lvm_vg";
lvs.root = { size = "100G"; content = { type = "filesystem"; format = "ext4"; mountpoint = "/"; }; };
lvs.home = { size = "100%FREE"; content = { type = "filesystem"; format = "ext4"; mountpoint = "/home"; }; };
};
};
记忆:单盘方案五要素——by-id 设备路径、ESP 1G 且 type=EF00、LUKS 包 Btrfs、子卷把 /nix 与 /home 分离并给 swapfile 独立子卷、子卷键与挂载点写成同一字符串。
4. 执行模式与安全边界
4.1 模式是动作列表
disko 的 --mode 接受逗号分隔的动作列表,按顺序执行:destroy(关闭已有 LUKS/LVM/RAID、擦除分区表)、format(建分区表、建加密容器、建文件系统与子卷)、mount(按 mountpoint 挂载,自动创建挂载点目录)。
# 全新安装:销毁 + 格式化 + 挂载(最常用,也最危险)
sudo disko --mode destroy,format,mount ./disko-config.nix
# 只挂载已有磁盘:不碰数据,用于 chroot 修复
sudo disko --mode mount ./disko-config.nix
# 只看会做什么,不真做:任何模式都应先跑一次
sudo disko --mode destroy,format,mount --dry-run ./disko-config.nix
4.2 dry-run 输出怎么读
--dry-run 会打印将要执行的命令序列:
sgdisk --zap-all /dev/nvme0n1
sgdisk -n 1:... -t 1:EF00 ...
cryptsetup luksFormat --type luks2 ...
mkfs.btrfs -f /dev/mapper/cryptroot
btrfs subvolume create ...
逐条核对设备路径与分区类型码,这一步能挡掉绝大多数误操作。
4.3 安全边界
- destroy 不可逆:确认 device 指向 by-id 而不是可能变化的符号链接
- 生产机上永远不要跑 destroy,除非确定这块盘可以被清空
- 有数据的盘在 format/mount 前,先 lsblk -f 看清现状
- 把 disko 配置与 NixOS 配置放在同一个 flake 里,评审时一起看
记忆:mode 是动作列表(destroy/format/mount),安装用
destroy,format,mount、修复用mount;任何危险模式先--dry-run逐条核对,设备路径必须 by-id。
5. 与 NixOS 配置的衔接
5.1 引入模块即可自动生成 fileSystems
# flake.nix
{
inputs.disko.url = "github:nix-community/disko";
outputs = { self, nixpkgs, disko, ... }: {
nixosConfigurations.myserver = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
disko.nixosModules.disko # ← 关键:提供 disko 选项并派生 fileSystems
./disko-config.nix # 上面那份配置
./configuration.nix
];
};
};
}
引入 disko.nixosModules.disko 后不需要再手写 fileSystems:模块会从 disko.devices 中所有带 mountpoint 的节点派生出 fileSystems 与 swapDevices 条目,例如 fileSystems."/nix" 会带上 subvol=/nix 与配置里声明的 compress=zstd:3。因此 configuration.nix 里不应再出现同名挂载点——两边都写会在模块合并时产生覆盖或重复挂载点,报错难以定位。
5.2 与 nixos-anywhere 的联动
远程安装一台机器时,disko 配置随 flake 一起送到目标机,由 nixos-anywhere 在 kexec 环境中执行:
nixos-anywhere --flake .#myserver --target-host root@203.0.113.10
这也是「同一份分区方案同时服务本地安装、远程安装与虚拟机测试」的落点。
记忆:引入
disko.nixosModules.disko后 fileSystems 由 disko 自动派生,configuration.nix 里不要再手写;同一份 disko 配置同时供本地安装、nixos-anywhere 远程安装与虚拟机测试使用。
6. LUKS 加密与密钥注入
6.1 密钥不能进 store
# 反例:密钥文件被写进 store,任何能读 /nix/store 的人都能拿到
passwordFile = ./luks.key;
# 正解一:安装期用临时路径,装完立即删除
passwordFile = "/tmp/luks.key";
# 正解二:从远程安装端推送密钥(nixos-anywhere 的 --disk-encryption-keys)
6.2 多密钥槽
# 安装期先用临时密钥,装好后追加一个正式密钥槽,再移除临时槽
sudo cryptsetup luksAddKey /dev/disk/by-id/nvme-... /root/luks.key
sudo cryptsetup luksRemoveKey /dev/disk/by-id/nvme-... /tmp/luks.key
sudo cryptsetup luksDump /dev/disk/by-id/nvme-... | grep -A2 Keyslots
6.3 initrd 侧要重复声明
boot.initrd.luks.devices.cryptroot = {
device = "/dev/disk/by-id/nvme-Samsung_SSD_990_PRO_0000-part2";
allowDiscards = true; # 与 disko 中的 settings.allowDiscards 保持一致
preLVM = true;
};
6.4 无人值守重启
若机器需要自动重启(无人在场输入口令):
方案一:密钥文件放在 initrd 可读的独立未加密分区(安全性下降,但可用)
方案二:用 TPM2 绑定(systemd-cryptenroll --tpm2-device=auto)自动解锁
方案三:网络解锁(clevis / initrd 中的 dropbear)
disko 负责"怎么建",解锁策略属于 initrd 配置,两者必须保持设备路径一致。
记忆:LUKS 密钥绝不写进 store——安装期用 /tmp 临时密钥或由 nixos-anywhere 推送,装完追加正式密钥槽再移除临时槽;initrd 侧要重复声明设备路径与 allowDiscards。
7. ZFS 与 swap 的处理
7.1 ZFS 池的写法
disko.devices = {
disk.a = { type = "disk"; device = "/dev/disk/by-id/disk-a"; content = { type = "gpt";
partitions.zfs = { size = "100%"; content = { type = "zfs"; pool = "tank"; }; }; }; };
zfs.tank = {
type = "zfs";
pool = "tank";
root = { type = "zfs_fs"; options.mountpoint = "none"; };
datasets = {
"tank/root" = { type = "zfs_fs"; options.mountpoint = "/"; };
"tank/nix" = { type = "zfs_fs"; options.mountpoint = "/nix"; };
"tank/home" = { type = "zfs_fs"; options.mountpoint = "/home"; };
};
};
};
7.2 ZFS 需要额外配置
boot.supportedFilesystems = [ "zfs" ];
boot.zfs.forceImportRoot = false; # 用 hostId 而不是强制导入
networking.hostId = "0a1b2c3d"; # ZFS 要求稳定的 hostId,务必显式设置
hostId 若随机器随机生成,池在另一台机器上导入时会警告甚至拒绝,因此必须写进配置。
7.3 swap 的三种选择
| 方案 | 优点 | 注意 |
|---|---|---|
| swapfile(Btrfs/ZFS 子卷) | 灵活、可调整大小 | Btrfs 上需 nodatacow;ZFS 上需 logbias=throughput 并关压缩 |
| 独立 swap 分区 | 支持休眠(hibernation) | 大小需 ≥ 内存,且要配 boot.resumeDevice |
| zram | 无磁盘 IO、速度快 | 不能休眠;压缩比取决于数据类型 |
放在 Btrfs 子卷里的 swapfile 做休眠会额外复杂(需要 resume_offset 内核参数),若明确要休眠,优先给一块独立 swap 分区。
记忆:ZFS 必须显式设
networking.hostId,否则换机导入池会告警;swap 三选一——swapfile 灵活但 Btrfs/ZFS 有特殊选项、独立分区才能休眠、zram 最快但不能休眠。
8. 在虚拟机里验证分区方案
8.1 用 emptyDiskImages 造一块盘
# checks 里验证 disko 配置是否可用
{ pkgs, inputs, ... }:
pkgs.testers.runNixOSTest {
name = "disko-single-disk";
nodes.machine = { lib, ... }: {
virtualisation.emptyDiskImages = [ 4096 ]; # 4G 空白盘
imports = [ inputs.disko.nixosModules.disko ./disko-config.nix ];
disko.devices.disk.main.device = lib.mkForce "/dev/vdb"; # 虚拟机无 by-id
boot.loader.grub.enable = false;
};
testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")
machine.succeed("findmnt -n -o FSTYPE / | grep -q btrfs")
machine.succeed("findmnt -n -o OPTIONS /nix | grep -q 'subvol=/nix'")
machine.succeed("swapon --show | grep -q swapfile")
'';
}
8.2 为什么值得这么做
价值:
- 分区方案改错(子卷名拼错、挂载点冲突)在 CI 就能暴露,不必等真机装完
- 加密容器能在虚拟机里真实创建与打开,验证密钥路径是否可用
- 与 nixosTest 生态一致,可纳入 flake 的 checks
取舍:
- 虚拟盘容量给足(≥4G),否则 Btrfs 元数据与 swapfile 会失败
- LUKS 在测试中用固定口令,避免依赖交互
- 断言落在"文件系统类型/挂载选项/swap 是否生效"这类可观测事实上
emptyDiskImages 提供的是裸块设备,disko 会在其上真实执行 sgdisk/mkfs,因此这类测试验证的是真实分区动作,而不是纯配置求值。
记忆:用
virtualisation.emptyDiskImages造裸盘 +runNixOSTest跑真实分区动作,断言 findmnt 的文件系统类型、挂载选项与 swapon 结果;分区方案改错在 CI 就能暴露。
9. 常见坑与排错
9.1 设备名漂移
# 错误:不同机器上 /dev/sda 可能是系统盘也可能是数据盘
device = "/dev/sda";
# 正确:用 by-id(或 by-path)
ls -l /dev/disk/by-id/ | grep nvme
9.2 子卷路径与挂载点混淆
症状:装完后 /nix 是空的,nix 命令找不到 store
原因:subvolumes 的键是"子卷名"、mountpoint 是"挂载点",两者写反
处理:把键与 mountpoint 写成同一个字符串,从根上避免
9.3 ESP 太小
症状:nixos-rebuild 时报 No space left on device(/boot)
原因:ESP 只给了 100M,装不下多个内核 + initrd
处理:给到 512M~1G;已装机器可清理旧内核或改用 systemd-boot
9.4 忘记引入 disko 模块
症状:error: The option 'fileSystems."/"' is used but not defined
原因:disko.nixosModules.disko 未引入,派生逻辑没生效
处理:加进 modules 列表,同时删掉手写的 fileSystems
9.5 LUKS 与 initrd 路径不一致
症状:重启后卡在 Waiting for device /dev/disk/by-id/...-part2
原因:disko 配置里的分区顺序变了(如 ESP 与 root 调换),part2 不再指向加密分区
处理:先 blkid 确认真实分区号,再同步修改 boot.initrd.luks.devices
9.6 ZFS 导入失败
症状:启动时报 pool tank cannot be imported: hostid mismatch
处理:显式设置 networking.hostId;首次导入可临时用 forceImportRoot = true 引导一次
记忆:六类坑——设备名漂移、子卷名与挂载点写反、ESP 过小、忘引 disko 模块、LUKS 分区号与 initrd 不同步、ZFS hostId 不匹配。
10. 速查表与一句话记忆
| 项目 | 一句话 |
|---|---|
| 设备路径 | 一律用 /dev/disk/by-id/ |
| ESP | ≥512M,type 必须是 EF00 |
| 子卷 | 键与 mountpoint 保持一致最不容易错 |
| mode | destroy,format,mount 安装、mount 修复 |
| 密钥 | 安装期 /tmp,装完追加正式槽 |
| ZFS | 必须显式 networking.hostId |
| 验证 | emptyDiskImages + runNixOSTest |
一句话记忆:disko 把「分区 → 加密 → 文件系统 → 子卷 → 挂载」这条过程链写成 disko.devices 下的递归 content 结构,由 disko.nixosModules.disko 派生出 fileSystems,于是安装、远程部署与虚拟机测试共用同一份定义;安全上守住三条——设备路径一律 by-id、任何 mode 先 --dry-run、LUKS 密钥绝不进 store;验证上给虚拟机一块 emptyDiskImages 裸盘跑真实 sgdisk/mkfs,用 findmnt 断言挂载选项即可。
延伸阅读
- NixOS 存储与文件系统
- NixOS 系统配置管理
- NixOS 虚拟机与集成测试
- NixOS 代际管理与回滚
- Linux 高级文件系统 — Btrfs 子卷与快照的底层机制
- Linux 磁盘加密 LUKS — 密钥槽与 initrd 解锁
- disko 项目仓库
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。