Kubernetes Grundlagen

Willkommen in einer Welt voller Abstraktionsebenen. Dieser Artikel gibt eine Einführung in die Welt von Kubernetes. Neben der Architektur und Hosting-Möglichkeiten werfen wir einen Blick auf einige grundlegende Objekte und ein Fallbeispiel.

Voraussetzungen

Um den Inhalt dieses Artikels verstehen zu können, sollte bereits etwas Vorwissen in den Themenbereichen Docker, Cloud Computing sowie dem Arbeiten mit YAML vorhanden sein. Falls das nicht der Fall ist: Keine Panik! Die genannten Themen werden dennoch grundlegend erklärt.

Die Nutzer eines Services wie einer einfachen Webseite oder einer komplexeren (Web)App, erwarten heutzutage eine durchgehende Verfügbarkeit. Seit einiger Zeit zeichnet sich ein klarer Wandel vom "Bare-Metal-Server" hin zu virtuellen Maschinen in der Cloud ab. Aber damit noch nicht genug. Virtualisierung ist insbesondere für Unternehmen interessant. Damit können Server mit wenigen Klicks oder gar vollautomatisiert gestartet werden - abhängig davon, wie viel Rechenpower benötigt wird.
Für jede Anwendung einen eigenen Server zu betreiben, wäre in vielerlei Hinsicht ungünstig. Einen Solchen aufzusetzen bringt viele zusätzliche Aufgaben mit sich (Konfiguration, Backup & Recovery, Security, ...). Unter Anderem deswegen sind neuere Arten der Virtualisierung interessant - wie Docker.
Damit können (um bei den Grundlagen zu bleiben) mehrere Anwendungen auf ein und derselben Maschine, aber dennoch völlig autark betrieben werden. Unsere Programme werden in sog. Containern ausgeführt. Es handelt sich hier um eine Art schlanke virtuelle (Linux-)Maschine, in der auch tatsächlich nur Programme laufen, die im Vorhinein festgelegt wurden. Von außen sieht ein Container immer gleich aus. Sein Inhalt kann jedoch angepasst werden (z.B. Software installieren).
Docker bietet auch die Möglichkeit, mehrere Server (welche Container ausführen) in einer Art "Verbund" zusammenzuschalten - Docker Swarm genannt. Damit können wir den Bogen zu Kubernetes schlagen.

Ursprung

Kubernetes (kurz k8s) wurde von Google entwickelt und 2014 als Open Source Projekt an die Cloud Native Computing Foundation (CNCF) übergeben. Seither wächst seine Bekannt- und Beliebtheit rasant. Viele Firmen, kleine wie große, setzen es ein, um ihre Programme/Services/Workloads zu orchestrieren. Aus besagten Gründen ist die Kubernetes-Community recht groß. Es lassen sich viele Dokus, Talks, Blogposts und mehr im Netz finden.

Stateful & Stateless Unterscheidung

Anwendungen können prinzipiell - bezüglich ihrer Datenhandhabung - in zwei Kategorien eingeteilt werden.
Diese Unterscheidung ist sehr wichtig. Kubernetes unterstützt mittlerweile Stateful- und Stateless-Anwendungen. Eigentlich wurde es von Google aber konzipiert, um den Betrieb von Stateless-Anwendungen wie APIs bzw. Microservices auf ein ganz neues Level zu heben.

Stateful

Ein Beispiel für eine klassische Stateful-Anwendung ist die Datenbank. Auch nach einem Neustart müssen alle bisher gespeicherten Daten vorhanden sein. Hierbei geht es nicht um das Thema Backup & Recovery, sondern um die Persistenz/Dauerhaftigkeit/Statefulness der Daten.

Stateless

Ein Beispiel für eine klassische Stateless-Anwendung ist die API. Sie speichert keine Daten wie z.B. Benutzereingaben. Wenn eine API beispielsweise eine Benutzeranmeldung anbietet, empfängt sie alle Eingaben wie Name und Passwort des Nutzers. Anschließend wird überprüft, ob die eingegebenen Daten mit den in der Datenbank vorhandenen übereinstimmen. Ist das der Fall, wird der Benutzer angemeldet. Falls nicht, wird meist eine Fehlermeldung ausgegeben.
Die API ist hier also für die Verarbeitung der Benutzereingaben zuständig. Sie wird häufig als Anwendungslogik (Business Logic) bezeichnet und ist mittig zwischen der Präsentationsschicht und der Datenhaltungsschicht angesiedelt - also zwischen Benutzerschnittstelle und Datenbank. Da sie keinerlei Daten speichert, die später erneut gebraucht werden, wird sie als transient/zustandslos/stateless bezeichnet.

Cluster Architektur

Grundlegend besteht Kubernetes nicht nur aus verschiedenen Software-Komponenten. Wir schließen mehrere Server zu einem Verbund zusammen und nennen diesen dann "Cluster".

Kubernetes Architektur (vereinfacht)

