내 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)”를 먼저 따라 한 뒤, 관심 있는 섹션을 차례대로 살펴보며 적용할 수 있습니다. 전체 설정을 바로 보고 싶다면 결론으로 건너뛰세요.
배경/연혁
지난 10여 년간 NAS 용도로 여러 운영체제를 사용해 왔습니다. 2대의 NAS 시스템인 storage2와 storage3의 변천사는 다음과 같습니다.
| 연도 | storage2 | storage3 | 상세 내용(블로그 글) |
|---|---|---|---|
| 2013 | Debian on qnap | Debian on qnap | qnap TS-119P2+에서 Debian으로 Wake-On-LAN |
| 2016 | CoreOS on PC | CoreOS on PC | Gigabit NAS (CoreOS 구동) |
| 2023 | CoreOS on PC | Ubuntu+ZFS on PC | 내 올플래시 ZFS NAS 제작기 |
| 2025 | NixOS on PC | Ubuntu+ZFS on PC | → 현재 글 ← |
| ? | NixOS on PC | NixOS+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 설정
- 그 외 모든 추가 작업은 nice-to-have이며 다른 날 다음 세션으로 미뤄도 됩니다.
실제로 정확히 계획대로 진행되었습니다. NixOS를 실제로 설치하고 마일스톤 M4까지 설정을 마치는 데 한 시간 남짓 걸렸습니다. 나머지 nice-to-have 항목들은 이후 며칠, 몇 주에 걸쳐 시간이 날 때마다 처리했습니다.
팁: 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 명령을 실행하고 싶기도 합니다. 그래서 docker를 systemPackages에도 추가했습니다.
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를 완료했습니다!
참고: Nice-to-haves: 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가 완료됩니다!
참고: Nice-to-haves: N6. NixOS의 rrsync
Nice-to-haves
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가 완전히 부팅되고 /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:1Flatcar 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의 imports에 jellyfin.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
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";
}
''그런 다음 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 표현식에 래퍼 스크립트를 추가해
$PATH에rsync가 포함되도록 한 뒤, 스크립트가 더 이상 절대 경로를 필요로 하지 않게 하는 것입니다. - 이런 작은 글루 스크립트의 경우, 설정 디렉터리에 파일 하나를 줄일 수 있다는 점에서 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에서 아이디어를 시험해 보는 워크플로가 정말 만족스럽습니다 🥰
…하지만 관리해야 할 시스템이 하나뿐이라면, 아마 이 모든 과정은 너무 복잡할 것입니다.
글을 무작위로 읽기
