技术迁移指南:NixOS 从 Ext4 转向声明式 Btrfs 存储架构

5 阅读5分钟

技术迁移指南:NixOS 从 Ext4 转向声明式 Btrfs 存储架构

1. 概述 (Overview)

本指南旨在详细记录将 NixOS 宿主系统从 Ext4 文件系统物理迁移至 Btrfs 的标准化流程。在 NixOS 的声明式(Declarative)运维体系下,此次迁移并非传统的“重装”,而是通过 Disko 实现“存储基础设施即代码”(Storage Infrastructure as Code)的深度实践。

迁移核心指标 (Objectives)

  • 空间优化:启用 zstd 透明压缩,显著降低 /nix/store 的物理磁盘占用。
  • 架构解耦:利用 Btrfs Subvolumes 实现根目录 (/)、用户家目录 (/home) 与 Nix 存储库 (/nix) 的逻辑隔离。
  • 可靠性增强:为后续开启 Btrfs 原生快照及原子化回滚奠定底层基础。

2. 技术栈与环境 (Tech Stack)

  • 文件系统: Btrfs (Copy-on-Write)
  • 分区工具: Disko (Nix-community 提供的声明式分区方案)
  • 环境: VMware Workstation 虚拟机
  • 配置管理: Nix Flakes & Home Manager

3. 迁移演练:分阶段操作指令

3.1 预备阶段:配置持久化与备份

在抹除磁盘前,必须将现有的 Nix 配置文件(Source of Truth)移出目标环境。

# 打包当前配置目录
sudo tar -czvf ~/nixos-config-backup.tar.gz /etc/nixos/

# 备份路径建议:
# 1. 挂载宿主机共享文件夹:vmhgfs-fuse .host:/ /mnt/hgfs -o allow_other
# 2. 或推送到远程 Git 仓库。

3.2 阶段一:定义声明式分区方案 (Disko)

创建 disko-config.nix,定义 GPT 分区表及 Btrfs 子卷结构。

{
  disko.devices = {
    disk = {
      main = {
        device = "/dev/sda"; 
        type = "disk";
        content = {
          type = "gpt";
          partitions = {
            # 1. ESP 分区 (UEFI 引导必须)
            # 大小建议 1G 比较充容,文件系统必须是 vfat
            ESP = {
              size = "1G";
              type = "EF00";
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
                # 建议加上 umask 权限控制
                mountOptions = [ "umask=0077" ];
              };
            };
            # 2. Btrfs 主分区
            root = {
              size = "100%";
              content = {
                type = "btrfs";
                extraArgs = [ "-f" ]; 
                subvolumes = {
                  "@" = {
                    mountpoint = "/";
                    mountOptions = [ "compress=zstd" "noatime" ];
                  };
                  "@home" = {
                    mountpoint = "/home";
                    mountOptions = [ "compress=zstd" "noatime" ];
                  };
                  "@nix" = {
                    mountpoint = "/nix";
                    mountOptions = [ "compress=zstd" "noatime" ];
                  };
                };
              };
            };
          };
        };
      };
    };
  };
}

3.3 阶段二:磁盘重构与挂载

进入 NixOS Live ISO 环境,执行 Disko 脚本。由于 Live 环境默认不启用 Flakes,需手动注入实验性功能参数:

sudo nix --extra-experimental-features 'nix-command flakes' run github:nix-community/disko -- --mode disko ./disko-config.nix

执行完后应该会出现这样的内容:

[nixos@nixos:/mnt/etc/nixos]$ sudo nix --extra-experimental-features 'nix-command flakes' run github:nix-community/disko -- --mode disko ./disko-config.nix
disko version 1.13.0-dirty
evaluation warning: the diskoScript output is deprecated and will be removed, please open an issue if you're using it!
these 2 derivations will be built:
  /nix/store/i5wi83fnlb8qaifj1zp5j85a5lj58xbs-disko.drv
  /nix/store/m31apykrx6pbx022mj5vrsnks44q309x-disko.drv
