Kubernetes-Kontrollcenter: Mit YTT-Templates Kubernetes-Cluster besser verwalten

Eine grundlegende Frage bei jedem Betrieb von Kubernetes ist, wie man all die benötigten Ressourcen-Definitionen effizient verwaltet. Ein durchdachtes, hierarchisch aufgebautes Templating zahlt den anfangs etwas höheren Aufwand schnell und vielfach zurück. Dabei steigt der Nutzen exponentiell: Je mehr gleichförmige Produkte man betreibt, desto höher der Benefit.
Wir wollen uns im Folgenden ein aus der Praxis bewährtes Ressourcen-Verwaltungssystem für Kubernetes anschauen. Um das Maß an Synthetik und Abstraktion so gering wie möglich zu halten, beschreiben wir Aufgabenstellung und Implementierung der Lösung anhand eines fiktiven und stark vereinfachten Beispiels.
Unsere fiktive junge und dynamische Firma hat folgende Geschäftsidee: Sie möchte lokalen Fahrradgeschäften helfen, ihre Produkte auch im Internet zu verkaufen. Wir bauen den Webshop, binden Zahlungs- und Versanddienstleister an und hosten das entstehende Softwarepaket Microservice-basiert in Kubernetes. Unsere Partner, die Fahrradhändler, müssen lediglich die Produktlisten pflegen und die Pakete packen.
Die von uns gebaute Software ist dabei ein sogenanntes Whitelabel-Produkt; wir können sie also mit geringem Designaufwand an den Partner anpassen, während die internen Funktionen bei allen Partnern gleich bleiben können. Zwei Partner konnten wir bereits gewinnen: Fahrrad Vogel und Uwes Radladen.
YAML Templating mit ytt
Um die für den Betrieb in Kubernetes benötigten Ressourcen zu erzeugen und zu verwalten, entscheiden wir uns für ein Templating auf Basis von ytt(öffnet im neuen Fenster) , einem flexiblen und mächtigen Tool zur Erzeugung und Manipulation von YAML-Dateien. Da eine Einführung in ytt an dieser Stelle den Rahmen sprengen würde, verweise ich auf die Dokumentation(öffnet im neuen Fenster) und vor allem auf die sehr aufschlussreiche interaktive Spielwiese(öffnet im neuen Fenster) . Außerdem darf gerne das Github-Repo zu diesem Artikel geforkt(öffnet im neuen Fenster) werden, um das Verständnis anhand von eigenen Experimenten zu vertiefen.
Mit unserem ytt-Templating können wir:
- die YAMLs nach einer Art Baukastenprinzip erstellen,
- die erstellten YAMLs vor der Verwendung prüfen und ggf. anpassen,
- die erstellten YAMLs unter Versionsverwaltung (Git) nehmen.
Die Kubernetes-Ressourcen in den YAMLs wenden wir per kubectl apply im Kubernetes-Cluster an. Dabei gilt die Konvention, dass alles, was ins origin des Git-Repos gepusht wird, auch gleichzeitig im Cluster angewendet werden muss und umgekehrt. Mit dieser Konvention (deren Einhaltung sich durch Tooling automatisieren ließe) können wir im Git-Repo jederzeit nachschlagen, welche Ressourcen zu welchem Zeitpunkt aktiv waren. Außerdem lässt sich das Git-Diff nutzen, um nach der Generierung der YAMLs Änderungen zu prüfen und gegebenenfalls gegenzusteuern, bevor diese im Cluster angewendet werden - eine äußerst praktische Prozedur, die viele potenzielle Fehler abfangen kann.
Erstellen wir also nun die Kubernetes-Ressourcen zum Betrieb unserer beiden Kunden-Anwendungen. Jede Anwendung besteht dabei aus 3 Microservices ( frontend , orders , stock ), die im Zusammenspiel die Business-Anforderungen umsetzen. orders und stock haben dabei eine Datenbank-Anbindung. Für jeden Microservice erstellen wir einen Service (= LoadBalancer für einen Pod) und ein Deployment (= Bauplan für einen Pod), die wir in einer Datei zusammenfassen.
Die Services werden nicht (wie meist) per ConfigMap konfiguriert. Stattdessen definieren wir einen Multiline-String innerhalb des Deployments, den wir per downwardApi als Konfig-Datei in den Pod mounten. Dadurch brauchen wir uns nicht um Kopplung von Pod und ConfigMap zu kümmern und wir starten den Pod bei jeder angewendeten Konfigurationsänderung automatisch neu - in unserem Fall ist das das gewünschte Verhalten.
Aus Gründen der Sicherheit und Unabhängigkeit läuft jedes Produkt (= partnerspezifischer Onlineshop) in einem separaten Kubernetes-Namespace. Zur Qualitätssicherung betreiben wir neben der prod (uktiven)-Installation auch eine test -Umgebung. Wir verwalten also vier Produkte, die alle nach der gleichen Microservice-Architektur aufgebaut sind, sich aber keinen Microservice teilen (dürfen). Das individuelle Anpassen an die Kombination aus Partner und Ziel-Umgebung realisieren wir per Konfiguration.
Hierarchische Konfiguration: Beherrschbarkeit und Übersicht
Jedes Produkt wird dabei anhand einer hierarchischen dreistufigen Kaskade konfiguriert. Bei der Generierung der YAMLs werden die Kaskaden übereinandergelegt, so dass ein virtueller Satz an Konfigurationswerten entsteht. Virtuell deshalb, weil die finale Konfiguration nirgendwo direkt einsehbar ist.
Das Übereinanderlegen der Kaskaden gestalten wir so, dass spezifischere Werte allgemeinere Werte überschreiben. In Fällen, in denen wir diese Überschreibung erzwingen wollen, nutzen wir als Wert auf der allgemeineren Ebene den String undefined-... . Dadurch wird im generierten YAML sofort sichtbar, dass eine nötige Überschreibung fehlt.
Beispielsweise definieren wir in der Produkt-Konfig (values-product.yaml) die produktspezifischen Services ( frontend , orders , stock ) inklusive Version und benötigtem RAM. Da sich aber üblicherweise die Versionen der Microservices in test und prod unterscheiden, delegieren wir durch den String undefined-version das Definieren der letztlich genutzten Version an eine tiefere Ebene. In unserem Fall ist das die zweite Ebene der Konfig-Kaskade. Dabei haben wir auch die Möglichkeit, (in Zukunft) die Service-Versionen im Produkt - also in der dritten und untersten Kaskade - noch mal zu definieren, um so verschiedene Versionen in verschiedenen Produkten abbilden zu können.

