NixOS in der Praxis: Reproduzierbare Server-Konfiguration
Zurück zum Blog

NixOS in der Praxis: Reproduzierbare Server-Konfiguration

6 Min. Lesezeit
Lesen auf English

Zwei Jahre später

Im Februar 2022 haben wir in unserem NixOS-Einführungsartikel beschrieben, warum wir NixOS als Host-Betriebssystem für unsere Kubernetes-Nodes gewählt haben. Die Grundprinzipien -- deklarative Konfiguration, atomare Upgrades, Rollbacks per Generation -- haben sich seitdem nicht verändert. Was sich verändert hat, ist die Art, wie wir NixOS im Alltag nutzen.

Damals hatten wir eine Handvoll Nodes, jeder mit einer eigenen configuration.nix, die wir per SSH auf den jeweiligen Server kopierten und dort mit nixos-rebuild switch anwendeten. Das funktionierte, war aber weder skalierbar noch reproduzierbar im strengen Sinne. Die nixpkgs-Version war nicht gepinnt, Konfiguration wurde zwischen Nodes per Copy-Paste geteilt, und das Deployment war ein manueller Vorgang.

Heute, mit NixOS 23.05 auf unseren Produktionsservern und dem anstehenden Upgrade auf 23.11, sieht unser Workflow fundamental anders aus. Der Schlüssel dazu sind Nix Flakes.

Nix Flakes: Reproduzierbarkeit ernst genommen

Flakes sind das Feature, das NixOS für uns von "gut" zu "unverzichtbar" gemacht hat. Das Konzept ist simpel: Eine flake.nix-Datei definiert explizite Inputs -- typischerweise die nixpkgs-Version -- und explizite Outputs -- in unserem Fall die Konfigurationen unserer Server. Jeder Input wird über eine flake.lock-Datei auf einen exakten Git-Commit gepinnt.

Das bedeutet: Wenn wir heute eine Konfiguration deployen und in sechs Monaten denselben Commit auschecken und erneut deployen, erhalten wir exakt dasselbe System. Nicht ähnlich, nicht "sollte passen" -- identisch. Keine überraschend aktualisierten Pakete, keine verschobenen Abhängigkeiten.

Unsere flake.nix sieht vereinfacht so aus:

{
  description = "encircle360 Server-Infrastruktur";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";
  };

  outputs = { self, nixpkgs }: {
    nixosConfigurations = {
      k3s-server-01 = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/k3s-server-01/configuration.nix
          ./modules/base.nix
          ./modules/k3s-node.nix
        ];
      };

      k3s-worker-01 = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/k3s-worker-01/configuration.nix
          ./modules/base.nix
          ./modules/k3s-node.nix
        ];
      };

      k3s-worker-02 = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/k3s-worker-02/configuration.nix
          ./modules/base.nix
          ./modules/k3s-node.nix
        ];
      };
    };
  };
}

Alle Server sind in einer einzigen flake.nix definiert. Jeder Server hat seine host-spezifische Konfiguration, teilt sich aber gemeinsame Module. Die flake.lock pinnt nixpkgs auf einen bestimmten Commit, und ein nix flake update aktualisiert diese Referenz gezielt -- nicht bei jedem Build anders, sondern nur wenn wir es ausdrücklich wollen.

Geteilte Module: Konfiguration ohne Copy-Paste

Der größte praktische Gewinn von Flakes ist die saubere Modularisierung. Früher haben wir identische Konfigurationsblöcke zwischen Nodes kopiert -- SSH-Einstellungen, Firewall-Regeln, K3s-Konfiguration. Bei einer Änderung mussten wir jeden Node einzeln anfassen. Das war fehleranfällig und hat der ganzen Idee von deklarativer Konfiguration widersprochen.

Heute extrahieren wir gemeinsame Konfiguration in wiederverwendbare Module. Unser base.nix-Modul enthält alles, was jeder Server braucht:

# modules/base.nix
{ config, pkgs, ... }:

