Im ersten Teil zu Kubernetes haben wir den generellen Aufbau eines Clusters und die ersten beiden Objekte, nämlich Pod und ReplicationController, kennengelernt. Dazu haben wir einen ersten Pod deployt und anschließend mittels eines ReplicationControllers dafür gesorgt, dass von diesem immer mehrere Instanzen laufen.
In diesem zweiten Artikel zu Kubernetes lernen wir ein weiteres Objekt zur Verwaltung von Pods kennen und sorgen zudem dafür, dass unsere Anwendung innerhalb des Clusters für Clients einfach erreichbar ist.
Vorbereitung
Wie bereits im ersten Teil zu Kubernetes nutze ich Minikube, um lokal ein
Kubernetes-Cluster aufzusetzen. Dazu verwende ich, nach erfolgter Installation,
den Befehl minkube start
.
Zusätzlich benötigen wir für die Beispiele in diesem Artikel eine eigene Anwendung inklusive Docker-Image. Diese minimale Spring Boot-Anwendung gibt uns als Antwort auf jeden HTTP-GET-Request einen Text zurück, welcher den Hostname des Servers beziehungsweise im Fall von Docker des Containers enthält. Listing 1 zeigt den notwendigen Java-Code. Den gesamten Anwendungscode und sämtliche Kubernetes-Objekte, die in diesem Artikel genutzt werden, können bei GitHub betrachtet und heruntergeladen oder mit Git „geclonet“ werden.
Um nun aus den Quelldateien ein im Minikube-Cluster verfügbares Docker-Image zu
erzeugen, müssen wir drei Schritte durchführen. Zuerst bauen wir mit dem Aufruf
mvn clean package
die Anwendung. Dabei werden die Java-Dateien kompiliert und
es entsteht die ausführbare JAR-Datei target/k8s-app.jar.
Anschließend stellen wir eine Dockerverbindung zum im Minikube-Cluster laufenden
Docker-Dämon her. Hierzu führen wir den Befehl eval $(minikube docker-env)
aus. Im letzten Schritt können wir nun mit docker build -t k8s-app .
ein
Docker-Image mit dem Namen k8s-app erstellen. Da wir uns im Schritt vorher mit
dem Minikube-Cluster verbunden haben, ist dieses Image anschließend auch im
Cluster vorhanden.
Nachdem diese Schritte durchgeführt sind, können wir erneut in die Welt von Kubernetes eintauchen.
ReplicaSet
Neben dem im letzten Artikel vorgestellten ReplicationController gibt es mit dem ReplicaSet ein zweites Objekt innerhalb von Kubernetes, um dafür zu sorgen, dass immer eine bestimmte Anzahl von Pods im Cluster läuft. Der ReplicationController erlaubt dabei als Selektor lediglich die Angabe eines Labels inklusive Wert. Diese Einschränkung wird durch das ReplicaSet aufgehoben. Bei diesem lassen sich innerhalb des selector-Eintrags erweiterte Ausdrücke formulieren.
Listing 2 zeigt die YAML-Beschreibung eines ReplicaSets für unsere Anwendung, welches dafür sorgt, dass immer zwei Instanzen laufen. Der primäre Unterschied zum ReplicationController besteht im selector. Beim ReplicationController werden dort Schlüssel-Wert-Paare angegeben. Diese matchen anschließend auf Pods, die ein Label zum Schlüssel mit einem identischen Wert haben.
Beim ReplicaSet benutzen wir stattdessen einen matchExpressions-Teil. Der key bezieht sich dabei weiterhin auf den Namen eines Pod-Labels. Als operator können aktuell die vier verschiedenen Operationen In, NotIn, Exists und NotExists verwendet werden.
Der In-Operator matcht einen Pod genau dann, wenn dieser das angegebene Label besitzt und dessen Wert mit mindestens einem Eintrag der values-Liste übereinstimmt. Im Gegensatz dazu matcht der NotIn-Operator, wenn das Pod-Label keinen der angegebenen Werte besitzt.
Die beiden Operatoren Exists und NotExists werden ohne values-Liste angegeben, da diese lediglich prüfen, ob der Pod ein Label mit dem passenden Namen besitzt oder nicht. Der Wert des Labels wird nicht geprüft. Werden mehrere Match-Expressions angegeben, so matcht ein Pod erst, wenn alle Expressions zutreffen.
Da das Matching mit dem In-Operator besonders häufig benötigt wird, gibt es hierzu eine verkürzte Syntax (s. Listing 3). Werden matchLabels und matchExpressions gleichzeitig verwendet, müssen beide zutreffen, damit ein Pod gematcht wird.
Neben den erweiterten Möglichkeiten, Labels zu matchen, haben wir mit einem ReplicaSet zusätzlich die Möglichkeit, ein Label mit verschiedenen Werten zu matchen. Mit einem ReplicationController ist es zum Beispiel nicht möglich, alle Pods mit dem Label stage zu matchen, dessen Wert production oder staging ist. Mittelfristig ist es wahrscheinlich so, dass ReplicationController verschwinden werden und nur noch das ReplicaSet verwendet wird.
Nachdem wir nun unsere Anwendung von einem ReplicationController auf ein ReplicaSet umgebaut haben, wollen wir das nächste Problem angehen. Wie können wir die nun laufenden Instanzen innerhalb des Clusters erreichen?
Service
Das im Cluster vorhandene ReplicaSet sorgt zwar nun dafür, dass jederzeit zwei Instanzen der Anwendung vorhanden sind. Doch wie können wir diese Instanzen nun innerhalb des Clusters erreichen?
Jeder Pod erhält beim Start eine eigene IP-Adresse. Um die beiden IP-Adressen
unserer Pods auszugeben, können wir den Befehl
kubectl get pods --selector=app=k8s-app -o jsonpath='{.items[*].status.podIP}'
ausführen. Die so ermittelten IP-Adressen sind jedoch nur innerhalb des Clusters
gültig und können nicht von außen erreicht werden. Um nun also einen der beiden
Pods zu erreichen, müssen wir irgendwie in den Cluster gelangen. Hierzu haben
wir die folgenden drei Möglichkeiten:
- Wir verbinden uns, zum Beispiel per ssh, auf einen der Kubernetes-Knoten und führen von dort einen HTTP-Request aus.
- Wir starten einen Container oder Pod innerhalb des Clusters, der einen HTTP-Request ausführt und das Ergebnis dieses Aufrufs logt. Anschließend können wir uns im Log den Aufruf anschauen.
- Mittels kubectl exec verbinden wir uns auf einen bereits laufenden Container und führen dort einen HTTP-Request aus.
An dieser Stelle nutzen wir die dritte Option, starten hierfür allerdings vorher
einen Pod (s. Listing 4), der nur für diese Aufgabe da ist. Nachdem wir diesen
Pod im Cluster angelegt haben, können wir uns mit dem Befehl
kubectl exec -it busybox /bin/sh
auf diesen verbinden. Innerhalb dieser Shell
können wir nun per wget einen HTTP-Request an die vorher ermittelten IPs
senden (s. Listing 5).
Somit sind die Pods zwar erreichbar, jedoch müssen Clients die IP-Adressen kennen. Da diese jedoch erst beim Start vergeben werden und bei einem Neustart wechseln, benötigen wir eine andere Lösung. Die Lösung, die wir suchen, nennt sich innerhalb des Kubernetes-Universums Service. Listing 6 zeigt die YAML-Beschreibung eines Service für unsere Anwendung.
Ein Service funktioniert dabei wie ein klassischer Load-Balancer auf TCP-Ebene.
Nachdem wir diesen angelegt haben, müssen wir zuerst dessen IP herausbekommen.
Hierzu nutzen wir den Befehl kubectl get services
. Anschließend verbinden wir
uns wieder auf unseren busybox-Pod und setzen die HTTP-Requests dieses Mal gegen
die Service-IP ab (s. Listing 7).
Wie wir sehen können, wird der HTTP-Request mal von der einen und mal von der anderen Instanz unseres Pods beantwortet. Allerdings wiederholen wir aktuell noch die Nummer des Ports im Service und ReplicaSet. Ändern wir also den Port im Pod, müssen wir auch daran denken, den Service zu ändern. Um dies zu verhindern, können wir benannte Ports nutzen. Hierzu ergänzen wir die Container-Spezifikation des ReplicaSets um den Eintrag aus Listing 8 und ersetzen im Service den Wert 8080 des targetPort durch http.
Neben der bisher genutzten Round-Robin-Strategie können wir den Service auch so
konfigurieren, dass anhand der IP des Clients die Requests immer in dieselbe
Pod-Instanz gelangen. Hierzu können wir sessionAffinity: ClientIP
im
Service-Objekt setzen. Eine Lösung mit HTTP-Cookie, um immer auf dieselbe
Instanz zu gelangen, ist an dieser Stelle nicht möglich, da der Service auf
TCP-Ebene basiert und somit unterhalb des HTTP-Protokolls arbeitet.
Zudem ist es auch möglich, mehrere Ports in einem Service zu definieren. Da wir allerdings nur einen Selektor angeben können, müssen die selektierten Pods dann auch alle Ports bedienen können. Brauchen wir also eine andere Menge an Pods, müssen wir einen zweiten Service mit einem anderen Selektor anlegen.
Service-Discovery
Der so von uns angelegte Service behält die ihm zugewiesene IP über seinegesamte Lebenszeit. Lediglich die Ziele, an die er weiterleitet, ändern sich, wenn Pods kommen oder gehen. Um damit Pods, die unseren Service aufrufen möchten, zu konfigurieren, müssen wir allerdings nicht erst die IP ermitteln und diese anschließend über einen Konfigurationsparameter bekannt machen. Kubernetes bietet uns hierfür die Möglichkeit einer Service-Discovery über Umgebungsvariablen und DNS an.
Damit wir allerdings Umgebungsvariablen zur Service-Discovery nutzen können,
müssen die Pods, die den Service aufrufen wollen, nach dem Service angelegt
werden. Dazu löschen wir nun unseren busybox-Pod mit dem Befehl
kubectl delete pods busybox
. Legen wir diesen anschließend erneut an und
verbinden uns dann auf ihn, können wir uns über den Befehl env
alle gesetzten
Umgebungsvariablen ansehen (s. Listing 9).
Wie wir sehen können, gibt es nun diverse Umgebungsvariablen, die wir nutzen können, um den Service aufzurufen. Neben der IP haben wir so auch die Möglichkeit, an den freigegebenen Port zu gelangen.
Neben der Umgebungsvariable können wir auch DNS zur Service-Discovery verwenden. Listing 10 zeigt diverse Varianten, mit denen wir aktuell unseren Service innerhalb des Clusters per DNS-Auflösung erreichen können.
Der komplette DNS-Name unseres Service lautet k8s-app.default.svc.cluster.local. Dadurch, dass Kubernetes allerdings die passenden Einträge in der Datei /etc/resolv.conf anlegt, können wir auch die kürzere Variante k8s-app.default nutzen, default steht hier für den Namen des Kubernetes-Namespaces, in dem der Service angelegt wurde. Befinden wir uns mit unseren Pod im selben Namespace, können wir auch diesen Teil beim DNS-Namen weglassen und es reicht der Name des Service aus.
Sollte der Service nicht den Default-Port für das genutzten Protokoll nutzen, müssen wir den Port entweder hart codieren oder nutzen hierfür dann doch die Umgebungsvariable. Üblicherweise sollten wir aber versuchen, den Standardport, zum Beispiel 80 für HTTP, für das genutzte Protokoll zu nutzen.