Kubernetes macht es leicht, Pods zu erzeugen und wieder zu ersetzen. Genau das ist eine seiner Stärken. Ein Pod kann verschwinden, weil ein Node ausfällt, weil ein Deployment eine neue Version ausrollt, weil der Scheduler Arbeit anders verteilt oder weil eine Probe ständig fehlschlägt. Für zustandslose Workloads ist das meist in Ordnung. Ein Web-Frontend lässt sich ersetzen. Eine API-Replika kommt wieder hoch. Ein Queue-Worker startet neu und arbeitet weiter.

Daten sind weniger nachsichtig.

Wenn eine Datenbank in das Container-Dateisystem schreibt und der Pod ersetzt wird, sind die Daten mit dem Container weg. Wenn ein Upload-Service Dateien in /tmp ablegt und der Node verschwindet, verschwinden die Dateien ebenfalls. Viele Einsteiger lernen das schmerzhaft, weil Gewohnheiten aus lokalem Docker nicht sauber auf Kubernetes übertragbar sind. Ein Container-Image ist keine kleine virtuelle Maschine mit dauerhafter Festplatte. Es ist eine wiederholbare Dateisystem-Schicht plus ein laufender Prozess. Alles, was in die beschreibbare Container-Schicht geschrieben wird, sollte als temporär gelten, solange Storage nicht bewusst angebunden ist.

Darum hat Kubernetes ein Storage-Modell. Es existiert nicht, um YAML länger zu machen. Es existiert, weil Compute und Daten unterschiedliche Lebenszyklen haben.

Das mentale Modell: Pods leihen sich Storage

Ich denke bei Kubernetes Storage in drei Ebenen.

Erstens gibt es die Anwendung. Sie weiß, dass sie einen Pfad wie /var/lib/postgresql/data, /data oder /uploads braucht.

Zweitens gibt es einen Anspruch. Die Anwendung fragt normalerweise nicht nach einer bestimmten Festplatte mit Seriennummer. Sie beschreibt, welche Art von Storage sie braucht: „Ich brauche 20Gi, schreibbar von einem Node, aus der Standard-Storage-Klasse.“ In Kubernetes heißt diese Anfrage PersistentVolumeClaim, kurz PVC.

Drittens gibt es den tatsächlichen Storage. Das kann ein AWS-EBS-Volume sein, eine Azure Disk, eine Google Persistent Disk, ein Ceph-Volume, eine lokale Platte, ein NFS-Export oder Storage aus einem Container-Storage-Interface-Treiber. In Kubernetes wird dieses angebundene Storage-Objekt als PersistentVolume, kurz PV, dargestellt.

Der Pod mountet den PVC. Der PVC bindet an ein PV. Das PV zeigt auf echten Storage.

Diese Indirektion ist wichtig. Anwendungsteams sollten nicht jedes Cloud-Disk-Detail kennen müssen, um dauerhaften Speicher anzufordern. Plattformteams sollten nicht jedes Deployment anfassen müssen, wenn sich das Storage-Backend ändert. Kubernetes legt einen Vertrag zwischen beide Seiten.

emptyDir ist nützlich, aber nicht persistent

Bevor PersistentVolumes ins Spiel kommen, lohnt sich ein häufiger Stolperstein. Kubernetes kennt den Volume-Typ emptyDir. Er wird erstellt, wenn ein Pod startet, und entfernt, wenn der Pod vom Node entfernt wird.

Für temporären Arbeitsbereich kann das genau richtig sein:

apiVersion: v1
kind: Pod
metadata:
  name: worker-with-cache
spec:
  containers:
    - name: worker
      image: busybox:1.36
      command: ["sh", "-c", "while true; do date >> /cache/ticks; sleep 10; done"]
      volumeMounts:
        - name: cache
          mountPath: /cache
  volumes:
    - name: cache
      emptyDir: {}