{
  # SSH-Haertung
  services.openssh = {
    enable = true;
    settings = {
      PermitRootLogin = "no";
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
    };
  };

  # Grundlegende Pakete auf dem Host
  environment.systemPackages = with pkgs; [
    vim
    htop
    curl
    tmux
    git
  ];

  # Automatische Garbage Collection
  nix.gc = {
    automatic = true;
    dates = "weekly";
    options = "--delete-older-than 30d";
  };

  # Flakes aktivieren
  nix.settings.experimental-features = [ "nix-command" "flakes" ];

  # Deploy-Benutzer
  users.users.deploy = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAA... deploy@encircle360"
    ];
  };

  security.sudo.wheelNeedsPassword = false;
}

Das K3s-Modul kapselt die gesamte Kubernetes-Host-Konfiguration:

# modules/k3s-node.nix
{ config, pkgs, ... }:

{
  # Firewall: K3s-relevante Ports
  networking.firewall = {
    enable = true;
    allowedTCPPorts = [ 22 6443 10250 ];
    allowedUDPPorts = [ 8472 51820 ];
  };

  # Kernel-Parameter fuer Container-Networking
  boot.kernel.sysctl = {
    "net.ipv4.ip_forward" = 1;
    "net.bridge.bridge-nf-call-iptables" = 1;
  };

  # kubectl auf dem Host verfuegbar machen
  environment.systemPackages = with pkgs; [
    kubectl
    k9s
  ];
}

Die host-spezifische Konfiguration enthält dann nur noch das, was diesen einen Server von den anderen unterscheidet -- Hostname, IP-Adresse, K3s-Rolle:

# hosts/k3s-worker-01/configuration.nix
{ config, pkgs, ... }:

{
  imports = [ ./hardware-configuration.nix ];

  networking.hostName = "k3s-worker-01";
  networking.interfaces.ens3.ipv4.addresses = [{
    address = "10.0.1.11";
    prefixLength = 24;
  }];
  networking.defaultGateway = "10.0.1.1";

  services.k3s = {
    enable = true;
    role = "agent";
    serverAddr = "https://10.0.1.10:6443";
    tokenFile = "/etc/k3s/token";
  };

  system.stateVersion = "23.05";
}

Diese Trennung ist entscheidend. Wenn wir die SSH-Konfiguration ändern, ändern wir base.nix einmal, und alle Nodes übernehmen die Änderung beim nächsten Deployment. Wenn wir einen neuen K3s-Port öffnen müssen, ändern wir k3s-node.nix. Wenn ein einzelner Node eine andere IP bekommt, ändern wir nur dessen host-spezifische Datei.

Remote-Deployment: Kein SSH-Copy mehr

Der zweite große Fortschritt gegenüber unserem früheren Workflow ist das Remote-Deployment. Statt die Konfiguration auf jeden Server zu kopieren und dort lokal nixos-rebuild switch auszuführen, deployen wir von unserer lokalen Maschine aus:

# Einen einzelnen Node deployen
nixos-rebuild switch --flake .#k3s-worker-01 \
  --target-host deploy@10.0.1.11 \
  --use-remote-sudo

# Server-Node deployen
nixos-rebuild switch --flake .#k3s-server-01 \
  --target-host deploy@10.0.1.10 \
  --use-remote-sudo

Das --flake .#k3s-worker-01 sagt nixos-rebuild, welche Konfiguration aus der flake.nix verwendet werden soll. --target-host gibt den Zielserver an. Der Build passiert lokal (oder auf einem Build-Server), die fertigen Pakete werden per SSH auf den Zielhost kopiert und dort aktiviert.

Das hat mehrere Vorteile. Erstens brauchen die Zielserver kein Git-Repository mit der Konfiguration. Zweitens können wir einen Build testen, bevor wir ihn deployen -- nixos-rebuild build --flake .#k3s-worker-01 baut die Konfiguration lokal, ohne irgendetwas auf dem Server zu ändern. Drittens ist der gesamte Deployment-Vorgang reproduzierbar und von einem Ort aus steuerbar.

Für die Aktualisierung aller Nodes haben wir ein simples Skript:

#!/usr/bin/env bash
set -euo pipefail

NODES=("k3s-server-01:10.0.1.10" "k3s-worker-01:10.0.1.11" "k3s-worker-02:10.0.1.12")

for entry in "${NODES[@]}"; do
  name="${entry%%:*}"
  host="${entry##*:}"
  echo "Deploying $name..."
  nixos-rebuild switch --flake ".#$name" \
    --target-host "deploy@$host" \
    --use-remote-sudo
  echo "$name deployed successfully."
