12.8 Docker-Container erstellen
In diesem Kapitel haben wir bereits viel über das Übersetzen und Installieren von Software, die dafür benötigten Tools und über dynamisch an Programme gelinkte Bibliotheken gesprochen. Aus den vorherigen Kapiteln kennen Sie auch die unterschiedlichen Distributionen mit ihren jeweiligen Paketverwaltungen. Sie können sich sicher vorstellen, dass dies in der Praxis zu umfangreichen und komplizierten Installationsanleitungen führen kann, die sich zu allem Überfluss auch noch je nach Distribution unterscheiden können. Wäre es nicht toll, wenn man Software so »paketieren« könnte, dass man einfach einen Prozess startet und dieser Prozess schon alle seine Abhängigkeiten, Bibliotheken etc. mitbringt und isoliert vom Rest des Systems läuft?
Das ist genau die Grundidee von Docker. Ein Docker-Container ist nichts anderes als ein normaler Linux-Prozess, also ein gestartetes Programm – jedoch mit einigen Unterschieden: Durch die Nutzung bestimmter Kernelfunktionalitäten wie Prozess-Namespaces, Cgroups und Capabilities sind Container von allen anderen Prozessen des Systems isoliert. Sie haben ihr eigenes Dateisystem und können nicht ohne Weiteres auf das Dateisystem des Hauptsystems zugreifen. Außerdem sind sie von allen anderen Prozessen des Systems isoliert, sie haben einen eigenen (virtuellen) Netzwerk-Stack und können bezüglich der zur Verfügung stehenden Systemressourcen wie CPU und RAM limitiert werden.
In einem Docker-Image können Sie also ein komplettes Dateisystem verpacken, verbunden mit einem Prozess, der beim Starten des Docker-Containers aufgerufen wird. Es ist nicht übertrieben zu sagen, dass diese Idee die IT in den letzten Jahren ein Stück weit revolutioniert hat.
12.8.1 Virtualisierung, Container und andere Betriebssysteme
Container ermöglichen damit eine neue und leichtgewichtige Art der Virtualisierung. Anstatt die komplette Hardware ganzer Computersysteme inklusive CPU, RAM, Festplatten, BIOS und aller Peripheriegeräte zu emulieren und als virtuelle Maschine (VM) wiederum ein ganzes Betriebssystem einschließlich aller Overheads laufen zu lassen, ist ein Docker-Container so leichtgewichtig wie ein normaler, gestarteter Linux-Prozess – trotzdem kann eine weitgehende Isolierung einzelner Workloads erreicht werden.
Container basieren dabei auf nativen Features des Linux-Kernels. Zwar ist Docker als Open-Source-Software auch für Windows und macOS verfügbar und kann daher auch für Projekte auf diesen Plattformen genutzt werden, jedoch bringt Docker für diese Betriebssysteme noch eine »versteckte« Linux-VM mit, in der die eigentlichen Container gestartet werden.
12.8.2 Docker-Images bauen
Ein Docker-Image ist eine Blaupause für einen Container. Es besteht im Wesentlichen aus dem Dateisystem Image) und einem Programm, das beim Starten des Containers aufgerufen wird. Ein Docker-Image wird vollständig durch eine Datei, das sogenannte Dockerfile, beschrieben:
FROM debian:11 RUN apt-get update && apt-get -y install nginx COPY index.html /var/www/html CMD ["/usr/sbin/nginx","-g","daemon off;"]
Listing 12.51 Ein einfaches Dockerfile
Ein Dockerfile besteht aus mehreren Befehlswörtern, die jeweils am Zeilenanfang stehen. Es wird in YAML-Syntax verfasst, die Einrückungen auf unterschiedliche Ebenen sind also wichtig. Die meisten Editoren unterstützen Sie mit entsprechenden Formatierungshilfen. Unser Beispiel nutzt folgende Befehle:
-
FROM
Jedes Dockerfile beginnt mit einem FROM-Befehl. Über diesen Befehl kann ein anderes Docker-Image referenziert werden, das als Basis für das neu zu erstellende Image dienen soll. Zwar kann man auch mittels »FROM scratch« mit einem vollständig leeren Dateisystem starten, jedoch nimmt man in der Regel eine Linux-Distribution oder ein anderes, offizielles Docker-Image als Basis.Oft sieht man mit FROM alpine eine spezielle Linux-Distribution, die gern in Docker-Images genutzt wird. Diese Distribution erzeugt sehr kleine Images. Das angegebene Image besteht aus zwei Teilen, die durch einen Doppelpunkt getrennt sind: dem Names Image und dem Tag, einer Art Versionsnummer. Ist kein Tag angegeben, wird automatisch das Tag »latest« benutzt. Die Anweisung FROM alpine nimmt also das Docker-Image alpine mit dem Tag latest als Basis.
-
RUN
Die Kommandos nach RUN werden im Docker-Image ausgeführt, während es gebaut wird (und nicht zur Laufzeit). In unserem Fall starten wir also mit einem Debian-System der Version 11 und führen dann zwei apt-get-Befehle aus, um den NGINX-Webserver zu installieren. Nach diesem Schritt ist also der NGINX in unserem Docker-System vorinstalliert. Es können beliebig viele RUN-Befehle in einem Dockerfile stehen. -
COPY
Der COPY-Befehl kopiert Dateien aus dem Verzeichnis des Hostsystems, in dem das Dockerfile liegt, an eine bestimmte Stelle im Container. -
CMD
Zuletzt gibt der CMD-Befehl an, welcher Prozess beim Start des Containers gestartet werden soll. In unserem Beispiel wird der Webserver gestartet, in dem der Befehl /usr/sbin/nginx -g "daemon off;" aufgerufen wird. Dies startet den NGINX-Webserver als Vordergrundprozess.Der gestartete Prozess ist dabei der Initprozess des Containers. Ist er beendet, wird der gesamte Container gestoppt bzw. als beendet betrachtet. Da NGINX als klassischer Systemdienst versucht, sich selbst im Hintergrund zu starten, muss er über die angegebenen Kommandozeilenoptionen gezwungen werden, dies nicht zu tun und im Vordergrund zu laufen.
Ein Dockerfile ist also meist recht einfach und kurz gehalten. Oft wird es gemeinsam mit dem Sourcecode einer Applikation in das Code-Repository abgelegt.
Um ein Docker-Image aus diesem Dockerfile zu bauen, muss docker auf dem lokalen Rechner installiert sein. In einem Verzeichnis mit der Datei Dockerfile (sowie einer schnell erstellten index.html) bauen wir das Image mit dem folgenden Befehl:
$ docker build . [+] Building 1.5s (9/9) FINISHED => [internal] load build definition from Dockerfile => => transferring dockerfile: 192B => [internal] load .dockerignore => => transferring context: 2B => [internal] load metadata for docker.io/library/debian:11 => [auth] library/debian:pull token for registry-1.docker.io => [1/3] FROM docker.io/library/debian:11@sha256:cc58a29c[...] => [internal] load build context => => transferring context: 39a51ca2767f1b096a3a1962124e8[...] => [2/3] RUN apt-get update && apt-get -y install nginx => [3/3] COPY index.html /var/www/html => exporting to image => => exporting layers => => writing image sha256:39a51ca2767f1b096a3a1962124e8d[...]
Listing 12.52 Docker-Image bauen
Mittels des Parameters build wird das Docker-Image gebaut, und der Punkt gibt an, dass das aktuelle Verzeichnis der Buildkontext ist. Im Buildkontext wird automatisch nach einer Datei gesucht, die Dockerfile heißt (alternativ könnte auch mit -f ein anderer Dateiname angegeben werden), und sie wird übersetzt. Der Buildkontext ist auch die Basis für alle COPY-Befehle. (Achtung: Dateien aus übergeordneten Verzeichnissen können aus Sicherheitsgründen nicht kopiert werden.)
Während des Bauens werden die einzelnen RUN- und COPY-Kommandos ausgeführt. Am Ende wird ein Image erzeugt, das mit einem Hash (39a51ca...) identifiziert wird. Dieser Hash ist der Name des Images, mit dem Sie den Container starten können. Sie müssen dabei nicht den ganzen Hash angeben, es reichen die ersten 5–6 Zeichen des Hashes – diese müssen nur eindeutig sein, sollte man mehrere Images lokal gebaut oder gespeichert haben. Alternativ können Sie das Image auch »taggen« und ihm einen »richtigen« Namen sowie ein Tag geben:
$ docker tag 39a51 jploetner/nginx-test:v1
Listing 12.53 Docker-Image taggen
Mit dem passenden Tag kann das Image sogar direkt auf den Dockerhub (https://hub.docker.com/) veröffentlicht werden. Dazu muss man wissen, dass der Repository-Name im Imagenamen angegeben wird – die Konvention lautet Servername/Repository/Imagename:Tag. Ist wie im obigen Beispiel kein Servername explizit angegeben, wird Dockerhub bzw. docker.io angenommen. Auf https://hub.docker.com/ können Sie sich auch selbst einen (aktuell noch kostenlosen) Account anlegen. Der Accountname entspricht dem Repository-Namen.
Im obigen Beispiel lautet der Dockerhub-Account jploetner, der Imagename nginx-test sowie der Tag v1. Um ein Image auf Dockerhub zu veröffentlichen, müssen Sie das mit Ihrem Accountnamen korrekt getaggte Image nach einem Login auf der Kommandozeile »pushen«:
$ docker login [...] $ docker push jploetner/nginx-nginx-test:v1 The push refers to repository [docker.io/jploetner/nginx-test] afcc13ba555c: Pushed 1df77f15b23c: Pushed afa3e488a0ee: Mounted from library/debian [...]
Listing 12.54 Docker-Image zu Dockerhub pushen
In diesem Beispiel haben wir auch gesehen, dass Docker für jedes Kommando im Dockerfile einen eigenen »Layer« erstellt hat. Diese Layer werden einzeln gepusht, was den Vorteil hat, dass bekannte Layer wie das debian:11-Image schon vorhanden sind und nicht mehrfach gespeichert werden müssen. Nach dem Push ist das Image überall auf der Welt verfügbar und kann von jedem anderen Rechner im Internet heruntergeladen werden (»pull«):
$ docker pull jploetner/nginx-test:v1 v1: Pulling from jploetner/nginx-test 627b765e08d1: Pull complete 523f49663665: Pull complete 68f1e9413f31: Pull complete Digest: sha256:dd08f90e8866831e5ad12045be3cad7a4afe318[...] Status: Downloaded newer image for jploetner/nginx-test:v1 docker.io/jploetner/nginx-test:v1
Listing 12.55 Docker-Image herunterladen
Damit haben wir erfolgreich ein Dockerfile geschrieben, daraus ein Docker- Image gebaut und im Internet veröffentlicht.
12.8.3 Docker-Container starten und verwalten
Um einen Docker-Container zu starten, wird ebenfalls das docker-Kommando bemüht:
$ docker run -d jploetner/nginx-test:v1 64ac4203d5aee2f8097a33176b8e3b680a536083cd44e2349bd2c98b10dd7590
Listing 12.56 Docker-Container starten
Via docker run -d wird ein neuer Docker-Container im Hintergrund (»d« steht für »detached«) gestartet, der standardmäßig über eine UUID identifiziert wird. Diese UUID ist dabei nicht mit der im letzten Abschnitt genannten Image-ID, einem Hashwert, zu verwechseln.
Selbstverständlich kann das gleiche Image auch in mehreren Containern instanziiert werden, wobei jede Instanz ihre eigene, zufällige UUID bekommt. Alle laufenden Container sehen Sie sich mit docker ps:
$ docker ps CONTAINER ID IMAGE COMMAND 64ac4203d5ae jploetner/nginx-test:v1 "/usr/sbin/nginx -g ?" [..] cfdfbbbea648 jploetner/nginx-test:v1 "/usr/sbin/nginx -g ?" [..]
Listing 12.57 docker ps
Über die Container-ID können Sie einen laufenden Container auch stoppen und wieder starten:
$ docker stop 64ac4203d5ae 64ac4203d5ae $ docker ps [der Container wird nicht als laufend angezeigt] $ docker ps --all [--all zeigt auch gestoppte Container] $ docker start 64ac4203d5ae 64ac4203d5ae
Listing 12.58 Container stoppen und starten
Alternativ können Sie anstatt der UUID auch den Namen des Containers verwenden. Um einen eigenen Namen zu setzen, rufen Sie docker run mit der Option --name sowie dem zu verwendenden Namen auf.
12.8.4 Mit Containern interagieren
Es gibt mehrere Möglichkeiten, mit Docker-Containern zu interagieren. Die wichtigsten zwei Möglichkeiten sind Port-Forwards von Netzwerkports des Hostsystems zum Container sowie Mounts von Verzeichnissen des Hostsystems in den Container. Diese Optionen werden als zusätzliche Optionen zum docker run-Befehl hinzugefügt:
$ docker run -d -p 8080:80 -v /home/jploetner/html:/var/www/html
jploetner/nginx-test:v1
Listing 12.59 Ein Container mit Port-Forward und Volume-Mount
Die Option -p erwartet den Port auf dem Hostsystem, einen Doppelpunkt als Trenner und daneben gleich den Zielport im Container. In unserem Beispiel läuft im Container ein NGINX-Webserver auf Port 80, und wir mappen Port 8080 des Hostsystems auf diesen Containerport. Wenn Sie also in der Folge http://localhost:8080 in Ihrem Browser aufrufen, werden Sie mit dem Webserver im Container verbunden.
Die Option -v erwartet ein Verzeichnis auf dem Hostsystem, einen Doppelpunkt als Trenner und den Pfad im Container, in den das Hostverzeichnis eingebunden werden soll. Im obigen Beispiel mappen wir das Verzeichnis /home/jploetner/html in den Container auf das Verzeichnis, das der Webserver als Hauptverzeichnis nutzt. Legen Sie in dieses Verzeichnis eine Datei index.html mit einem beliebigen Text, und besuchen Sie dann noch einmal http://localhost:8080 im Browser.
Über den Befehl docker exec können Sie auch zusätzliche Prozesse im Container starten:
$ docker exec -it 64ac4203d5ae /bin/bash root@64ac4203d5ae:/#
Listing 12.60 Eine Shell im Container ausführen
Die Optionen -i und -t erstellen eine interaktive Terminal-Session und verbinden diese mit dem aufgerufenen Prozess, in unserem Fall wird das (im Container vorhandene) Programm /bin/bash, also eine Shell, gestartet. Dieses Vorgehen wird gern zu Debuggingzwecken genutzt, setzt aber voraus, dass es tatsächlich eine Shell als Binary im Container gibt.