Kubernetes: Pimp my k8s
Kubernetes ist eine der etabliertesten Container-Hosting-Plattformen. Zum einen dient es als Open-Source-Lösung für selbst gehostete Private Clouds. Zum anderen gibt es diverse kommerzielle Derivate, die auf Kubernetes aufbauen. Darüber hinaus steht Kubernetes bei allen großen Cloud-Anbietern als Hosting-Dienst für Container-Images zur Verfügung. Das Verständnis eines Platform-as-a-Service-Angebots beschränkt sich heute nicht mehr nur auf Betrieb und Management von Containern mittels Skalierung, Lastverteilung oder Rolling Updates. Vielmehr wird auch die Bereitstellung von Umsystemen wie persistentem Speicher, Caching oder einem Eventbus erwartet. Weiterhin benötigen cloud-native Anwendungen Daten oder Konfigurationen von externen Diensten, beispielsweise von einer externen Zertifizierungsstelle ausgestellte Zertifikate oder die Konfiguration eines Hardware-Load-Balancers zur Lastverteilung über mehrere Verfügbarkeitszonen hinweg.
Während bekannte Managed-Cloud-Plattformen für diese Anforderungen eigene Dienste anbieten, fehlt im Umfeld eines selbst gehosteten Kubernetes der Plattformgedanke oftmals. Kubernetes kann mit passenden Erweiterungen, sogenannten Operators, jedoch ebenfalls zu einer Cloud-Plattform mit aller von großen Plattformen gewohnter Funktionalität erweitert werden, die über das reine Container-Hosting hinausgeht.
Im Folgenden geben wir einen kurzen Überblick über die deklarative Konfiguration von Kubernetes, die grundlegende Funktionsweise von Operators in Kubernetes und den Lifecycle von Kubernetes-Objekten und wie dieser mit selbst implementierten Operators abgedeckt werden kann.
Deklarative Konfiguration von Kubernetes
Die Konfiguration von Kubernetes wird über eine deklarative Domain Specific Language (DSL) definiert. Im Gegensatz zu einem konventionellen, imperativen Ansatz muss der Nutzer nicht einzelne Schritte ausführen, sondern er beschreibt einen Zielzustand und Kubernetes stellt diesen her.
Ein Update einer Anwendung erfordert bei einem imperativen Ansatz mindestens das Stoppen der alten Version der Anwendung, ein anschließendes Ausrollen der neuen Version und einen Start dieser neuen Anwendungsversion. All diese Schritte müssen entweder vom Nutzer selbst ausgeführt oder in einer Deployment-Pipeline explizit implementiert werden. Mit Kubernetes dagegen reicht es aus, den gewünschten Zielzustand in der DSL zu definieren und als Datei an die Kubernetes-API zu übermitteln. In Listing 1 sehen wir die Definition eines Pods in der YAML-Repräsentation der Kubernetes DSL. Ein "Pod" ist die kleinste Einheit in Kubernetes und umfasst die Ausführung eines oder mehrerer Container mit geteilten Speicher- und Netzwerkressourcen. Zum Ausrollen einer anderen Version eines beinhalteten Containers müssen wir lediglich den Tag des Container-Images auf die gewünschte Version aktualisieren und die geänderte YAML-Datei an die Kubernetes-API übermitteln. Kubernetes stellt dann den beschriebenen Zielzustand her, stoppt also die aktuell ausgeführten Container und startet neue Container, basierend auf dem geänderten Image. In der Standardeinstellung von Kubernetes erfolgt, wenn nötig, ein graceful shutdown der aktuell laufenden Container, wobei nie alle Instanzen der Anwendung gleichzeitig heruntergefahren sind. Eingehender Anwendertraffic wird währenddessen auf die noch bestehenden Instanzen umgeleitet, wodurch für die Anwender keine Downtime entsteht.
In Listing 1 definieren wir einen solchen Zielzustand: Es soll einen Pod namens "web" geben, in dem ein Container mit dem Namen "http" das image "nginx:1.21.6" ausführt. Dieses soll den TCP-Port 80 exponieren.
Listing 1:
apiVersion: v1
kind: Pod
metadata:
name: web
spec:
containers:
- name: http
image: nginx:1.21.6
ports:
- name: http-80
containerPort: 80
protocol: TCP
Anschließend ist es Aufgabe des Clusters, diesen Zustand zu erreichen. Existiert bereits ein Pod mit dem Namen "web" in dem ein Container namens http mit dem Image "nginx:1.21.6" läuft, der TCP-Port 80 exponiert, ist der gewünschte Zustand erreicht und der Cluster muss keine Änderungen durchführen.
Existiert noch kein Pod namens "web" oder weicht der Pod mit diesem Namen von der aktuellen Spezifikation ab, wird der Cluster den bestehenden Pod terminieren und einen neuen Pod mit den Spezifikationen aus web.yaml erzeugen. Um ein anderes Image – beispielsweise nginx in einer anderen Version – auszuführen, müssen wir lediglich die entsprechende Zeile ändern und die Ressource in Kubernetes erneut mit kubectl apply -f web.yaml anwenden.
Ein Einblick in Kubernetes-Ressourcen
Kubernetes bringt von Haus aus viele API-Ressourcen mit. Eine Liste der verfügbaren Ressourcen können wir mit kubectl api-resources anzeigen lassen. Die Ausgabe davon ist eine Liste ähnlich zu der in Abb. 1. In dieser Auflistung sehen wir neben dem Typ "Pod", den wir in Listing 1 verwendet haben, auch Volume-Typen zur Speicherung von Daten oder Namespaces zur Gruppierung von Ressourcen.
Die standardmäßig verfügbaren Ressourcentypen reichen aber nicht immer aus, um alle Aufgaben zu erledigen, die in einem Cluster anfallen, beispielsweise wenn Datenbank-Tabellen oder Zertifikate für die Kommunikation per mTLS bereitgestellt werden sollen. In diesen Fällen ist es möglich, eigene Ressourcentypen in Kubernetes zu definieren. Allerdings muss dann auch der Lebenszyklus von der Erzeugung bis hin zu Updates und natürlich der Löschung selbst implementiert werden.
Unsere eigene Kubernetes-Ressource
Als konkretes Beispiel wollen wir einen Kubernetes-Operator implementieren, der basierend auf Maven-Koordinaten eine Java-Anwendung herunterlädt und in einem Pod ausführt. Im zugehörigen Kubernetes-Objekt brauchen wir also Felder für die gav-Koordinaten und den Pod-Namen. Ein passendes Kubernetes-Objekt mit allen Feldern die wir benötigen ist in Listing 2 gezeigt.
Listing 2:
apiVersion: k8s-demo.dxfrontiers.de/v1
kind: MavenDeployment
metadata:
name: sample-resource
spec:
gav: de.dxfrontiers.demo.kubernetes.operator:sample-app:0.0.1
podName: sample-resource-pod
Dieses Objekt beschreibt, dass ein Kubernetes-Objekt vom Typ "MavenDeployment" angelegt werden soll. Dieses Objekt soll den Namen "sample-resource" haben und einen Pod mit dem Namen "sample-resource-pod" erzeugen, in dem die Anwendung mit den Maven-Koordinaten "de.dxfrontiers.demo.kubernetes.operator:sample-app:0.0.1" ausgeführt wird.
Damit Kubernetes das definierte Objekt aber validieren und anlegen kann, müssen wir zuerst eine passende Definition hinterlegen, in der das Objekt mit den zu setzenden Feldern und deren gültigen Wertebereichen definiert wird. Dafür verwenden wir ein weiteres Kubernetes-Objekt, die Custom Resource Definition (CRD). Die Custom Resource Definition selbst finden wir auch wieder in der Auflistung aller Kubernetes-Objekte in Abb. 1.
Die relevanten Felder aus der CRD für das Maven-Deployment sehen wir in Listing 3 dargestellt. Die vollständige CRD ist im Gitlab-Repository hinterlegt [1].
Listing 3:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: mavendeployments.k8s-demo.dxfrontiers.de
spec:
group: k8s-demo.dxfrontiers.de
scope: Namespaced
names:
plural: mavendeployments
singular: mavendeployment
kind: MavenDeployment
shortNames:
- mvndepl
[...]
spec:
type: object
properties:
gav:
type: string
podName:
type: string
required:
- gav
- podName
[...]
Innerhalb dieses Objekts der Sorte "CustomResourceDefinition" gibt es verschiedene Abschnitte, in denen Eigenschaften unseres Maven-Deployments definiert sind: Neben dem Namen in singular, plural und als Kurzbezeichnung, ist im "spec"-Teil definiert, dass es sich bei einer Kubernetes-Ressource vom Typ MavenDeployment um ein "Object" handelt, dass die beiden String-Felder "gav" und "podName" hat.
Nach dem Aufruf vom kubectl apply -f crd.yaml sehen wir den Ressourcentyp "MavenDeployment" auch in der Auflistung der bekannten Custom Resource Definitions wie in Abb. 2.
Wenden wir nun die maven-deployment.yaml aus Listing 2 an, erscheint zwar kein Fehler, der gewünschte Pod wird aber noch nicht angelegt, wie wir im Screenshot in Abb. 3 nachvollziehen können. Dass Kubernetes zwar das Objekt "sample-resource" anlegt, nicht aber den darin beschriebenen Pod erzeugt, liegt daran, dass im Cluster Objekte vom Typ MavenDeployment zwar bekannt sind, die Implementierung zur Interpretation dieser Objekte aber noch fehlt.
Übersicht zu Operators
Für die Interpretation der Maven-Deployment-Objekte brauchen wir einen Operator. Operators werden verwendet, um Objekte in Kubernetes zu verwalten. Änderungen an Objekten werden durch den Operator erkannt und umgesetzt. Der Operator läuft dabei als Anwendung, idealerweise im Cluster selbst in mehreren Instanzen. Die Cluster-Berechtigungen des Operators sollten aus Sicherheitsgründen soweit wie möglich eingeschränkt sein, sodass der Operator nur Änderungen an den Objekten vornehmen kann, die durch ihn verwaltet werden.
Anmerkung: Den vollständigen Code des vorgestellten Operators haben wir auf Gitlab veröffentlicht [1].
Aufruf des Operators im "Reconcile-Loop"
Der Operator sorgt also dafür, dass Objekte in Kubernetes tatsächlich dem Zustand entsprechen, der gegenüber der API definiert wird. Um dabei nicht selbst Änderungen an den Objekten feststellen zu müssen, gibt es in Kubernetes das Konstrukt des "Reconcile Loop", das schematisch in Abb. 4 gezeigt ist. Die Entscheidung, wann genau der Operator aufgerufen wird, unterliegt komplett dem Cluster selbst.
Gibt es eine Änderung eines Objekts, für dessen Typ der Operator registriert ist, wird der Operator durch den Cluster über diese Änderung benachrichtigt. Der Operator vergleicht anschließend den gewünschten Zustand des Objekts mit dem aktuellen Zustand im Cluster. Stimmen diese beiden Zustände überein, beispielsweise weil der gewünschte Pod schon existiert, beendet der Operator den Loop und wartet auf eine erneute Auslösung durch den Cluster. Weichen der aktuelle und der gewünschte Zustand der Ressource aber voneinander ab, führt der Operator einen möglichst kleinen Schritt aus, um das Objekt in den gewünschten Zielzustand zu überführen. Die auszuführenden Schritte sollten möglichst klein gewählt sein, da die Ausführung des Operators durch den Cluster jederzeit unterbrochen werden kann. Anschließend wird der neue Status des Objekts im Objekt selbst aktualisiert. Über den nächsten Aufruf des Operators entscheidet dann der Cluster.
Die Operator-Implementierung
In unserem Beispiel soll der Operator sicherstellen, dass in einem Pod eine definierte Anwendung ausgeführt wird. Beim Vergleich von aktuellem und gewünschtem Zustand prüfen wir also zunächst, ob schon ein Pod mit dem definierten Namen existiert. Existiert dieser Pod nicht – beispielsweise weil das MavenDeployment-Objekt neu angelegt wurde – wird ein neuer Pod angelegt. Den Abgleich von aktuellem und gewünschtem Zustand des Objekts zusammen mit der Neuanlage – sofern noch kein Pod existiert – sehen wir in Listing 4.
Listing 4:
//get existing pod for podName if any
Optional<Pod> podOptional =
pods.list().getItems().stream()
.filter(p -> p.getMetadata().getName().equals(resource.getSpec().getPodName()))
.findFirst();
//if no pod for resource is available, create one
if (podOptional.isEmpty()) {
Pod pod = preparePod(resource.getSpec().getPodName(), resource.getSpec().getGav());
pods.create(pod);
addCondition(resource, createPodCreatedCondition());
}
Existiert der Pod bereits, werden die Maven-Koordinaten des existierenden Pods und der Wert aus dem Kubernetes-Objekt verglichen. Den relevanten Programmcode zu diesem Schritt sehen wir in Listing 5.
Listing 5:
else {
Pod existingPod = podOptional.get();
//if GAV has changed delete the old pod and create a new one
if (doesGavNotMatch(existingPod, resource.getSpec().getGav())) {
if ((isLastConditionEmpty(resource) || isLastConditionCreated(resource))) {
pods.delete(existingPod);
addCondition(resource, createPodDeletedCondition());
}
//if the pod still exists and is marked as being deleted, wait a little while.
if (isLastConditionPodDeleted(resource)) {
//TODO: watch for pod being deleted instead of polling
Thread.sleep(100);
addCondition(resource, createPodDeletedCondition());
}
}
//if GAV has not changed return "noUpdate"
else {
updateControl = UpdateControl.noUpdate();
}
}
Stimmen die Koordinaten überein, werden keine Änderungen vorgenommen und es wird UpdateControl.noUpdate() zurückgegeben. Dies ist der Hinweis für den Cluster, dass das Objekt auf dem gewünschten Stand war und der Operator kein Update durchgeführt hat. Weichen die tatsächlichen und die gewünschten Maven-Koordinaten voneinander ab, wird der bestehende Pod gelöscht und ein neuer Pod erzeugt. Die einzige Ausnahme entsteht, wenn der Status bereits auf "deleted" gesetzt ist. In diesem Fall wartet die Implementierung eine kurze Zeit, bevor erneut der Status "deleted" in das Objekt eingetragen wird. Statt des Aufrufes von Thread.sleep() sollten wir in einer realen Implementierung allerdings auf einen Mechanismus zurückgreifen, der den Operator in diesem Fall gar nicht erst erneut aufruft.
Löschen eines Objekts
Bisher haben wir das Anlegen und Manipulieren einer Ressource durch einen Operator betrachtet. Während das Anlegen eines Objekts in Kubernetes unkompliziert und intuitiv abläuft, gibt es beim Löschen eine Besonderheit, um gegebenenfalls anfallende Nacharbeiten bei der Löschung mit durchzuführen. Wollen wir ein Objekt löschen, erfolgt das nicht direkt durch das Entfernen der Verweise auf das Objekt. Stattdessen wird das Objekt zunächst zur Löschung markiert. Dadurch können vorbereitende Aufgaben ausgeführt werden, um die endgültige Löschung sauber durchzuführen.
Repräsentiert ein Objekt beispielsweise eine Datenbank oder eine Datenbanktabelle außerhalb des Clusters, würden diese beim einfachen Löschen des Objektes in Kubernetes weiterhin bestehen und nie gelöscht werden. Die Verweise in Kubernetes wären zwar entfernt, die Daten aus der Datenbank wären aber weiterhin vorhanden und würden weiterhin Speicherplatz belegen. Auch Ressourcen, die noch in Pods verwendet werden, können nicht ohne Weiteres gelöscht werden. In diesem Fall müssen wir abwarten, bis der Pod terminiert wurde oder die Ressource nicht mehr im Pod verwendet wird.
Um ein sauberes Löschen von Objekten zu gewährleisten, verwendet Kubernetes das Konzept der "Finalizers". Dazu wird beim Erzeugen des Objekts im Objekt selbst in einem Feld hinterlegt, dass ein Finalizer angehängt wurde. Diese Markierung erfolgt über einen Eintrag im Feld "metadata.finalizers" und kann auch erst nach Erzeugung des Objektes erfolgen oder um weitere Einträge erweitert werden.
Wollen wir das Objekt jetzt mit kubectl delete sample-resource löschen, wird zunächst nur der deletionTimestamp in der Ressource gesetzt und das Objekt damit zur Löschung markiert. Anschließend wird der Operator im Rahmen des Reconcile-Loop aufgerufen und über die geplante Löschung der Ressource informiert. Der Operator führt dann die Aufgaben aus, um die Ressource ordnungsgemäß löschen zu können. Beispielsweise indem die dadurch repräsentierte Datenbanktabelle auch in der Datenbank gelöscht wird. Anschließend entfernt der Operator den erfüllten Finalizer-Eintrag aus den Metadaten. Sind in den Metadaten eines Objekts keine Finalizer mehr eingetragen und ist der deletionTimestamp gesetzt, entfernt Kubernetes das Objekt.
Fazit
Wir können Kubernetes durch das Operator-Pattern schnell und einfach erweitern. Indem wir dieselben Pattern und Schnittstellen verwenden, mit denen auch in der Basisversion verwendete Kubernetes-Ressourcen verwaltet werden, fügen sich Erweiterungen nahtlos in die Basis-API ein und werden mit den gleichen Mitteln verwaltet wie Basis-Funktionalitäten. Dabei macht es keinen Unterschied, ob die Erweiterungen von Drittanbietern zur Verfügung gestellt werden, oder ob die Erweiterungen selbst implementiert sind.