Migrating my NAS from CoreOS/Flatcar Linux to NixOS

Michael Stapelberg

将我的 NAS 从 CoreOS/Flatcar Linux 迁移至 NixOS

原文由 Michael Stapelberg 发布,订阅该博客

在本文中,我想展示如何将一台现有的 Linux 服务器迁移到 NixOS——在我的例子中,就是安装在网络附加存储(NAS)主机上的 CoreOS/Flatcar Linux。

我会详细介绍之前的 CoreOS 配置是什么样子(大量通过 systemd 单元启动 Docker 容器)、如何先将其迁移到一个中间状态(在 NixOS 上使用 Docker)以便让系统先跑起来,以及最后如何一步步将所有单元从 Docker 迁移到原生的 NixOS 模块。

如果你还没听说过 NixOS,建议先阅读NixOS 官网首页,了解一下 NixOS 是什么以及它能实现哪些功能。

这篇博文的目标读者是对在 NAS 场景下尝试 NixOS 感兴趣、喜欢通过实例来学习如何配置系统的人。

你可以先按照我的博文《我喜欢的 NixOS(声明式)安装方式》操作,然后再阅读你感兴趣的章节。如果你想直接查看完整配置,可以跳至结论部分

2023 年组装的 PC NAS 主机

背景与历史

过去十年里,我为 NAS 需求使用过多种不同的操作系统。以下是两台 NAS 主机 storage2 和 storage3 的概况:

年份storage2storage3详情(博文)
2013Debian on qnapDebian on qnap在 qnap TS-119P2+ 上通过 Debian 实现网络唤醒
2016CoreOS on PCCoreOS on PC千兆 NAS(运行 CoreOS)
2023CoreOS on PCUbuntu+ZFS on PC我的全闪存 ZFS NAS 组装
2025NixOS on PCUbuntu+ZFS on PC→ 你在这里 ←
?NixOS on PCNixOS+ZFS on PC把更多机器换成 NixOS 似乎势在必行 ;)

我对 NAS 的软件需求

  • (本文只讨论软件!关于硬件选型的使用模式和需求,请参阅我 2023 年文章《我的全闪存 ZFS NAS 组装》中的“设计目标”一节。)
  • 远程管理:我非常喜欢将网络存储的配置纳入版本控制、并在主力电脑上统一管理的方式。这样一来,即使需要重装,我也能在几分钟内从主力电脑上恢复对备份系统的访问。
  • 自动更新,且易于回滚:手动更新所有安装并不是我想要的生活方式。因此,自动更新是必需的——但更新失败时,能够快速、轻松地恢复也同样必需。
    • CoreOS/Flatcar 通过 A/B 分区更新方案实现了这一点(更新失败?启动旧分区),而 NixOS 则通过“代(generation)”的概念来实现(更新失败?选择旧的代),粒度更细。

为什么要从 CoreOS/Flatcar 迁移到 NixOS?

我刚开始使用 CoreOS 时,Docker 还是相当新的技术。我喜欢使用 Docker 容器能以统一的方式对待各种服务——归根结底,它们都只是暴露某个端口(提供 HTTP、Postgres 或其他协议),因此你可以在稳定的操作系统上灵活地运行更新版本的软件,或者在更新出问题时回退到旧版本。

十多年后的今天,Docker 已是成熟的技术。人们早已习以为常地享受着容器化带来的各种好处。

那么,以下就是我对 Flatcar Linux 不再满意的原因。

R1. cloud-init 已被弃用

CoreOS cloud-init 项目在某个时间点被弃用,转而推荐 Ignition,后者显然更强大,但作为业余爱好者,上手也更麻烦。据我所知,我必须将配置托管在某个 URL 上,然后通过内核参数来提供它。像以前那样直接复制一个文件的做法似乎已不再受支持。

Ignition 在其他方面似乎也不那么方便:不再支持 YAML,只支持 JSON,而我并不喜欢手写 JSON。而且,格式似乎变化很大

结果,我一直没有从 cloud-init 迁移到 Ignition,而依赖一个早已被弃用的系统使用方式,显然不是好事。

R2. 容器腐化

