How I like to install NixOS (declaratively)

Michael Stapelberg

내가 선호하는 NixOS 설치 방법 (선언적으로)

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

몇 가지 네트워크 스토리지 PC 빌드 중 하나에서 Flatcar Container Linux의 대안을 찾다가 거의 10년 만에 NixOS를 다시 써 보게 됐다. NixOS를 설치하는 방법은 여러 가지가 있는데, 이 글에서는 내가 물리 하드웨어나 가상 머신에 NixOS를 설치할 때 선호하는 방식, 즉 네트워크를 통해 완전히 선언적으로 설치하는 방법을 설명하려 한다.

소개: 선언적이란?

선언적이라는 말은 어떻게 할지가 아니라 무엇을 달성해야 하는지를 기술한다는 뜻이다. NixOS에서는 apt install 같은 명령을 실행하는 대신, 시스템에 포함하고 싶은 소프트웨어가 무엇인지 선언한다(설정 옵션 environment.systemPackages에 추가하거나 모듈을 활성화하는 식으로).

선언적 접근 방식의 좋은 점은 시스템이 설정을 따른다는 것이다. 따라서 설정 변경을 되돌리면 시스템에 대한 변경도 깔끔하게 되돌릴 수 있다.

나는 선언적 설정 파일들을 보통 Git으로 버전 관리하는 것을 선호한다.

현재 네트워크 스토리지 빌드를 처음 구성했을 때 나는 cloud-init 설정이 선언적인, 자동으로 업데이트되는 기본 시스템이었기 때문에 CoreOS(나중의 Flatcar Container Linux)를 선택했다.

NixOS 설치 방법

그래픽 설치 프로그램: 데스크톱 전용

NixOS 매뉴얼의 “Installation” 섹션에서는 그래픽 설치 프로그램(“데스크톱 사용자용”, Calamares 시스템 설치 프로그램 기반이며 2022년에 추가됨)과 수동 설치 프로그램에 대해 설명한다.

그래픽 설치 프로그램으로는 NixOS를 디스크에 설치하기 쉽다. 기본값을 충분히 여러 번 확인만 하면 동작하는 시스템이 완성된다. 하지만 몇 가지 단점이 있다:

  • 설치 후 SSH를 수동으로 활성화해야 한다 — 네트워크가 아니라 로컬에서 직접.
  • 그래픽 설치 프로그램이 초기 NixOS 설정을 생성해 주긴 하지만, 자신만의 초기 NixOS 설정을 주입할 방법은 없다.

그래픽 설치 프로그램은 분명 원격 설치나 자동화된 설치를 염두에 두고 만든 것이 아니다.

수동 설치

반면 수동 설치 프로그램은 내 취향에는 너무 수동적이다. NixOS 매뉴얼의 Installation summary 섹션에서 “Example 2”와 “Example 3”을 펼쳐 보면 어떤 느낌인지 알 수 있다. 물론 단계 자체는 충분히 따라 할 만하지만, 급할 때 이런 방식으로 시스템을 설치하고 싶지는 않다. 우선 수동 절차는 스트레스를 받는 상황에서 실수하기 쉽다. 그리고 대화형으로 명령을 복사해 붙여 넣는 행위는 선언적 설정 파일을 작성하는 것과 정반대이기도 하다.

네트워크 설치: nixos-anywhere

이상적으로는 설치 과정 대부분을 내 PC에서 편하게 수행하고 싶다. 즉, 설치 프로그램이 네트워크를 통해 사용할 수 있어야 한다. 또, 설치 직후 별도의 수동 단계 없이 바로 동작하는 초기 NixOS 설정으로 머신이 부팅되길 원한다.

다행히 (커뮤니티에서 제공하는) 해결책이 있다. 바로 nixos-anywhere다. NixOS 설치 프로그램으로 부팅하는 것만 직접 해두면, 이후 단일 명령을 실행해 nixos-anywhere가 해당 설치 프로그램에 SSH로 접속해 디스크를 파티셔닝하고 NixOS를 디스크에 설치한다. 특히 nixos-anywhere는 선언적으로 설정되므로, 이 단계를 언제든지 반복할 수 있다.

(nixos-anywhere가 심지어 임의의 시스템에 SSH로 접속해 kexec로 재부팅하여 NixOS 설치 프로그램으로 진입시킬 수도 있다는 건 알고 있다. 분명 멋진 묘기지만, 나는 명시적으로 설치 프로그램으로 부팅하는 방식이 더 위험이 적고 더 일반적으로 적용 가능하며 반복 가능하다고 느껴서 그 방식을 선호한다.)

