Migrating my NAS from CoreOS/Flatcar Linux to NixOS

Michael Stapelberg

NASをCoreOS/Flatcar LinuxからNixOSへ移行する

この記事では、既存のLinuxサーバーをNixOSへ移行する方法を紹介します。具体的には、私のNetwork Attached Storage(NAS)用PCで動いていたCoreOS/Flatcar Linux環境の移行事例です。

以前のCoreOSでの構成がどうなっていたのか(Dockerコンテナを起動するsystemdユニットが多数ある状態)、それをまずNixOS上でDockerを使った中間状態へ移してとりあえず動くようにした方法、そして最終的に各ユニットをDockerからネイティブなNixOSモジュールへ一つずつ移行していった過程を、順を追って詳しく見ていきます。

NixOSをご存じない方は、まずNixOSウェブサイトのトップページを読んで、NixOSが何であり、どんなことが可能になるのかを把握されることをおすすめします。

この記事は、NAS用途でNixOSを試してみたい方や、具体的な設定例を見ながらシステム構築を理解したい方を想定しています。

これらの例は、まず私のブログ記事「How I like to install NixOS (declaratively)」の手順に沿ってインストールしたうえで、興味のあるセクションを順に試していく形で活用できます。全体の設定を一気に確認したい場合は、結論までスキップしてください。

2023年に自作したPCベースのNAS

背景と経緯

この10年ほど、NAS用途でいくつものOSを使い分けてきました。2台の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」をご覧ください。)
  • リモート管理: ネットワークストレージの構成をバージョン管理し、メインPCから一元管理できるモデルをとても気に入っています。万一のときでも、PCから数分でNASを再インストールしてバックアップ環境へ再びアクセスできるのは大きな利点です。
  • 自動アップデートと簡単なロールバック: すべての環境を手作業でアップデートするのは楽しい作業とは思えません。そのため自動アップデートは必須ですが、アップデートが失敗したときに素早く簡単に復旧できることも同じく必須です。
    • CoreOS/FlatcarではA/Bアップデート方式(失敗したら古いパーティションで起動)でこれを実現していましたが、NixOSでは「generation(世代)」という概念(失敗したら古い世代を選択)で実現しており、よりきめ細かく管理できます。

なぜCoreOS/FlatcarからNixOSへ移行するのか

CoreOSを使い始めた当時、Dockerはまだかなり新しい技術でした。Dockerコンテナを使えばサービスを一様に扱える点が気に入っていました。結局のところ、どのサービスも何らかのポートを公開する(HTTPだったりPostgresだったり)わけで、安定したOSの上でより新しいバージョンのソフトウェアを動かしたり、逆にアップデートで不具合が出たときには古いバージョンに戻したりといった柔軟性が得られたのです。

それから10年以上が経ち、Dockerはすっかり確立された技術になりました。いまやコンテナ方式のさまざまな利点は当たり前のものとして受け止められています。

そこで、Flatcar Linuxに満足できなくなった理由を挙げていきます。

R1. cloud-initが非推奨になった

CoreOS cloud-initプロジェクトは、ある時点からIgnitionに置き換えられ非推奨となりました。Ignitionは明らかに強力ですが、ホビーユースとして使い始めるには手間がかかります。私の把握する限り、設定ファイルをどこかのURLでホストし、それをカーネルパラメーターで渡す必要があります。ファイルをただコピーするだけという従来の手軽な方法は、もはやサポートされていないようです。

Ignitionは他にも使い勝手がよくありません。YAMLがサポートされずJSONのみになり、手書きするのは気が進みません。また、フォーマットもかなり頻繁に変わるようです。

結果として、私はcloud-initからIgnitionへの移行を一度も行わず、長く非推奨のままの方法に依存して愛用のOSを使い続けるのは健全ではないと感じるようになりました。

R2. コンテナの陳腐化

あるときDocker Hub上の自分のコンテナを棚卸ししてみると、その多くがかなり古くなっていることに気づきました。しばらく前までDocker HubはGitHubから取得したDockerfileに基づく自動ビルドを提供していましたが、現在は自動ビルドにサブスクリプションが必要になりました。自分のコンピューターを使うためだけにサブスクリプションを契約するつもりはありません。

R3. 中央サービスへの依存

もしDockerがいつかDocker Hubの運営を停止すれば、私はNASにソフトウェアをデプロイできなくなります。これは決して絵空事ではありません。2023年にはDocker Hubが無料プランでのOrganization提供を終了すると発表し、コミュニティからの反発を受けて撤回したことがありました。

私のようなホビーユーザー向けに、無料サービスがいつまで続くのかは誰にもわかりません。

R4. FlatcarではImmichを試せなかった

決定打となったのは、NASでImmichを試せないことに気づいたときでした。Immichのような最近のウェブアプリケーションは複数のDockerコンテナ(PostgresやRedisなど)を必要とするため、サポートされるインストール方法としてDocker Composeしか提供されていません。

ところがFlatcarにはDocker Composeが含まれていません