有一次,我审计了自己在 Docker Hub 上的所有容器,发现大多数都已相当陈旧。Docker Hub 曾提供基于 GitHub 上 Dockerfile 的自动构建。然而,现在自动构建需要订阅,而我不会为了使用自己的电脑就去付费订阅。

R3. 对中心化服务的依赖

如果 Docker 某天停止运营 Docker Hub,我就无法再向 NAS 部署软件。这并非杞人忧天:2023 年,Docker Hub 曾宣布取消免费套餐的组织功能,随后在社区强烈反对后才收回成命。

谁知道他们还能为像我这样的业余爱好者免费提供多久服务。

R4. 在 Flatcar 上无法尝试 Immich

压垮骆驼的最后一根稻草,是我发现自己无法在 NAS 系统上尝试 Immich!像 Immich 这样的现代 Web 应用需要多个 Docker 容器(用于 Postgres、Redis 等),因此只提供 Docker Compose 作为官方支持的安装方式。

遗憾的是,Flatcar 并未包含 Docker Compose

我没心情为了在非 Docker Compose 系统上持续重新打包 Immich 而费心,所以我认定,一个既不能直接运行 Immich 这类软件、甚至连 Docker Compose 都无法运行的系统,已经无法满足我的需求了。

原因小结

考虑到以上种种原因,我本需要自行搭建自动化的容器构建、运行自己的中心化镜像仓库,却依然无法运行 Immich 这样知名的开源软件。

因此,我决定时隔十年再次尝试 NixOS,因为它如今似乎是最流行的声明式解决方案,拥有庞大的社区和丰富的软件包。

那么 NixOS 在我的场景下表现如何呢?

  • 相同点:我同样需要设置一个自动任务来更新 NixOS 系统。
    • 我已经为我的 gokrazy 设备设置了类似的任务。
    • Docker 的推送是异步的:推送成功后,我还需要额外的自动化来在目标主机上拉取更新后的容器并重启相关服务,而 NixOS 则把这些都涵盖在内了。
  • 更好的一点:没有中心化仓库。借助 NixOS,我可以直接通过 SSH 将构建结果推送到目标主机。
  • 更好的一点:NixOS 中可用软件的数量要多得多(例如就包含了 Immich),而且 NixOS 模块通常比单个 Docker 容器抽象层级更高,这意味着用更少的配置行就能配置更多功能。

在虚拟机中做原型验证

我的 NAS 需要每天正常工作,因此我想先在虚拟机中验证理想的配置,再对实际系统做改动。这样不仅更安全,还能让我在无需做出任何承诺的情况下,发现潜在的障碍,并感受使用 NixOS 是什么体验。

我从之前的测试安装中复制了 NixOS 配置(参见《我喜欢的 NixOS(声明式)安装方式》),并使用以下命令构建虚拟机镜像并在 QEMU 中启动:

nix build .#nixosConfigurations.storage2.config.system.build.vm

export QEMU_NET_OPTS=hostfwd=tcp::2222-:22
export QEMU_KERNEL_PARAMS=console=ttyS0
./result/bin/run-nixplay-vm

下面介绍的配置说明都可以在这个虚拟机中尝试,等你对现有配置足够满意后,就可以在真实机器上重复这些步骤来完成迁移。

迁移

对于实际系统的迁移,我设定了以下几个里程碑,每个里程碑都应该能在约一小时的典型时间段内完成(在虚拟机中预先验证过):

  • M1. 安装 NixOS
  • M2. 配置远程磁盘解锁
  • M3. 配置 Samba 以提供访问
  • M4. 配置 SSH/rsync 用于备份
  • 其他所有额外功能都属于可选,完全可以推迟到改天的另一次会话中再做。

实践证明,计划完全可行:实际安装 NixOS 并将配置推进到 M4 里程碑,只花了一个多小时。其他所有可选功能则在接下来几天到几周内有空时陆续完成。

提示:自从 2000 年代因安装程序的 bug 丢失过数据后,我就养成了在重装系统盘时物理断开所有数据盘(即拔掉 SATA 线)的习惯。

M1. 安装 NixOS

按照《我喜欢的 NixOS(声明式)安装方式》操作后,这是我最初的 configuration.nix