준비: Nix 설치

나는 머신 중 하나에는 NixOS를 쓰고 싶지만, (현재로서는) 메인 데스크톱 PC에서는 쓰고 싶지 않다.

그래서 Arch Linux에는 (NixOS를 실행하지 않고도 빌드하기 위한) nix 도구만 설치했다:

% sudo pacman -S nix
% sudo groupadd -r nixbld
% for n in $(seq 1 24); do sudo useradd -c "Nix build user $n" \
    -d /var/empty -g nixbld -G nixbld -M -N -r -s "$(which nologin)" \
    nixbld$n; done
% sudo systemctl enable --now nix-daemon.socket

이제 nix-shell -p hello를 실행하면 GNU hello 패키지가 설치된 새 셸로 진입해야 한다:

% export NIX_PATH=nixpkgs=channel:nixos-25.05
% nix-shell -p hello
hello

[nix-shell:/tmp]$ hello
Hello, world!

참고로 Arch Linux 위키의 Nix 페이지에서는 nix로 패키지를 설치하는 방법을 설명하지만, 그건 내가 관심 있는 부분이 아니다. 나는 단지 NixOS 시스템을 원격으로 관리하고 싶을 뿐이다.

나만의 설치 프로그램 빌드하기

앞서 “NixOS 설치 프로그램으로 부팅하는 건 직접 해야 한다”고 했는데, 그건 충분히 쉽다. ISO 이미지를 USB 스틱에 쓰고 그걸로 머신을 부팅하면 된다(또는 VM에서 ISO를 선택해 부팅하면 된다).

하지만 SSH로 원격 로그인을 하려면 먼저 수동으로 비밀번호를 설정해야 한다. 또 나는 TERM=xterm 환경 변수로 SSH 접속을 해야 하는데, 내가 선호하는 터미널인 rxvt-unicode의 termcap 파일이 기본 NixOS 설치 프로그램 환경에 포함되어 있지 않기 때문이다. 마찬가지로 내가 설정한 로케일도 동작하지 않고, 선호하는 셸인 Zsh도 사용할 수 없다.

설치 프로그램이 애초에 편한 환경으로 미리 설정되어 있다면 훨씬 좋지 않을까?

Debian이나 Fedora, Arch Linux 같은 다른 리눅스 배포판이었다면 공식 설치 프로그램 ISO 이미지를 직접 다시 빌드하려고 시도하지 않았을 것이다. 그들의 프로세스와 툴링이 잘 동작하리라는 건 확신하지만, 그건 내가 추가로 배워서 디버깅하고 유지 관리해야 할 일 하나가 더 생긴다는 뜻이기도 하다.

하지만 NixOS 설치 프로그램을 빌드하는 과정은 일반 NixOS 시스템을 설정하는 것과 매우 유사하다. 같은 설정, 같은 빌드 도구다. 절차는 공식 NixOS 위키에 문서화되어 있다.

나는 보통 configuration.nix에 넣는 커스터마이징을 그대로 복사하고, nixpkgs에서 installation-cd-minimal.nix 모듈을 가져와서 그 결과를 iso.nix 파일에 담았다:

{ config, pkgs, ... }:

{
  imports = [
    <nixpkgs/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix>
    <nixpkgs/nixos/modules/installer/cd-dvd/channel.nix>
  ];

  i18n.supportedLocales = [
    "en_DK.UTF-8/UTF-8"
    "de_DE.UTF-8/UTF-8"
    "de_CH.UTF-8/UTF-8"
    "en_US.UTF-8/UTF-8"
  ];
  i18n.defaultLocale = "en_US.UTF-8";

  security.sudo.wheelNeedsPassword = false;
  users.users.michael = {
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"
    ];

    isNormalUser = true;
    description = "Michael Stapelberg";
    extraGroups = [ "wheel" ];
    initialPassword = "SGZ3odMZIesxTuh2Y2pUaJA";  # random for this post
    shell = pkgs.zsh;
    packages = with pkgs; [];
  };

  environment.systemPackages = with pkgs; [
    git  # for checking out github.com/stapelberg/configfiles
    rsync
    zsh
    vim
    emacs
    wget
    curl
    rxvt-unicode  # for terminfo
    lshw
  ];

  programs.zsh.enable = true;
  services.openssh.enable = true;

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "25.05"; # Did you read the comment?
}