Unser Cluster besteht grundlegend aus verschiedenen Servern (im Folgenden "Nodes" genannt). Wir unterscheiden zwei Arten von Nodes. Auf beiden Arten laufen unterschiedliche Programme im Hintergrund, die nötig sind, um Kubernetes zu nutzen.

Master

Die Master-Node ist die Schaltzentrale. Hier laufen alle Informationen die im Cluster anfallen zusammen. Dazu zählen zum Beispiel der Gesundheitsstatus der Worker-Nodes und welche Workloads/Anwendungen gerade ausgeführt werden.

Wir als Anwender nutzen ein Tool namens kubectl. Damit können wir allerlei Informationen aus dem Cluster abfragen oder neue Workloads provisionieren. Als Nutzer kommunizieren wir mittels kubectl direkt mit dem Master der für unser Cluster zuständig ist. Grundlegend handelt es sich hier um API-Abfragen. Sollte der Master nicht mehr erreichbar sein, funktioniert das Cluster (resp. die Anwendungen darin) zwar weiterhin, aber es können keinerlei Informationen abgefragt bzw. Aktionen ausgeführt werden. Wir wären also handlungsunfähig.

Worker

Auf den Worker-Nodes laufen unsere Workloads. Also Programme in Docker Containern (manche Anbieter unterstützen auch Windows-Container). Zusätzlich werden eine Reihe von Kubernetes-Deamons ausgeführt. Diese beanspruchen nur wenig Ressourcen und teilen dem Master durchgehend mit, was passiert. Provisionieren wir also neue Workloads, informiert der Master einzelne Worker. Diese führen die aufgetragenen Aufgaben/Arbeitsschritte aus und lassen den Master anschließend u.a. wissen, ob unsere Programme laufen oder ob es zu Fehlern gekommen ist.

Übrigens: Um die dauerhafte Verfügbarkeit der Master-Nodes für Managed Kubernetes Services (siehe Hosting) sicherzustellen, setzen viele Anbieter Kubernetes ein. Das wäre also Kubernetes in Kubernetes. Hier gibt es ein Beispiel aus der Praxis.

Hosting

Da es sich bei Kubernetes um ein Open Source Projekt handelt, könnte ein eigenes Cluster von Hand aufgesetzt werden. Dazu bräuchte es mindestens zwei Server. Einer fungiert als Master und der andere als Worker. Diese Vorgehensweise bringt jedoch gleich mehrere Probleme mit sich. Es empfiehlt sich daher sehr, einem Hosting-Provider das Aufsetzen und den Betrieb des eigenen Clusters zu überlassen. Zwar wurde Kubernetes von Google entwickelt, mittlerweile bieten allerdings alle bekannten Cloud-Anbieter ein Managed Cluster / Managed Kubernetes Service an. Beispiele hierfür sind: Azure, AWS, GCP, DigitalOcean, OVH.

Die Preisgestaltung unterscheidet sich natürlich je nach Anbieter. Die Kunden zahlen jedoch nur für die Worker-Nodes die sie im Cluster einbinden möchten. Entscheiden sie sich später dafür, alle Vorteile von Kubernetes in der Cloud zu nutzen (wie LoadBalancer und Persistent Volumes), bringt das weitere Kosten mit sich. Das ist einer der Kernunterschiede zu Docker Swarm. Hier müssten alle Maschinen aus welchen ein Docker Swarm besteht, selber betreiben (Konfiguration, Backup & Recovery, Security, ...) werden. Zusätzlich fallen Kosten für den Betrieb der Swarm-Manager-Node an.

Wahrung des Cluster-Zustands

Kubernetes wahrt den Cluster-Zustand. Wenn wir Workloads provisionieren und dadurch Ressourcen im Cluster erstellt werden, sorgt Kubernetes dafür, dass genau diese Beschreibung (Bsp.: 2 Webserver, 1 NodeJS-App, 1 Datenbank) zu jedem Zeitpunk laufen und verfügbar sind. Sollte es Probleme mit einer Anwendung geben, wie einen Absturz, wird sie automatisch neu gestartet. Es ist kein weiteres Eingreifen erforderlich. Aus diesem Grund bringt der Einsatz von Kubernetes zu Beginn eine steile Lernkurve mit sich. Im weiteren Betrieb wird uns jedoch ein Großteil der Arbeit abgenommen. Wie in der Einführung erwähnt wurde, müssen sich die Nutzer eines Clusters nicht um die Instandhaltung der einzelnen Infrastrukturkomponenten (im Fall eines Managed Kubernetes Service) sorgen. Das übernimmt der Hosting-Anbieter.

Beschreibung von Objekten