{ modulesPath, lib, pkgs, ... }:
{
  imports = [
    (modulesPath + "/installer/scan/not-detected.nix")
    ./hardware-configuration.nix
    ./disk-config.nix
  ];
  nix.settings.trusted-users = [ "michael" "root" ];
  nix.gc = {
    automatic = true;
    dates = "weekly";
    options = "--delete-older-than 7d";
  };
  boot.loader.systemd-boot = {
    enable = true;
    configurationLimit = 10;
  };
  boot.loader.efi.canTouchEfiVariables = true;
  networking.hostName = "storage2";
  time.timeZone = "Europe/Zurich";
  services.resolved.enable = true;
  networking.useDHCP = false;
  systemd.network.enable = true;
  systemd.network.networks."10-e" = {
    matchConfig.Name = "e*";
    networkConfig = {
      IPv6AcceptRA = true;
      DHCP = "yes";
    };
  };
  users.mutableUsers = false;
  security.sudo.wheelNeedsPassword = false;
  users.users.michael = {
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"
    ];
    isNormalUser = true;
    description = "Michael Stapelberg";
    extraGroups = [ "networkmanager" "wheel" ];
    initialPassword = "secret";
    shell = pkgs.zsh;
  };
  environment.systemPackages = with pkgs; [
    git rsync zsh vim emacs wget curl
  ];
  programs.zsh.enable = true;
  services.openssh.enable = true;
  system.stateVersion = "25.05";
}

以下所有章节描述的都是在这个 configuration.nix 内的改动。

我家网络中的所有设备都通过 DHCP 获取 IP 地址。如果想让某个 IP 固定,我会在路由器上做相应配置。

我的 NAS 主机在 IP 配置上有一个特殊之处:它们同时可通过 IPv4 和 IPv6 访问,且 IPv6 地址可由 IPv4 地址推导而来。

因此,我将上面的 systemd-networkd 配置改成了在动态配置的 IPv6 网络中配置静态 IPv6 地址:

systemd.network.networks."10-e" = {
  matchConfig.Name = "e*";
  networkConfig = {
    IPv6AcceptRA = true;
    DHCP = "yes";
  };
  ipv6AcceptRAConfig = {
    Token = "::10:0:0:252";
  };
};

✅ 至此,里程碑 M1 已完成。

M2. 配置远程磁盘解锁

为了在启动时解锁加密磁盘,我有一个自定义的 systemd 服务单元,它使用 wget(1)cryptsetup(8) 将密钥文件拆分到 NAS 和一台远程服务器上(即攻击者需要同时拿到两部分才能解锁)。

在 CoreOS/Flatcar 上,我的 cloud-init 配置如下所示:

coreos:
  units:
    - name: unlock.service
      command: start
      content: |
        [Unit]
        Description=unlock hard drive
        Wants=network.target
        After=systemd-networkd-wait-online.service
        Before=samba.service
        [Service]
        Type=oneshot
        RemainAfterExit=yes
        ExecStart=/bin/sh -c "c=0; while [ $c -lt 5 ]; do /bin/ping6 -n -c 1 r.zekjur.net && break; c=$((c+1)); sleep 1; done"
        ExecStart=/bin/sh -c "[ -e \"/dev/mapper/S5SSNF0T205183F_crypt\" ] || (echo -n my_local_secret && wget ... https://r.zekjur.net:8443/nascrypto) | /sbin/cryptsetup --key-file=- luksOpen ..."
        ExecStart=/bin/sh -c "vgchange -ay"
        ExecStart=/bin/mount /dev/mapper/data-data /srv

我将其转换成了如下的 NixOS 配置:

systemd.services.unlock = {
  wantedBy = [ "multi-user.target" ];
  description = "unlock hard drive";
  wants = [ "network.target" ];
  after = [ "systemd-networkd-wait-online.service" ];
  serviceConfig = {
    Type = "oneshot";
    RemainAfterExit = "yes";
    ExecStart = [
      ''/bin/sh -c "c=0; while [ $c -lt 5 ]; do ${pkgs.iputils}/bin/ping -n -c 1 r.zekjur.net && break; c=$((c+1)); sleep 1; done"''
      ''/bin/sh -c "[ -e \"/dev/mapper/S5SSNF0T205183F_crypt\" ] || (echo -n my_local_secret && ${pkgs.wget}/bin/wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/sdb2_crypt) | ${pkgs.cryptsetup}/bin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNF0T205183F S5SSNF0T205183F_crypt"''
      ''/bin/sh -c "[ -e \"/dev/mapper/S5SSNJ0T205991B_crypt\" ] || (echo -n my_local_secret && ${pkgs.wget}/bin/wget ... https://r.zekjur.net:8443/sdc2_crypt) | ${pkgs.cryptsetup}/bin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNJ0T205991B S5SSNJ0T205991B_crypt"''
      ''/bin/sh -c "${pkgs.lvm2}/bin/vgchange -ay"''
      ''/run/wrappers/bin/mount /dev/mapper/data-data /srv''
    ];
  };
};

我们还需要将自定义的 TLS 证书文件保存到磁盘上。为此,可以使用 environment. 配置:

environment.etc."ssl/certs/r.zekjur.net.crt".text = ''
-----BEGIN CERTIFICATE-----
MIID8TCCAlmgAwIBAgIRAPWwvYWpoH+lGKv6rxZvC4MwDQYJKoZIhvcNAQELBQAw
[...]
-----END CERTIFICATE-----
'';

${pkgs.wget} 这样的引用会被替换为 Nix store 中的路径(→ nix.dev 文档)。在 CoreOS/Flatcar 上,我只能使用基础镜像中包含的(极少的)软件,否则就必须借助 Docker。而在 NixOS 上,我们可以使用 nixpkgs 中提供的所有软件包。

部署并执行 reboot 后,我就可以在 /srv 下访问已解锁的磁盘了!🎉

% df -h /srv
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/data-data   15T   14T  342G  98% /srv

在列出文件时,我注意到旧系统和新系统之间的组 ID 不同。这可以通过显式指定所需的组 ID 来修复:

users.groups.michael = {
  gid = 1000;  # for consistency with storage3
};

✅ M2 已完成。

M3. 配置 Samba 以提供访问

远程磁盘解锁我想在 systemd 服务层面配置,而对于 Samba,我想先使用 Docker:我想先将原有可用的基于 Docker 的配置原样迁移过来,之后再转换为 Nix。

我们启用Docker NixOS 模块,它会设置好 Docker 所需的守护进程以及其他必要组件:

virtualisation.docker.enable = true;

这已经足以让其他服务使用 Docker,但我还想能够交互式地运行 docker 命令来进行调试。因此,我将 docker 添加到了 systemPackages 中:

environment.systemPackages = with pkgs; [
  git rsync zsh vim emacs wget curl
  docker
];

部署此配置后,我可以运行 docker run -ti debian 来验证一切正常。

cloud-init 版本的 samba 配置如下:

[Unit]
Description=samba server
After=docker.service unlock.mount
Requires=docker.service unlock.mount
[Service]
Restart=always
StartLimitInterval=0
ExecStartPre=-/usr/bin/docker pull stapelberg/docker-samba:latest
ExecStartPre=-/usr/bin/docker kill smb
ExecStartPre=-/usr/bin/docker rm smb
ExecStartPre=/usr/bin/docker run --name smb-prep stapelberg/docker-samba sh -c 'adduser ... && sed -i ... /etc/samba/smb.conf'
ExecStartPre=/usr/bin/docker commit smb-prep smb-prepared
ExecStart=/usr/bin/docker run -p 137:137 -p 138:138 -p 139:139 -p 445:445 --tmpfs=/run -v /srv/data:/srv/data --name smb -t smb-prepared /usr/sbin/smbd --foreground --debug-stdout --no-process-group

我们可以将其 1:1 翻译成 NixOS 配置:

systemd.services.samba = {
  wantedBy = [ "multi-user.target" ];
  description = "samba server";
  after = [ "unlock.service" ];
  requires = [ "unlock.service" ];
  serviceConfig = {
    Restart = "always";
    StartLimitInterval = 0;
    ExecStartPre = [
      ''-${pkgs.docker}/bin/docker pull stapelberg/docker-samba:latest''
      ''-${pkgs.docker}/bin/docker kill smb''
      ''-${pkgs.docker}/bin/docker rm smb''
      ''-${pkgs.docker}/bin/docker run --name smb-prep stapelberg/docker-samba sh -c 'adduser --quiet --disabled-password --gecos "" --uid 29901 michael && sed -i "s,\\[global\\],[global]\\nserver multi channel support = yes\\naio read size = 1\\naio write size = 1,g" /etc/samba/smb.conf' ''
      ''-${pkgs.docker}/bin/docker commit smb-prep smb-prepared''
    ];
    ExecStart = ''-${pkgs.docker}/bin/docker run -p 137:137 -p 138:138 -p 139:139 -p 445:445 --tmpfs=/run -v /srv/data:/srv/data --name smb -t smb-prepared /usr/sbin/smbd --foreground --debug-stdout --no-process-group'';
  };
};

✅ 现在我可以通过网络管理文件了,这标志着 M3 已完成!

另见:锦上添花:N5. 来自 NixOS 的 Samba

M4. 配置 SSH/rsync 用于备份

为了备份数据,我使用基于 SSH 的 rsync。我通过在 Docker 容器中使用 rrsync 来限制此 SSH 访问,使其只能执行 rsync 命令。要配置 SSH authorized_keys(5),我们设置:

users.users.root.openssh.authorizedKeys.keys = [
  ''command="${pkgs.docker}/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/backup/midna:/srv/backup/midna stapelberg/docker-rsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''
];

✅ 一次成功的测试备份标志着里程碑 M4 已完成!

另见:锦上添花:N6. 来自 NixOS 的 rrsync

锦上添花的功能

N1. Prometheus Node Exporter

我喜欢用 Prometheus(和 Grafana)来监控所有机器。在网络连接和认证方面,我使用 Tailscale 组网 VPN。

要安装 Tailscale,我启用其 NixOS 模块并让 tailscale 命令可用:

services.tailscale.enable = true;
environment.systemPackages = with pkgs; [ tailscale ];

部署后,我运行 sudo tailscale up 并在浏览器中打开登录链接。

Prometheus Node Exporter 也可以通过其 NixOS 模块轻松启用:

services.prometheus.exporters.node = {
  enable = true;
  listenAddress = "storage2.example.ts.net";
};

然而,这还不够可靠:当系统启动时 Tailscale 启动较慢,Node Exporter 可能在还无法监听 Tailscale IP 地址时就耗尽了全部重启次数。我们可以为该服务启用无限重启,以确保它最终能启动:

systemd.services."prometheus-node-exporter" = {
  startLimitIntervalSec = 0;
  serviceConfig = {
    Restart = "always";
    RestartSec = 1;
  };
};

N2. 可靠的挂载

在迁移配置的过程中,我注意到直接从 unlock.service 调用 mount(8) 并不可靠,最好让 systemd 来管理挂载:

fileSystems."/srv" = {
  device = "/dev/mapper/data-data";
  fsType = "ext4";
  options = [
    "nofail"
    "x-systemd.requires=unlock.service"
  ];
};

之后,我就可以从 unlock.service 中移除 mount(8) 调用:

@@ -247,7 +247,10 @@
         ''/bin/sh -c "${pkgs.lvm2.bin}/bin/vgchange -ay"''
-        ''/run/wrappers/bin/mount /dev/mapper/data-data /srv''
+        # Let systemd mount /srv based on the fileSystems./srv
+        # declaration to prevent race conditions: mount
+        # might not succeed while the fsck is still in progress,
+        # for example, which otherwise makes unlock.service fail.

在 systemd 服务中,我现在可以依赖 /srv 挂载单元:

systemd.services.jellyfin = {
 unitConfig.RequiresMountsFor = [ "/srv" ];
 wantedBy = [ "srv.mount" ];
};

N3. nginx-healthz

为了省电,我会在不使用时关闭 NAS。

我的备份编排使用网络唤醒(Wake-on-LAN)来唤醒设备,并需要等待 NAS 完全启动并挂载好 /srv 后,才能开始备份任务。

