Neuigkeiten von trion.
Immer gut informiert.

Docker VOLUMEs und Kubernetes

Ursprünglich war Docker konzipiert als Eierlegendewollmilchsau Probleme rund um den Betrieb von Software in der Cloud zu lösen.
Dazu gehören verschiedene Aspekte von Bereitstellung der Software als Image bis hin zur Laufzeitumgebung mit Persistenz. Im Kontext von Kubernes kann es zu Überschneidungen kommen, die sich in unangenehmen Überraschungen äußern. Worauf es zu achten gilt, wenn Docker VOLUMEs und Kubernetes im Spiel sind - zum Beispiel bei Datenbanken - wird in diesem Beitrag vorgestellt.

Persistenz und Container

Container besitzen ein Filesystem, dass aus den zu einem Image gehörenden verschiedenen Layern besteht. Diese Layer sind nicht veränderbar, jedoch kann typischerweise ein Container in sein Dateisystem schreiben. Das wird durch einen weiteren, beschreibbaren, Layer gelöst, der zu dem Container gehört.

Daraus folgen ein paar Konsequenzen:

  1. Wird der Container gelöscht, sind auch die in diesem Layer befindlichen Daten gelöscht.

  2. Die Performance von diesem Layerfilesystem ist nicht geeignet, um darauf z.B. Datenbanken zu betreiben

Docker bietet für diese Probleme zwei Lösungen:
Zu meinen können durch Metainformationen die Bereiche im Dateisystem gekennzeichnet werden, die einen performanteren Zugriff benötigen. Das geschieht durch die Angabe eines VOLUME im Dockerfile. Wird aus einem solchen Image ein Container erzeugt, so erstellt der Docker Daemon automatisch ein (anonymes) Volume, also ein Verzeichnis außerhalb des Layerfilesystems, und bindet es in den Container an der spezifizierten Position ein.

Der zweite Weg ist, dass der Nutzer, der einen Container erzeugen und starten möchte, durch den Schalter -v bzw. --volumes den Names eines Volumes oder einen Pfad auf dem Docker-Host spezifiziert, der im Container unter einem bestimmten Ort bereitgestellt werden soll. Dies Verfahren nennt sich bind-mount.

Das ganze kann man sich am Beispiel des offizielen PostgreSQL Image einmal ansehen

Beispiel Erzeugung eines anonymen Volume durch PostgreSQL Container
$ docker volume ls
DRIVER    VOLUME NAME
$ docker run postgres
...
$ docker volume ls
DRIVER    VOLUME NAME
local     22993eaae8e6ebfd1ba02efe3f26dbcfa2aeb7d8174c199ecfde4b1af7c6618d

In den Metainformationen des Image kann man genau sehen, das ein einzelnes Volume mit dem Pfad /var/lib/postgresql/data spezifiziert ist.

Analyse PostgreSQL Image Volumes
$ docker inspect postgres | jq '.[].Config.Volumes'
{
  "/var/lib/postgresql/data": {}
}

Docker erkennt, ob ein Verzeichnis, dass als VOLUME deklariert ist, gleichzeitig auch als bind-mount durch den Aufrufer spezifiziert wurde, und erzeugt dann entsprechend kein zusätzliches anonymes Volume.

PostgreSQL Container mit Bind-Mount an Stelle des Volumes
$ docker run -d -e POSTGRES_HOST_AUTH_METHOD=trust \
  --name postgresql -v /tmp/foo:/var/lib/postgresql/data postgres
d7bacdb0b1c35fc6ad1859f97f5cd023e21733c7c2f985b0f7cafc5fcb205bb8
$ docker inspect d7bacd | jq '.[].Mounts'
[
  {
    "Type": "bind",
    "Source": "/tmp/foo",
    "Destination": "/var/lib/postgresql/data",
    "Mode": "",
    "RW": true,
    "Propagation": "rprivate"
  }
]

Fallstrick anonymes Volume

Wird ein anderes Verzeichnis für den Bind-Mount spezifiziert, erzeugt der Docker Dienst zusätzlich ein anonymes Volume.

Zusätzliches anonymes Docker Volume
$ docker run -e POSTGRES_HOST_AUTH_METHOD=trust -v /tmp/foo:/var/lib/postgresql postgres
0ac7df217a80
$ docker inspect 0ac7df2 | jq '.[].Mounts'
[
  {
    "Type": "bind",
    "Source": "/tmp/foo",
    "Destination": "/var/lib/postgresql",
    "Mode": "",
    "RW": true,
    "Propagation": "rprivate"
  },
  {
    "Type": "volume",
    "Name": "dff7a491aebb41fa46b24a973e7b49228da7f57b3f75457a477d357b4e146741",
    "Source": "/var/lib/docker/volumes/dff7a491aebb41fa46b24a973e7b49228da7f57b3f75457a477d357b4e146741/_data",
    "Destination": "/var/lib/postgresql/data",
    "Driver": "local",
    "Mode": "",
    "RW": true,
    "Propagation": ""
  }
]

Der - vermutlich unerwünschte - Effekt ist nun, dass die eigentlichen Daten, die PostgreSQL unter /var/lib/postgresql/data speichert, weiterhin in einem anonymen Volume abgelegt werden, und nicht in dem Verzeichnis, dass durch den Aufrufer spezifiziert wurde.

Das wird vor allem dann ein Problem, wenn der Container mitsamt dem Volume gelöscht wird: Die wertvollen Daten sind nicht dort, wo man es vielleicht vermutet.

Für Container, in denen sich kein zu sichernder Zustand befindet, wird oft die Docker Option --rm verwendet. Damit wird der Container gelöscht, wenn er terminiert. Anders jedoch, als wenn der Container nach terminierung durch docker container rm gelöscht wird, wird hier automatisch sofort das anonyme Volume mit gelöscht.
Und damit die Daten.

Kubernetes und Docker Volumes

Das gleiche Problem kann bei Kubernetes auftreten: Ganz gleich, welche Storage-Form verwendet wird, am Ende des Tages findet hier wieder ein Bind-Mount auf der jeweiligen Node statt, auf der der Pod läuft.

Ein extrem vereinfachtes Deployment für PostgreSQL in Kubernetes könnte folgendermaßen aussehen:

Beispiel PostgreSQL Deployment in Kubernetes (nicht produktiv nutzbar!)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_HOST_AUTH_METHOD
              value: trust
          volumeMounts:
            - mountPath: /var/lib/postgresql/ # WRONG!
              name: postgresdb
      volumes:
        - name: postgresdb
          emptyDir: {}

Hier wurde zur einfachen Veranschaulichung bewusst ein emptyDir Volume genutzt, die Verwendung eines persistentVolumeClaim würde ähnlich aussehen.

Zu beachten ist, dass vergleichbar zu dem reinen Docker Beispiel von oben auch hier lediglich /var/lib/postgresql als zu mountendes Verzeichnis angegeben wurde.

Schaut man sich dann die Node an, auf der mit Docker als Containerruntime der zugehörige Pod erzeugt wurde, findet sich auch hier ein anonymes Volume.

$ docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED             STATUS             PORTS     NAMES
de7dd2011342   postgres               "docker-entrypoint.s…"   10 seconds ago      Up 9 seconds                 k8s_postgres_postgres-55f_default_cfff7d9d_0
a7cfe86d32a9   k8s.gcr.io/pause:3.5   "/pause"                 26 seconds ago      Up 25 seconds                k8s_POD_postgres-55f-pt8b4_default_cfff7d9d_0
$ docker volume ls
DRIVER    VOLUME NAME
local     b9d404f3702ef78cf4085905b317ea3a86991002a44da05a90116253f31f1b35
$ docker inspect de7dd2011342
...
"Mounts": [
    {
        "Type": "bind",
        "Source": "/var/lib/kubelet/pods/cfff7d9d-/volumes/kubernetes.io~empty-dir/postgresdb",
        "Destination": "/var/lib/postgresql",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
    },
    {
        "Type": "bind",
        "Source": "/var/lib/kubelet/pods/cfff7d9d-/volumes/kubernetes.io~projected/kube-api-access-248t8",
        "Destination": "/var/run/secrets/kubernetes.io/serviceaccount",
        "Mode": "ro",
        "RW": false,
        "Propagation": "rprivate"
    },
    {
        "Type": "bind",
        "Source": "/var/lib/kubelet/pods/cfff7d9d-/etc-hosts",
        "Destination": "/etc/hosts",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
    },
    {
        "Type": "bind",
        "Source": "/var/lib/kubelet/pods/cfff7d9d-/containers/postgres/1a3e47a7",
        "Destination": "/dev/termination-log",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
    },
    {
        "Type": "volume",
        "Name": "b9d404f3702ef78cf4085905b317ea3a86991002a44da05a90116253f31f1b35",
        "Source": "/var/lib/docker/volumes/b9d404f3702ef78cf4085905b317ea3a86991002a44da05a90116253f31f1b35/_data",
        "Destination": "/var/lib/postgresql/data",
        "Driver": "local",
        "Mode": "",
        "RW": true,
        "Propagation": ""
    }
],