Das überlebt Container-Neustarts innerhalb desselben Pods. Es überlebt keine Pod-Ersetzung. Wird der Pod gelöscht und neu erstellt, ist das emptyDir wieder leer. Damit eignet es sich für temporäre Caches, generierte Dateien oder gemeinsamen Arbeitsbereich zwischen Containern in einem Pod. Für eine Datenbank ist es ein schlechter Ort.

Die einfache Anfängerregel lautet: Würde der Verlust jemanden überraschen oder die Wiederherstellung erschweren, sollte man sich nicht auf emptyDir verlassen.

PersistentVolumeClaims: die Storage-Anfrage der Anwendung

Ein PVC beschreibt, was der Workload benötigt. Ein kleiner Claim sieht so aus:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: app-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: standard

Die wichtigen Felder sind:

resources.requests.storage ist die Größe. Das ist kein Arbeitsspeicher, sondern Speicherkapazität.

accessModes beschreiben, wie das Volume gemountet werden darf. ReadWriteOnce bedeutet: schreibend von einem Node zur gleichen Zeit. ReadOnlyMany bedeutet: lesend von vielen Nodes. ReadWriteMany bedeutet: schreibend von vielen Nodes — aber nur Storage-Backends, die das wirklich unterstützen, können es bereitstellen.

storageClassName wählt die Art des Storage. In einem Cloud-Cluster kann eine Klasse für SSD, HDD, verschlüsselte Disks, bestimmtes Zonenverhalten oder einen bestimmten CSI-Treiber stehen.

Nach dem Anwenden prüft man den Claim:

kubectl apply -f pvc.yaml
kubectl get pvc app-data
kubectl describe pvc app-data

Ein gesunder, dynamisch bereitgestellter Claim wechselt normalerweise auf Bound. Bleibt er auf Pending, stehen die wichtigsten Hinweise oft unten in den Events von kubectl describe pvc. Diese Zeilen sind meist hilfreicher als die Statusspalte.

Den Claim in einem Pod verwenden

Ein PVC tut nichts, solange kein Workload ihn mountet. Dieser Pod schreibt Zeitstempel in das gemountete Volume:

apiVersion: v1
kind: Pod
metadata:
  name: storage-demo
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "while true; do date >> /data/ticks.txt; sleep 10; done"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: app-data

Der Anwendungspfad /data wird nun vom PVC getragen. Wenn der Container neu startet, bleibt die Datei erhalten. Wird der Pod neu erstellt und kann das Volume wieder angebunden werden, bleiben die Daten ebenfalls erhalten.

Nützliche Prüfungen:

kubectl get pod storage-demo
kubectl exec storage-demo -- sh -c "ls -l /data && tail /data/ticks.txt"
kubectl describe pod storage-demo

Hängt der Pod in ContainerCreating, stehen Mount- oder Attach-Fehler meist in kubectl describe pod. Ist der PVC noch Pending, kann der Pod ihn nicht mounten, weil noch kein Volume gebunden ist.

PersistentVolumes: das Storage-Objekt des Clusters

In vielen modernen Clustern erstellt man PVs nicht für jede Anwendung manuell. Dynamic Provisioning erledigt das. Trotzdem nimmt das Verständnis von PVs viel Magie aus dem Thema.

Ein statisches PV kann so aussehen:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: manual-data-volume
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: manual
  hostPath:
    path: /mnt/data

Dieses Beispiel nutzt hostPath. Für einen Lerncluster ist das in Ordnung — als allgemeines Produktionsmuster ist es gefährlich. Daten werden an einen Pfad auf einem Node gebunden und verhalten sich nicht wie echter Netzwerk- oder Cloud-Storage. In Produktion steckt hinter einem PV meistens ein CSI-Treiber oder ein verwalteter Storage-Dienst.

Das Feld, auf das man achten sollte, ist persistentVolumeReclaimPolicy.

