Kubernetes hat sich als das zentrale Werkzeug für die Orchestrierung von Containeranwendungen etabliert. Die Interaktion mit dem System erfolgt in der Regel über strukturierte YAML-Dokumente, die Ressourcen und deren Konfiguration beschreiben. Trotz seiner Popularität stößt YAML schnell an seine Grenzen, wenn es um die Definition komplexer Systemkonfigurationen geht. Wiederholungen, mangelnde Abstraktionsmöglichkeiten und fehlerträchtige Strukturen sind häufige Probleme.
Traditionelle Werkzeuge wie Helm versuchen, diese Herausforderungen zu adressieren, führen jedoch oft zu noch größerer Komplexität. Hier setzt Nix mit seiner funktionalen und domänenspezifischen Sprache an. Gemeinsam mit dem Tool Kubenix eröffnet Nix neue Möglichkeiten für die Definition und Verwaltung von Kubernetes-Ressourcen. Dieser Blogpost zeigt, wie Nix und Kubenix die Arbeit mit Kubernetes vereinfachen und flexibler gestalten können.
Kubernetes ist eine API
Kubernetes ist ein komplexes und potenziell verteiltes System, dessen wesentliche Komponenten ein sogenanntes Operatorenmuster implementieren. In einem kontinuierlichen Prozess werden Ressourcen auf Änderungen überwacht und basierend hierauf ein Handlungsstrategie gewählt. Diese führt unter Umständen zu einer oder mehreren Zustandsmutationen, was wiederum zu einem erneutes Durchlaufen dieses Prozesses führt. Der Zustand des Gesamtsystems konvergiert dabei gegen den spezifizierten Zustand. Die zugrunde liegenden Ressourcen unterliegen bei der Kommunikation und Verarbeitung einem stringenten Typensystem.
Aus Endnutzersicht hingegen stellt sich Kubernetes in der Regel als API dar. Hierbei stellen strukturierte Dokumente – allen voran YAML – das Austauschformat dar.
Limitierungen von YAML als strukturiertes Dokument
Während YAML als Format für eine ReSTful-API durchaus einen geeigneten Kandidaten darstellt, ist es dennoch ungeeignet damit komplexe Zusammenhänge zu definieren. Aufgrund der unzureichenden Abstraktionsmittel neigen entsprechende Dokumente zu einem großen Umfang, tiefer Verschachtelungstiefe und zahlreichen Wiederholungen. Bei strukturierten Dokumente stehen naturbedingt keine Mittel zur Erweiterbarkeit und Vererbung zur Verfügung, was zusätzlich dazu beiträgt, dass komplexe Dokumente eine schlechte Wartbarkeit aufweisen. Für YAML im speziellen gibt es darüber hinaus auch verschiedene Typenverwechselungen, die im einfachsten Fall Verwirrung bei den jeweiligen Autor:innen auslöst und im schlimmsten Fall zu technischen Fehlern führen kann.
Helm als unzureichende Lösung
Die naturbedingten Limitierungen von strukturierten Dokumenten werden durch unterschiedliche Werkzeuge adressiert. Dabei wird das ursprüngliche Problem allerdings nicht angegangen, sondern es werden bestenfalls ein paar Schwächen mitigiert. Alle diese Werkzeuge stellen eine weitere Schicht der Indirektion dar und führen dabei neben der allgemein gesteigerten Komplexität zusätzliche bisher nicht existierende Fehlerklassen ein. Neben Jsonnet und Kustomize zählen Helm und Helmfile zu den bekanntesten Vertretern von Werkzeugen zur Generierung von strukturierten Dokumenten im Kontext von Kubernetes.
Helm hat sich dabei zu einer Art de-facto-Standard in der Paketierung von Kubernetes Ressourcen etabliert und wird oftmals als der Paketmanager für Kubernetes gehandelt. Aus Endnutzer:innensicht stellt sich Helm zumeist als einfaches Mittel dar und bietet für nahezu sämtliche Applikationen im Cloud-Native-Ökosystem ein sogenanntes Chart, ein Paket in der Terminologie von Helm. Da Helm im Wesentlichen auf Templating von YAML als strukturierter Sprache setzt, ist dessen Code schwer zu lesen und schreiben sowie erschwert dessen Wartung. Das folgende Beispiel illustriert die Unzulänglichkeiten Zum einen gibt es eine hohe Dichte von Kontrollstrukturen, die sich über den gesamten Quellcode aufspannen. Zum anderen wird versucht eine Art von Validierung während der Renderingphase des Templates zu implementieren. Darüber hinaus muss die Einrückunstiefe beim Templaten stets beachtet werden.
{{- if .Values.rbac.create }}
{{- if and .Values.rbac.scope (not .Values.controller.scope.enabled) -}}
{{ required "Invalid configuration …" (index (dict) ".") }}
{{- end }}
{{- if not .Values.rbac.scope -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
{{- include "ingress-nginx.labels" . | nindent 4 }}
{{- with .Values.controller.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
name: {{ include "ingress-nginx.fullname" . }}
# …
{{- end }}
{{- end }}
Des Weiteren fehlt ein Typensystem und Typensicherheit vollständig und es Helm gibt nicht einmal die Sicherheit, dass die Ausgabe valides YAML darstellt. Es besteht daher das Bedürfnis nach einer Sprache, die ursächlich die Limitierungen eines strukturierten Dokuments löst. Gleichzeitig sollte diese Sprache in der Lage sein, hinreichende Abstraktionen zu schaffen, so dass komplexe Definitionen wartungsfreundlich ermöglicht werden und durch Modularisierung wiederverwendet werden können.
Nix ist eine funktionale und domänenspezifische Sprache
Nix ist eine rein funktionale Sprache, so dass jeder beliebige Nix-Code einen Ausdruck darstellt, der einen Wert zurückgibt. Die Evaluation eines Ausdrucks gibt einen Datenstruktur zurück und führt dabei insbesondere keine Sequenz von Operationen aus.
Dabei wertet Nix Ausdrücke nach dem Prinzip der Lazy Evaluation aus: Nur jene Teile von Ausdrücken werden ausgewertet, die notwendig sind, um die angefragten Ergebnisse zu liefern.
Als domänenspezifische Sprache stellt Nix keine Programmiersprache für allgemeine Verwendungszwecke dar. Auch wenn es für andere Anwendungszwecke genutzt werden kann, ist die Sprache ausschließlich für den gleichnamigen Paketmanager entworfen worden.
Der Begriff Nix wird häufig synonym für unterschiedliche Bereiche im Nixuniversum verwendet. Zumeist meint der Begriff die bereits erwähnte domänenspezifische Sprache selber. Gelegentlich wird aber auch die Werkzeugausstattung und der Paketmanager durch den Begriff Nix bezeichnet. Letzterer wird auch als nixpkgs bezeichnet und meint in dem Zusammenhang dann auch gleich das korrespondierende GitHub-Repositorium, in der die Ganzheit aller Pakete und damit auch das Nix-Ökosystem gepflegt wird. Auf dieses Paketsystem baut das Linux-basierte Betriebssystem NixOS auf, indem es Nix als Sprache nutzt um damit deklarativ komplexe Betriebssystem Bestandteile definiert.
Obwohl Nix nicht als Programmiersprache für allgemeine Verwendungszwecke entworfen wurde, ist die Sprache sehr mächtig und erlaubt universelle Anwendungsgebiete in der Nische der Paketierung. Dies gilt auch für die zuvor erwähnte Domäne der Paketierung von Kubernetes Ressourcen in YAML. Ein naheliegender Ansatz ist es, die Sprachfeatures wie komplexe Typen, lokale Variablen und Vererbung zu nutzen, um die gewünschte YAML-Ausgabe direkt zu erzeugen. Ein entsprechender Nix-Ausdruck für ein Kubernetes-Deployment könnte beispielsweise wie folgt aussehen:
Dabei wertet Nix Ausdrücke nach dem Prinzip der Lazy Evaluation aus: Nur jene Teile von Ausdrücken werden ausgewertet, die notwendig sind, um die angefragten Ergebnisse zu liefern.
Als domänenspezifische Sprache stellt Nix keine Programmiersprache für allgemeine Verwendungszwecke dar. Auch wenn es für andere Anwendungszwecke genutzt werden kann, ist die Sprache ausschließlich für den gleichnamigen Paketmanager entworfen worden.
{ pkgs ? import { } }:
let
name = "nginx"; # (1)
version = "1.27.4";
labels = { app = name; }; # (2)
in
pkgs.writeText "deployment.yaml" ( # (3)
pkgs.lib.generators.toYAML { } { # (4)
apiVersion = "apps/v1"; # (5)
kind = "Deployment";
metadata = { inherit name labels; }; # (6)
spec = {
selector.matchLabels = labels; # (7)
template = {
metadata = { inherit labels; };
spec.containers = [ # (8)
{
inherit name;
image = "docker.io/${name}:${version}-alpine"; # (9)
}
];
};
};
}
)
Im let-Block können lokale Variablen (1) definiert werden, welche einen festen Geltungsbereich haben. Auch komplexe Typen wie ein Attribute Set (2) können als lokale Variablen gehalten werden und sogar Bezug auf vorherige Variablen (1) nehmen. Nix-Ausdrücke werten häufig zu Derivations aus, welche meist den Inhalt eines Paketierungsschrittes erhalten. Mit pkgs.writeText kann eine entsprechende Datei (3) mit dem entsprechend definierten Inhalt angelegt werden. Da ein Attribute Set prinzipiell wie ein strukturiertes Dokument aufgebaut ist, kann dieses generisch zu YAML konvertiert werden (4). Werte in einem Attribute Set können statischer Natur sein und werden einem entsprechenden Schlüssel zugewiesen (5). Durch die Verschachtelung von Attribute Sets können komplexe Strukturen aufgebaut werden und zuvor definierte lokale Variablen können dabei vererbt (6) oder direkt zugewiesen (7) werden. Dadurch werden Wiederholungen von gleichen Bezeichnern auf elegante Weise vermieden und Fehler vermieden sowie die Wartbarkeit von komplexen Definitionen signifikant verbessert. Analog zu strukturierten Dokumenten können auch Listen in Attribute Sets definiert werden (8). Zuvor definierte lokale Variablen können mittels Zeichenketteninterpolation zu neuen Zeichenketten kombiniert werden (9).
Unter der Annahme, dass das obige Beispiel in der Datei k8s.nix gespeichert ist, kann mit nix build –file k8s.nix die entsprechende Derivation gebaut werden. Nix legt standardmäßig die Ergebnisse unter ./result ab, von wo aus sie weiter inspiziert oder angewendet werden können.
$ yq -c < ./result '[.apiVersion, .kind]'
["apps/v1","Deployment"]
$ kubectl apply --file=./result
deployment.apps/nginx created
Bei diesem Prozess wird evident, dass Nix als Werkzeugausstattung Vorteile aufweist, die über Nix als Sprache hinausgehen. Es gibt ein zentrales Werkzeug, welches den vollständigen Funktionsumfang erfüllt um reproduzierbar und deklarativ Kubernetes Ressourcen zu beschreiben. Zudem ist der Arbeitsablauf sehr GitOps-freundlich und hinreichend flexibel um an individuelle Bedürfnisse angepasst zu werden.
Kubenix optimiert die Definition von Kubernetesmanifesten
Der naive Ansatz Kubernetes Manifeste mit reinem Nix zu definieren, bietet bereits eine ganze Reihe von Vorteilen gegenüber etablierten Lösungen wie Helm. Die Sicherheit valide strukturierte Dokumente zur Bauzeit zu generieren ist ein signifikanter Vorteil gegenüber dem Ansatz von Helm durch Templating von strukturierten Dokumenten den Mangel an Flexibilität zu überwinden. Während sichergestellt ist, dass die Ausgabe ein valides strukturiertes Dokument ist, bietet der Ansatz jedoch kein Versprechen darüber, dass die Ausgabe auch valide im Sinne der entsprechenden Kubernetes-API ist. Dies ist einer der zentralen Mehrwerte, die das Werkzeug Kubenix erbringen kann.
Kubenix nutzt sogenannte NixOS-Module, um Kubernetesmanifeste zu definieren. Wie der Name vermuten lässt, entspammen NixOS-Module dem Linux-basierten Betriebssystem NixOS und bieten ein mächtiges und flexibles Modulsystem an, um komplexe Dienste zu definieren. Neben den allgemeinen Vorteilen die aus der Ausdrucksfähigkeit von Nix als Sprache erwachsen und die gesteigerte Kompositionsfähigkeit durch NixOS-Module, implementiert Kubenix die OpenAPI Spezifikation der gesamten Kubernetes-API. Damit kann zum einen der Boilerplatecode wie apiVersion/kind signifikant reduziert werden, zum anderen ist das Typensystem so stark, dass ausschließlich valide Kubernetes Manifeste generiert werden können.
Das folgende Beispiel soll eine möglichst realitätsnahe Webapplikation demonstrieren, die durch mehrere Kubernetes Manifeste definiert ist. Zunächst wird Kubenix als Abhängigkeit importiert (1), was es ermöglicht, es als Modul zu verwenden (2). Analog zum vorherigen Beispiel können lokale Variablen definiert werden (3), um häufig genutzte Werte nicht-redundant zu speichern. Die Funktionalität, um Kubernetes Ressourcen mit Kubenix zu definieren befindet sich im Module k8s und muss importiert werden (4), was es ermöglicht, die Kubernetes Ressourcen deklarativ zu definieren (5). Hierbei folgt die Struktur der Kubernetes-API, wobei ein Großteil des Boilerplatecodes ausgelassen werden kann. Um beispielsweise ein Deployment zu definieren, kann direkt das spec-Feld definiert werden (6), der Name der Ressource in der metadata-Sektion wird automatisch durch Kubenix gesetzt. Im Gegensatz zum vorherigen Ansatz mit reinem Nix, spielen die korrekten Typen der einzelnen Felder eine wichtige Rolle. Wenn beispielsweise für den definierten port keine Ganzzahl, sondern eine Zeichenkette verwendet wird (7), wirft Kubenix einen Fehler zur Bauzeit und nicht erst, wenn das fertig gerenderte Manifest einem Kubernetes API-Server präsentiert wird. Durch die konsequente Verwendung von spezifischen Bezeichnern in Form von lokalen Variablen, kann zudem sichergestellt werden, dass die Pods des Deployments korrekt durch den Service selektiert werden und dieser durch den korrespondierenden Ingress referenziert wird (8).
{
kubenix ? import ( # (1)
builtins.fetchGit {
url = "https://github.com/hall/kubenix.git";
ref = "main";
}
),
}:
(kubenix.evalModules.x86_64-linux { # (2)
module =
{ kubenix, ... }:
let # (3)
app = "nginx";
version = "1.27.4";
host = "example.com";
labels = { inherit app version; };
in
{
imports = [ kubenix.modules.k8s ]; # (4)
kubernetes.resources = { # (5)
deployments.${app}.spec = { # (6)
selector.matchLabels = labels;
template = {
metadata = { inherit labels; };
spec.containers.${app}.image = "${app}:${version}-alpine";
};
};
services.${app}.spec = {
selector = labels;
ports = [
{
protocol = "TCP";
port = 80; # (7)
}
];
};
ingresses.${app}.spec.rules = [
{
inherit host;
http.paths = [
{
path = "/";
pathType = "Prefix";
backend.service = {
name = app; # (8)
port.number = 80;
};
}
];
}
];
};
};
}).config.kubernetes.result
Kubenix hat praktisch keinen Einfluss auf den zuvor illustrierten Arbeitsablauf zum Bauen. Unter der Annahme, dass der oben ausgeführte Nix-Code in der Datei kube.nix gespeichert ist, kann analog zum ersten Beispiel dieser mit nix build –file kube.nix gebaut werden. Die Ausgabe ist List-Objekt unter ./result, welches inspiziert oder angewendet werden kann.
$ yq -c < ./result '.items[] | [.apiVersion, .kind]'
["apps/v1","Deployment"]
["v1","Service"]
["networking.k8s.io/v1","Ingress"]
$ kubectl apply --file=./result
deployment.apps/nginx created
service/nginx created
ingress.networking.k8s.io/nginx created
Helm-Charts mit Kubenix konsumieren
Während Helm als Werkzeug seine Grenzen und Einschränkungen hat, stellt es nichtsdestotrotz ein großes Ökosystem dar. Nahezu jede Komponente aus der Cloud-Native-Welt hat ein korrespondierendes Helm-Chart, oftmals direkt im Repositorium der Hauptanwendung, gepflegt. Mit Kubenix können beliebige Helm-Charts konsumiert werden, ohne dabei auf die zuvor hingewiesenen Vorteile zu verzichten. Dabei durchläuft das Rendering von Helm-Charts den gleichen Prozess, den auch andere Kubernetesressourcen in Kubenix durchlaufen.
Das zuvor erläuterte Kubenix-Beispiel wird wie folgt erweitert. Die durch Kubenix implementierte Helm-Funktionalität befindet sich in dem gleichnamigen Modul, welches importiert werden muss (1). Anschließend können die Helm-Charts deklarativ definiert werden, die Teil der Ausgabe sein sollen (2). Dabei können Helm-Charts wie gewohnt online bezogen werden (3), spezifiziert durch eine Version und URL sowie geschützt durch eine Prüfsumme. Die values, die verwendet werden sollen, um das Helm-Chart zu rendern, werden ebenfalls durch Nix definiert (4) und verfügen daher über die zuvor erläuterten Vorteile bezüglich Modularisierung. Ein weiterer mächtiger Vorteil des Rendern von Helm-Charts mit Kubenix liegt darin, sämtliche Felder der resultierenden Manifeste modifizieren zu können. Da Kubenix die Ausgabe von Helm in den Ergebnisraum integriert, ist dies komplett unabhängig von den im Helm-Chart definierten values und den zugrunde liegenden Templates. Beispielsweise können die replicas des Ingress-Nginx-Controllers mit mkForce auf einen beliebigen Wert gesetzt werden (5). Neben dem Überschreiben kann der Ergebnisraum auch beliebig erweitert werden, so dass beliebige Helm-Charts durch individuelle Kubernetesressourcen angereicht werden können. In diesem Beispiel kann das Helm-Chart von Ingress-Nginx durch die zuvor illustrierte Webapplikation ergänzt werden, so dass alle notwendigen Komponenten in sich geschlossen definiert sind.
{
pkgs ? import { },
kubenix ? import (
builtins.fetchGit {
url = "https://github.com/hall/kubenix.git";
ref = "main";
}
),
}:
(kubenix.evalModules.x86_64-linux {
module =
{ kubenix, ... }:
let
app = "nginx";
version = "1.27.4";
host = "example.com";
labels = { inherit app version; };
in
{
imports = [
kubenix.modules.k8s
kubenix.modules.helm # (1)
];
kubernetes.helm.releases.ingress-nginx = { # (2)
chart = kubenix.lib.helm.fetch { # (3)
version = "4.12.1";
repo = "https://kubernetes.github.io/ingress-nginx";
chart = "ingress-nginx";
sha256 = "sha256-GEzgtcwJ+lZ9ymCDey54bD7BZkCpXoDcIQU0MjzAxcA=";
};
values.controller.replicaCount = 2; # (4)
};
kubernetes.resources = {
deployments = with pkgs.lib; {
ingress-nginx-controller.spec.replicas = mkForce 3; # (5)
${app}.spec = {
# …
};
};
services.${app}.spec = {
# …
};
ingresses.${app}.spec.rules = [
# …
];
};
};
}).config.kubernetes.result
Fazit
Nix bietet einen innovativen Ansatz zur Verwaltung komplexer Systemkonfigurationen, indem es das Prinzip der Lazy Evaluation und die Vorteile einer rein funktionalen Programmiersprache vereint. Durch die Definition von lokalen Variablen, Typensicherheit und Modularisierung ermöglicht Nix die Erstellung von wiederverwendbaren und wartbaren Konfigurationen. Diese Eigenschaften erleichtern nicht nur die Handhabung von Konfigurationen, sondern bieten auch eine hohe Flexibilität und Zuverlässigkeit, was besonders in der Verwaltung von Kubernetes-Ressourcen von Vorteil ist.
Aufbauend auf den Stärken von Nix erweitert Kubenix diese Vorteile speziell für die Kubernetes-Domäne. Es integriert die mächtigen Konzepte von Nix mit der Spezifikation der Kubernetes-API, was sicherstellt, dass nur valide Manifeste erzeugt werden. Kubenix eliminiert den Boilerplate-Anteil klassischer YAML-Definitionen und ermöglicht eine fein granulierte Kontrolle über die Ressourcendefinitionen. Darüber hinaus erlaubt es die nahtlose Integration von Helm-Charts, während es gleichzeitig dessen Einschränkungen umgeht. Mit Kubenix wird die Bereitstellung und Verwaltung von Kubernetes-Ressourcen nicht nur effizienter, sondern auch nachhaltiger und sicherer.
In Kombination bietet Nix mit dem Cloud-Native-Ökosystem zahlreiche Vorteile und stellt eine spannende Open-Source-Technologie dar, die es sich lohnt zu unterstützen – ebenso wie Kubenix, das als Open-Source-Lösung viel Potenzial birgt und auf Unterstützung angewiesen ist. Gleichzeitig bietet SysEleven als innovativer Arbeitgeber spannende Karrieremöglichkeiten in der Entwicklung von Open-Source-Produkten, die auf fortschrittliche Technologien wie Nix und Kubenix setzen, um Kubernetes-Ressourcen effizient zu paketieren.