c't Teil 4: Containerladeoffizier
Container sind flüchtige und vergängliche Gebilde, erzeugt aus Abbildern. Damit sie Daten dauerhaft speichern können, brauchen sie Volumes für Dateien und Ordner. Was in der Docker-Welt mit einem Einzeiler abgehakt ist, ist im Kubernetes-Cluster kompliziert – dafür aber perfekt steuerbar.
Von Jan Mahn
c't 26/2022, Seite 130
Einfach ist alle Containerei, solange die Software, die im Container steckt, nichts speichern will. Dann ist es egal, wo man die Container startet und ob man sie wegwirft, ersetzt oder klont. „Stateless“ heißen solche Anwendungen in der Werbesprache der Cloudanbieter. Der Haken: Nur die wenigsten Anwendungen sind wirklich stateless, fast immer gibt es zur Laufzeit etwas zu speichern oder von der Festplatte zu lesen.
Weil das so ist, haben die Entwickler des Container-Orchestrators Kubernetes für solche Fälle vorgesorgt und sehen Schnittstellen vor, über die Container an Speicherplatz kommen. In einem Cluster aus mehreren Servern kann das schnell kompliziert werden – daher mussten Sie bis zum vierten Teil dieser Reihe für Kubernetes-Einsteiger warten, bis Ihre Pods persistenten Speicher bekommen.
In den ersten drei Artikeln dieser Reihe haben Sie erfahren, wie Sie einen Cluster einrichten und darauf zugreifen [1], was es mit Containern und Pods auf sich hat [2] und wie Netzwerkverkehr von innen und außen in die Container kommt [3]. Sind diese Voraussetzungen geschaffen, kann es mit Speicherplatz weitergehen.
In der Docker-Welt ist der Umgang mit Speicherplatz eine einfache Aufgabe. Docker unterscheidet zwischen zwei Arten von Volumes, benannten und unbenannten. Bei benannten Volumes kümmert sich der Docker-Daemon um einen zentralen Speicherort und führt für jedes benannte Volume ein Objekt in seiner internen Datenhaltung. Darauf kann man mit Docker-Kommandozeilenbefehlen wie docker volume ls zugreifen. Unbenannte Volumes dagegen sind eine direkte Verknüpfung zwischen einer Datei oder einem Ordner auf der lokalen Platte zu einem Pfad in einem Container („Bind Mounts“). Eine gängige Aufgabe für unbenannte Volumes sind in der Docker-Welt alle Formen von Konfigurationsdateien. In einer Docker-Compose-Datei sieht das zum Beispiel so aus:
services:
web:
image: nginx:alpine
ports:
- 80:80
volumes:
- ./config/nginx.conf:/etc/nginx/nginx.conf
Docker-Compose arbeitet immer relativ zum Speicherort der Datei docker-compose.yml, in diesem Beispiel muss im selben Ordner also der Ordner config mit der Konfigurationsdatei nginx.conf liegen. Dann hat der Container Zugriff auf diese Datei. Fehlt die Datei, legt Docker-Compose ein leeres Verzeichnis dieses Namens an, um die Anforderung zu erfüllen.
Mit Kubernetes funktioniert ein solches Konzept nicht, weil die Maschine, auf der Sie Ihre YAML-Dateien zum Verwalten des Clusters schreiben, völlig unabhängig vom Kubernetes-Cluster arbeitet und der Cluster nach einem Kubectl-Apply-Befehl keinerlei Kontakt mehr mit dem PC des Administrators hat.
Konfigurationsdateien
Die Kubernetes-Alternative für solche Aufgaben ist die ConfigMap. Das ist ein eigenständiges Kubernetes-Objekt (wie ein Pod oder ein Service), das erst in den Cluster gebracht wird, dort wie alle Objekte in der Kubernetes-Datenbank gespeichert wird und dann in einen oder mehrere Pods eingebunden wird. Von der lokalen Maschine, auf der die zugehörige Datei entstand, ist das gänzlich entkoppelt. Eine vereinfachte ConfigMap für eine Nginx-Konfiguration kann zum Beispiel so aussehen:
apiVersion: v1
kind: ConfigMap
metadata:
name: configmap-nginx-example
data:
nginx.conf: |
user www www;
worker_processes 5;
http {
[...]
}
Zum Einsatz kommt dabei ein Trick, den YAML von Haus aus mitbringt. Der Block-Operator | sagt dem YAML-Parser: „Behandle die folgenden (eingerückten) Zeilen als eine Zeichenkette und erhalte die Zeilenumbrüche.“ In der ConfigMap liegt ein Schlüssel namens nginx.conf mit dem Inhalt der Konfigurationsdatei als Wert. Die Inhalte sind kein YAML, sondern in diesem Fall die Nginx-eigene Syntax für diese Datei. Es wäre aber nicht verboten (und durchaus gängig), auf diese Weise YAML in YAML zu verschachteln, wenn eine Anwendung YAML verlangt.
Nicht immer muss der Inhalt einer ConfigMap ein solcher mit | eingeleiteter Block sein, man kann auch einen einfachen Wert in einer ConfigMap speichern. Und auch mehrere Schlüssel sind unterhalb von data: möglich. Auch das Folgende ist eine gültige ConfigMap:
apiVersion: v1 kind: ConfigMap metadata: name: config-example data: timeout: 5 username: demo-user
Sobald eine ConfigMap im Cluster liegt, kann man sie an Pods anhängen und in den Containern im Pod nutzen. Auch dafür gibt es mehrere Varianten. Die gängigste: Der Inhalt eines Schlüssels in der ConfigMap soll als Datei im Dateisystem des Containers landen – im Beispiel geht es um den Schlüssel nginx.conf, der in einem Nginx-Container als gleichnamige Datei erwartet wird. Die passende YAML-Datei für den Pod sieht so aus:
kind: Pod
apiVersion: v1
metadata:
name: nginx-with-config
spec:
volumes:
- name: nginx-config
configMap:
name: configmap-nginx-example
containers:
- name: webserver
image: nginx:alpine
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx
Der überwiegende Teil ist vertraute Materie. Neu ist die zusätzliche Angabe volumes: unterhalb von spec:. Auf der Ebene des Pods (damit also für alle Container gültig) können Volumes deklariert werden. Konkret entsteht im Beispiel ein Volume namens nginx-config, mit den Inhalten aus der ConfigMap namens configmap-nginx-example, die zuvor angelegt wurde. Mit dem Deklarieren des Volumes im Pod allein ist es aber noch nicht getan, es muss anschließend an einen oder mehrere Container in diesem Pod geheftet werden. Das passiert unterhalb von volumeMounts: auf der Ebene des Nginx-Containers. Der Name nginx-config ist der, der zuvor innerhalb des Pods definiert wurde, der mountPath ist der Zielordner im Container. Kubernetes erzeugt jetzt für jeden Schlüssel in der ConfigMap eine Datei und legt dort die Inhalte hinein – der Nginx-Container erhält somit seine Datei nginx.conf.
Mit einem solchen Konstrukt haben Sie es im Kubernetes-Alltag recht häufig zu tun, weil viele Anwendungen über Konfigurationsdateien verwaltet werden.
Variable Umgebung
Der zweite gängige Weg im klassischen Linux-Umfeld: Umgebungsvariablen. Auch die kann man – muss man aber nicht – über ConfigMaps befüllen. In dem Fall muss man die ConfigMap nicht auf Pod-Ebene einbinden. Stattdessen verweist man direkt in der Container-Beschreibung auf Schlüssel aus einer ConfigMap. Der folgende Schnipsel veranschaulicht das anhand eines MariaDB-Containers, der eine Umgebungsvariable aus einer ConfigMap bezieht:
[...]
spec:
containers:
- name: mariadb
image: mariadb
ports:
- containerPort: 3306
env:
- name: MARIADB_USER
valueFrom:
configMapKeyRef:
name: mariadb-config
key: MARIADB_USER
Der Container bekommt die Umgebungsvariable MARIADB_USER aus dem Schlüssel MARIADB_USER, der in der ConfigMap mariadb-config liegen soll. Dieser Schreibweise begegnet man in YAML-Dateien von großen Projekten hin und wieder, häufiger sieht man jedoch in solchen Fällen die Angabe envFrom:. Damit spart man es sich, einzelne Schlüssel aus einer ConfigMap den jeweiligen Umgebungsvariablen zuzuweisen. Kubernetes nimmt dann einfach alle Werte, die unterhalb von data: in der ConfigMap liegen und pflanzt sie als Umgebungsvariablen ein. Das sieht zum Beispiel so aus:
[...]
spec:
containers:
- name: mariadb
image: mariadb
envFrom:
- configMapRef:
name: mariadb-config
Um die Konstruktion in Aktion zu sehen, werfen Sie einen Blick auf die YAML-Beispiele zu diesem Artikel, die wir über ct.de/y45k bereitstellen. Das WordPress-Beispiel aus Teil 3 dieser Reihe wird dort per ConfigMap konfiguriert.
Geheimnisse im Cluster
Wenn Sie das Prinzip von ConfigMaps verinnerlicht haben, können Sie ein damit verwandtes Objekt ebenfalls einsetzen: Für sensible Inhalte wie Kennwörter, Token und Zertifikate nutzt man statt einer ConfigMap das Secret. Angelegt und eingesetzt wird ein Secret fast identisch. Der wesentliche Unterschied: Die Inhalte unterhalb von data: müssen als Base64-Zeichenkette enkodiert sein. Ein Secret, das den Benutzernamen wp und das Kennwort sicher für eine Datenbank enthält, legt man zum Beispiel so an:
apiVersion: v1 kind: Secret metadata: name: secret-wp-db type: Opaque data: username: d3A= password: c2ljaGVy
Eine Zeichenkette ist in unixoiden Betriebssystemen schnell auf der Kommandozeile in Base64 enkodiert:
echo -n 'sicher' | base64
Weil es oft verwechselt wird, noch einmal der Hinweis: Base64 ist ein Kodierungsverfahren, keine Verschlüsselung oder Hash! Es geht bei der Umwandlung also nicht darum, die Sicherheit zu erhöhen. Base64 stellt lediglich sicher, dass eine Zeichenkette mit Sonderzeichen, Zeilenumbrüchen und ähnlichen Zeichen fehlerfrei übertragen wird. Wer ein Secret ohne vorherige Base64-Umwandlung definieren will, schreibt statt data: einfach stringData: und die Inhalte als lesbare Zeichenkette. Setzt man beide Schlüssel untereinander, verwendet Kubernetes die Inhalte von stringData:.
Ein anderes Missverständnis: Von Haus aus ist ein Secret nicht sicherer als eine ConfigMap. Solange es im Cluster keine Berechtigungsverwaltung und nur einen Nutzer mit Vollzugriff gibt, kann dieser den Inhalt auch wieder sichtbar machen. Dafür reichen die Kubectl-Bordmittel:
kubectl get secret secret-wp-db --output=yaml
Eine Base64-Zeichenkette ist schnell zurückübersetzt:
echo "c2ljaGVy" | base64 --decode
Konfigurationen und Geheimnisse in Secrets und ConfigMaps zu trennen, zahlt sich dennoch später aus. Wenn Sie sich irgendwann mit Berechtigungsverwaltung im Cluster auseinandersetzen, können Sie den Zugriff auf Secrets einfach für bestimmte Nutzer verhindern. Eher ein Nischenproblem löst dagegen die Funktion „encryption at rest“. Kubernetes und auch k3s kennen eine Möglichkeit, die Secrets verschlüsselt in der darunterliegenden etcd-Datenbank abzuspeichern (siehe ct.de/y45k). Dann kann man den Klartext auch dann nicht extrahieren, wenn man sich des Servers bemächtigt hat und an Kubernetes vorbei aufs Dateisystem zugreifen kann. In normalen Umgebungen (in der nur wenige Administratoren überhaupt auf den Server zugreifen), ist das nicht nötig.
In einen Container kommen die Inhalte von Secrets und ConfigMaps per Umgebungsvariable oder als Volume. Der folgende Beispiel-Schnipsel zeigt gleich beide Varianten:
[...]
spec:
volumes:
- name: secret-volume
secret:
secretName: secret-example
containers:
- name: mariadb
image: mariadb
env:
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: secret-wp-db
key: password
volumeMounts:
- name: secret-volume
mountPath: /etc/example
Das Passwort wird als Umgebungsvariable injiziert und im Ordner /etc/example liegen Dateien, die in einem Secret namens secret-example definiert wurden. Im Container müssen Sie sich in beiden Fällen nicht mehr mit Base64 herumschlagen, Kubernetes übersetzt automatisch wieder zurück.
Wenn Sie den Einsatz von Secrets und ConfigMaps am praktischen Beispiel nachvollziehen wollen, finden Sie eine umgebaute Version des WordPress-Beispiels aus Teil 3 dieser Reihe über ct.de/y45k. Konfigurationen liegen in ConfigMaps, Geheimnisse in Secrets. Die Deployments heißen wie vorher, Kubernetes wird bestehende Objekte also ersetzen und keine zweite Instanz starten.
Echte Volumes
Genug der schnöden Konfigurationsdateien und Umgebungsvariablen – in diesen Artikel gelockt haben wir Sie mit dem Versprechen, auch solche Daten im Cluster zu speichern, die im Betrieb einer Anwendung anfallen. Eine Datenbank wie MariaDB ist dafür ein gutes Beispiel. Ohne persistenten Speicher ist sie ziemlich nutzlos, weil sie bei jedem Update oder Neustart mit einem leeren Verzeichnis beginnt und zunächst eine leere Datenbank anlegt.
Der Zugriff auf das Dateisystem ist in Kubernetes gleich hinter zwei Abstraktionsschichten versteckt. Doch kein Grund zur Panik, mit deren Konfiguration hat man am Anfang nur wenig zu tun. Auf der untersten Ebene kennt Kubernetes das Konzept der StorageClass. Dabei handelt es sich um ein Kubernetes-Objekt, das Konfigurationsdaten für eine Klasse von Speicherplatz definiert. Die zentrale Information in diesem Objekt ist der Name eines sogenannten Provisioners. Dabei handelt es sich um einen Verweis auf eine containerisierte Software, die wie ein Treiber oder Adapter funktioniert und mit einem Speichergerät im Hintergrund kommuniziert. Der Speicherplatz selbst kann dabei im Cluster liegen, irgendwo im lokalen Netz oder auch im Internet. Der Provisioner vermittelt zwischen verschiedenen Techniken und kümmert sich darum, dass am anderen Ende des Adapters immer ein neutrales Dateisystem ankommt. Wie ein Provisioner im Detail funktioniert, müssen Sie als Anwender nicht bis ins Detail durchdringen. Ihre Aufgabe besteht darin, einen passenden Provisioner zu wählen.
Wer in einem Unternehmensnetz zum Beispiel ein bestehendes NFS hat und seine Nutzdaten dort, also außerhalb des Clusters ablegen will, greift zu einem NFS-Provisioner. Provisioner gibt es auch für andere in Unternehmen verbreitete Systeme wie vSphere oder Ceph (siehe ct.de/y45k). Wer dagegen seinen Cluster bei einem der drei großen Cloudprovider (Amazon AWS, Google Cloud oder Microsoft Azure) betreibt, bekommt von seinem Provider einen Provisioner, der mit den hauseigenen Block-Storage-Systemen kommuniziert. Wie man solche Provisioner einsetzt, würde diesen Artikel sprengen – die zugehörige Dokumentation finden Sie über ct.de/y45k.
In einem selbst betriebenen Cluster, den Sie, wie im ersten Teil der Reihe beschrieben, mit der Kubernetes-Distribution k3s angelegt haben, ist ein Provisioner bereits angelegt und auch eine zugehörige StorageClass steht direkt bereit. Der Provisioner namens local-path funktioniert denkbar einfach: Er schnappt sich den Ordner /var/lib/rancher/k3s/storage auf den Nodes und legt dort Volumes an, die Sie an Container hängen können. Das entspricht ziemlich genau dem, was Sie von einem benannten Volume aus der Docker-Welt bekommen.
Zwischen StorageClass und Pod haben die Entwickler noch eine Abstraktionsschicht gestellt – den PersistentVolumeClaim (PVC). Mit einem solchen Objekt bestellt man zunächst ein Volume beim Provisioner und verweist im Pod dann auf den Namen dieses PVC. Klingt theoretisch kompliziert, wird am praktischen Beispiel aber schnell klar. Im Kasten auf Seite 132 sehen Sie, wie ein Datenbank-Container seinen Speicherplatz vom Provisioner local-pathbekommt.
Das erste Objekt beschreibt einen PVC mit dem Namen pvc-wp-db. Bestellt werden 4 GByte Speicherplatz. An dieser Angabe können Sie direkt einen Vorteil der Abstraktionsschicht erkennen: Weil der maximale Platz vorab bestellt wird, kann der Provisioner direkt auf die Bestellung reagieren. Wenn seine Festplatten im Hintergrund zum Beispiel voll sind und er den Wunsch nicht erfüllen kann, wird er den PVC ablehnen – dann wird auch der Container nicht erstellt und Sie bekommen direkt eine Fehlermeldung. Das ist besser als ein vollgelaufenes Laufwerk, das Sie als Admin in einigen Monaten ausgerechnet am Freitagabend überrascht. Andere Möglichkeiten von PVCs für Fortgeschrittene: Wenn Sie in einem Unternehmen einen großen Cluster verwalten, können Sie zum Beispiel Regeln erlassen, welches Team wie viel Speicherplatz konsumieren darf (was ja in der Regel mit Kosten verbunden ist).
Die CNCF-Landkarte (landscape.cncf.io) zeigt, wie vielfältig das Angebot an Storage-Anbindungen für Kubernetes ist. Per StorageClass und PersistentVolumeClaim verbindet man sie mit Containern.
In Ihrem eigenen Cluster gibt es solche Restriktionen nicht, der PVC ist erfüllbar und wird angelegt. Zum Einsatz kommt er im Pod für die Datenbank. Auf Ebene des Pods (also für alle Container) wird der PVC als Volume eingebunden und als volume-wp-db Pod-intern bekannt gemacht. Das allein reicht aber immer noch nicht, damit die Daten auch im Container ankommen. Diese Zuordnung von Volume zu Pfad geschieht zu guter Letzt auf Container-Ebene unterhalb von volumeMounts:, wie Sie es von Secrets und ConfigMaps bereits kennen.
Damit sind Sie bereit, eine WordPress-Instanz mit einer persistenten Datenbank zu betreiben. Wenn Sie das Beispiel nachvollziehen wollen, laden Sie die YAML-Definition über ct.de/y45k herunter und bringen Sie es in den Cluster. Wenn dort noch eine WordPress-Instanz aus Teil drei dieser Reihe läuft, wird sie aktualisiert. Wenn nicht, wird sie neu angelegt.
Den Erfolg der Umstellung können Sie leicht überprüfen: Löschen Sie den Datenbank-Pod mit kubectl delete pod <Name> und warten darauf, dass aus dem Deployment ein neuer Pod entsteht. Die Daten überleben jetzt im Volume.
Redundanter Speicherplatz
Der Local-Path-Provisioner ist einfach zu handhaben, doch er hat auch Schwächen. In einem Cluster aus mehreren Nodes wird der Provisioner die Daten nur auf einem Server lokal ablegen und sich dann darum kümmern, dass Pods, die das Volume brauchen, immer auf dieser Maschine platziert werden. Fällt ausgerechnet die Maschine mit der Datenbank aus, kann Kubernetes den Pod nicht woanders starten.
Besser wird die Welt erst mit einem anderen Provisioner, der in der Lage ist, Daten über mehrere Server redundant zu halten. Das ist keine ganz triviale Aufgabe, weil auf dem Weg viel schiefgehen kann (Konflikte, Paketverluste, Ausfälle). Ein Provisioner, der mit solchen Widrigkeiten umgehen kann, heißt Longhorn, ist Open-Source-Software und stammt aus dem Hause Rancher (die Firma, die ursprünglich auch mal k3s erfunden und dann an die CNCF übergeben hat). Und Longhorn kann noch mehr als redundante Datenhaltung, eingebaut ist zum Beispiel auch ein Backup-Mechanismus, der die Inhalte von Volumes auf externe Datenhalden (zum Beispiel S3-Speicher) kopieren kann.
Grundsätzlich ist Longhorn schnell eingerichtet und der Local-Path-Provisioner ersetzt. Folgendes Problem ist jedoch für das Kubernetes-Umfeld nicht ganz untypisch: Zum Zeitpunkt, als dieser Artikel entsteht, ist Kubernetes 1.25 aktuell. Diese Version haben Sie auch installiert, wenn Sie der Anleitung aus [1] gefolgt sind. Mit 1.25 haben die Kubernetes-Entwickler alte Zöpfe abgeschnitten, sogenannte PodSecurityPolicies, um genau zu sein. Diese kommen während der Longhorn-Installation aber noch vor, auch wenn sie verzichtbar sind. Aktuell arbeiten die Longhorn-Entwickler daran, diese Konstruktion zu entfernen; versprochen ist das Longhorn-Update, das mit dem neuen Kubernetes zurechtkommt, für Ende des Jahres 2022 – einen Einstieg in Longhorn liefern wir Anfang 2023 nach. Für Ihre Kubernetes-Karriere können Sie sich notieren: Es ist nicht immer ratsam, zu den Ersten zu gehören, die eine neue Version ausprobieren.
Raus aus dem Dickicht
Nach dieser Einführung in Volumes und Konfigurationsdateien können Sie langsam das Dickicht der Kubernetes-Objekte verlassen und sind bereit, sich auf der weiten Ebene nützlicher Cloud-Native-Helfer (wie Longhorn) umzusehen. Den wichtigsten Kubernetes-Ideen sind Sie jetzt begegnet, ab jetzt geht es vor allem darum, fertige Cloud-Native-Software zu konfigurieren und Routine im Umgang mit Deployment, PVC & Co. zu sammeln. Im Kasten links sehen Sie, welche Schritte Sie bereits hinter sich gebracht haben und was es noch zu erkunden gibt. In einer der nächsten Ausgaben erfahren Sie im fünften Teil dieser Reihe, wie Sie Ihren Cluster absichern und TLS einrichten – für die Arbeit mit Zertifikaten sind Volumes eine wichtige Voraussetzung, die Sie jetzt in der Tasche haben.
Um das bisher gesammelte Wissen zu vertiefen, sollten Sie sich als Docker-Umsteiger Ihre bereits erprobten Docker-Compose-Rezepte schnappen und Erfahrungen sammeln, indem Sie eigene Anwendungen von Docker in Kubernetes übersetzen. (jam@ct.de)
Beispiele und Dokumentationen: ct.de/y45k