disko 声明式磁盘分区:把分区表写成 Nix 表达式

分区、文件系统与挂载点通常只活在安装那一刻的几条命令里,换台机器就得凭记忆重来。disko 把 GPT 分区表、LUKS 加密、LVM、Btrfs 子卷与 ZFS 池写成一份 Nix 表达式,由它生成分区动作与 fileSystems 配置,安装、重建与虚拟机测试共用同一份定义。本文给出完整配置、执行模式、密钥注入与常见坑。

引言

装一台 NixOS 机器时,真正「不可复现」的部分往往不是 configuration.nix,而是装之前那十几条命令:sgdisk 划区、cryptsetup luksFormat、mkfs.btrfs 建子卷、mount 到正确位置。这些命令只存在于安装那一刻的终端历史里,半年后要重建同一台机器,只能靠记忆和截图。disko 把这一段变成声明式配置:磁盘上有什么分区、每个分区装什么文件系统、子卷如何划分、挂载到哪里,全部写成 Nix 表达式,由工具生成并执行。

本文给出一份可直接用的单盘方案(GPT + LUKS + Btrfs 子卷 + swapfile),讲清 disko 的执行模式与安全边界、它如何与 NixOS 的 fileSystems 衔接、密钥从哪来,以及在没有真机时怎样用虚拟机验证分区方案。

前置:NixOS 存储与文件系统 、NixOS 系统配置 。

目录

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 保持一致最不容易错
modedestroy,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 断言挂载选项即可。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nix」更多文章

  1. Nix 语言服务器与编辑器工具链:补全、格式化与静态检查
  2. Store 垃圾回收与存储优化:gc root、去重与瘦身
  3. 构建沙箱与可复现性:Nix 如何隔离构建过程