ImmichをDocker Composeなしで動くように継続的に再パッケージする気にはなれなかったため、Immichのようなソフトウェアを直接実行できず、Docker Composeすら使えないシステムでは、もはや自分の要件を満たせないと判断しました。

理由のまとめ

上記の理由を踏まえると、自動化されたコンテナビルドを自前で用意し、独自の中央レジストリを運用したとしても、Immichのようなよく知られたオープンソースソフトウェアを実行できないままだったでしょう。

そこで、10年ぶりにNixOSを再び試すことにしました。現在最も人気のある宣言的なソリューションに見え、コミュニティも大きく、利用できるパッケージも豊富だからです。

私の状況でNixOSを比較するとどうなるでしょうか。

  • 同じ点: NixOSでもシステムをアップデートする自動ジョブは必要です。
    • すでにgokrazyデバイスのアップデート用には、同様のジョブを用意しています。
    • Dockerのpushは非同期です。pushが成功した後も、対象ホストで更新されたコンテナをpullし、関連サービスを再起動するための追加の自動化が必要ですが、NixOSではそれらがすべて含まれています。
  • より良い点: 中央レジストリが不要です。NixOSならビルド成果物をSSH経由で対象ホストに直接pushできます。
  • より良い点: NixOSで利用できるソフトウェアの数ははるかに多く(例えばImmichも含まれています)、NixOSモジュールは個々のDockerコンテナよりも高い抽象度で記述されていることが一般的で、より少ない設定行数で多くの機能を構成できます。

VMでのプロトタイピング

NASは毎日稼働する必要があるため、実機に変更を加える前に、VMで望む構成をプロトタイプしておきたいと考えました。その方が安全なだけでなく、障害になりそうな点を事前に発見でき、コミットする前に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

以下で説明する設定手順はこのVMで試すことができ、内容に満足できたら、同じ手順を実機で繰り返して移行できます。

移行

実機の移行にあたっては、1時間程度の作業セッション(VMでのプロトタイプ後)で達成できるように、次のマイルストーンを定義しました。

  • M1. NixOSをインストールする
  • M2. リモートでのディスクアンロックを設定する
  • M3. アクセス用にSambaを設定する
  • M4. バックアップ用にSSH/rsyncを設定する
  • それ以外はあれば便利なものとして、別の日に先送りしてもよいことにしました。

実際、この計画どおりに進みました。NixOSのインストールからM4までの設定は1時間強で完了し、それ以外の「あれば便利なもの」は、その後の数日から数週間で時間のあるときに順次対応しました。

ヒント: 2000年代にインストーラーのバグでデータを失って以来、システムディスクを再インストールする際は、すべてのデータディスクを物理的に取り外す(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用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. リモートでのディスクアンロックを設定する

起動時に暗号化ディスクをアンロックするため、wget(1)cryptsetup(8)を使ってキーファイルをNASとリモートサーバーに分割する(=攻撃者がアンロックするには両方が必要になる)カスタムのsystemdサービスユニットを用意しています。

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ストアへのパスに置換されます(→ 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に必要なデーモンやその他諸々をセットアップしてくれるDockerのNixOSモジュールを有効化します。

virtualisation.docker.enable = true;

これだけで他のサービスがDockerを使えるようになりますが、デバッグのためにdockerコマンドを対話的に実行したいので、dockersystemPackagesにも追加しました。

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

この設定をデプロイした後、docker run -ti debianを実行して動作を確認できます。

sambaのcloud-init版は次のようになっていました。

[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

これをNixOSへ1対1で置き換えることができます。

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を使っています。このSSHアクセスはrrsync(Dockerコンテナ内)を使って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

Docker版(Flatcar Linuxから移植)は次のようになります。

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

Docker版(Flatcar Linuxから移植)は次のようになります。

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'' ];
  };
};

これまでと同様、jellyfinをNixOSから使うと設定はシンプルになります。

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.niximportsjellyfin.nixを追加します。

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ストアに残したくない場合は別の方法が必要です。別のマシンでは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

Docker版(Flatcar Linuxから移植)は次のようになります。

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スクリプト

Docker版(Flatcar Linuxから移植)は次のようになります。

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ファイルを、PerlスクリプトをNixストアに書き出すNix式に置き換えました。

{ 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式にラッパースクリプトを追加して$PATHrsyncが含まれるようにすれば、スクリプト側で絶対パスを必要としなくなるという手もあります。
  • このような小さなグルースクリプトであれば、設定ディレクトリ内のファイルが一つ減る分、内容を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でもう一台以上を管理することを検討しているなら、ぜひおすすめします!今後のプロジェクトの一つとして、もう一台のNASであるstorage3もUbuntu ServerからNixOSへ移行し、手作業での管理を減らす予定です。設定全体をコピーするだけで別システムをセットアップできたり、使い捨てのVMでアイデアを気軽に試せたりするワークフローは、本当に快適です 🥰

……ただ、管理するシステムが1台だけであれば、こうした仕組みはおそらく複雑すぎるかもしれません。

原文は Michael Stapelberg により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。