Migrating my NAS from CoreOS/Flatcar Linux to NixOS

Michael Stapelberg

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

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

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

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

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

你可以先按照我的博文《我喜欢如何(以声明式方式)安装 NixOS》来应用这些示例,然后再阅读你感兴趣的章节。如果你更想直接查看完整配置,请跳至结论

2023 年的 PC NAS 组装

背景/历史

在过去十年中,我为 NAS 需求使用过多种不同的操作系统。以下是 2 台 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(代)”的概念来实现(更新失败?选择旧的 generation),粒度更细。

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

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

十多年后的今天,Docker 已是一项成熟的技术。人们如今已将容器方式的各种好处视为理所当然。

所以,以下是我不再满意 Flatcar Linux 的原因列表。

R1. cloud-init 已被弃用

CoreOS cloud-init 项目在某个时候被弃用,取而代之的是 Ignition,它显然更强大,但作为一名业余爱好者,上手也更繁琐。据我所知,我必须将配置托管在某个 URL 上,然后通过内核参数提供该 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 都无法运行的系统,已经不能满足我的需求了。

原因总结

基于上述所有原因,我本来需要搭建自动化的容器构建、运行自己的中心化 registry,而且仍然无法运行像 Immich 这样知名的开源软件。

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

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

  • 相同:我也需要设置一个自动任务来更新我的 NixOS 系统。
    • 我已经为更新我的 gokrazy 设备设置了这样的任务。
    • Docker 的推送是异步的:在成功推送后,我仍然需要额外的自动化来在目标主机上拉取更新后的容器并重启受影响的服务,而 NixOS 则包含了所有这些步骤。
  • 更好:没有中心化的 registry。使用 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 年代因安装程序错误丢失数据后,我养成了在重装系统盘时物理断开所有数据盘(即拔掉 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。我通过使用 rrsync(在 Docker 容器中)将此 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 mesh 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。

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

为此,我配置了一个不带任何文件的 Web 服务器,它依赖于 /srv 挂载。因此,一旦 Web 服务器响应 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" ];
};

有一段时间,我还设置了兼容性符号链接,将旧位置(/data/movies,在 Docker 容器内)映射到新位置(/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
'';

注意:在激活脚本中设置 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!

从 CoreOS/Flatcar 切换过来的一个动机就是我无法尝试 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,以减少手动管理工作。能够直接复制整个配置来搭建另一个系统,或在一次性的虚拟机中尝试一个想法,是如此美好的工作流 🥰

……但如果你只有一个系统要管理,可能所有这些都过于复杂了。

原文由 Michael Stapelberg 发布

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