为此,我配置了一个不带任何文件的网页服务器,它依赖于 /srv 挂载。因此,一旦网页服务器能响应 HTTP 请求,就说明 /srv 已挂载完成。

cloud-init 配置如下所示:

[Unit]
Description=nginx for /srv health check
Wants=network.target
After=srv.mount
Requires=srv.mount
[Service]
Restart=always
ExecStartPre=/bin/sh -c 'systemctl is-active docker.service'
ExecStartPre=/usr/bin/docker pull nginx:1
ExecStartPre=-/usr/bin/docker kill nginx-healthz
ExecStart=/usr/bin/docker run --name nginx-healthz --publish 10.0.0.252:8200:80 --log-driver=journald nginx:1

从 Flatcar Linux 移植过来的 Docker 版本如下:

systemd.services.healthz = {
  description = "nginx for /srv health check";
  wants = [ "network.target" ];
  unitConfig.RequiresMountsFor = [ "/srv" ];
  wantedBy = [ "srv.mount" ];
  startLimitIntervalSec = 0;
  serviceConfig = {
    Restart = "always";
    ExecStartPre = [
      ''/bin/sh -c 'systemctl is-active docker.service' ''
      ''-${pkgs.docker}/bin/docker pull nginx:1''
      ''-${pkgs.docker}/bin/docker kill nginx-healthz''
    ];
    ExecStart = [ ''-${pkgs.docker}/bin/docker run --name nginx-healthz --publish 10.0.0.252:8200:80 --log-driver=journald nginx:1'' ];
  };
};

将此配置从 Docker 迁移到 NixOS 后,会变得简单得多:

# Signal readiness on HTTP port 8200 once /srv is mounted:
networking.firewall.allowedTCPPorts = [ 8200 ];
services.caddy = {
  enable = true;
  virtualHosts."http://10.0.0.252:8200".extraConfig = ''
    respond "ok"
  '';
};
systemd.services.caddy = {
  unitConfig.RequiresMountsFor = [ "/srv" ];
  wantedBy = [ "srv.mount" ];
};

N4. NixOS Jellyfin

从 Flatcar Linux 移植过来的 Docker 版本如下:

networking.firewall.allowedTCPPorts = [ 4414 8096 ];
systemd.services.jellyfin = {
  wantedBy = [ "multi-user.target" ];
  description = "jellyfin";
  after = [ "docker.service" "srv.mount" ];
  requires = [ "docker.service" "srv.mount" ];
  startLimitIntervalSec = 0;
  serviceConfig = {
    Restart = "always";
    ExecStartPre = [
      ''-${pkgs.docker}/bin/docker pull lscr.io/linuxserver/jellyfin:latest''
      ''-${pkgs.docker}/bin/docker rm jellyfin''
    ];
    ExecStart = [ ''-${pkgs.docker}/bin/docker run --rm --net=host --name=jellyfin -e TZ=Europe/Zurich -v /srv/jellyfin/config:/config -v /srv/data/movies:/data/movies:ro -v /srv/data/series:/data/series:ro -v /srv/data/mp3:/data/mp3:ro lscr.io/linuxserver/jellyfin:latest'' ];
  };
};

和之前一样,使用 NixOS 提供的 Jellyfin 后,配置变得更简单:

services.jellyfin = {
  enable = true;
  openFirewall = true;
};
systemd.services.jellyfin = {
  unitConfig.RequiresMountsFor = [ "/srv" ];
  wantedBy = [ "srv.mount" ];
};

有段时间,我还设置了兼容性符号链接,将旧位置(Docker 容器内的 /data/movies)映射到新位置(/srv/data/movies),但在 Jellyfin 中遇到了奇怪的问题,最后干脆重新初始化了整个 Jellyfin 状态。虽然所需配置行数变多了,但我觉得将其放到独立文件中很整洁,具体做法如下:

configuration.nix 中移除上面的几行,并将其移至 jellyfin.nix

{
  config, lib, pkgs, modulesPath, ...
}:
{
  services.jellyfin = {
    enable = true;
    openFirewall = true;
    dataDir = "/srv/jellyfin";
    cacheDir = "/srv/jellyfin/config/cache";
  };
  systemd.services.jellyfin = {
    unitConfig.RequiresMountsFor = [ "/srv" ];
    wantedBy = [ "srv.mount" ];
  };
}

