Spring Boot in Docker und Docker Compose

Dank Docker setzt sich das Konzept der Containerisierung, welches bereits die weltweite Logistik revolutioniert hat, nun auch in der IT Welt durch. In diesem Artikel soll es um die Software Docker des gleichnamigen Unternehmens Docker Inc. gehen. Dieser Artikel soll anhand einiger Beispiele die Funktionsweise von Docker Containern und Docker Compose erläutern.

Dazu zunächst eine kurze Einführung in die Begrifflichkeiten von Docker. Wie oben bereits erwähnt, geht es bei Docker darum, Anwendungen in einzelne Container “einzutuppern”, in denen die Anwendungen dann leben können. In diesen Containern sollte sich also neben der Binary (beispielsweise .exe oder .jar) auch alle für die Anwendungen notwendigen Ressourcen und Konfigurationsdateien befinden. Beispielsweise sollte bei einer Java Anwendung die JVM ebenfalls zur Anwendung in den Container gepackt werden, genau wie die relevanten Systembibliotheken.

Mit Docker können die Anwendungen vollständig von der Infrastruktur abgekapselt werden. Wenn die Anwendung erst mal im Container ist, ist es nicht mehr relevant, ob der Container auf einem Windows-Container-Host oder auf einem Linux-Container-Host oder wo auch immer lebt. So können auch beispielsweise beliebig viele Datenbankserver auf dem gleichen Container-Host laufen, ohne dass diese sich durch gleiche Ports in die Quere kämen. Jeder Container, der einen Datenbankserver benötigt, kann mit diesem in ein eigenes virtuelles Netzwerk gepackt werden, in dem sich nur der Datenbankserver und die Anwendung sehen. Von außerhalb des virtuellen Netzwerkes könnte dann niemand auf den Datenbankserver der Anwendung zugreifen.

Docker soll also eine Virtualisierung bieten, durch die wie auch bei anderen Lösungen gewaltige Hardware-Einsparungen ermöglicht werden können. Die Container in Docker sind jedoch deutlich leichtgewichtiger, als die üblichen VM-Images. Hier wird das gleiche Betriebssystem für alle Container verwendet, die Container erhalten nur jeweils ein eigenes Dateisystem und eigene Netzwerkschnittstellen. Dadurch wird eine Menge Betriebssystem-Overhead eingespart und die Container sind trotzdem noch ausreichend isoliert voneinander. Diese fehlende VM-Abtrennung ist natürlich unter dem Gesichtspunkt der Sicherheit ein Dorn im Auge. Die Bewertung und Einordnung der Sicherheitsaspekte bei der Verwendung von Docker ist jedoch äußerst umfangreich und würde den Rahmen dieses Artikels sprengen.

Nun zu den Begrifflichkeiten. Zunächst wäre da die Docker Engine, die auf einem Docker Container-Host läuft. Diese ist die Laufzeitumgebung, in der die Container später laufen sollen. Ein Docker Container enthält wie oben erwähnt, alle anwendungsspezifischen Daten und Dateien. Ein Container wird mithilfe eines Docker Images definiert, welches aus Docker Layern besteht. Ein Layer ist dabei eine Zeile in der Konfiguration eines Docker Images. Die Docker Images können auch aufeinander aufbauen. Dadurch kann Speicherplatz gespart und die Erstellung neuer Images beschleunigt werden, da nur die Teile, die wirklich neu sind, neu zu erstellen sind. Dann gibt es da noch Dockerhub, das zentrale Container Repository, quasi das Maven Central für Docker Container. Hier sind allerhand fertige Images zu finden, die in eigenen Container-Projekten verwendet werden können.

Da die Docker Container vor der Laufzeit erstellt werden, sind sie read only. Um dennoch Daten persistieren zu können, werden sogenannte Docker Volumes benötigt. Diese Volumes können ins laufende Dateisystem eines Docker Containers gemountet werden. Es ist ebenfalls möglich, ein fixes Verzeichnis der Docker Host-Maschine in einen oder mehrere Container zu mounten und darüber Daten auszutauschen.

Insbesondere für Microservices, etwa mit Spring Boot erstellt, eignet sich eine Docker-Virtualisierung perfekt. Bei RESTful-Services kann das System trivial skalieren, indem einfach neue Container-Instanzen zur Laufzeit erstellt und gestartet werden.

Hier ist all dies noch einmal visualisiert:

Abbildung 1: Docker Architektur

Abbildung 1: Docker Architektur

Wie in der Abbildung 1 erkennbar ist, läuft die Docker Engine direkt auf dem Host OS auf der Hardware. Auf dieser Docker Engine laufen dann die Container in separierten Prozessen. Diese Container können untereinander über Netzwerke kommunizieren. In diesem Fall kommunizieren die beiden Container test-backend-db und postgre-db über das blau markierte virtuelle Netzwerk network-back miteinander. Außerdem können Container Daten in externe Volumes persistieren und laden. In einem solchen Container db-data, hier grün dargestellt, sichert der PostgreSQL-Datenbank Server aus dem postgre-db Container seine Datenbank.

Da dies doch recht abstrakt ist, folgen hier zwei einfache Beispiele für Docker Container und ein kleines Cluster, welches aus zwei Containern besteht.

Ein erster Container

Für diesen Artikel wurde ein kleiner Backenddienst (Quellcode) mit Spring Boot entwickelt, der einige triviale Funktionen besitzt. Damit sich die Funktionen direkt im Webbrowser testen lassen, können passenderweise alle Funktionen über einen HTTP-GET-Aufruf aufgerufen werden. Der Dienst simuliert einen trivialen Storage, auf dem Einträge angezeigt, hinzugefügt und entfernt werden können. Um die Einträge anzuzeigen, wird ein HTTP-GET an /entries gesendet, wodurch eine Liste der aktuellen Einträge im Klartextformat zurückgegeben wird (vgl. Abbildung 2).

Abbildung 2: Entries

Abbildung 2: Entries

Mit einem Aufruf von /createEntry kann ein neuer (zufälliger) Eintrag hinzugefügt werden, mit /clear können die bestehenden Einträge entfernt werden.

Im ersten Schritt besitzt dieser Dienst keine Möglichkeit, seine Einträge zu persistieren. Somit ist die Liste nach jedem Neustart wieder leer.

Diese Anwendung soll jetzt in einen Container verpackt werden. Dazu wir erstellen wir zunächst ein Dockerfile, um daraus später ein Image generieren zu können. Das Dockerfile dafür ist ziemlich trivial:

FROM openjdk:8-jdk-alpine
MAINTAINER Steffen Jacobs 
COPY test-backend-0.0.1-SNAPSHOT.jar /home/test-backend.jar
CMD ["java","-jar","/home/test-backend.jar"]

In der obersten Zeile wird zunächst das openjdk:8-jdk-alpine als Basis-Image mittels des FROM-Befehls importiert. Die Zeile “MAINTAINER Steffen Jacobs” gibt den für das aktuelle Image Verantwortlichen an und dient ausschließlich dokumentarischen Zwecken. Anschließend wird die Datei test-backend-0.0.1-SNAPSHOT.jar, welche sich im gleichen Ordner befindet, in das Home-Verzeichnis im Image kopiert. Im letzten Schritt wird der Befehl java -jar /home/test-backend.jar ausgeführt, um die Anwendung zu starten.

Dieses Dockerfile und alle im Artikel verwendeten Ressourcen sind, wie der Quellcode zur Spring-Boot-Anwendung auch, auf Github verfügbar [2].

Nun wird im Docker-Terminal zu dem Ordner mit dem Dockerfile und der jar navigiert, und der Befehl docker build -t test-backend. ausgeführt. Eine Übersicht über die wichtigsten Befehle gibt es übrigens auf der Website direkt von Docker. Nachdem das Image fertig gebaut ist, wird es nach der Eingabe des Befehls docker image ls ein neuer Container mit dem Namen “test-backend” angezeigt (vgl. Abbildung 3), sowie das verwendete Basis Image namens openjdk. Docker prüft übrigens bei einem erneuten Bauen des Images nur, ob sich das Dockerfile geändert hat. Wenn im Dockerfile auf eine Datei verwiesen wird (hier etwa die test-backend-0.0.1-SNAPSHOT.jar), wird diese Datei nicht auf Änderungen überprüft. Um das Neubauen eines Images und die Verwendung der aktuellen Dateien zu erzwingen, muss beim docker build Kommando der Zusatz --no-cache verwendet werden.

Abbildung 3: docker image ls

Abbildung 3: docker image ls

Nun kann der Befehl docker run -p 80:8080 test-backend ausgeführt werden. Damit wird nicht nur der Container erstellt und gestartet, sondern auch gleich der Port 8080, auf dem die Anwendung läuft, auf den Port 80 der Docker Maschine weitergeleitet.

Somit kann nun über die IP-Adresse der Docker Maschine auf die Anwendung zugegriffen werden (vgl. Abbildung 2). Um die IP-Adresse der Docker Maschine herauszufinden, kann in einem Terminal der Befehl docker-machine ip eingegeben werden (vgl. Abbildung 4).

Abbildung 4: docker-machine-ip

Abbildung 4: docker-machine-ip

In diesem Fall lautet die IP-Adresse: 192.168.99.100. Mit einigen Aufrufen der createEntry-HTTP-Methode in der Testanwendung können nun ein paar Einträge erzeugt werden (vgl. Abbildung 5), die dann mit der /entries-Methode wieder abgerufen werden können (vgl. Abbildung 2).

Abbildung 5: Create Entry

Abbildung 5: Create Entry

Beendet werden kann der Container mit docker stop.

Laufzeitdaten persistieren

Im nächsten Schritt sollen die Einträge nun in eine Textdatei auf dem Server persistiert werden. Die Einträge können mit dem Aufruf des HTTP-GET-Endpunkts /persist gespeichert und mit /load wieder geladen werden. Die Textdatei soll hierbei nicht in den Container gespeichert werden, da dieser Speicher flüchtig ist und die Textdatei nach einem Neustart des Containers nicht mehr vorhanden wäre. Stattdessen soll die Textdatei in ein Docker-Volume gespeichert werden. Dafür wird zunächst mit docker volume create vol-test-backend ein Volume namens vol-test-backend erstellt. Mit docker volume ls kann dieses auch angezeigt werden (vgl. Abbildung 6).

Abbildung 6: docker volume ls

Abbildung 6: docker volume ls

Das Dockerfile ist sehr ähnlich zu dem aus dem ersten Fall:

FROM openjdk:8-jdk-alpine
MAINTAINER Steffen Jacobs 
COPY test-backend-persistent-0.0.1-SNAPSHOT.jar /home/test-backend-persistent.jar 
CMD ["java","-jar","/home/test-backend-persistent.jar"]

Nachdem das Image mit docker build -t test-backend-persistent . gebaut ist, wird mit dem Befehl docker run -d --name vol-test-backend --mount source=vol-test-backend,target=/vol -p 80:8080 test-backend-persistent ein Container erstellt und gestartet. Außerdem wird wieder der Port auf Port 80 umgeleitet. Zusätzlich wird das Volume vol-test-backend im Container unter /vol eingebunden.

Wenn nun wieder wie oben Einträge hinzugefügt wurden und anschließend die Liste der Einträge mit /persist in die Datei gespeichert wird, befindet sich diese auf dem Volume. Wenn nun der Container beendet wird (Strg + C, bzw. docker stop), bleibt die Textdatei im Volume und kann nach einem erneuten Start des Containers (mittels docker start) wieder eingelesen werden. Nach dem Aufruf des HTTP-GET-Endpunkts /load, ist die Liste der Einträge, die vor dem Neustart persistiert wurden, wieder verfügbar. Mehr Informationen zu Volumes sind hier zu finden [1, 2, 3].

Mehrere Dienste orchestriert ausführen

Im finalen Schritt soll der Entry-Dienst seine Einträge nicht mehr in eine einfache Textdatei, sondern in eine Datenbank auf einem Datenbankserver speichern. Dazu wird neben dem Container mit der Anwendung selbst ein weiterer Container für den Datenbankserver benötigt. Wir verwenden hier einen PostgreSQL Server. Außerdem wird ein Volume benötigt, in welches der PostgreSQL-Datenbankserver seine Daten persistieren kann.

Diese Aufgabe lässt sich mittels Docker Compose lösen. Dazu wird eine Datei namens docker-compose.yml im gleichen Ordner wie das Dockerfile und die test-backend-db-0.0.1-SNAPSHOT.jar angelegt. Die Datei docker-compose.yml beginnt zunächst mit der verwendeten Version von Docker Compose, hier Version 3. Die erste Zeile lautet also:

version: "3"

Es ist zu beachten, dass zum Zeitpunkt der Veröffentlichung dieses Artikels diese Version 3 von Docker Compose unter Ubuntu noch nicht über die Standardpaketquellen verfügbar war.

Als nächstes werden für dieses Tutorial einige Netzwerke erstellt. Dazu werden diese Zeilen in die docker-compose.yml eingefügt:

networks:
  network-front:
    driver: bridge
  network-back:
    driver: bridge

Es werden also zwei Netzwerke (network-front, network-back) erstellt. Eine Einführung in die Netzwerk-Driver wird hier aus Platzgründen ausgelassen, ist aber in der offiziellen Dokumentation von Docker zu finden.

Als nächstes wird das Volume für den PostgreSQL-Server definiert:

volumes:
  db-data:

Schließlich werden die Services definiert. Ein Docker Service ist in diesem Kontext gleichbedeutend mit einem Docker Container. Der erste Service ist die Spring-Boot-Anwendung:

services:
  test-backend-db:
    build: .
    container_name: test-backend-db
    depends_on:
      - "postgre-db"
    ports:
      - "80:8080"
    networks:
      - network-front
      - network-back

Der Container soll “test-backend-db” heißen. Da das zugehörige Dockerfile im gleichen Verzeichnis wie die docker-compose.yml liegt, zeigt die Build-Variable auf das aktuelle Verzeichnis. Damit die Spring-Boot-Anwendung vernünftig starten kann, sollte zuerst der Datenbankserver laufen, daher wird hier direkt auf den zweiten Service, postgre-db als Startabhängigkeit verwiesen. Unter “ports” wird der Port 8080 der Spring-Boot-Anwendung auf den Port 80 der Docker Maschine weitergeleitet. Diese Weiterleitung wurde bisher immer über den Zusatz -p 80:8080 im docker-run-Befehl definiert. Als letztes wird noch festgelegt, dass sich der Anwendungsdienst in den beiden Netzwerken “network-front” und “network-back” befinden soll.

Der zweite Service ist etwas anders aufgebaut:

postgre-db:
    image: postgres:9.5
    container_name: postgre-db
    expose:
      - "5432"
    volumes:
      - "db-data:/var/lib/postgresql/data"
    networks:
      - network-back
    environment:
      POSTGRES_DB: springbootdb

Der Name des Containers wird ebenfalls über container_name, wie oben, festgelegt, hier auf “postgre-db”.

Diese Container-Namen stellen im Übrigen auch gleichzeitig Host-Namen dar und können über den internen DNS-Server der Docker Maschine aufgelöst werden. So kann beispielsweise in der Testanwendung direkt auf postgre-db:5432/springbootdb verwiesen werden.

Allerdings wird hier kein Docker Container aus einem lokalen Dockerfile gebaut, sondern das Image “postgres” in Version 9.5 heruntergeladen und in den Container verpackt [1, 2].
In der nächsten Zeile folgt eine expose-Section. Diese dient ausschließlich als Dokumentation, dass Port 5432 verwendet wird. Die Spring-Boot-Anwendung könnte auch ohne diese expose-Angabe auf den Datenbankserver zugreifen, sofern sie sich im gleichen Netzwerk (siehe unten) befindet. Eine Weiterleitung des Ports über die Docker Maschine findet hier allerdings nicht statt.

Anschließend wird festgelegt, dass das oben definierte Image “db-data” in das Verzeichnis /var/lib/postgresql/data innerhalb des Containers zu mounten ist. Damit die Spring-Boot-Anwendung auch auf den Datenbankserver zugreifen kann, müssen sich Datenbankserver und Anwendung im gleichen Netzwerk (z.B. “network-back”) befinden.

Als letztes folgt noch eine optionale Environment-Section. Hier können image-spezifische Parameter gesetzt werden. So ist hier etwa angegeben, dass der PostgreSQL-Server nach dem Starten eine Datenbank “springbootdb” anlegen soll. Auch die Erstellung eines Users oder das Ändern eines Passwortes wäre über diese Parameter möglich. Eine Übersicht über die möglichen Parameter ist auf der Website des verwendeten PostgreSQL-Images zu finden [1, 2].

Hier das Docker Compose File also noch einmal in seiner Gesamtheit:

version: "3"

networks:
  network-front:
    driver: bridge
  network-back:
    driver: bridge

volumes:
  db-data:
  
services:
  test-backend-db:
    build: .
    container_name: test-backend-db
    depends_on:
      - "postgre-db"
    ports:
      - "80:8080"
    networks:
      - network-front
      - network-back

  postgre-db:
    image: postgres:9.5
    container_name: postgre-db
    expose:
      - "5432"
    volumes:
      - "db-data:/var/lib/postgresql/data"
    networks:
      - network-back
    environment:
      POSTGRES_DB: springbootdb

Das Dockerfile unterscheidet sich nur im Dateinamen der jar-Datei von dem aus dem vorherigen Schritt und wird daher hier ausgelassen.

Um nun die notwendigen Images, Volumes und Container zu erstellen, genügt ein Aufruf des Befehls docker-compose up im Verzeichnis mit der docker-compose.yml-Datei. Dieser Befehl startet auch gleich alle Dienste in der korrekten Reihenfolge.

Wenn die Dienste (Testanwendung und Datenbankserver) nun also laufen, können einige Einträge erstellt und persistiert werden. Nachdem das Cluster einmal mit dem Befehl docker-compose down beendet wurde und mit docker-compose up wieder gestartet wurde, können die persistierten Daten via /load wieder vom Datenbankserver geladen werden.

Sollten noch Unklarheiten bestehen, verweise ich an dieser Stelle gerne auf die Kommentarfunktion und die unten und im Artikel verlinkten Ressourcen.

Fazit

Wie in der Einleitung schon angesprochen, eignet sich Docker hervorragend, um ein ganzes Cluster modularer Microservices mit begrenztem Aufwand abbilden und steuern zu können. Auch größere Einzelanwendungen können von Docker profitieren, da deren Installation und Migration extrem vereinfacht wird, wenn sie erst einmal eingedockert und mit einem Volume verbunden sind. Die Sicherheit ist einer der größten Knackpunkte bei der Verwendung von Docker im Unternehmensumfeld und sollte separat für jeden Fall recherchiert werden. Dafür arbeitet Docker relativ effizient und mit viel weniger Overhead als andere Virtualisierungstechnologien und startet/stoppt viel schneller.

Ressourcen


[Spring Boot Example Quellcode]
[Docker Example Quellcode]
[Docker]
[Docker Compose]
[Spring Boot]
[Docker Hub]
[Docker Hub PostgreSQL]
[Docker Docs Volumes]
[Docker Docs]
[Docker Docs Network Drivers]
[Docker Docs PostgreSQL]
[Container Solutions]
[Tricksofthetrades]
[Docker Cheat Sheet]
[Heise Developer]
[Computerwoche]

Short URL for this post: https://wp.me/p4nxik-30s
This entry was posted in Java Runtimes - VM, Appserver & Cloud and tagged , , , , , , , , . Bookmark the permalink.

Leave a Reply