Migrating my NAS from CoreOS/Flatcar Linux to NixOS

Michael Stapelberg

將我的 NAS 從 CoreOS/Flatcar Linux 遷移至 NixOS

在這篇文章中,我想示範如何將現有的 Linux 伺服器遷移至 NixOS——以我的情況來說,就是將網路附加儲存(NAS)PC 上的 CoreOS/Flatcar Linux 安裝環境遷移過去。

我會詳細說明先前的 CoreOS 設定長什麼樣子(大量透過 systemd unit 啟動 Docker 容器的設定)、如何先將其遷移至過渡狀態(在 NixOS 上使用 Docker)以讓系統先動起來,最後再逐步將所有服務從 Docker 遷移至原生的 NixOS 模組。

如果你還沒聽過 NixOS,建議先閱讀NixOS 網站的首頁,了解 NixOS 是什麼以及它能實現哪些功能。

這篇部落格文章的目標讀者是想在 NAS 這個使用情境下嘗試 NixOS,且喜歡透過範例來了解如何設定系統的人。

你可以先依照我的部落格文章〈如何以宣告式安裝 NixOS〉的步驟操作,再依序閱讀你感興趣的章節。如果你想直接查看完整設定,請跳至結論

2023 年打造的 PC NAS

背景/歷史

過去十年間,為了滿足 NAS 的需求,我使用過多種不同的作業系統。以下是我兩台 NAS 系統 storage2 與 storage3 的概覽:

年份storage2storage3詳細資訊(部落格文章)
2013qnap 上的 Debianqnap 上的 Debian在 qnap TS-119P2+ 上使用 Debian 實現 Wake-On-LAN
2016PC 上的 CoreOSPC 上的 CoreOSGigabit NAS(執行 CoreOS)
2023PC 上的 CoreOSPC 上的 Ubuntu+ZFS我的全快閃 ZFS NAS 組裝
2025PC 上的 NixOSPC 上的 Ubuntu+ZFS→ 你在這裡 ←
PC 上的 NixOSPC 上的 NixOS+ZFS將更多 PC 轉換為 NixOS 似乎勢在必行 ;)

我對 NAS 軟體的需求

  • (本文僅討論軟體!關於我的使用模式與硬體選擇方面的需求,請參閱我在〈我的全快閃 ZFS NAS 組裝〉一文(2023 年)中的「Design Goals(設計目標)」一節。)
  • 遠端管理:我非常喜歡將網路儲存設備的設定納入版本控制,並在我的主要 PC 上進行管理的模式。這樣有個好處:我可以在幾分鐘內透過 PC 重新安裝 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 這類現代化的網頁應用程式需要多個 Docker 容器(用於 Postgres、Redis 等),因此僅提供Docker Compose 作為官方支援的安裝方式。

遺憾的是,Flatcar 並未包含 Docker Compose

我沒心情持續為不支援 Docker Compose 的系統重新打包 Immich,因此我認定,一個既無法直接執行像 Immich 這類軟體、甚至連 Docker Compose 都無法執行的系統,已經無法滿足我的需求。

原因總結

綜合以上所有原因,我將不得不自行架設自動化的容器建置流程、運行自己的中心化 registry,卻仍然無法執行像 Immich 這樣知名的開源軟體。

因此,我決定再次嘗試 NixOS(睽違 10 年),因為它如今似乎是最受歡迎的宣告式解決方案,擁有龐大的社群與豐富的套件選擇。

那麼就我的情況而言,NixOS 的表現如何呢?

  • 相同:我同樣需要設定自動化工作來更新我的 NixOS 系統。
    • 我已經有類似的工作用於更新我的 gokrazy 裝置。
    • Docker push 是非同步的:在成功推送後,我仍需額外的自動化機制,在目標主機上拉取更新的容器並重新啟動受影響的服務,而 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 PC 在 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 未使用時將其關機。

我的備份協調流程會使用 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 script 中設定 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.pl 而去維護以下的 Dockerfile

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 中透過 import 來參照此檔案,並將其指向我的 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 運算式中加入一個 wrapper 指令稿,確保 $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 進行翻譯