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:
-
Wird der Container gelöscht, sind auch die in diesem Layer befindlichen Daten gelöscht.
-
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
$ 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.
$ 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.
$ 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.
$ 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:
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:
$ 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.
$ 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.
$ 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.