然后,在 configuration.nix 中,将 jellyfin.nix 添加到 imports

imports = [
  ./hardware-configuration.nix
  ./jellyfin.nix
];

N5. NixOS Samba

要在 NixOS 上使用 Samba,我将 M3 中的 systemd.services.samba 配置替换为以下内容:

services.samba = {
  enable = true;
  openFirewall = true;
  settings = {
    "global" = {
      "map to guest" = "bad user";
    };
    "data" = {
      "path" = "/srv/data";
      "comment" = "public data";
      "read only" = "no";
      "create mask" = "0775";
      "directory mask" = "0775";
      "guest ok" = "yes";
    };
  };
};
system.activationScripts.samba_user_create = ''
  smb_password="secret"
  echo -e "$smb_password\n$smb_password\n" | ${lib.getExe' pkgs.samba "smbpasswd"} -a -s michael
'';

注意:通过 activation 脚本设置 Samba 密码对小规模配置可行,但如果你想让 Samba 密码不出现在 Nix store 中,就需要采用其他方式。在另一台机器上,我使用 sops-nix 来管理密钥,并发现像这样重构 smbpasswd 调用可以可靠地工作:

let
  setPasswords = pkgs.writeShellScript "samba-set-passwords" ''
    set -euo pipefail
    for user in michael; do
        smb_password="$(cat /run/secrets/samba_passwords/$user)"
        echo -e "$smb_password\n$smb_password\n" | ${lib.getExe' pkgs.samba "smbpasswd"} -a -s $user
    done
  '';
in {
  services.samba = { /* …as above… */ }
  systemd.services.samba-smbd.serviceConfig.ExecStartPre = [ "${setPasswords}" ];
  sops.secrets."samba_passwords/michael" = {
    restartUnits = [ "samba-smbd.service" ];
  };
}

我还注意到 NixOS 默认不会为每个用户创建同名组,但我习惯于那样管理权限。我们可以很容易地声明一个组:

users.groups.michael = {
  gid = 1000; # for consistency with storage3
};
users.users.michael = {
  extraGroups = [
    "wheel"
    "docker"
    "michael"
  ];
};

N6. NixOS rrsync

从 Flatcar Linux 移植过来的 Docker 版本如下:

users.users.root.openssh.authorizedKeys.keys = [
  ''command="${pkgs.docker}/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/backup/midna:/srv/backup/midna stapelberg/docker-rsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''
];

要在 NixOS 上使用 rrsync,我将配置改成了这样:

users.users.root.openssh.authorizedKeys.keys = [
  ''command="${pkgs.rrsync}/bin/rrsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''
];

N7. sync.pl 脚本

从 Flatcar Linux 移植过来的 Docker 版本如下:

users.users.root.openssh.authorizedKeys.keys = [
  ''command="${pkgs.docker}/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/data:/srv/data -v /root/.ssh:/root/.ssh:ro -v /etc/ssh:/etc/ssh:ro -v /etc/static/ssh:/etc/static/ssh:ro -v /nix/store:/nix/store:ro stapelberg/docker-sync",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3Npublickey sync@dr''
];

我想不再维护下面这个用于交付 sync.plDockerfile

FROM debian:stable
RUN apt-get update \
    && apt-get install -y rsync ssh perl
ADD sync.pl /usr/bin/
ENTRYPOINT ["/usr/bin/sync.pl"]

为了摆脱 Docker 容器,我将 sync.pl 文件翻译成了一个 Nix 表达式,它会将 sync.pl Perl 脚本写入 Nix store:

{ pkgs }:
pkgs.writers.writePerlBin "syncpl" { libraries = []; } ''
# This script is run via ssh from dornröschen.
use strict;
use warnings;
use Data::Dumper;
if (my ($destination) = ($ENV{SSH_ORIGINAL_COMMAND} =~ /^([a-z0-9.]+)$/)) {
    print STDERR "rsync version: " . `${pkgs.rsync}/bin/rsync --version` . "\n\n";
    my @rsync = (
        "${pkgs.rsync}/bin/rsync",
        "-e", "ssh",
        "--max-delete=-1", "--verbose", "--stats",
        "-ax", "--ignore-existing", "--omit-dir-times",
        "/srv/data/", ''$ {destination}:/",
    );
    print STDERR "running: " . Dumper(\@rsync) . "\n";
    exec @rsync;
} else {
    print STDERR "Could not parse SSH_ORIGINAL_COMMAND.\n";
}
''

然后,我可以在 configuration.nix 中通过导入该文件并将其指向 NixOS 配置的 pkgs 表达式来引用它:

{ modulesPath, lib, pkgs, ... }:
let
  syncpl = import ./syncpl.nix { pkgs = pkgs; };
in {
  imports = [ ./hardware-configuration.nix ];
  users.users.root.openssh.authorizedKeys.keys = [
    ''command="${syncpl}/bin/syncpl",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3Npublickey sync@dr''
  ];
  environment.systemPackages = [ syncpl ];
}

这能工作,但这是最好的方法吗?以下是一些想法:

  • 通过在 Nix 表达式中管理这个脚本,我无法再使用编辑器的 Perl 支持。
    • 我或许也可以将 sync.pl 保留为独立文件,并在 Nix 表达式中使用字符串插值将 rsync 二进制文件的绝对路径注入脚本。
  • 另一种替代方案是在 Nix 表达式中添加一个包装脚本,确保 $PATH 中包含 rsync,这样脚本就不再需要绝对路径了。
  • 对于这种小型粘合脚本,我认为在 Nix 表达式中“内联”管理内容更简单,因为这意味着配置目录中少一个文件。

N8. 共享配置

我想让所有 NixOS 系统的用户设置都保持一致。

为此,我可以将 configuration.nix 的部分内容提取到 user-settings.nix 中,然后声明一个附带的 flake.nix,将其作为输出提供。

将这些文件发布到 git 仓库后,我就可以在自己的 flake.nix 中引用该仓库:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
    stapelbergnix.url = "github:stapelberg/nix";
  };
  outputs = { self, nixpkgs, stapelbergnix }: let
    system = "x86_64-linux";
    pkgs = import nixpkgs { inherit system; config.allowUnfree = false; };
  in {
    nixosConfigurations.storage2 = nixpkgs.lib.nixosSystem {
      inherit system; inherit pkgs;
      modules = [
        ./configuration.nix
        stapelbergnix.lib.userSettings
        stapelbergnix.lib.systemdBoot
      ];
    };
    formatter.${system} = pkgs.nixfmt-tree;
  };
}