ISO 이미지를 빌드하기 위해 NIX_PATH 환경 변수를 설정해 nix-build(1)iso.nix 파일을 가리키도록 하고 NixOS 25.05용 업스트림 채널을 선택했다:

% export NIX_PATH=nixos-config=$PWD/iso.nix:nixpkgs=channel:nixos-25.05
% nix-build '<nixpkgs/nixos>' -A config.system.build.isoImage

2025년 하이엔드 리눅스 PC에서 약 1분 30초가 지나자, 설치 프로그램 ISO는 result/iso/nixos-minimal-25.05.802216.55d1f923c480-x86_64-linux.iso(내 경우 크기는 1.46GB)에서 찾을 수 있었다.

Nix Flakes 활성화하기

안타깝게도 nix 프로젝트는 5년 넘게 제공된 “실험적” 새 커맨드 라인 인터페이스(CLI)를 아직 기본값으로 활성화하지 못했기 때문에, 설정 파일을 만들고 최신 nix-command 인터페이스를 활성화해야 한다:

% mkdir -p ~/.config/nix
% echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf

구버전과 신버전은 어떻게 구분할까? 이전 명령은 하이픈으로 연결되고(nix-build), 새 명령은 공백으로 구분된다(nix build).

내가 Nix flakes도 함께 활성화한 걸 눈치챘을 것이다. 나는 nix 빌드를 hermetic하게 만들고 nixpkgs 및 빌드에 포함하고 싶은 다른 nix 모듈들의 특정 리비전에 고정하기 위해 flakes를 사용한다. 나는 flakes를 다른 프로그래밍 환경의 버전 잠금 파일에 비유하곤 한다. 아이디어는 5개월 뒤에 시스템을 빌드해도 오늘과 같은 결과가 나오도록 하는 것이다.

flakes가 동작하는지 확인하려면 nix shell(nix-shell이 아니다)을 실행하면 된다:

% nix shell nixpkgs#hello
/tmp 2 % hello
Hello, world!

(재)설치 단계

참고로 Proxmox에서 NixOS용 새 VM을 만들 때 내가 사용하는 설정은 다음과 같다. 가장 중요한 설정은 bios=ovmf(= UEFI 부팅, 기본값이 아님)이며, 이를 통해 물리 머신과 VM에서 동일한 부트로더 설정을 사용할 수 있다:

Proxmox VM 생성 대화상자 스크린샷

(서명되지 않은) 설치 프로그램으로 부팅하기 전에 UEFI 설정으로 들어가 Secure Boot를 비활성화해야 한다. 예를 들어 Proxmox는 기본적으로 Secure Boot를 활성화한다.

그런 다음 대상 시스템에서 커스텀 설치 프로그램 ISO로 부팅하고, ssh [email protected]이 비밀번호를 묻지 않고 동작하는지 확인한다.

다음 내용으로 flake.nix를 선언한다:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";

    disko.url = "github:nix-community/disko";
    # Use the same version as nixpkgs
    disko.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs =
    {
      nixpkgs,
      disko,
      ...
    }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        config.allowUnfree = false;
      };
    in
    {
      nixosConfigurations.zammadn = nixpkgs.lib.nixosSystem {
        inherit system;
        inherit pkgs;
        modules = [
          disko.nixosModules.disko
          ./configuration.nix
        ];
      };
      formatter.${system} = pkgs.nixfmt-tree;
    };
}

disk-config.nix에 디스크 설정을 선언한다:

disk-config.nix
{ lib, ... }:

{
  disko.devices = {
    disk = {
      main = {
        device = lib.mkDefault "/dev/sda";
        type = "disk";
        content = {
          type = "gpt";
          partitions = {
            ESP = {
              type = "EF00";
              size = "500M";
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
                mountOptions = [ "umask=0077" ];
              };
            };
            root = {
              size = "100%";
              content = {
                type = "filesystem";
                format = "ext4";
                mountpoint = "/";
              };
            };
          };
        };
      };
    };
  };
}

원하는 NixOS 설정을 configuration.nix에 선언한다:

{ modulesPath, lib, pkgs, ... }:

{
  imports =
    [
      (modulesPath + "/installer/scan/not-detected.nix")
      ./hardware-configuration.nix
      ./disk-config.nix
    ];

  # Adding michael as trusted user means
  # we can upgrade the system via SSH (see Makefile).
  nix.settings.trusted-users = [ "michael" "root" ];
  # Clean the Nix store every week.
  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 = "zammadn";
  time.timeZone = "Europe/Zurich";

  # Use systemd for networking
  services.resolved.enable = true;
  networking.useDHCP = false;
  systemd.network.enable = true;

  systemd.network.networks."10-e" = {
    matchConfig.Name = "e*";  # enp9s0 (10G) or enp8s0 (1G)
    networkConfig = {
      IPv6AcceptRA = true;
      DHCP = "yes";
    };
  };

  i18n.supportedLocales = [
    "en_DK.UTF-8/UTF-8"
    "de_DE.UTF-8/UTF-8"
    "de_CH.UTF-8/UTF-8"
    "en_US.UTF-8/UTF-8"
  ];
  i18n.defaultLocale = "en_US.UTF-8";

  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 = "install";  # TODO: change!
    shell = pkgs.zsh;
    packages = with pkgs; [];
  };

  environment.systemPackages = with pkgs; [
    git  # for checking out github.com/stapelberg/configfiles
    rsync
    zsh
    vim
    emacs
    wget
    curl
  ];

  programs.zsh.enable = true;

  services.openssh.enable = true;

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "25.05"; # Did you read the comment?
}

…그리고 잠근다:

% nix flake lock
  1. nixos-anywhere를 이용해 설치 프로그램에서 hardware-configuration.nix를 가져오고 NixOS를 디스크에 설치한다:
% nix run github:nix-community/nixos-anywhere -- \
  --flake .#zammadn \
  --generate-hardware-config nixos-generate-config ./hardware-configuration.nix \
  --target-host [email protected]

약 1분 후, 내 VM 설치가 완료되고 재부팅됐다!

전체 nixos-anywhere 설치 로그가 궁금하다면
% nix run github:nix-community/nixos-anywhere -- \      
  --flake .#wiki \                                                                 
  --generate-hardware-config nixos-generate-config ./hardware-configuration.nix \
  --target-host [email protected]                                               
[... transcript truncated for brevity but contains full nixos-anywhere log ...]
### Installing NixOS ###
installing the boot loader...
setting up /etc...
Created "/boot/EFI".
installation finished!
### Rebooting ###
### Done! ###

설치 후 단계

이제 시스템의 선언적인 부분이 갖춰졌으니, 상태를 가지는 부분을 챙겨야 한다.

내 경우에는 설정이 필요한 상태를 가지는 부분은 Tailscale 메시 VPN뿐이다.

Tailscale을 설정하려면 SSH로 로그인해 sudo tailscale up을 실행한다. 그런 다음 링크를 따라 새 노드를 내 네트워크에 추가한다. 이후 Tailscale Machines 콘솔에서 키 만료를 비활성화하고 ACL 태그를 추가한다.

변경 사항 적용하기

이제 설정 파일에서 무언가를 변경한 뒤에는 nixos-rebuild를 원격으로 사용해 변경 사항을 NixOS 시스템에 배포한다:

% nix run nixpkgs#nixos-rebuild -- \
  --target-host michael@zammadn \
  --use-remote-sudo \
  switch \
  --flake .#zammadn

nixos-rebuild switch의 일부로 모든 변경 사항이 완전히 적용되는 것은 아니라는 점에 유의하자. systemd 서비스는 일반적으로 재시작되지만, 새로 필요해진 커널 모듈은 자동으로 로드되지 않는다(예: Frigate에서 edgetpu Coral 하드웨어 가속기를 활성화한 뒤).

따라서 모든 변경이 확실히 적용되도록 하려면, 변경 사항을 배포한 뒤 시스템을 재부팅하자.

NixOS의 장점 중 하나는 부팅 메뉴에서 실행하고 싶은 시스템 세대를 선택할 수 있다는 것이다. 최근 변경으로 뭔가 고장 났다면, 이전 세대로 빠르게 재부팅해 변경을 되돌릴 수 있다. 물론 설정 변경 자체를 되돌리고 새 세대를 배포할 수도 있다. 상황에 따라 더 편한 방법을 선택하면 된다.

결론

이 글을 통해 Nix와 NixOS를 처음 시작할 때 누군가 나에게 해줬으면 했던 이야기를 전달할 수 있었기를 바란다:

  1. flakes와 새로운 CLI를 활성화하라.
  2. 원격 설치에는 nixos-anywhere를 사용하라.
    • 원한다면 커스텀 설치 프로그램을 빌드하라, 쉽다!
  3. 원격 배포에는 nixos-rebuild의 내장 --target-host 플래그를 사용하라.

이제 어디로 가면 될까?

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

댓글