done

Kein Ansible, kein Puppet, kein Terraform. Die Nix-Konfiguration ist gleichzeitig die Infrastruktur-Definition und das Deployment-Werkzeug. Das ist weniger Tooling, weniger Indirektion, weniger Fehlerquellen.

Secrets: Die offene Flanke

Reproduzierbare Konfiguration hat ein natürliches Spannungsfeld: Secrets. Die flake.nix und alle Module liegen in einem Git-Repository. K3s-Tokens, SSH-Private-Keys und andere Geheimnisse gehören dort nicht hinein.

Unsere Lösung ist pragmatisch. Secrets liegen als Dateien auf den Servern in /etc/secrets/ und werden über die Nix-Konfiguration referenziert, aber nicht definiert. Das K3s-Token beispielsweise wird bei der initialen Einrichtung manuell auf den Server kopiert und in der Konfiguration nur als Pfad angegeben (tokenFile = "/etc/k3s/token").

Das ist nicht elegant. Werkzeuge wie agenix oder sops-nix verschlüsseln Secrets so, dass sie im Repository liegen können und erst auf dem Zielserver entschlüsselt werden. Wir haben uns bewusst dagegen entschieden -- nicht weil die Werkzeuge schlecht sind, sondern weil unsere Anzahl an Secrets überschaubar ist und wir die zusätzliche Komplexität aktuell nicht brauchen. Bei mehr Nodes oder häufiger rotierenden Secrets würden wir das überdenken.

Lektionen nach zwei Jahren

Ein paar Erfahrungen, die sich nicht aus der Dokumentation ergeben:

Flakes früher aktivieren. Wir haben fast ein Jahr ohne Flakes gearbeitet. Das war verschwendete Zeit. Flakes lösen so viele Alltagsprobleme -- gepinnte Versionen, Modularisierung, reproduzierbare Builds --, dass wir sie jedem empfehlen, der NixOS ernsthaft nutzen will. Mit NixOS 23.05 und 23.11 sind Flakes stabil genug für jeden Produktionseinsatz.

Nix Store Garbage Collection nicht vergessen. Der Nix Store wächst mit jeder Generation. Ohne regelmäßige Garbage Collection belegen alte Generationen schnell zweistellige Gigabyte an Plattenplatz. nix.gc.automatic = true in der Konfiguration ist Pflicht.

Konfiguration von Anfang an modularisieren. Wir haben zu lange mit monolithischen configuration.nix-Dateien pro Node gearbeitet. Die Umstellung auf Module war aufwändig, hätte aber von Tag eins passieren sollen. Wer mit mehr als einem Node startet, sollte sofort in Module denken.

nixos-rebuild build vor switch. Klingt offensichtlich, war es für uns anfangs nicht. Einen Build lokal prüfen, bevor man ihn auf einen Produktionsserver schiebt, ist mit Flakes trivial und spart Nerven.

Fazit

NixOS hat sich für uns in zwei Jahren von einem vielversprechenden Experiment zu einem unverzichtbaren Bestandteil unserer Infrastruktur entwickelt. Die Kombination aus Nix Flakes für reproduzierbare Builds, geteilten Modulen für konsistente Konfiguration und Remote-Deployment für skalierbare Verwaltung löst Probleme, für die wir früher Ansible, manuelle Skripte und viel Hoffnung brauchten.

Wie schon in unserem K3s-Produktionsartikel beschrieben, bleiben wir bei der bewussten Schichtentrennung: NixOS als Host-OS konfiguriert die Maschine, K3s läuft darauf als Kubernetes-Distribution, und unsere Anwendungen laufen als Container in Kubernetes. Nix verwaltet keine Anwendungen -- es verwaltet das Fundament, auf dem alles andere läuft.

Wer NixOS für Server evaluiert und unseren Einführungsartikel von 2022 gelesen hat: Flakes machen den entscheidenden Unterschied. Die Lernkurve ist immer noch real, aber das Ergebnis ist eine Infrastruktur, die wir mit gutem Gewissen als reproduzierbar bezeichnen können. Nicht "sollte reproduzierbar sein". Ist es.

Teilen

Patrick Hütter

Geschrieben von

Patrick Hütter

Gründer & Software-Architekt

Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.