现在,在 user-settings.nix 中声明的所有内容都可以从 configuration.nix 中移除了!

N9. 试试 Immich!

切换到 NixOS 的一个重要原因就是之前无法尝试 Immich,所以让我们在 NixOS 上试试吧:

services.immich = {
  enable = true;
  host = "10.0.0.252";
  port = 2283;
  openFirewall = true;
  mediaLocation = "/srv/immich";
};
systemd.services."immich-server" = {
  unitConfig.RequiresMountsFor = [ "/srv" ];
  wantedBy = [ "srv.mount" ];
};

结论

你可以在GitHub 上找到完整的配置目录

我对这套 NixOS 配置相当满意!以前(使用 CoreOS/Flatcar 时),我可以声明式地管理基础系统,但还必须额外管理大量 Docker 容器。而有了 NixOS,我可以声明式地管理所有东西(或者说,所有有意义的部分)。

像我基于 SSH+rsync 的备份基础设施这样的自定义配置,可以清晰地、在一处、以所需的抽象/复用层级来表达。

如果你正在考虑至少再用 NixOS 管理一台系统,我会推荐尝试!我的后续计划之一是将 storage3(我的另一台 NAS)也从 Ubuntu Server 转换为 NixOS,以减少手动管理工作。能够直接复制整套配置来搭建另一台系统,或在一次性的虚拟机中尝试新想法,实在是一种非常棒的工作流 🥰

……但如果你只有一台机器要管理,可能所有这些就过于复杂了。

本文章由 muse-spark-1.2-contributor 进行翻译

评论