Wenn in dem Container der Prozess abbricht, oder der Liveness-Healthcheck fehlschlägt, startet Kubernetes den Container automatisch neu.
Das kennt man ja von Docker, ein docker restart startet einen bestehenden Container neu. Bei Kubernetes jedoch sieht das etwas anders aus:

Container Neustart bei Kubernetes
$ kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
postgres-55f6ffc55-pt8b4   1/1     Running   0          1m14s
$ kubectl exec -it postgres-55f6ffc55-pt8b4 -- /bin/bash
root@postgres-55f6ffc55-pt8b4:/# touch /var/lib/postgresql/data/MARKER
root@postgres-55f6ffc55-pt8b4:/# kill 1
root@postgres-55f6ffc55-pt8b4:/# command terminated with exit code 137

$ kubectl get pods
NAME                       READY   STATUS    RESTARTS      AGE
postgres-55f6ffc55-pt8b4   1/1     Running   1 (17s ago)   10m

$ docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED          STATUS          PORTS     NAMES
120ac491b121   postgres               "docker-entrypoint.s…"   58 seconds ago   Up 57 seconds             k8s_postgres_postgres-55f6ffc55-pt8b4_default_cfff7d9d-
a7cfe86d32a9   k8s.gcr.io/pause:3.5   "/pause"                 11 minutes ago   Up 11 minutes             k8s_POD_postgres-55f6ffc55-pt8b4_default_cfff7d9d-

Zu erkennen ist, dass der eigentliche PostgreSQL Container nicht (nur) neu gestartet wurde, sondern komplett neu erzeugt wurde.
Der Container, der den Pod erzeugt hat, also der pause Container läuft weiter, so konnte im selben Pod ein neuer PostgreSQL Container gestartet werden.

Die Daten sind allerdings zunächst nicht weiter zugreifbar und eine neue Datenbank wurde durch den Container in einem neuen Volume erzeugt.

Container Neustart bei Kubernetes
$ kubectl get pods
NAME                       READY   STATUS    RESTARTS      AGE
postgres-55f6ffc55-pt8b4   1/1     Running   1 (17s ago)   10m
$ kubectl exec -it postgres-55f6ffc55-pt8b4 -- cat /var/lib/postgresql/data/MARKER
cat: /var/lib/postgresql/data/MARKER: No such file or directory
command terminated with exit code 1

Noch ist allerdings nicht alles verloren:
Auf der Node existieren nun zwei Volumes, denn der Container wurde nicht durch den Docker Dämon gelöscht, so dass das Volume mit gelöscht wurde, sondern durch Kubernetes.

Zwei Volumes nach dem Neustart
$ docker volume ls
DRIVER    VOLUME NAME
local     576bcbf1328b6d00a09cd648a8364c80a7065cf0235d31f590f97890f86d4cf8
local     b9d404f3702ef78cf4085905b317ea3a86991002a44da05a90116253f31f1b35

Es besteht also noch etwas Hoffnung, falls einem dies Malheur einmal widerfahren sollte: Aus dem alten Volume können die Daten gerettet werden.

Fazit

Die Mount Points sollten sehr genau gewählt werden, um hier keine Überraschungen zu erleben.
Dabei reicht es nicht "Kubernetes verstanden" zu haben, sondern auch die zugrundeliegenden Prinzipien von Containern sollten einem vertraut sein.
Entsprechende Betriebsverfahren mit Backup und Recovery und vor allem auch entsprechenden zugehörigen Übungen können einen vor sehr unangenehmen Überraschungen bewahren.




Zu den Themen Kubernetes, Docker und Cloudarchitektur bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:

Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.

Feedback oder Fragen zu einem Artikel - per Twitter @triondevelop oder E-Mail freuen wir uns auf eine Kontaktaufnahme!

Zur Desktop Version des Artikels