Delete bedeutet: Der darunterliegende Storage soll gelöscht werden, wenn der Claim gelöscht wird. Das ist bequem für kurzlebige Umgebungen und riskant, wenn der Claim wichtige Daten repräsentiert.

Retain bedeutet: PV und darunterliegender Storage bleiben nach dem Löschen des Claims erhalten. Das schützt Daten, erzeugt aber auch Aufräumarbeit. Jemand muss bewusst löschen oder neu binden.

PVs prüft man so:

kubectl get pv
kubectl describe pv manual-data-volume

Wenn ein PVC gebunden ist, sieht man das zugehörige PV:

kubectl get pvc app-data -o wide

StorageClasses: wie Dynamic Provisioning funktioniert

Eine StorageClass sagt Kubernetes, wie Storage erstellt werden soll, wenn ein passender PVC auftaucht. Sie verbindet die generische Kubernetes-Anfrage mit einem echten Provisioner.

kubectl get storageclass
kubectl describe storageclass standard

Oft ist eine Klasse als Standard markiert. Wenn ein PVC kein storageClassName setzt, kann Kubernetes diese Standardklasse verwenden. Ich sage bewusst „kann“, weil die Cluster-Konfiguration zählt. In einem Einsteigercluster wirkt das oft automatisch. In einer streng verwalteten Produktionsumgebung kann es absichtlich deaktiviert sein.

Eine vereinfachte StorageClass sieht so aus:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true

Der genaue Provisioner und die Parameter hängen von der Umgebung ab. Für Einsteiger ist vor allem volumeBindingMode wichtig.

Immediate bedeutet: Kubernetes provisioniert oder bindet das Volume, sobald der PVC erstellt wird.

WaitForFirstConsumer bedeutet: Kubernetes wartet, bis ein Pod den Claim wirklich braucht, und berücksichtigt dann Scheduling-Bedingungen wie Zonen. Das ist in Cloud-Umgebungen wichtig, weil eine Disk in einer Zone nicht einfach an einen Node in einer anderen Zone gehängt werden kann. Wird Storage zu früh in der falschen Zone erstellt, kann der Pod später unschedulable sein.

Wenn ein PVC mit WaitForFirstConsumer auf Pending steht, kann im Event stehen, dass auf einen Pod gewartet wird. Das ist nicht automatisch ein Fehler. Das Storage-System wartet auf genug Kontext.

StatefulSets und stabile Identität

Eine häufige Anfängerfrage lautet: „Kann ich einen PVC einfach in drei Replicas mounten?“ Manchmal ja, oft nein.

Wenn ein Deployment drei Replicas hat und alle in dasselbe Dateisystem schreiben, muss das Storage-Backend ReadWriteMany unterstützen — und die Anwendung muss gleichzeitige Schreibzugriffe korrekt beherrschen. Viele Datenbanken werden nicht verteilt und korrekt, nur weil eine gemeinsame Platte existiert. Kubernetes macht aus einer Single-Node-Datenbank keine saubere verteilte Datenbank.

Für zustandsbehaftete Workloads ist ein StatefulSet oft der bessere Controller. Es gibt Pods stabile Namen und kann über volumeClaimTemplates pro Replica einen eigenen PVC erzeugen.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: web
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          volumeMounts:
            - name: data
              mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi

Dadurch entstehen Claims wie data-web-0 und data-web-1. Jede Replica bekommt eigenen Storage. Ein Scale-down löscht diese PVCs nicht automatisch. Das ist Absicht. Kubernetes löscht Zustand nicht nur deshalb, weil eine Replica-Zahl geändert wurde.

Prüfen lässt sich das so:

kubectl get statefulset web
kubectl get pod -l app=web
kubectl get pvc

Häufige Missverständnisse

Das erste Missverständnis: Ein PVC sei die Festplatte. Das stimmt nicht. Er ist Anfrage und Bindung. Der echte Storage liegt hinter dem PV und dem Storage-Anbieter.