building '/nix/store/i5wi83fnlb8qaifj1zp5j85a5lj58xbs-disko.drv'...
building '/nix/store/m31apykrx6pbx022mj5vrsnks44q309x-disko.drv'...
umount: /mnt/nix unmounted
umount: /mnt/home unmounted
umount: /mnt/boot unmounted
umount: /mnt: target is busy.
++ realpath /dev/sda
+ disk=/dev/sda
+ lsblk -a -f
NAME   FSTYPE   FSVER            LABEL                        UUID                                 FSAVAIL FSUSE% MOUNTPOINTS
loop0  squashfs 4.0                                                                                      0   100% /nix/.ro-store
loop1                                                                                                             
loop2                                                                                                             
loop3                                                                                                             
loop4                                                                                                             
loop5                                                                                                             
loop6                                                                                                             
loop7                                                                                                             
sda                                                                                                               
├─sda1 vfat     FAT32                                         ADF7-E047                                           
└─sda2 btrfs                                                  3e6d0243-9d58-4e68-b541-8322cbedae48     19G     0% /mnt
sr0    iso9660  Joliet Extension nixos-graphical-25.11-x86_64 1980-01-01-00-00-00-00                     0   100% /iso
+ bash -x
+ lsblk --output-all --json
++ dirname /nix/store/a3rwk6z88c1635ih1kw7nwsc9fkg68w3-disk-deactivate/disk-deactivate
+ jq -r -f /nix/store/a3rwk6z88c1635ih1kw7nwsc9fkg68w3-disk-deactivate/zfs-swap-deactivate.jq
+ lsblk --output-all --json
+ bash -x
++ dirname /nix/store/a3rwk6z88c1635ih1kw7nwsc9fkg68w3-disk-deactivate/disk-deactivate
+ jq -r --arg disk_to_clear /dev/sda -f /nix/store/a3rwk6z88c1635ih1kw7nwsc9fkg68w3-disk-deactivate/disk-deactivate.jq
+ set -fu
+ wipefs --all -f /dev/sda1
/dev/sda1: 8 bytes were erased at offset 0x00000052 (vfat): 46 41 54 33 32 20 20 20
/dev/sda1: 1 byte was erased at offset 0x00000000 (vfat): eb
/dev/sda1: 2 bytes were erased at offset 0x000001fe (vfat): 55 aa
+ umount -R /mnt
umount: /mnt: target is busy.
+ wipefs --all -f /dev/sda2
/dev/sda2: 8 bytes were erased at offset 0x00010040 (btrfs): 5f 42 48 52 66 53 5f 4d
++ type zdb
++ zdb -l /dev/sda
++ sed -nr 's/ +name: '\''(.*)'\''/\1/p'
+ zpool=
+ [[ -n '' ]]
+ unset zpool
++ lsblk /dev/sda -l -p -o type,name
++ awk 'match($1,"raid.*") {print $2}'
+ md_dev=
+ [[ -n '' ]]
+ wipefs --all -f /dev/sda
/dev/sda: 8 bytes were erased at offset 0x00000200 (gpt): 45 46 49 20 50 41 52 54
/dev/sda: 8 bytes were erased at offset 0x4fffffe00 (gpt): 45 46 49 20 50 41 52 54
/dev/sda: 2 bytes were erased at offset 0x000001fe (PMBR): 55 aa
+ dd if=/dev/zero of=/dev/sda bs=440 count=1
1+0 records in
1+0 records out
440 bytes copied, 1.5766e-05 s, 27.9 MB/s
+ lsblk -a -f
NAME   FSTYPE   FSVER            LABEL                        UUID                                 FSAVAIL FSUSE% MOUNTPOINTS
loop0  squashfs 4.0                                                                                      0   100% /nix/.ro-store
loop1                                                                                                             
loop2                                                                                                             
loop3                                                                                                             
loop4                                                                                                             
loop5                                                                                                             
loop6                                                                                                             
loop7                                                                                                             
sda                                                                                                               
├─sda1 vfat     FAT32                                         ADF7-E047                                           
└─sda2 btrfs                                                  3e6d0243-9d58-4e68-b541-8322cbedae48     19G     0% /mnt
sr0    iso9660  Joliet Extension nixos-graphical-25.11-x86_64 1980-01-01-00-00-00-00                     0   100% /iso
++ mktemp -d
+ disko_devices_dir=/tmp/tmp.yTPu2R5EHA
+ trap 'rm -rf "$disko_devices_dir"' EXIT
+ mkdir -p /tmp/tmp.yTPu2R5EHA
+ destroy=1
+ device=/dev/sda
+ imageName=main
+ imageSize=2G
+ name=main
+ type=disk
+ device=/dev/sda
+ efiGptPartitionFirst=1
+ type=gpt
+ blkid /dev/sda
+ sgdisk --clear /dev/sda
Creating new GPT entries in memory.
Warning: The kernel is still using the old partition table.
The new table will be used at the next reboot or after you
run partprobe(8) or kpartx(8)
The operation has completed successfully.
+ sgdisk --align-end --new=1:0:+1M --partition-guid=1:R --change-name=1:disk-main-biosboot --typecode=1:EF02 --attributes=1:=:0 /dev/sda
Warning: The kernel is still using the old partition table.
The new table will be used at the next reboot or after you
run partprobe(8) or kpartx(8)
The operation has completed successfully.
+ partprobe /dev/sda
Error: Partition(s) 2 on /dev/sda have been written, but we have been unable to inform the kernel of the change, probably because it/they are in use.  As a result, the old partition(s) will remain in use.  You should reboot now before making further changes.
+ :
+ udevadm trigger --subsystem-match=block
+ udevadm settle --timeout 120
+ sgdisk --align-end --new=2:0:-0 --partition-guid=2:R --change-name=2:disk-main-root --typecode=2:8300 --attributes=2:=:0 /dev/sda
Warning: The kernel is still using the old partition table.
The new table will be used at the next reboot or after you
run partprobe(8) or kpartx(8)
The operation has completed successfully.
+ partprobe /dev/sda
Error: Partition(s) 2 on /dev/sda have been written, but we have been unable to inform the kernel of the change, probably because it/they are in use.  As a result, the old partition(s) will remain in use.  You should reboot now before making further changes.
+ :
+ udevadm trigger --subsystem-match=block
+ udevadm settle --timeout 120
+ device=/dev/disk/by-partlabel/disk-main-root
+ extraArgs=('-f')
+ declare -a extraArgs
+ mountOptions=('defaults')
+ declare -a mountOptions
+ mountpoint=
+ type=btrfs
+ blkid /dev/disk/by-partlabel/disk-main-root -o export
+ grep -q '^TYPE='
+ mkfs.btrfs /dev/disk/by-partlabel/disk-main-root -f
btrfs-progs v6.19
See https://btrfs.readthedocs.io for more information.

ERROR: mount check: cannot open /dev/disk/by-partlabel/disk-main-root: No such file or directory
WARNING: forced overwrite but cannot check mount status of /dev/disk/by-partlabel/disk-main-root: No such file or directory
ERROR: zoned: unable to stat /dev/disk/by-partlabel/disk-main-root
ERROR: failed to check size for /dev/disk/by-partlabel/disk-main-root: No such file or directory
+ rm -rf /tmp/tmp.yTPu2R5EHA

[nixos@nixos:/mnt/etc/nixos]$ 

3.4 阶段三:生成硬件基础 (Provisioning)

此阶段涉及新旧配置的整合与硬件信息的重新识别。

  1. 重构硬件配置

    # 扫描由 Disko 创建并挂载至 /mnt 的新存储结构(使用此命令需手动删除与Disko重复定义的fileSystem配置)
    sudo nixos-generate-config --root /mnt
    
    sudo nixos-generate-config --no-filesystems --root /mnt
    

    (带上 --no-filesystems 参数,它就不会去生成那些和 Disko 冲突的挂载点信息了,非常干净!)

    执行完后会出现这样的内容:

    [nixos@nixos:~/Downloads]$ sudo nixos-generate-config --root /mnt
    writing /mnt/etc/nixos/hardware-configuration.nix...
    writing /mnt/etc/nixos/configuration.nix...
    For more hardware-specific settings, see https://github.com/NixOS/nixos-hardware.
    
    [nixos@nixos:~/Downloads]$ 
    
  2. 还原 Flake 配置: 将备份的 flake.nixconfiguration.nixhome.nixdisko-config.nix 拷贝至 /mnt/etc/nixos/,覆盖默认生成的模板。

  3. 执行系统安装

    sudo nixos-install --flake /mnt/etc/nixos/#<your-hostname>
    

    执行完后应该会返回这样的内容:

    [nixos@nixos:/mnt/etc/nixos]$ sudo nixos-install --flake /mnt/etc/nixos/#nixos
    warning: Git tree '/mnt/etc/nixos' is dirty
    copying channel...
    building the flake in git+file:///mnt/etc/nixos...
    warning: Git tree '/mnt/etc/nixos' is dirty
    evaluation warning: maorila profile: The option `programs.git.userEmail' defined in `/nix/store/9h5lj32j592zf3g5kfk8ganzl9dm39k1-source/flake.nix' has been renamed to `programs.git.settings.user.email'.
    evaluation warning: maorila profile: The option `programs.git.userName' defined in `/nix/store/9h5lj32j592zf3g5kfk8ganzl9dm39k1-source/flake.nix' has been renamed to `programs.git.settings.user.name'.
    installing the boot loader...
    setting up /etc...
    Running in a chroot, enabling --graceful.
    Created "/boot/EFI".
    Created "/boot/EFI/systemd".
    Created "/boot/EFI/BOOT".
    Created "/boot/loader".
    Created "/boot/loader/keys".
    Created "/boot/loader/entries".
    Created "/boot/EFI/Linux".
    Copied "/nix/store/y2rzx7s3kr3v95rsrl2141s8vaa4mkjf-systemd-258.5/lib/systemd/boot/efi/systemd-bootx64.efi" to "/boot/EFI/systemd/systemd-bootx64.efi".
    Copied "/nix/store/y2rzx7s3kr3v95rsrl2141s8vaa4mkjf-systemd-258.5/lib/systemd/boot/efi/systemd-bootx64.efi" to "/boot/EFI/BOOT/BOOTX64.EFI".
    Random seed file /boot/loader/random-seed successfully written (32 bytes).
    Successfully initialized system token in EFI variable with 32 bytes.
    Created EFI boot entry "Linux Boot Manager".
    setting up /etc...
    setting up /etc...
    setting root password...
    New password: 
    Retype new password: 
    passwd: password updated successfully
    installation finished!
    
    [nixos@nixos:/mnt/etc/nixos]$ 
    

4. 技术复盘:关键异常处理 (Post-mortem)

遇到的问题根本原因 (Root Cause)解决方案 (Solution)
GitHub API 403 报错无认证的 API 请求触发了 GitHub 的 Rate Limit。1. 优先使用 flake.lock 锁定版本。
2. 将 URL 改为 tarball+https:// 绕过 API 查询。
Attribute 'btrgs' missing声明式配置文件中存在语法拼写错误。修正为 type = "btrfs" 后重新评估。
Experimental features disabledLive ISO 默认未开启 nix-commandflakes 支持。运行命令时显式添加 --extra-experimental-features 标志。

5. 架构验证 (Validation)

系统重启后,执行以下指令确保 Btrfs 架构部署符合预期:

  • 分区验证lsblk -f (确认 FSTYPE 为 btrfs)
  • 子卷验证sudo btrfs subvolume list / (确认存在 @, @home, @nix)
  • 挂载属性验证mount | grep btrfs (确认 compress=zstd 处于 Active 状态)

6. 总结 (Conclusion)

本次迁移验证了 NixOS 配置的高度可移植性。通过 Disko,我们实现了从分区到挂载的全流程声明式管理。在未来的运维中,若需再次更换硬件或扩展存储,只需复用该 disko-config.nix 即可在数分钟内重建整套存储环境。