28 Mai 2020

Was sind eigentlich Container?

Kategorien: HowTos
Tags: Container Docker Kubernetes

Seit einiger Zeit spricht ein Großteil der IT-Landschaft nur noch nur von „Containern“, „Microservices“ und „Kubernetes“.

Doch was sind Container eigentlich und auf welcher technischen Grundlage bauen sie auf?

Allgemeines

Ein Container ist, einfach erklärt, eine abgekapselte Laufzeitumgebung für Prozesse. Es gibt verschiedene Bereiche, die getrennt werden können – die wichtigsten dabei sind Prozesse (pid), Netzwerk (net), Volumes / Festplatten (mnt) und User / Gruppen-IDs (user).

Die Technologie dahinter nennt sich „Namespaces“ und ist im Linux Kernel seit Version 2.4.19 (2002) erstmals implementiert und später erweitert, jedoch erst seit der Version 3.8 (2013) im Userspace, also für die Nutzer sinnvoll nutzbar. Zusätzlich spielt hier die cgroups-Technologie eine große Rolle. Diese ermöglicht es den getrennten Bereichen Ressourcen wie CPU und RAM zur Verfügung zu stellen, bzw. diese zu definieren.

Eine bekannte und frühe Implementierung dieser Features ist lxc (linux containers) welche auch heute noch weiterentwickelt wird und diese Features systemnah umsetzt.

Linux Namespaces

Ein Namespace ist eine Möglichkeit, Ressourcen und Objekte in logische Gruppen zu unterteilen. Man könnte es auch als System-Kontext beschreiben in dem ein Prozess gestartet wird. Dabei ist es kein Problem innerhalb eines Namespaces eigene Namespaces für neu gestartete Prozesse zu erstellen.

Ein Beispiel aus der täglichen Praxis:

Wenn ein Linux Host startet, dann wird je Namespace-Typ eine Instanz erstellt. Der Init-Prozess mit der PID 1 (heute meist systemd) wird dann entsprechend den Instanzen zugeteilt. Dies ist soweit transparent und für die meisten Nutzer auch nur begrenzt relevant. Denn diesen Namespaces stehen alle Ressourcen des Systems zur Verfügung und auch neue Ressourcen werden diesem initial zugeordnet.

Um sich eine Liste der aktuell auf dem System laufenden Namespaces anzusehen gibt es das Tool lsns.

Im folgenden Beispiel sehen wir die initial erstellen Namespaces und die Zuweisung des init-Prozesses.

[root@buildah ~]# lsns -p1
        NS TYPE   NPROCS PID USER COMMAND
4026531835 cgroup     96   1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 18
4026531836 pid        96   1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 18
4026531837 user       95   1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 18
4026531838 uts        96   1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 18
4026531839 ipc        96   1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 18
4026531840 mnt        90   1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 18
4026531992 net        96   1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 18

Ein Prozess kann immer nur einem Namespace pro Typ zugewiesen sein. So kann dem Prozess mit der PID 1 aus dem obigen Beispiel kein zusätzlicher pid-Namespace zugewiesen werden.

Die verschiedenen Typen haben untereinander keine Wechselwirkungen oder Abhängigkeiten. So kann man einem neuen Prozess (z.B. einer Shell) auch nur einen eigenen net-namespace zuordnen.

Um als Nutzer einen neuen Namespace zu erstellen, gibt es das Tool unshare. Mittels Parameter ist es hier möglich festzulegen, welche Namespacetypen für den Prozess erstellt werden sollen.

Folgend ein Beispiel wie eine Container-ähnliche Umgebung (in der alle möglichen Bereiche vom Hostsystem separiert werden) manuell erstellt werden kann.

Dazu starten wir mit einem normalen Nutzer ohne root-Berechtigungen eine Bash-Shell mit den gezeigten Parametern.

[podmanager@buildah ~]$ unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash

[root@buildah ~]# id
uid=0(root) gid=0(root) Gruppen=0(root) Kontext=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

[root@buildah ~]# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

[root@buildah ~]# lsns
        NS TYPE   NPROCS   PID USER COMMAND
4026531835 cgroup      3   952 root unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash
4026531836 pid         1   952 root unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash
4026532192 user        3   952 root unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash
4026532193 mnt         3   952 root unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash
4026532194 uts         3   952 root unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash
4026532196 ipc         3   952 root unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash
4026532198 pid         2   953 root /bin/bash
4026532200 net         3   952 root unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash

Wie zu sehen ist, befindet sich der Prozess nun in einem gekapselten Bereich mit eigenen IDs und Netzwerkbereich. Es wurde jedoch die gesamte Festplattenkonfiguration mit in den neuen Bereich übernommen, und somit auch der Ordner /proc, in dem die Prozesse des Hostsystems aufgelistet sind.

[root@buildah ~]# ps -ef f  | head
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
nobody       2     0  0 14:22 ?        S      0:00 [kthreadd]
nobody       3     2  0 14:22 ?        I<     0:00  \_ [rcu_gp]
nobody       4     2  0 14:22 ?        I<     0:00  \_ [rcu_par_gp]
nobody       6     2  0 14:22 ?        I<     0:00  \_ [kworker/0:0H-kblockd]
nobody       8     2  0 14:22 ?        I<     0:00  \_ [mm_percpu_wq]
nobody       9     2  0 14:22 ?        S      0:00  \_ [ksoftirqd/0]
nobody      10     2  0 14:22 ?        I      0:00  \_ [rcu_sched]
nobody      11     2  0 14:22 ?        S      0:00  \_ [migration/0]
nobody      12     2  0 14:22 ?        S      0:00  \_ [watchdog/0]

Um dies noch zu berichtigen ist ein mount -t proc proc /proc nötig. Dieses überlagert das /proc des Host-Systems wodurch nun ausschließlich die Prozesse der neuen Umgebung sichtbar sind.

[root@buildah ~]# mount -t proc proc /proc
[root@buildah ~]# ps -ef f 
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
root         1     0  0 15:05 pts/1    S      0:00 /bin/bash
root        27     1  0 15:11 pts/1    R+     0:00 ps -ef f

Um die Umgebung zu verlassen genügt ein exit, oder  die Tastenkombination STRG+D.

[root@buildah ~]# exit
exit
[podmanager@buildah ~]$

cgroups

Control Groups (kurz cgroups) sind kein direkter Namespace, sondern ermöglichen es Prozesse in einer Art Namespace zu gruppieren und die zur Verfügung stehenden Ressourcen wie CPU und / oder RAM zu begrenzen bzw. zu priorisieren.

Es gibt mittlerweile eine aktualisierte Version der cgroups (cgroupsV2) im Kernel. Jedoch wird diese produktiv wohl nur von Fedora >= 31 genutzt, da es hier einige Inkompatibilitäten mit Docker, jedoch nicht mit Podman gibt.

Die Einrichtung ist jedoch etwas komplexer und soll daher hier nicht erläutert werden, sondern wird Gegenstand eines eigenen Artikels.

Weiterführende Information dazu finden sich jedoch für Interessierte hier (cgroupsv1) und hier (cgroupsv2)

Das Dateisystem eines Containers

Jeder Container beinhaltet alle für den Betrieb der Binaries notwendigen Komponenten, wie Bibliotheken und Binaries.
Die einzige Abhängigkeit zum Hostsystem besteht im Allgemeinen darin, dass die Applikationen auf dem Kernel des Hostsystems lauffähig sein müssen.

Das Dateisystem ist dabei jedoch keine separate Festplatte oder ähnliches, sondern lediglich ein Archiv, das einen Verzeichnisbaum enthält.

Dieses Archiv wird dann spätestens beim Start eines Containers in einen Ordner entpackt und mittels eines mnt-Namespaces und chroot auf diesen Ordner als neues Dateisystem genutzt. Das chroot ändert dabei den Einstiegspunkt für das Dateisystem auf das der Nutzer Zugriff hat. So wird z.B. /var/lib/docker/container1/dateisystem auf dem Host zum neuen / innerhalb des Containers.

Auch dazu ein Beispiel mit der separierten Umgebung aus dem vorherigen Abschnitt.

Zuerst exportieren wir das Dateisystem des Postgres-Containers als tar-Archiv und entpacken es anschließend in einen Unterordner.