Das zweite: ReadWriteOnce bedeute genau ein Pod. Präziser heißt es: schreibend von einem Node zur gleichen Zeit. Mehrere Pods auf demselben Node können das Volume je nach Treiber und Situation möglicherweise verwenden. Für Anfänger ist es aber fragil, darauf ein Design aufzubauen. ReadWriteOnce sollte man als „kein Shared Storage“ behandeln.

Das dritte: Das Löschen eines Pods lösche die Daten. Normalerweise nicht, wenn die Daten auf einem PVC liegen. Gefährlich ist eher das Löschen des PVC. Selbst dann hängt das Ergebnis von der Reclaim Policy ab.

Das vierte: Kubernetes Storage mache Backups unnötig. Nein. Eine persistente Disk schützt gegen Pod-Wechsel. Sie schützt nicht automatisch gegen versehentliches Löschen, Anwendungsfehler, Ransomware, schlechte Migrationen oder einen Menschen mit dem falschen Befehl. Für wichtige Daten gehören Backups und getestete Restores zum Storage-Design.

Eine praktische Debugging-Reihenfolge

Wenn ein Workload wegen Storage nicht startet, springe ich nicht sofort in die Cloud-Konsole. Ich beginne mit Kubernetes-Objekten.

kubectl get pvc
kubectl describe pvc app-data
kubectl get pv
kubectl describe pod storage-demo
kubectl get events --sort-by=.lastTimestamp

Ich suche nach diesen Hinweisen:

Ist der PVC Pending oder Bound?

Melden die PVC-Events kein passendes PV, keine Standard-StorageClass, Quota-Probleme oder Provisioning-Fehler?

Zeigt der Pod FailedMount, FailedAttachVolume oder Timeouts?

Ist das Volume schon an einem anderen Node angebunden?

Passt der angeforderte Access Mode zu dem, was die StorageClass bereitstellen kann?

Ist der Name der StorageClass korrekt geschrieben?

Blockiert eine Namespace-Quota die Storage-Anfrage?

Wenn die Kubernetes-Objekte korrekt aussehen, prüfe ich CSI-Treiber und Cloud-Anbieter. In vielen Clustern laufen CSI-Pods in kube-system oder in einem Storage-Namespace:

kubectl get pods -A | grep -i csi
kubectl get csidrivers

Treiber-Logs und Cloud-Events sind plattformspezifisch, aber die Kubernetes-Events zeigen meistens in die richtige Richtung.

Was man im Labor üben sollte

Am besten lernt man das mit einer kleinen Übung. Einen PVC erstellen. In einen Pod mounten. Eine Datei schreiben. Den Pod löschen. Den Pod mit demselben Claim neu erstellen. Prüfen, ob die Datei noch da ist. Danach in einem Wegwerf-Namespace den PVC löschen und beobachten, was mit dem PV passiert.

Das gehört in einen lokalen oder entbehrlichen Cluster — nicht neben Produktionsdaten.

Die Lehre ist nicht: Storage ist leicht. Die Lehre ist: Kubernetes Storage wird verständlicher, wenn man die Teile trennt. Der Pod braucht einen Mount. Der PVC fordert Kapazität und Zugriffsart an. Die StorageClass entscheidet, wie Storage erzeugt wird. Das PV repräsentiert das Ergebnis. Der darunterliegende Anbieter erledigt die echte Festplattenarbeit.

Wenn dieses Modell sitzt, werden Fehlermeldungen lesbarer. Pending heißt nicht mehr automatisch „Kubernetes ist kaputt“, sondern „diese Anfrage hat noch keinen passenden Storage gefunden.“ FailedMount ist kein diffuser Startfehler mehr, sondern ein Hinweis auf Claim-Bindung, Node-Attachment, Access Modes und Treiberzustand.

Das reicht, um Storage weniger einschüchternd zu machen. Nicht trivial, aber bearbeitbar.