Kubernetes baut auf Abstraktionsebenen. Es müssen folglich Objekte (wie Services, Pods oder Secrets) erstellt werden, die dafür sorgen, dass Anwendungen innerhalb eines Clusters laufen können. Um eben diese Abstraktionsebenen bzw. Objekte zu beschreiben, wird YAML (Yet Another Markup Language / YAML Ain't Markup Language) verwendet. Um ein Objekt in Kubernetes zu erstellen, muss seine Spezifikation inklusive weiterer Informationen wie der gewünschte Name des Objektes beschrieben werden. Anschließend wird die Beschreibung als JSON an den Master übermittelt. Einfacher ist es jedoch, das Tool kubectl zu nutzen. Damit können wir die Objekte per YAML (lesefreundlicher als JSON) beschreiben und anschließend mit kubectl an den Master übermitteln. So werden die Vorteile von YAML nutzbar, denn die Konvertierung zu JSON wird von kubectl automatisch durchgeführt.
Im Folgenden Kapitel "Abstraktionsebenen" werden einige Kubernetes-Objekte inklusive ihrer YAML-Spezifikation vorgestellt.

Abstraktionsebenen

Wenn wir über Kubernetes sprechen, sollte man sich den Begriff Abstraktionsebenen bzw. Objekte einprägen. Der einzelne Server an sich interessiert uns nicht mehr. Lediglich einige physische Merkmale wie Anzahl der CPU-Kerne und Größe des RAM müssen beachtet werden. Bevor wir also beginnen und ein Cluster mieten, sollte in Erfahrung gebracht werden, welche Anwendungen wir betreiben möchten. Entsprechend unserer Anforderungen kann dann eine gewisse Zahl und Art an Worker-Nodes bestellt werden. Über die eigentliche Infrastruktur legt Kubernetes Abstraktionsschichten. Das erspart uns viel Aufwand und sorgt gleichzeitig für eine Verringerung der Abhängigkeit zu ihr.

Pod

Ein Pod ist das kleinste bereitstellbare Objekt in Kubernetes. Schlussendlich ist k8s für die Orchestrierung von Docker-Containern verantwortlich. Ein Pod kann mehrere solcher Container beinhalten in welchen unsere Anwendungen laufen. Alle in einem Pod laufenden Containern haben auf Ressourcen Zugriff, auf die der Pod Zugriff hat (Beispiel: ein Volume zum Speichern von Dateien). Empfohlen wird allerdings, einen Pod pro Container bzw. pro Anwendung zu nutzen. Container in einem Pod laufen immer auf ein und der selben Worker-Node in einem Cluster und können sich untereinander via localhost erreichen. Zusätzlich erhält jeder Pod eine eigene IP-Adresse und einen DNS-Namen unter denen er innerhalb des Clusters erreichbar ist. Pods können von Kubernetes jederzeit beendet und z.B. auf einer anderen Worker-Node wieder gestartet werden.

Pod-Definition in YAML:

apiVersion: v1
kind: Pod
metadata:
  name: mein-webserver
  labels:
    app: mein-webserver
spec:
  containers:
  - name: nginx-container
    image: nginx
    ports:
    - containerPort: 80

Service

Um Anwendungen die in Pods laufen nach außen (innerhalb oder außerhalb des Clusters) erreichbar zu machen, benötigen wir einen Service. Ein solcher bietet von Haus aus eine Lastenverteilung (LoadBalancing) zwischen den ihm zugewiesenen Pods an. Da Pods jederzeit neu gestartet werden können, muss sichergestellt werden, dass sie im Nachhinein erreichbar sind (die IP-Adressen der Pods können sich ändern). Auch dafür ist ein Service zuständig. Er ist also eine Abstraktionsebene, die eine Menge an Pods und die Art und Weise wie sie von außen erreicht werden können, beschreibt. Ein Service findet den Pod an welchen er eintreffende Anfragen weiterleitet über den Selector (Zeile 6-7 in der Service-Definition). In der Beispiel-YAML werden Anfragen an den Pod mit dem Label app: mein-webserver auf Port 80 weitergereicht.

Service-Definition in YAML:

apiVersion: v1
kind: Service
metadata:
  name: mein-service
spec:
  selector:
    app: mein-webserver
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

Ingress

Damit Anwendungen die in einem Kubernetes Cluster laufen von außerhalb des Clusters erreichbar sind, wird ein Ingress benötigt. Dieses Objekt kann als eine speziellere Art eines Service angesehen werden. Neben Lastenverteilung bietet er auch SSL-Terminierung (sprich HTTPS) und name-based virtual routing an. Ähnlich wie ein "normaler" Webserver können einem Ingress mehrere Domains zugewiesen werden. Je nachdem über welche Domain eine Anfrage eintrifft, kann er sie an den zuständigen Service innerhalb des Clusters weiterleiten. So können Anfragen an example.com an den/die Pod(s) welche die statische Webseite ausliefern weitergeleitet werden. Anfragen an api.example.com werden hingegen an den/die Pod(s) welche die API-Anwendung (wie NodeJS) beinhalten weitergeleitet. Zu beachten ist jedoch, dass ein Ingress-Objekt lediglich einen Regelsatz darstellt. Die eigentliche Software dahinter (meist Nginx, HA-Proxy oder Traefik) wird zusätzlich benötigt. Diese Thematik wird in einem späteren Beitrag betrachtet.

Ingress-Definition in YAML:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: mein-ingress
spec:
  tls:
  - hosts:
    - example.com
    - api.example.com
  rules:
  - host: example.com
    http:
      paths:
      - path: /
        backend:
          serviceName: example-webseite
          servicePort: 80
  - host: api.example.com
    http:
      paths:
      - path: /
        backend:
          serviceName: example-api
          servicePort: 8000

Persistent Volume (Claim)

Wenn eine Stateful-Anwendung in einem Kubernetes Cluster betrieben werden soll, müssen ihre Daten gespeichert werden. Unabhängig davon, ob die Worker-Node auf der die Anwendung ausgeführt wird abstürzt oder die Anwendung an sich neu gestartet wird - ihre Daten müssen persistent gespeichert werden. Mieten wir ein K8s-Cluster bei einem Cloud-Anbieter, ist ein Persistent Volume (PV) ein externes Laufwerk. Es ist also unabhängig von unserem Cluster. Ein Persistent Volume Claim (PVC) ist eine Anfrage (an den Anbieter bei dem unser Cluster betrieben wird), ein Persistent Volume für uns bereitzustellen. Wir geben u.a. an, wie groß (z.B. in Gigabyte) es sein soll sowie welche Art wir nutzen möchten (Auswahl meist zw. HDD und SSD). Das Persistent Volume wird nach der Bereitstellung durch den Anbieter an einen Pod gemountet/angehängt.
Beispielsweise speichert eine Datenbank wie MySQL/MariaDB ihre persistenten Daten (die Datenbanken und darin enthaltene Tabellen sowie Nutzer und deren Zugangsdaten) in dem Verzeichnis /var/lib/mysql. Das Persistent Volume wird folglich an genau diesem Pfad gemountet. Das führt dazu, dass alle persistenten Daten der Datenbank direkt auf dem PV - also extern und nicht innerhalb des Datenbank-Containers - gespeichert werden. Sollte der Datenbank-Pod neu gestartet werden, wird das Persistent Volume von Kubernetes automatisch erneut an den Datenbank-Pod und unter dem gleichen Pfad angehängt. Sollte Kubernetes den Datenbank-Pod auf einer anderen Worker-Node starten, wird das PV automatisch an diese Maschine gehangen und steht anschließend für den Pod zur Verfügung.

Persistent Volume Claim-Definition in YAML:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mein-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: ssd

Secret

Um vertrauliche Informationen wie Passwörter oder API-Tokens speichern zu können, benötigen wir Secrets. Es ist deutlich sicherer, derartige Informationen nicht direkt im Code unserer Anwendung oder auf einem Persistent Volume abzulegen, sondern dafür ein Secret zu nutzen. Zugleich sind wir flexibler, denn wir können genau festlegen, welche Pods auf ein Secret zugreifen dürfen. Des Weiteren kann darauf verzichtet werden, solch sensible Zugangsdaten in den YAML-Dateien/Kubernetes-Manifesten als Klartext abzuspeichern. Die Daten, die ein Secret enthält, werden base64-encoded und auf der Master-Node (Anbieter abhängig meist verschlüsselt) gespeichert. Ein Secret kann seinen Inhalt entweder per Volume das an einen Pod angehängt wird oder mittels Umgebungsvariablen verfügbar machen.

Secret-Definition in YAML:

apiVersion: v1
kind: Secret
metadata:
  name: mein-secret
type: Opaque
stringData:
  DB_USER: "Nutzername"
  DB_PASSWORT: "Passwort"
  API_KEY: "apiKey"

ConfigMap

Um nicht vertrauliche Informationen wie Einstellungsparameter von Anwendungen zu speichern, können ConfigMaps genutzt werden. Sie sind das Pendant zu Secrets. Die Konfigurationsparamenter sind damit unabhängig von der Anwendung. Sollten wir also einige Einstellungen ändern, muss nicht der Code der Anwendung, sondern lediglich der Inhalt der ConfigMap editiert werden.

ConfigMap-Definition in YAML:

apiVersion: v1
kind: ConfigMap
metadata:
  name: meine-configmap
data:
  URL: "https://example.com"
  LOGGING_LEVEL: "development"
  CHECK_UPDATE: "yes"

CronJob

Wie auch auf einem Linux-Server, gibt es in Kubernetes eine Möglichkeit, bestimmte Aufgaben regelmäßig auszuführen. Ein Beispiel für Aufgaben dieser Art sind Backups. Wollen wir regelmäßig (z.B. täglich 2 Uhr morgens) ein Backup unserer Datenbank erstellen, können wir einen CronJob nutzen. Dieser besteht grundlegend aus der Information, wann er ausgeführt werden soll und der Konfiguration des Pods. Schlussendlich wird durch den CronJob zu den von uns festgelegten Zeiten ein Pod gestartet. Wenn er seine Aufgabe erfolgreich erledigt hat, beendet er sich automatisch. Als Pod kann das Image "busybox" verwendet werden. Es besteht aus einer sehr leichtgewichtigen Linux-Distribution. Wir teilen dem CronJob bei Bedarf mit, an welches (Persistent) Volume und unter welchem Pfad er sich anhängen soll. Wird der Job gestartet, hängt der Pod das Volume ein, führt die von uns definierten Befehle aus, gibt es nach getaner Arbeit wieder frei und beendet sich anschließend. Die Backups können je nach Bedarf natürlich auch direkt auf einen externen Speicher kopiert werden.

CronJob-Definition in YAML:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: mein-cronjob
spec:
  schedule: "0 2 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: db-backup
            image: busybox
            args:
            - /bin/sh
            - -c
            - <backupBefehle>
          restartPolicy: OnFailure

ReplicaSet

Um die Verfügbarkeit einer festen Anzahl an Pods jederzeit garantieren zu können, wird ein ReplicaSet benötigt. Wie der Name bereits erschließen lässt, handelt es sich immer um die gleiche Art von Pods. Wollen wir beispielsweise wegen eines erwarteten Besucheransturms skalieren, können wir die Replikate unserer Webserver-Pods erhöhen. Kubernetes startet automatisch weitere Instanzen des Webserver-Pods entsprechend der von uns festgelegten Anzahl. Ein ReplicaSet kann von einem Deployment verwaltet werden (siehe Punkt "Deployment"). Dementsprechend ist es nicht erforderlich, ein ReplicaSet händisch zu erstellen - das kann ein Deployment übernehmen. Sofern wir ein Cluster von einem Anbieter betreiben lassen, können wir die Anzahl der Worker-Nodes sowie die Anzahl von Replikaten unserer Anwendungen vollautomatisiert und je nach Bedarf skalieren lassen. Voraussetzung dafür ist aber natürlich die Unterstützung dieser Funktionalität durch den Anbieter.

ReplicaSet-Definition in YAML:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: frontend
  labels:
    app: webserver
    tier: frontend
spec:
  replicas: 3
  selector:
    matchLabels:
      tier: frontend
  template:
    metadata:
      labels:
        tier: frontend
    spec:
      containers:
      - name: nginx
        image: nginx

Deployment

Mit Hilfe eines Deployments können wir Pods und ReplicaSets verwalten. Wir beschreiben hier einen Zustand, der unter allen Umständen gewahrt werden soll. Wollen wir beispielsweise einen Webserver betreiben, beschreiben wir ihn nicht als einzelnen Pod. Stattdessen nutzen wir ein Deployment. Dadurch können z.B. die Anzahl der Replikate des Webservers beliebig erhöht werden, sollten wir mehr Besucher unserer Webseite erwarten. Ist der Ansturm wieder vorüber, verringern wir die Anzahl der Webserver wieder, indem wir in der YAML-Definition unseres Deployments die Zahl der replicas: 3 (siehe YAML) verringern. Sollte sich einmal der Fehlerteufel einschleichen, können wir auf einen früheren Stand des Deployments zurückspringen. Zusammenfassend ist ein Deployment also eine Möglichkeit, mehrere Kubernetes-Objekte wie Pods und ReplicaSets zentralisiert zu verwalten. Übergeben wir dem Cluster unser Deployment, werden die darin beschriebenen Ressourcen (wie ein Pod) automatisch erstellt. Löschen wir es, werden auch alle zugehörigen Ressourcen automatisch gelöscht.
Neben dem Namen des Deployments geben wir an, wie viele Replikate (hier Pods die den Webserver Nginx ausführen) vorhanden sein sollen. Mit selector: matchLabels: app: nginx stellen wir sicher, dass das Deployment weiß, für welche Pods es zuständig ist. In diesem Fall ist es ausschließlich für Pods mit dem Label app: nginx verantwortlich. Es folgt unter dem Punkt template: die eigentliche Definition der Pods, die zu unserem Deployment gehören. Hier also drei Mal ein Pod mit dem Container Nginx.

Deployment-Definition in YAML:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mein-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80

Fallbeispiel

Es soll ein Online Shop mit folgendem Technologie-Stack betrieben werden:

Typ Frontend Backend Backend Backend
Art WebApp Shop-API Payment-API Datenbank
Beispiel React NodeJS Python MongoDB

WebApp (stateless)
Eine React-WebApp besteht nach einem Production Build aus einer HTML sowie einigen JavaScript- und CSS-Dateien. Diese müssen von einem Webserver ausgeliefert werden.

Shop-API (stateless)
Die Shop-API bestehend aus NodeJS und Express nimmt Anfragen der WebApp entgegen. Sie kommuniziert mit der Payment-API und der Datenbank.

Payment-API (stateless)
Die Payment-API kommuniziert mit der Shop-API und der Datenbank. Für die Besucher des Shops ist sie nicht erreichbar. Hier wird mit Zahlungsdienstleistern wie PayPal und Stripe kommuniziert, um z.B. Käufe von Kunden im Online Shop abzuwickeln.

Datenbank (stateful)
Unsere Datenbank speichert alle Informationen des Online Shops. Dazu zählen z.B. Kundendaten und Details zu Produkten die angeboten werden.

Alle Komponenten des Shops sollen in einem Kubernetes Cluster betrieben werden. Damit das möglich ist, müssen wir dafür sorgen, dass unsere Anwendungen "containerisiert" sind. Also in Docker Containern laufen können und passende Images vorliegen. (Mehr dazu in einem späteren Beitrag)

Beispielarchitektur

Fallbeispiel Architektur (vereinfacht)

Die Besucher des Shops greifen über den Ingress zuerst auf die WebApp zu. Wie oben erwähnt, werden hier von Nginx die Dateien der React-WebApp an die Nutzer ausgeliefert. Die WebApp stellt Anfragen an die Shop-API. Diese kommuniziert im Hintergrund mit der Datenbank des Shops. Sollten Zahlungen abgewickelt werden müssen (wie bei einem Kauf), greift die Shop-API auch auf die Payment-API zu. Diese steht in Verbindung mit externen Zahlungsdienstleistern wie Stripe und PayPal. Die Shop- und Payment-API nutzen Secrets, um auf sensible Daten wie die Datenbank-Anmeldeinformationen und die API-Tokens der externen Zahlungsdienstleister zuzugreifen. Da es sich bei der Datenbank um eine Stateful-Anwendung handelt, speichert sie ihre Daten auf einem Persistent Volume.
Die Anwendungen greifen aufeinander über ihre Cluster-internen Services zu.

Um zu verstehen, wie die oben dargestellte Architektur in Kubernetes-Manifesten umgesetzt werden könnte, sind die einzelnen Bestandteile inkl. einer kurzen Erläuterung im Folgenden aufgeführt.

Ingress

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: demo-shop-com-ingress
  annotations:
  # Einfachheitshalber entfernt
  # Hiermit könnte z.B. Weiterleitung von HTTP zu HTTPS konfiguriert werden
spec:
  tls:
  - hosts:
    - demo-shop.com
    - api.demo-shop.com
    secretName: demo-shop-com
  rules:
  - host: demo-shop.com
    http:
      paths:
      - backend:
          serviceName: shop-webapp-svc
          servicePort: 8080
  - host: api.demo-shop.com
    http:
      paths:
      - backend:
          serviceName: shop-api-svc
          servicePort: 8000
  • secretName: Name des Secrets das genutzt wird, wenn HTTPS angeboten wird (es würde z.B. die Zertifikats-Informationen enthalten).
  • paths: Regel für Weiterleitung an angegebenen Service kann auch als Pfad definiert werden. Damit könnte auf eine Subdomain für die API verzichtet werden. Sie könnte z.B. stattdessen unter demo-shop.com/api erreichbar gemacht werden.
  • serviceName: & servicePort: Daten des Cluster-internen Service, an den alle Anfragen für demo-shop.com bzw. api.demo-shop.com weitergeleitet werden.

Hinweis: Um einen Ingress nutzen zu können, ist etwas Vorarbeit nötig. Insbesondere, wenn HTTPS genutzt werden soll (wie in dem YAML-Beispiel zu sehen ist). In einem späteren Beitrag auf diesem Blog wird näher darauf eingegangen.


Service (WebApp)

apiVersion: v1
kind: Service
metadata:
  name: shop-webapp-svc
spec:
  type: ClusterIP
  selector:
    app: shop-webapp
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 80
      name: shop-webapp-svc
  • name: Name unter dem der Service erreichbar ist (siehe bspw. Ingress).
  • type: Art des Service. ClusterIP=nur innerhalb des Clusters erreichbar.
  • selector: app: shop-webapp Anfragen werden an Pods (z.B. eines Deployments) mit dem Label app: shop-webapp weitergeleitet.
  • ports: Service ist erreichbar unter Port 8080 und gibt Anfragen weiter an Port 80. Der Port wird hier folglich 'umgewandelt'.

Deployment (WebApp)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-webapp-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: shop-webapp
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: shop-webapp
    spec:
      containers:
      - name: shop-webapp
        image: demo-shop-gmbh/webapp:1.0
        imagePullPolicy: Always
        ports:
        - containerPort: 80
      imagePullSecrets:
      - name: private-registry
  • replicas: 3 Das Deployment besteht aus 3 gleichen Pods die den gleichen Container (die WebApp) ausführen. Kubernetes sorgt i.d.R. für eine gleichmäßige Verteilung auf vorhandene Worker-Nodes.
  • selector: matchLabels: app: shop-webapp Das Deployment ist für alle Pods mit dem Label app: shop-webapp zuständig. Alle Einstellungen des Deployments wirken sich lediglich auf diese Pods aus.
  • template: Definition des/der Pods der/die durch das Deployment verwaltet werden.
  • labels: app: shop-webapp Ein Label dient der Bezeichnung/Beschreibung von Kubernetes-Objekten. Hier wird es gebraucht, damit der WebApp-Service Anfragen an den/die Pods des Deployments weiterleiten kann und damit eine Verwaltung des/der Pods mit diesem Label durch das WebApp-Deployment möglich ist.
  • containers: Innerhalb eines Pods können mehrere Container ausgeführt werden. Hier wird lediglich einer definiert.
  • image: Angabe eines Docker-Images. Die in der YAML verwendete Angabe gibt an, dass Kubernetes das Image von der Plattform/Docker-Registry "Docker Hub" herunterladen soll. Syntax: "dockerHubNutzer/repositoryBzwImageName:version". Wenn die Images nicht veröffentlicht werden sollen, kann eine private Container-Registry verwendet werden (wie GitLab Container Registry). In diesem Fall wird als Image folgendes angegeben: "registry.gitlab.com/nutzername/webapp:1.0".
  • imagePullSecrets: Viele Docker Images sind öffentlich über hub.docker.com verfügbar. Sollte allerdings eine private Container-Registry genutzt werden, muss sich das Kubernetes Cluster authentifizieren, um auf die eigenen privaten Images zugreifen zu können. Unter imagePullSecrets wird der Name des Secrets angegeben, das die Anmeldeinformationen der privaten Registry enthält.

Service (Shop-API)

apiVersion: v1
kind: Service
metadata:
  name: shop-api-svc
spec:
  type: ClusterIP
  selector:
    app: shop-api
  ports:
    - protocol: TCP
      port: 8000
      targetPort: 8000
      name: shop-api-svc

Deployment (Shop-API)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: shop-api
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: shop-api
    spec:
      containers:
      - name: shop-api
        image: demo-shop-gmbh/api:1.0
        imagePullPolicy: Always
        ports:
        - containerPort: 8000
          name: shop-api
        envFrom:
        - configMapRef:
            name: shop-api-config
        env:
          - name: DB_USER
            valueFrom:
              secretKeyRef:
                name: shop-api-secret
                key: DB_USER
          - name: DB_PASSWORD
            valueFrom:
              secretKeyRef:
                name: shop-api-secret
                key: DB_PASSWORD
      imagePullSecrets:
      - name: private-registry
  • envFrom: - configMapRef: name: Um eine ConfigMap, die Umgebungsvariablen enthält, einbinden zu können, wird ihr Name hier angegeben. Der/Die Pod(s) des Deployments können dadurch auf die Werte der ConfigMap als Umgebungsvariablen zugreifen. Die Shop-API kann also z.B. auf die Umgebungsvariable NODE_ENV mit dem Wert "production" zugreifen.
  • env: Unter diesem Punkt können die Werte von Secrets dem/den Pod(s) eines Deployments als Umgebungsvariablen zugänglich gemacht werden. Die Shop-API kann also z.B. auf die Umgebungsvariable DB_USER mit dem Wert "Nutzername" zugreifen.

Secret (Shop-API)

apiVersion: v1
kind: Secret
metadata:
  name: shop-api-secret
type: Opaque
stringData:
  DB_USER: "Nutzername"
  DB_PASSWORD: "Passwort"
  • name: Name der z.B. in der Deployment-YAML verwendet wird, um Inhalte des Secrets als Umgebungsvariable verfügbar zu machen.
  • stringData: Vertrauliche Informationen, die von der Shop-API als Umgebungsvariable abgerufen werden, können hier als KeyValue Pair eingefügt werden.

Hinweis: Eine YAML-Datei mit sensiblen Daten im Klartext sollte nicht verwendet werden. Ein Secret kann stattdessen mittels kubectl erstellt werden. Entweder mit den Daten direkt im eigentlichen Befehl (1) oder mit Daten aus einer separaten Datei (2). Mit Option 2 wird verhindert, dass die sensiblen Informationen in der Bash-History gespeichert werden.

(1) kubectl create secret generic shop-api-secret --from-literal=DB_USER=Nutzername --from-literal=DB_PASSWORD=Passwort

(2) kubectl create secret generic shop-api-secret --from-file=pfad/zur/datei.txt

ConfigMap (Shop-API)

apiVersion: v1
kind: ConfigMap
metadata:
  name: shop-api-config
data:
  NODE_ENV: "production"
  DB_HOST: "shop-db-svc"
  DB_DATABASE_NAME: "shop_db"
  DB_PORT: "27017"
  • name: Name der z.B. in der Deployment-YAML verwendet wird, um Inhalte der ConfigMap als Umgebungsvariable einbinden zu können.
  • data: Konfigurationsparameter die von der Shop-API als Umgebungsvariable abgerufen werden, können hier als KeyValue Pair eingefügt werden.

Service (Payment-API)

apiVersion: v1
kind: Service
metadata:
  name: shop-payment-api-svc
spec:
  type: ClusterIP
  selector:
    app: shop-payment-api
  ports:
    - protocol: TCP
      port: 8000
      targetPort: 8000
      name: shop-payment-api-svc

Deployment (Payment-API)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-payment-api-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: shop-payment-api
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: shop-payment-api
    spec:
      containers:
      - name: shop-payment-api
        image: demo-shop-gmbh/payment-api:1.0
        imagePullPolicy: Always
        ports:
        - containerPort: 8000
          name: shop-payment-api
        envFrom:
        - configMapRef:
            name: shop-payment-api-config
        env:
          - name: DB_USER
            valueFrom:
              secretKeyRef:
                name: shop-payment-api-secret
                key: DB_USER
          - name: DB_PASSWORD
            valueFrom:
              secretKeyRef:
                name: shop-payment-api-secret
                key: DB_PASSWORD
      imagePullSecrets:
      - name: private-registry

Secret (Payment-API)

apiVersion: v1
kind: Secret
metadata:
  name: shop-payment-api-secret
type: Opaque
stringData:
  DB_USER: "Nutzername"
  DB_PASSWORD: "Passwort"
  PAYPAL_API_KEY: "PaypalApiKey"
  STRIPE_API_KEY: "StripeApiKey"

Hinweis: Die Payment-API sollte nicht die gleichen Zugriffsrechte auf die Datenbank haben wie die Shop-API. Aus diesem Grund sollten in diesem Secret andere Werte für DB_USER und DB_PASSWORD als bei dem der Shop-API angegeben werden.

ConfigMap (Payment-API)

apiVersion: v1
kind: ConfigMap
metadata:
  name: shop-payment-api-config
data:
  NODE_ENV: "production"
  DB_HOST: "shop-db-svc"
  DB_DATABASE_NAME: "shop_db"
  DB_PORT: "27017"
  STRIPE_CONFIG: "..."
  PAYPAL_COMFIG: "..."

Service (Datenbank)

apiVersion: v1
kind: Service
metadata:
  name: shop-db-svc
spec:
  type: ClusterIP
  selector:
    app: shop-db
  ports:
    - protocol: TCP
      port: 27017
      targetPort: 27017
      name: shop-db-svc

Deployment (Datenbank)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-db-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: shop-db
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: shop-db
    spec:
      containers:
      - name: shop-db
        image: mongodb:4.0
        imagePullPolicy: Always
        ports:
        - containerPort: 27017
          name: mongodb
        volumeMounts:
        - mountPath: /data/db
          name: shop-db-storage
      volumes:
      - name: shop-db-storage
        persistentVolumeClaim:
          claimName: shop-db-pvc
  • volumeMounts: - mountPath: Alle Dateien in "/data/db" sollen nicht innerhalb des Datenbank-Containers, sondern auf einem Volume gespeichert werden.
  • volumes: Es kann sich z.B. um ein hostPath-Volume handeln. Diese Art Volume speichert seine Dateien auf dem Dateisystem der Worker-Node, auf der der Datenbank-Pod aktuell läuft. Wird diese Worker-Node gelöscht, werden auch alle Dateien des Volumes gelöscht. Alternativ kann der Name eines Persistent Volume Claim angegeben werden. Dadurch werden alle Dateien im Ordner "/data/db" direkt auf einem Persistent Volume gespeichert und sind somit unabhängig von dem Container und der Worker-Node, auf der er läuft.

Persistent Volume Claim (Datenbank)

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: shop-db-pvc
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: ssd #Name unterscheidet sich zw. Providern
  • name: Name für die Referenzierung auf das Persistent Volume, welches durch den Persistent Volume Claim erstellt wurde.
  • accessModes: - ReadWriteOnce Auf das Volume können nicht mehrere Worker-Nodes gleichzeitig zugreifen. Je nach Anbieter gibt es weitere Access-Modes.
  • storage: 10Gi Durch den Persistent Volume Claim wird bei dem Anbieter ein Persistent Volume von der Größe 10GB angefordert.
  • storageClassName: Bestimmung der Art des Persistent Volume. Meist kann zwischen SSD- und HDD-Volumes gewählt werden. Die genaue Bezeichnung dafür (die an dieser Stelle anzugeben ist) muss bei dem Anbieter der unser Cluster betreibt eingeholt werden. Meist hilft eine rasche Websuche wie "NameDesAnbieters kubernetes storage class" weiter.

Abschließende Worte

Kubernetes ist eine fantastische Möglichkeit, 'containerisierte' Anwendungen zu betreiben. Durch zahlreiche Abstraktionsebenen werden die Programme von der eigentlichen Infrastruktur, auf der sie laufen, entkoppelt. Mittlerweile hat sich das Projekt als De-facto-Standard für Container-Orchestrierung etabliert. Dank YAML sind die K8s-Manifeste rasch erstellt und auch nach längerer Zeit noch gut verständlich. Mit einem erfahrenen Anbieter als Partner ist es ein Leichtes, ein Kubernetes Cluster zu erstellen und zu betreiben.

Dieser Artikel ist ein Einstiegspunkt. In späteren Beiträgen befassen wir uns mit dem Aufsetzen eines Clusters und provisionieren erste Workloads. Bis es allerdings soweit ist, freue ich mich über die ein oder andere Rückmeldung.