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 unit 啟動 Docker 容器)、如何先遷移到一個過渡狀態(在 NixOS 上使用 Docker)讓系統先跑起來,最後再一步步將所有服務從 Docker 遷移到原生的 NixOS 模組。

如果你還沒聽過 NixOS,建議先閱讀NixOS 官網的首頁,了解 NixOS 是什麼、它能做到哪些事。

這篇文章的目標讀者是對在 NAS 這個使用情境下嘗試 NixOS 感興趣、喜歡透過範例來了解如何設定系統的人。

你可以先照著我的另一篇文章「How I like to install NixOS (declaratively)」的步驟操作,再依序閱讀你感興趣的章節。如果你想直接看完整的設定檔,請跳至結論

2023 年組裝的 PC NAS 主機

背景與歷程

過去十年間,我為了 NAS 的需求使用過好幾種不同的作業系統。以下是兩台 NAS 主機 storage2 與 storage3 的沿革總覽:

年份storage2storage3詳細資訊(部落格文章)
2013Debian on qnapDebian on qnapWake-On-LAN with Debian on a qnap TS-119P2+
2016CoreOS on PCCoreOS on PCGigabit NAS (running CoreOS)
2023CoreOS on PCUbuntu+ZFS on PCMy all-flash ZFS NAS build
2025NixOS on PCUbuntu+ZFS on PC→ 你現在在這裡 ←
?NixOS on PCNixOS+ZFS on PC將更多 PC 轉換成 NixOS 似乎無可避免 ;)

我對 NAS 軟體的需求

  • (本文只談軟體!關於使用情境與硬體選購的需求,請參考我在〈My all-flash ZFS NAS build〉(2023)一文中的「Design Goals」一節。)
  • 遠端管理:我很喜歡將網路儲存設備的設定檔納入版本控管、並在我的主力電腦上集中管理的模式。這樣的好處是,即使需要重灌,也能在幾分鐘內從主力電腦重新安裝 NAS、取回備份環境的存取權。
  • 自動更新,且可輕鬆 rollback:手動更新所有裝置可不是什麼愉快的體驗。因此,自動更新是必須的——但當更新出錯時,能快速、輕鬆地復原也同樣重要。
    • 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. 容器逐漸腐壞(Container Bitrot)

有一次我盤點了自己在 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,因為它如今似乎是最受歡迎的宣告式解決方案,擁有龐大的社群與豐富的套件選擇。

以我的情況來看,NixOS 相比之下如何呢?

  • 相同之處:我同樣需要設定自動化任務來更新 NixOS 系統。
    • 我已經為更新 gokrazy 裝置設有這類任務。
    • Docker push 是非同步的:推送成功後,我還需要額外的自動化機制在目標主機上拉取更新後的容器並重啟相關服務,而 NixOS 則把這些都包含在內了。
  • 更好的地方:不需要中央 registry。在 NixOS 上,我可以直接透過 SSH 將建置結果推送到目標主機。
  • 更好的地方:NixOS 可用的軟體數量龐大得多(例如就包含了 Immich),而且 NixOS 模組通常比單個 Docker 容器抽象層級更高,意味著你可以用更少的設定達到更多的功能。

在虛擬機中先做原型

我的 NAS 每天都得正常運作,因此我在對實體系統動手之前,想先在虛擬機中把理想的設定做個原型。這樣不僅更安全,也能及早發現可能遇到的阻礙,並在不做任何承諾的情況下體驗使用 NixOS 的感覺。

我複製了先前測試安裝的 NixOS 設定(見〈How I like to install NixOS (declaratively)〉),並使用以下指令來建置 VM 映像檔並在 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 以供備份
  • 其他額外的部分都屬於 nice-to-have,可以留到改天再做。

實際操作時,完全如計畫進行:安裝 NixOS 並將設定做到里程碑 M4,總共花了一個多小時。其他所有 nice-to-have 的項目,則是在接下來的幾天、幾週內有空時陸續完成。

小技巧:在 2000 年代曾因安裝程式的 bug 導致資料遺失後,我養成了在重灌系統碟時,實體拔除所有資料碟(也就是拔掉 SATA 線)的習慣。

M1. 安裝 NixOS

照著〈How I like to install NixOS (declaratively)〉操作後,這是我的初始 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 service unit,它會使用 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

列出檔案時,我發現舊系統和新系統的 group id 不同。這可以透過明確指定想要的 group id 來修正:

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

✅ M2 完成。

M3. 設定 Samba 以提供存取

遠端磁碟解鎖我想直接在 systemd service 層級設定,但 Samba 我想先用 Docker:我想先把舊的、能正常運作的 Docker 設定原封不動地搬過來,之後再慢慢轉換成 Nix。

我們啟用Docker 的 NixOS 模組,它會幫我們設定好 Docker 所需的 daemon 以及其他讓它能正常運作的東西:

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!

另見:Nice-to-haves:N5. 來自 NixOS 的 samba

M4. 設定 SSH/rsync 以供備份

備份資料時,我透過 SSH 使用 rsync。為了限制這個 SSH 存取只能執行 rsync 指令,我使用了 rrsync(放在 Docker 容器中)。要設定 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!

另見:Nice-to-haves:N6. 來自 NixOS 的 rrsync

Nice-to-haves

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,並需要等到 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" ];
};

有一段時間,我也設定了相容性的 symlink,將舊位置(在 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 來將這個表達式作為 output 提供。

把這些檔案發佈到 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 來管理至少另一台系統,我會推薦你試試!我的後續計畫之一就是把另一台 NAS(storage3)也從 Ubuntu Server 轉換成 NixOS,以減少手動管理的負擔。能夠直接複製整份設定來架設另一台系統,或是在用完即丟的虛擬機中嘗試新點子,真的是非常美好的工作流程 🥰

……不過,如果你只有一台系統要管理,這一切大概就太複雜了。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言