Migrating my NAS from CoreOS/Flatcar Linux to NixOS

Michael Stapelberg

내 NAS를 CoreOS/Flatcar Linux에서 NixOS로 옮기기

원문은 Michael Stapelberg님이 에 게재했습니다. 이 블로그 구독하기

이 글에서는 기존 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 용도로 여러 운영 체제를 사용해 왔습니다. 다음은 두 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→ you are here ←
?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만 지원되는데, 저는 JSON을 손으로 쓰는 걸 좋아하지 않습니다. 또 포맷이 꽤 자주 바뀌는 것 같습니다.

결과적으로 저는 cloud-init에서 Ignition으로 넘어가지 않았고, 오랫동안 지원이 중단된 방식으로 쓰고 싶은 OS에 의존하는 건 좋지 않습니다.

R2. 컨테이너 비트 로트(Container Bitrot)

어느 시점에 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에서 시도해 볼 수 있으며, 충분히 만족스러우면 실제 머신에서 같은 단계를 반복해 마이그레이션하면 됩니다.

마이그레이션

실제 시스템 마이그레이션을 위해, (VM에서 프로토타이핑한 뒤) 한 번의 세션(약 한 시간) 안에 달성할 수 있어야 하는 다음과 같은 마일스톤을 정했습니다:

  • M1. NixOS 설치
  • M2. 원격 디스크 잠금 해제 설정
  • M3. 접근을 위한 Samba 설정
  • M4. 백업을 위한 SSH/rsync 설정
  • 그 외 모든 추가 요소는 있으면 좋은 것들이며 다른 날 이후 세션으로 미뤄도 됩니다.

실제로는 정확히 계획대로 되었습니다. NixOS를 실제로 설치하고 마일스톤 M4까지 설정을 마치는 데 한 시간 남짓 걸렸습니다. 나머지 있으면 좋은 것들은 시간 나는 대로 며칠, 몇 주에 걸쳐 완료했습니다.

팁: 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 NixOS 모듈을 활성화하면 Docker에 필요한 데몬과 동작에 필요한 것들이 모두 설정됩니다:

virtualisation.docker.enable = true;

이것만으로도 다른 서비스들이 Docker를 사용할 수 있지만, 디버깅을 위해 docker 명령을 직접 실행하고 싶기도 합니다. 그래서 dockersystemPackages에 추가했습니다:

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

이를 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 주소에서 아직 listen할 수 없을 때 재시작 횟수를 모두 소진해 버릴 수 있습니다. 서비스가 결국 올라오도록 무한 재시작을 활성화할 수 있습니다:

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

한동안 이전 위치(/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.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
'';

참고: activation 스크립트에서 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

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 스토어에 Perl 스크립트로 쓰는 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";
}
''

그런 다음 이 파일을 제 NixOS 설정의 pkgs 표현식을 가리키도록 configuration.nix에서 import하여 참조할 수 있습니다:

{ 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로 추출한 뒤, 이를 output으로 제공하는 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에서 아이디어를 시도해 볼 수 있다는 워크플로는 정말 좋습니다 🥰

…하지만 관리할 시스템이 단 하나뿐이라면, 아마 이 모든 것은 너무 복잡할 것입니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글