Um den größtmöglichen Nutzen aus diesem System zu ziehen, müssen alle Konfigurationen in den values-... -Dateien stehen. Zwar ist es manchmal verlockend, in den Source-YAMLs einfach eine Verzweigung einzubauen, doch auf diese Weise fragmentiert man die Konfiguration, was auf Dauer die Beherrschbarkeit deutlich einschränkt.
Entwickeln wir zum Beispiel ein neues Feature, das einen bestimmten Konfigurationswert voraussetzt, sollten wir diesen Wert vorerst nur für test setzen - denn die Version des Microservice in prod kennt diesen Wert ja nicht. Soll das Feature irgendwann später dann nach prod ausgerollt werden, besteht die Möglichkeit, dass wir die Verzweigung in der Source-YAML vergessen und den Fehler womöglich erst nach langen Debug-Sessions bemerken. Ein Vergleich der beiden Konfig-Dateien würde das Fehlen des Wertes allerdings direkt offenbaren.
Die Sources - ein Mix aus YAML und Code
In den Source-YAMLs definieren wir die Bausteine und referenzieren die Konfigurationen. Für unseren orders Microservice sieht diese Datei folgendermaßen aus (→ github):
#@ load("@ytt:data", "data")
#@ load("@k8syaml:main.lib.yaml", "createService", "createDeployment")
#@ serviceName = "orders"
#@ def getConfig():
#@yaml/text-templated-strings
serviceConfig: |
maximumAmountPerOrder=(@= str(data.values.configs.maxOrderAmount) @)
someOtherProp=someValue
#@ end
--- #@ createService(serviceName, 8080)
--- #@ createDeployment(serviceName, config=getConfig(), withDb=True)
Zuerst definieren wir den Namen des Microservice (Zeile 4), der in den beiden Funktionsaufrufen zur Erstellung von Kubernetes-Service und Kubernetes-Deployment verwendet wird. Die aufgerufenen Funktionen (Zeilen 13 und 14) importieren wir (Zeile 2) aus unserer main-Library(öffnet im neuen Fenster) .
Zusammengefasst bietet diese Library eine Sammlung an Funktionen, die wiederum YAML-Fragmente mit (internen) Funktionen kombinieren. Diese Kombination hält die YAML-Fragmente einerseits übersichtlich und bietet andererseits enorm vielfältige Möglichkeiten der Ausgestaltung. So entsteht eine mächtige und flexible Mischung aus statischem YAML und dynamischer Programmierung.
Die für den Microservice benötigte Konfiguration geben wir per Multiline-String in die createDeployment()-Funktion(öffnet im neuen Fenster) . Innerhalb dieses Strings laden wir die produktspezifischen Konfig-Werte (hier nur einer, in Zeile 9) die den Standard-Werten innerhalb der createDeployment()-Funktion hinzugefügt werden. Auf diese Weise erhalten wir die finale Konfig-Datei, die dann in den Container gemountet wird.
Haben wir alle Bauteile und Konfigurationen zusammen, ist es Zeit, die YAMLs zu erzeugen, die wir im Cluster betreiben wollen. Dazu erschaffen wir uns ein kleines Bash-Skript(öffnet im neuen Fenster) , das uns für alle 4 benötigten Produkte sämtliche Kubernetes-Resourcen erzeugt. Diese wenden wir nun per kubectl apply... im Cluster an. Damit sind die Produkte live. Fertig - denken wir.
Nach kurzer Zeit fällt uns allerdings auf, dass der Datenbanktreiber Probleme mit der Entropie hat. Um das Problem zu lösen, müssten wir allen Pods mit Containern, die eine Datenbankverbindung aufbauen, einen bestimmten Datei-Mount hinzuzufügen. Also erweitern wir die createDeployment()-Funktion(öffnet im neuen Fenster) und starten unser Generierungs-Skript erneut.
Der Commit 44ae035 zeigt uns die daraus resultierenden Änderungen(öffnet im neuen Fenster) (bei Fehlern oder Problemen könnten wir gegensteuern) und die Auswirkung auf die generierten YAMLs: Mit wenigen Zeilen und einem kleinen if konnten wir alle betroffenen Deployments ( orders , stock ) anpassen.
Natürlich sind nicht immer alle Änderungen derart einfach zu integrieren. Trotzdem illustriert dieses kleine Beispiel das Potenzial sehr gut. Wäre der Fehler erst später aufgefallen, nachdem noch 7 weitere Produkte hinzugekommen sind, wäre die Änderung die gleiche gewesen, hätte aber trotzdem alle 13 Produkte angepasst.
ytt-basiertes Templating ist ein enormer Hebel
Wir haben in unserem Beispiel eine Art umgekehrten Ansatz gewählt: Statt die YAMLs als Ausgangsbasis zu nehmen und Teile durch templatisierbare Elemente zu ersetzen, haben wir eine Generierung aufgebaut, die die finalen YAMLs aus den benötigten Bausteinen zusammensetzt. Auf diese Weise erhalten wir ein mächtiges Werkzeug, was uns viel Copy-Paste-Arbeit bei Änderungen erspart.
Alle zu einem Produkt gehörenden Dateien können mit nur einem kubectl apply -Befehl im Cluster angewendet werden; trotzdem ist die Anwendung der Dateien maximal transparent und man weiß vor der Anwendung exakt, was im Cluster laufen wird. Gleichzeitig bekommen wir durch die Nutzung von Git eine Historisierung und ein Quality-Gate bei Änderungen.
Wir konnten hier das tatsächliche Potenzial dieser Templating-Lösung nur anreißen. Trotzdem sollte recht klar werden, welchen enormen Hebel man mit ihr in der Hand hält. Natürlich können andere Lösungen manche Dinge genauso gut. Die absolute Stärke eines ytt-basierten Templatings nach dieser Bauart ist aber die Universalität und die enorme Vielseitigkeit.
Dazu ein hochaktuelles Beispiel: Bei der Mitigierung der Bedrohung von log4shell half das Templating enorm: In einer Produktions-Umgebung konnten über 970(!) Kubernetes-Deployments mit einem Schlag angepasst und abgesichert werden; wiederholbar und deterministisch. Und das mit nur einer einzigen geänderten Zeile in den Sources.
Ausblick
Wenn wir nun - ausgehend von unserem fiktiven (und rudimentären) Beispiel - einen Blick in die Realität wagen, wird schnell klar, dass viele weitere Anforderungen abgebildet werden müssen. Zum Beispiel:
- mTLS (via Linkerd)
- Ingresses und Network-Policies
- Image-Policies (nur signierte Images in prod)
- Service-Accounts
- Secrets und/oder Secret-Store-Anbindung (Vault)
- Persistenz-Anbindung (Persistent Volumes, S3)
- Backups
- Feature-Toggles (Produkt-Features an-/abschalten)
- Zusätzliche Produktlinien (z. B.: Shop für Custom-Räder)
Dass all diese Funktionen lauffähig und wartbar in das hier gezeigte Beispiel eingeflochten werden können, ist dabei keineswegs ein gewagtes Versprechen, sondern gelebte Praxis. In konkreten Zahlen eines realen Anwendungsfalls bedeutet das:
20 in Kubernetes betriebene Produkte in verschiedenen Umgebungen. Jedes Produkt besteht aus rund 60 Pods plus zusätzlichen Ressourcen wie Ingresses, Service-Accounts, Secrets, Volumes und so weiter.
Das Templating erzeugt außerdem Skripte für das Befüllen von Vault oder das Anlegen der Datenbank-User. Der Code des Beispiel-Repositories(öffnet im neuen Fenster) kann hier aus Platzgründen nicht vollständig diskutiert werden.
Für Fragen, Anregungen und Kritik steht der Autor gerne über die Kommentarfunktion zur Verfügung.
Jochen R. Meyer arbeitet seit über 15 Jahren in der Softwareentwicklung. In seiner aktuellen Rolle als Software Dev & Ops Engineer kümmert er sich in einem kleinen Team um die CI/CD-Toolchain und vor allem um den reibungslosen Betrieb der Kubernetes-basierten Services und Komponenten.