[podmanager@buildah ~]$ podman export 3b62694339c6 -o postgres_container.tar
[podmanager@buildah ~]$ ls -l postgres_container.tar 
-rw-r--r--. 1 podmanager podmanager 313597440 31. Mär 15:31 postgres_container.tar
[podmanager@buildah ~]$ mkdir postgres_root
[podmanager@buildah ~]$ tar -xf postgres_container.tar -C postgres_root/
[podmanager@buildah ~]$ ls -l postgres_root/
insgesamt 12
drwxr-xr-x.  2 podmanager podmanager 4096  3. Mär 01:27 bin
drwxr-xr-x.  2 podmanager podmanager    6  1. Feb 18:09 boot
drwxr-xr-x.  2 podmanager podmanager    6 24. Feb 01:00 dev
drwxr-xr-x.  2 podmanager podmanager    6  3. Mär 01:27 docker-entrypoint-initdb.d
lrwxrwxrwx.  1 podmanager podmanager   34  4. Mär 18:35 docker-entrypoint.sh -> usr/local/bin/docker-entrypoint.sh
drwxr-xr-x. 37 podmanager podmanager 4096 31. Mär 14:24 etc
drwxr-xr-x.  2 podmanager podmanager    6  1. Feb 18:09 home
drwxr-xr-x.  8 podmanager podmanager   96 26. Feb 01:54 lib
drwxr-xr-x.  2 podmanager podmanager   34 24. Feb 01:00 lib64
drwxr-xr-x.  2 podmanager podmanager    6 24. Feb 01:00 media
drwxr-xr-x.  2 podmanager podmanager    6 24. Feb 01:00 mnt
drwxr-xr-x.  2 podmanager podmanager    6 24. Feb 01:00 opt
drwxr-xr-x.  2 podmanager podmanager    6  1. Feb 18:09 proc
drwx------.  2 podmanager podmanager   76 31. Mär 14:44 root
drwxr-xr-x.  5 podmanager podmanager   84 31. Mär 14:24 run
drwxr-xr-x.  2 podmanager podmanager 4096  3. Mär 01:27 sbin
drwxr-xr-x.  2 podmanager podmanager    6 24. Feb 01:00 srv
drwxr-xr-x.  2 podmanager podmanager    6  1. Feb 18:09 sys
drwxrwxr-x.  2 podmanager podmanager    6  3. Mär 01:27 tmp
drwxr-xr-x. 10 podmanager podmanager  105 24. Feb 01:00 usr
drwxr-xr-x. 11 podmanager podmanager  139 24. Feb 01:00 var

Nun erstellen wir wieder eine Shell mit eigenen Namespaces und führen das chroot aus.

[podmanager@buildah ~]$ unshare --mount --uts --ipc --net --pid --fork --user --map-root-user /bin/bash

[root@buildah ~]# cat /etc/redhat-release 
CentOS Linux release 8.1.1911 (Core)

[root@buildah ~]# chroot postgres_root

root@buildah:/var/lib/postgresql/data# /bin/cat /etc/issue
Debian GNU/Linux 10 \n \l

Zum Abschluss muss noch das /proc-Dateisystem, wie erwähnt, berichtigt werden.
Ist dies erledigt, haben wir die gleiche Arbeitsumgebung die wir auch in einem Container hätten.

root@buildah:/var/lib/postgresql/data# /bin/mount -t proc proc /proc

root@buildah:/var/lib/postgresql/data# /bin/ps -ef f 
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
root         1     0  0 13:34 ?        S      0:00 /bin/bash
root        27     1  0 13:35 ?        S      0:00 /bin/bash -i
root        54    27  0 13:43 ?        R+     0:00  \_ /bin/ps -ef f

Fazit

Dies ist natürlich nur ein schneller Überblick über das technische Gerüst der Containertechnologie, das auf bekannten Features des Linux-Kernels baut.
Docker und auch Podman nutzen diese Features, bieten jedoch sehr viel mehr Funktionen und vor allem Komfort-Funktionen im Handling dazu.

Spätestens bei der Nutzung von Containerorchestrierungswerkzeugen wie Kubernetes oder okd kommen ebenfalls einige Schichten an Komplexität hinzu.

Bei Fragen rund um den Einsatz von Containern stehen wir Ihnen natürlich gerne zur Verfügung. Sprechen Sie uns an!

Kategorien: HowTos
Tags: Container Docker Kubernetes

über den Autor

Danilo Endesfelder

Berater

zur Person

Danilo ist seit 2016 Berater bei der credativ GmbH. Sein fachlicher Fokus liegt bei Containertechnologien wie Kubernetes, Podman, Docker und deren Ökosystem. Außerdem hat er Erfahrung mit Projekten und Schulungen im Bereich RDBMS (MySQL/Mariadb und PostgreSQL). Seit 2015 ist er ebenfalls im Organisationsteam der deutschen PostgreSQL Konferenz PGConf.DE.

Beiträge ansehen


Beitrag teilen: