Docker – Eine Einführung


In immer mehr Unternehmen setzt sich Container-basierte Virtualisierung mittels Docker durch. Die Gründe dafür sind vielfältig. Zum einen spielt sicherlich eine große Rolle, dass man mit Docker-Containern CI-Prozesse wesentlich vereinfachen kann. Auch für die Entwickler bringt Docker Vorteile, da sie auf ihren Entwicklungsmaschinen identische Umgebungen wie auf den Produktiv-Servern laufen lassen können, so dass es später nicht zu Problemen kommt, wenn ihr Code dann in die Prod-Umgebung geht. Für uns Sysadmins liegt einer der größten Vorteile darin, dass die Versionierung von Änderungen wesentlich vereinfacht wird.

So vielseitig Docker-Container auch sind, so einfach ist der Umgang mit ihnen. In vielen Fällen reichen wenige Befehle um neue Server auszurollen. Und dazu braucht man theoretisch nicht einmal Konfigurationsmanagement-Tools. Wir schauen uns das gleich noch genauer an.

Die Installation von Docker

Docker lässt sich problemlos auf den meisten Linux-Distributionen installieren. Ich werde hier nicht näher auf die Installation von Docker eingehen. Detaillierte Anleitungen findet man unter https://docs.docker.com/engine/installation/linux/.

Das Starten von Containern

Docker stellt keine komplette Virtualisierung bereit. Anstatt komplette VMs mit eigenem Kernel zu starten, wird vielmehr eine Laufzeitumgebung gestartet, in der jede beliebige Linux-Distribution laufen kann. Die meisten Distributionen stellen auch bereits ihre Minimal-Versionen auf dem Docker-Hub zur Verfügung, so dass man dafür nichtmal selbst Arbeit investieren muss.

Will man z.B. eine Debian-Umgebung starten, reicht ein einfacher Befehl:

docker run -d --name=debian debian:latest tail -f /var/log/dmesg

Nun hat man eine VM mit einer minimalen Debian-Umgebung laufen. Die Parameter besagen:

-d            - im Hintergrund ausführen
--name=debian - der Name unter dem man den Container später ansteuern kann
debian:latest - das Image, das für den Container verwendet wird
tail ...      - der initiale Befehl, der im Container ausgeführt wird

Will man in diese VM „reinschauen“, kann man ‚docker exec‘ dafür verwenden.

docker exec -it debian /bin/bash

Damit erhält man eine Shell, die sich im Container befindet. Ein Blick mit ‚ps ax‘ zeigt auch, dass in dem Container tatsächlich nur ein Befehl, nämlich das tail, sowie die aufgerufene Shell läuft.

  PID TTY      STAT   TIME COMMAND
    1 ?        Ss     0:00 tail -f /var/log/dmesg
    6 ?        Ss     0:00 /bin/bash
   11 ?        R+     0:00 ps ax

Ein Druck auf Ctrl+D bringt uns wieder zurück auf unser Host-System.

Wichtig beim Ausführen von Containern ist, dass der initiale Befehl, den man beim ‚run‘ angibt (oder im Dockerfile definiert, dazu gleich mehr) nicht in den Hintergrund geht. Er muss prinzipiell im Vordergrund laufen. Wird nämlich dieser Befehl beendet, wird damit auch der Container beendet.

Stoppen und Löschen von Containern

Da uns eine solche minimale Debian-Umgebung nicht weiter bringt, können wir den Container erstmal wieder stoppen und entfernen. Zum Stoppen reicht ein einfaches ‚docker stop containername‘:

docker stop debian

Entfernt wird er dann mit ‚docker rm containername‘:

docker rm debian

Weitere wichtige Befehle und Grundlagen

Wenn wir mit Docker arbeiten, sollten wir uns immer bewusst sein, dass wir mit Single-Purpose-Instanzen arbeiten. Das heisst, dass in jedem Container genau ein Prozess läuft. Die Möglichkeit im Container eine Shell aufzurufen nutzt man üblicherweise eigentlich nur für Fehleranalysen.

Wollen wir den Output des Befehls sehen, der im Container läuft, können wir das wie folgt tun:

docker logs containername

Eine Liste aller laufenden Container auf einem Host bekommen wir mit:

docker ps

Wollen wir auch die gestoppten Container sehen, fügen wir einfach den Parameter ‚-a‘ an:

docker ps -a

Starten wir einen Container mit einem Image vom Docker-Hub, dann wird dieses Image automatisch auf das Host kopiert, auf dem wir den Container laufen lassen. Wir haben allerdings auch die Möglichkeit eigene Images zu bauen oder gar ein eigenes Hub, eine sogenannte Registry, zu betreiben. Zum Bau eigener Images später mehr. Eine Liste der auf dem Host abgelegten Images erhält man mit:

docker images

Will man auch die einzelnen Teile von Images sehen, reicht auch hier wieder der Parameter ‚-a‘:

docker images -a

Und damit wissen wir eigentlich auch schon alles, was man grundlegend benötigt um mit den Images vom Docker-Hub zu arbeiten.

Natürlich kann man Images auch wieder löschen:

docker rmi ImageNameOderId

Unser Debian-Image könnten wir also so löschen:

docker rmi debian:latest

Schauen wir nun etwas tiefer in die Materie.

Eigene Images erstellen

Natürlich wollen wir in einem solchen Container lieber unsere eigenen Sachen laufen haben. Ein einfaches tail auf eine Log-Datei bringt uns ja nicht viel. Wie wäre es z.B. mit einem Apache-Webserver mit PHP 5?

Wie ein Image erstellt werden soll, wird in einer Datei beschrieben, die Dockerfile genannt wird. Sie ist im Prinzip der Bauplan unseres Images. Wir erstellen uns also einen Projekt-Ordner, für unser Image und erstellen darin mit einem Editor die Datei ‚Dockerfile‘.

mkdir /home/meinbenutzer/meinimage
cd /home/meinbenutzer/meinimage
vim Dockerfile

Kommen wir nun zu den Inhalten, die wir in so ein Dockerfile packen können.

In der ersten Zeile geben wir üblicherweise an, welches Image die Basis für unser individualisiertes Image werden soll. Dies geschieht mit dem Schlüsselwort ‚FROM‘. Schlüsselwörter in Dockerfiles sind immer komplett großgeschrieben. Wir nehmen einfach mal als Basis unser bereits bekanntes Debian-Image:

FROM debian:latest

Als nächstes sollten wir sicherstellen, dass jegliche Software im Image aktuell ist. Wir lassen daher ein Systemupdate durchlaufen. Um zu verstehen wie das vor sich geht, muss man sich bewusst machen, dass beim Erstellen eines eigenen Images temporär ein Container gestartet wird, in dem jeder Befehl, den wir im Dockerfile definieren, ausgeführt wird. Will man einen CLI-Befehl ausführen, nutzt man dafür das Schlüsselwort ‚RUN‘ im Dockerfile.

RUN apt-get update
RUN apt-get -y upgrade

Wichtig ist hierbei zu beachten, dass RUN nicht interaktiv ist. Wir müssen daher dafür sorgen, dass keine Eingabe notwendig ist. Das ist der Grund, warum hier ‚-y‘ (alles mit ‚yes‘ bestätigen) beim apt-get verwendet wird. Vergessen wir das mal, müssen wir den Bau des Images gewaltsam (Strg+C) abbrechen. Sonst wartet er ewig auf das Beenden des Befehls oder bricht mit einem Fehler ab.

Als nächstes installieren wir uns unseren Webserver:

RUN apt-get -y install apache2 \
  libapache2-mod-php5

Hier im Beispiel sehen wir auch schonmal, wie wir in einen Befehl einen Zeilenumbruch einfügen können. Gerade bei sehr langen Befehlen wird dadurch die Übersichtlichkeit im Dockerfile bewahrt. Auch hier wieder darauf achten, dass RUN nicht interaktiv ist.

Dummerweise würde der Webserver von Debian aber im Hintergrund starten, wodurch der Container, den wir aus diesem Image starten, sich sofort beenden würde. Wir können somit das Init-Skript von Debian nicht zum Starten des Webservers verwenden. Wir legen uns lieber ein eigenes kleines Startskript an. Dazu erstellen wir uns in unserem Projekt-Ordner das Verzeichnis ‚bin/‘ und legen darin eine Datei mit Namen apache2-foreground ab, die wir mit folgendem Inhalt füllen:

#!/bin/bash
set -e

# Apache gets grumpy about PID files pre-existing
rm -f /var/run/apache2/apache2.pid

source /etc/apache2/envvars
exec apache2 -DFOREGROUND

Diese können wir nun in unserem Dockerfile mit dem Keyword ‚COPY‘ in das Image einfügen und dann ausführbar machen:

COPY bin/apache2-foreground /usr/local/bin/
RUN chmod a+rx /usr/local/bin/apache2-foreground

Auf die gleiche Weise können wir auch VirtualHost-Konfigurationen und ähnliche individuelle Dateien in unser Image packen. Zum Schluss sollten wir unser Image noch etwas aufräumen und unnötigen Kram entfernen:

RUN rm -rf /var/lib/apt/lists/*

Wir benötigen ja den Paket-Index von apt nicht, da wir im Container üblicherweise nichts nachinstallieren. Wenn wir was vergessen haben, erstellen wir einfach ein neues Image und starten den Container mit diesem Image neu.

Nun müssen wir nur noch festlegen, welcher Befehl per Default ausgeführt wird, wenn ein Container aus diesem Image gestartet wird. Dadurch kann man sich die Angabe des Befehls beim Ausführen von ‚docker run‘ dann sparen:

CMD ["/usr/local/bin/apache2-foreground"]

Hier wird eine Liste für den Befehl verwendet. Diese ermöglicht es uns auch beliebige Parameter hinzuzufügen, wenn dafür Bedarf besteht:

CMD ["befehl", "parameter1", "parameter2", ... ]

Final sollte unser Dockerfile also etwa so aussehen:

FROM debian:latest

# update
RUN apt-get update
RUN apt-get -y upgrade

# install apache2 with php5
RUN apt-get -y install apache2 \
  libapache2-mod-php5

# add startup script
COPY bin/apache2-foreground /usr/local/bin/
RUN chmod a+rx /usr/local/bin/apache2-foreground

# cleanup
RUN rm -rf /var/lib/apt/lists/*

# define command
CMD ["/usr/local/bin/apache2-foreground"]

Nun erstellen wir uns aus unserer Image-Beschreibung ein eigenes Image:

docker build -t bitmuncher/apache2 .

Mit ‚-t‘ definieren wir das Tag des Image, also quasi seinen Namen. Man kann hier jeden beliebigen Namen einsetzen. Relevant wird die Benennung erst, wenn man selbst Images auf Docker-Hub bereitstellen will oder eine private Registry nutzt. Über diesen Namen können wir es später beim ‚docker run‘ oder beim ‚docker rmi‘ auswählen. Der einzelne Punkt am Ende gibt an, dass der Bau im aktuellen Verzeichnis stattfinden soll. In diesem Verzeichnis sucht ‚docker build‘ nach dem Dockerfile und nutzt es als Basis für etwaige relative Pfade im Dockerfile (wie z.B. unser bin/apache2-foreground).

Wir können unser selbst gebautes Image nun starten. Da es sich um einen Webserver handelt, den wir ja auch irgendwie erreichen wollen, benutzen wir diesmal den Parameter ‚-p‘ beim ‚docker run‘ um den Port 80 aus dem Container an den Port 80 auf dem Host zu binden.

docker run -d --name=apache2 -p 80:80 bitmuncher/apache2

Rufen wir nun http://localhost/ in unserem Webbrowser auf, sehen wir die Default-Seite von Debian. Unser Webserver funktioniert also.

Going deeper

Docker bietet natürlich noch einige Möglichkeiten mehr. Auf 2 davon will ich noch etwas eingehen, da sie essentiell für die produktive Nutzung von Docker sind. Zum einen seien die Volumes genannt und danach schauen wir uns auch noch die Netzwerk-Funktionalitäten von Docker genauer an.

Volumes geben uns die Möglichkeit Daten vom Host direkt im Container verfügbar zu machen. Um beim Beispiel unseres Webservers zu bleiben, wäre es natürlich schön, wenn wir einfach unsere Webapp aus einem Ordner vom Host in den Container „einhängen“ könnten. Unter anderem dafür sind Volumes da. Das Default DocumentRoot für unseren Webserver ist /var/www/html. Wollen wir nun den Ordner /home/meinewebapp in diesem Ordner sichtbar machen, verwenden wir beim ‚docker run‘ den Parameter ‚-v‘.

docker run -d --name=apache2 -p 80:80 -v /home/meinewebapp:/var/www/html bitmuncher/apache2

Schon steht unsere Webapp als Default-Webapp im Container zur Verfügung. Rufen wir nun localhost im Browser auf, wird uns unsere eigene Webapp angezeigt.

Arbeitet man mit mehreren Containern auf einem Host, bietet Docker ein paar Netzwerk-Funktionalitäten, die es uns ermöglichen die Container untereinander zu verbinden. Zum einen können wir Ports direkt an andere Container binden. Übersichtlicher ist es aber meistens, wenn wir uns ein Docker-Netzwerk erstellen und den Containern entsprechende IPs zuweisen, über die sie sich untereinander erreichen können. Ein Netzwerk erstellen wir mittels:

docker network create --subnet=172.20.0.0/16 bitnet

‚bitnet‘ ist hier der Name, den wir dem Netzwerk geben. Das Subnet definiert die IPs, die wir innerhalb dieses Netzwerks zuweisen können.

Wir können nun z.B. unseren Apache-Webserver mit einer festen IP starten:

docker run -d --net bitnet --ip 172.20.0.10 --name=apache2 -p 80:80 -v /home/meinewebapp:/var/www/html bitmuncher/apache2

Wichtig ist das vor allem, wenn wir z.B. von einer Webapp auf einen Container mit einer Datenbank zugreifen wollen. Da müssen wir in der Webapp ja definieren unter welcher Adresse der DB-Server erreichbar ist. Wir könnten uns z.B. eine MySQL-Datenbank wie folgt starten:

docker run --name mysql -v /home/mysql/:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=meinrootpasswort --net bitnet --ip 172.20.0.20 -d mysql:5.7

Schon haben wir eine funktionierende MySQL, die von unserer Webapp genutzt werden kann. Erreichen tun wir sie über die angegebene IP auf dem Port 3306. Übrigens nicht nur aus anderen Containern heraus sondern auch vom Host-System aus.

Abschliessende Worte

Natürlich sind die Möglichkeiten von Docker damit noch nicht ausgeschöpft. Man kann z.B. mit Tools wie ‚flannel‘ auch Host-übergreifende Netzwerke zwischen den Containern erstellen. Mit Tools wie Kubernetes oder Docker Swarm kann man sogar komplett dynamische Cloud-Umgebungen aufbauen, bei denen der Admin nicht einmal mehr wissen muss auf welchem Host die jeweiligen Container gerade laufen.

Ich hoffe aber, dass aus diesem Artikel ersichtlich ist, warum die Konfigurationsverwaltung damit vereinfacht wird. Da alle Konfigurationen zusammen mit den Image-Beschreibungen in einem Ordner liegen können, können wir diesen ganz einfach in eine Versionsverwaltung packen. Folgt man der „best practice“ und modifiziert Container nicht manuell mittels ‚docker exec‘ sondern immer über ein sauberes neues Image, stimmen die Container immer mit der Image-Beschreibung und den beiliegenden Konfigurationen überein.

In CI-Prozessen kann man sogar so weit gehen, dass sogar die (Web)Applikationen zusammen mit den Containern ausgerollt werden, d.h. sie werden direkt mit ‚docker build‘ in die Container kopiert und existieren gar nicht mehr auf den Hosts. Da der Admin seine Änderungen an Containern nur noch in eine Versionsverwaltung packen muss, die dann von den Tools, die an den CI-Prozessen beteiligt sind, sowie von den Entwicklern genutzt werden kann um aktuelle Images zu erstellen, vermeidet man auch Konflikte zwischen Entwicklungsumgebung und Prod-Netzwerk.

Insgesamt kann also Docker nicht nur den Alltag der Sysadmins vereinfachen sondern auch den der Entwickler. Schliesslich können die auf ihrem lokalen Rechner identische Umgebungen zum Prod-Netzwerk starten. Ausserdem wird ein Server-Netzwerk damit wesentlich portabler. Ein Umzug zu einem anderen Host stellt mit Docker kein Problem mehr dar. Einfach die Container im neuen Netzwerk starten und etwaige Daten übernehmen und die Sache ist erledigt. Entscheidet man sich in eine Cloud-Umgebung wie GCE umzuziehen ist auch das kein Problem mehr. Viele Cloud-Umgebungen wie AWS oder GCE unterstützen Docker.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.