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.

package de.mvitz.k8s.app;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.InetAddress;
import java.net.UnknownHostException;

@SpringBootApplication
@RestController
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    private final String hostName;

    public Application() throws UnknownHostException {
        this.hostName = InetAddress.getLocalHost().getHostName();
    }

    @GetMapping
    public String index() {
        return "Response from instance '" + hostName + "'!";
    }
}
Listing 1: Java-Code der Beispielanwendung

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.

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: k8s-app
spec:
  replicas: 2
  selector:
    matchExpressions:
      - key: app
        operator: In
        values:
          - k8s-app
  template:
    metadata:
      name: k8s-app
      labels:
        app: k8s-app
    spec:
      containers:
        - name: k8s-app
          image: k8s-app
          imagePullPolicy: Never
Listing 2: ReplicaSet mit Match-Expression

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.

...
  selector:
    matchLabels:
      app: k8s-app
...
Listing 3: matchLabels-Selektor

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:

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).

apiVersion: v1
kind: Pod
metadata:
  name: busybox
spec:
  restartPolicy: Always
  containers:
    - name: busybox
      image: busybox
      command:
        - sleep
        - "3600"
      imagePullPolicy: IfNotPresent
Listing 4: busybox-Pod
$ kubectl apply -f busybox.yaml
pod/busybox created
$ kubectl get pods --selector=app=k8s-app -o \
    jsonpath='{.items[*].status.podIP}'
172.17.0.5 172.17.0.3
$ kubectl exec -it busybox /bin/sh
/ # wget -q -O -- http://172.17.0.5:8080
Response from instance 'k8s-app-5vp4h'
/ # wget -q -O -- http://172.17.0.3:8080
Response from instance 'k8s-app-ww5nv'
Listing 5: HTTP-Requests an die Pod-IPs

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.

apiVersion: v1
kind: Service
metadata:
  name: k8s-app
spec:
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: k8s-app
Listing 6: 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).

$ kubectl apply -f service.yaml
service/k8s-app created
$ kubectl get services
NAME       TYPE      CLUSTER-IP     EXTERNAL-IP PORT(S) AGE
k8s-app    ClusterIP 10.107.160.215 <none>      80/TCP  19s
kubernetes ClusterIP 10.96.0.1      <none>      443/TCP 122d
$ kubectl exec -it busybox /bin/sh
/ # wget -q -O -- http://10.107.160.215
Response from instance 'k8s-app-5vp4h'
/ # wget -q -O -- http://10.107.160.215
Response from instance 'k8s-app-ww5nv'
Listing 7: HTTP-Requests an die Service-IP

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.

apiVersion: apps/v1
kind: ReplicaSet
...
spec:
  ...
  template:
    ...
    spec:
      containers:
        - name: k8s-app
          ...
          ports:
            - name: http
              containerPort: 8080
Listing 8: Benannter Port im ReplicaSet

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).

$ kubectl apply -f busybox.yaml
pod/busybox created
$ kubectl exec -it busybox /bin/sh
/ # env | sort
...
K8S_APP_PORT=tcp://10.107.160.215:80
K8S_APP_PORT_80_TCP=tcp://10.107.160.215:80
K8S_APP_PORT_80_TCP_ADDR=10.107.160.215
K8S_APP_PORT_80_TCP_PORT=80
K8S_APP_PORT_80_TCP_PROTO=tcp
K8S_APP_SERVICE_HOST=10.107.160.215
K8S_APP_SERVICE_PORT=80
...
Listing 9: Umgebungsvariablen des busybox-Pods

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.

$ kubectl exec -it busybox /bin/sh
/ # wget -q -O -- http://k8s-app.default.svc.cluster.local
Response from instance 'k8s-app-pn9fw'!
/ # wget -q -O -- http://k8s-app.default
Response from instance 'k8s-app-5v2w9'!
/ # wget -q -O -- http://k8s-app
Response from instance 'k8s-app-5v2w9'!
/ # cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
Listing 10: Service über DNS ansprechen

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.

Conclusion

In diesem Artikel haben wir mit dem ReplicaSet den Nachfolger des ReplicationControllers kennengelernt. Mit diesem haben wir erweiterte Selektoren zur Verfügung.

Zudem haben wir gesehen, wie wir mittels eines Service unsere Pod-Instanzen innerhalb des Clusters erreichen können. Dieser funktioniert wie ein TCP basierter Load-Balancer zwischen dem Client und den Pod-Instanzen. Die IP-Adresse eines Service lässt sich dabei zur Laufzeit per Umgebungsvariable oder DNS herausfinden.

Wir haben nun insgesamt die vier Kubernetes-Objekte, Pod, ReplicationController, ReplicaSet und Service, kennengelernt und haben mehrere laufende Instanzen unserer Anwendung, die wir innerhalb des Clusters ansprechen können.

Allerdings ist die Anwendung immer noch nicht von außerhalb des Clusters erreichbar. Zudem haben wir weitere wichtige Aspekte, wie die Übergabe von Konfigurationswerten oder das Ausrollen neuer Versionen, nicht betrachtet. Außerdem haben wir bisher auch nur über dauerhaft laufende Anwendungen gesprochen und nur einmal oder regelmäßig laufende Jobs ignoriert. Diese Themen werden vermutlich Bestandteil von weiteren Teilen zu Kubernetes.