Vanilla Kubernetes auf NixOS

2. Dezember 2025

Letztes Jahr habe ich an der Uni zum ersten Mal mit Kubernetes gearbeitet. Nach einer anfangs steilen Lernkurve lernte ich das Gefühl zu schätzen, mehrere Computer gleichzeitig zu bedienen und jeden Pod in k9s1 als “healthy” zu sehen. Nachdem das Semester vorbei war, hatte ich natürlich noch einige Dinge, mit denen ich experimentieren wollte. Also musste ich Kubernetes installieren.

Da ich bisher nur laufende Installationen angefasst hatte (ich wusste, dass sie mit Ansible und Terraform aufgesetzt waren), fand ich mich dabei wieder, wie ich die services.kubernetes Option im NixOS GitHub Repo2 anstarrte — das war nur ein paar Wochen, nachdem ich es zum ersten Mal auf meinem Desktop installiert hatte.

Also fuhr ich drei arm64-Nodes bei Hetzner hoch — falkenberg, stadeln und ronhof — und beschloss, sie auf die harte Tour zu orchestrieren. Die Namen sind eine Anspielung auf ihre physischen Standorte: falkenberg ist nach dem Rechenzentrum bei Falkenstein in Bayern benannt, während stadeln und ronhof Stadtteile in Fürth sind, wo sich die anderen beiden Nodes befinden.

Die Kosten? Fünf Euro pro Monat pro VPS. Nicht billig… aber erträglich.

Die NixOS-Schicht

Ich definiere meine drei Hosts in einer einzigen default.nix, die ein gemeinsames kubernetes.nix-Modul nutzen, da die drei Nodes im Grunde jede Konfiguration teilen. Die Datei ist Teil meiner übergreifenden Nix-Konfiguration — natürlich muss NeoVim richtig konfiguriert sein, falls ich mich jemals per SSH auf einen Worker-Node schalten muss!

Um den OS-Zustand synchron zu halten, nutze ich Comin. Es läuft auf jedem Node, pollt mein Git-Repository und wendet die Konfiguration automatisch an. Es ist der GitOps-Ansatz, den ich von ArgoCD lieben gelernt habe, aber für Bare-Metal. Das bedeutet, ich muss nicht einmal nixos-rebuild switch manuell ausführen. Ich pushe auf main, und innerhalb einer Minute sind alle Nodes auf dem neuesten Stand. Sei es der neueste Linux-Kernel, ein Kubernetes-Update oder ein neuer vertrauenswürdiger öffentlicher SSH-Schlüssel.

services.comin = {
  enable = true;
  remotes = [{
    name = "origin";
    url = "[https://github.com/m4r1vs/NixConfig.git](https://github.com/m4r1vs/NixConfig.git)";
    branches.main.name = "main";
  }];
};

Container sind 2025 immer noch chaotisch

Als ich die Nodes zum ersten Mal bootete und das Secret des Controllers auf die Worker-Nodes kopierte, stieß ich auf ein großes Problem: Nichts funktionierte.

Schnell fand ich den Übeltäter:

❯ kubectl logs -n kube-system coredns-66bbb957b6-rrvzb
exec /bin/coredns: exec format error

Es ist immer DNS — auch wenn diesmal nicht direkt.

Jeder, der schon mal in die Containerisierung auf einer anderen Architektur als 64bit x86 eingetaucht ist, hat diesen Fehler wahrscheinlich schon gesehen. Die CoreDNS-Binary war nicht für arm64 kompiliert. Also ging ich auf die Suche.

Es stellte sich heraus, dass das Image, das ich nutzte, zwar korrekt für mein System gelabelt war, aber irgendwie die x86-Version im Image landete. Die Lösung war ziemlich einfach: Ich kompilierte CoreDNS für arm64, stopfte es in mein eigenes Image und überschrieb die Nix-Config, um stattdessen auf dieses zu zeigen:

services.kubernetes.addons.dns.coredns = {
  imageName = "mariusniveri/my-coredns";
  imageDigest = "...";
  finalImageTag = "latest";
  sha256 = "sha256-ID+qV6/knQDQ8leyq4r08uexPdDiu739Qeh/cBP0GfE=";
};

Ein weiteres kleines Problem war das Sandbox-pause-Image, das von containerd genutzt wird (ich weiß immer noch nicht wirklich, was es tut? Was macht es?). Irgendwie ist die Pull-Policy dieses Images auf Always gesetzt. Wieder ist alles transparent im Nix-Repo dargelegt und ich wusste genau, was zu tun war, um die strengen Rate-Limits von Dockerhub zu fixen:

virtualisation.containerd = {
  settings = lib.mkForce {
    plugins."io.containerd.grpc.v1.cri" = {
      sandbox_image = "registry.k8s.io/pause:3.10.1";
    };
  };
};

Bootstrapping von ArgoCD und dem Cluster

Hier wird es interessant. Ich wollte nichts manuell per kubectl apply anwenden. Ich wollte, dass der Cluster mit laufendem ArgoCD aufwacht, das auf meine “App of Apps” zeigt, bereit, alles zu synchronisieren!

Beim erneuten Lesen des Nix-Quellcodes dachte ich mir, dass der passend benannte AddonManager am besten für diese Art von Aufgabe geeignet wäre. Er nimmt einfach YAML-Dateien und stellt sicher, dass sie angewendet werden. Im Fall von Nix können diese YAML-Dateien nicht direkt referenziert werden, sondern müssen Nix-Attribute sein, die dann in YAML konvertiert werden.

Ärgerlich, da das Addon, das ich installieren möchte (ArgoCD), eine YAML-Datei ist und ich sie nicht jedes Mal manuell in einen Nix-Expression konvertieren möchte, wenn ich sie aktualisieren will. Zeit für ein wenig übertriebene Ingenierskunst, um die folgende Pipeline zu bauen:

fetch Argo yamlconvert to nix attrsetconvert to yamlkubectl apply

Es gibt wahrscheinlich einen besseren Weg.. den gibt es immer. Aber wir lassen uns Mal nicht aufhalten.

Einen Prompt an mein damals liebstes LLM später hatte ich eine funktionierende Nix-Funktion resourceFromYAML, die gut genug für das ArgoCD-Installationsmanifest war, und einfach so lief ArgoCD mit meiner konfigurierten “App of Apps”:

addonManager = lib.mkIf isMaster {
  enable = true;
  bootstrapAddons = (
    resourceFromYAML {
      path = builtins.fetchurl {
        url = "[https://raw.githubusercontent.com/argoproj/argo-cd/v3.2.0/manifests/install.yaml](https://raw.githubusercontent.com/argoproj/argo-cd/v3.2.0/manifests/install.yaml)";
        sha256 = "...";
      };
      ns = "argocd";
    })
    // {
      argo-namespace = {
        apiVersion = "v1";
        kind = "Namespace";
        metadata = {
          name = "argocd";
        };
      };
      cluster-bootstrap = {
        apiVersion = "argoproj.io/v1alpha1";
        kind = "Application";
        metadata = {
          name = "cluster-bootstrap";
          namespace = "argocd";
        };
        spec = {
          project = "default";
          source = {
            repoURL = "[https://github.com/m4r1vs/argo-apps](https://github.com/m4r1vs/argo-apps)";
            targetRevision = "HEAD";
            path = "bootstrap";
            directory = {
              recurse = true;
            };
          };
          destination = {
            server = "[https://kubernetes.default.svc](https://kubernetes.default.svc)";
            namespace = "bootstrap";
          };
          syncPolicy = {
            syncOptions = [
              "CreateNamespace=true"
            ];
            automated = {
              prune = true;
              allowEmpty = true;
              selfHeal = true;
            };
          };
        };
      };
  };
};

Das Ergebnis

Wenn das Cluster hochfährt, passiert nun folgendes: 1.  Systemstart: Der comin-Service startet, prüft periodisch meine Nix-Config auf Updates und wendet sie an. 2.  Control Plane: Sie startet etcd, generiert die CA und Zertifikate via easyCerts und fährt den kube-apiserver hoch. 3.  Addons: Der addonManager-Service springt an. Er sieht die deklarative Definition von ArgoCD, die wir aus yaml geparst haben. Es zieht sich dessen Deklaration aus dem Netz und wendet sie an. 4.  GitOps-Übernahme: ArgoCD läuft, sieht die eben angewandte cluster-bootstrap-Anwendung und beginnt, den Rest meiner k8s Resourcen aus Git zu ziehen.

Es ist ein komplett berührungsloser, deklarativer Boot-Prozess. Der einzige State ist das Git-Repository und ein einziges Secret, um die Nodes zu verbinden. Wenn ich die Festplatten löschen und neu deployen würde, würde sich der Cluster exakt so rekonstruieren, wie er war. Danke, Eelco3!

  1. k9scli.io
  2. github.com/NixOS/nixpkgs/tree/nixos-25.11/nixos/modules/services/cluster/kubernetes
  3. Eelco Dolstra, der Erfinder der Nix Programmiersprache.