Schon seit einigen Jahren ist Docker fester Bestandteil der Diskussion rund um Immutable Container, Microservices und deren Deployment. Auch bei comSysto haben wir uns bereits in einigen früheren Posts (z.B. “Developing a Modern Distributed System” Part II und Part III oder im Rahmen von Continuous Integration Pipelines) intensiv mit dem Thema auseinandergesetzt. In einem weiteren Lab haben wir uns dieses Mal vor allem auf die Datenbank und den Mehrwert, diese für die lokale Entwicklung im Container auszuführen, fokussiert. Der größte Mehrwert liegt auf der Hand: Niemand muss eine lokale Instanz aufsetzen und warten. Aber durch den konsequenten Einsatz von Docker ergeben sich auch weitere Möglichkeiten.
Doch der Reihe nach… Als Aufhänger für unsere Experimente haben wir einen Klassiker - die Spring Petclinic - ausgegraben. Jede andere Anwendung, die als Executable JAR gestartet werden kann und mindestens eine bereits initialisierte Datenbank braucht, wäre genauso gut geeignet. Unser fertiges Beispiel ist selbstverständlich auf github frei verfügbar.
SPRING PETCLINIC MIT DOCKER COMPOSE
Zunächst wollen wir unser Beispiel-Projekt als Docker Image bauen und brauchen dafür natürlich zuerst ein Dockerfile. Unter der Annahme, dass das fertige JAR bereits im target-Verzeichnis des Projekts liegt, ist hier praktisch nichts mehr zu tun. Eines der unzähligen Java-Base-Images auswählen, Anwendung dazu packen und ein passendes Start-Kommando definieren. In unserem Fall öffnen wir dabei auch noch einen Debug-Port, aber dazu später mehr. Den so gebauten Container kann man bereits starten und auf Basis der In-Memory Datenbank verwenden.
github:a59bc0eb67bf42a3a9587b9b2d33cfb1
Besteht die Anwendungslandschaft aus mehreren Services und Datenquellen wird es ohne Docker Compose schnell unübersichtlich. Daher definieren wir uns direkt ein dazu passendes docker-compose.yml, in dem wir eine Datenbank starten und über das automatisch eingerichtete Network ansprechen. Selbstverständlich muss für diese Variante auch das mysql Profil der Petclinic aktiviert werden.
github:45af71f9607543dec06ad079978ec0aa
Ein simples “docker-compose -f docker-compose-petclinic-mysql-plain.yml up” macht die Applikation nun auf http://localhost:18080 erreichbar - zumindest beim zweiten Versuch. Beim ersten mal muss die MySQL Datenbank initialisiert werden was ein paar Sekunden dauert obwohl der Port 3306 schon lange offen ist. Dadurch kann sich unsere Anwendung trotz “depends_on” nicht verbinden und bricht den Start mit einem Fehler ab.
DATENSTÄNDE VORBEREITEN
Die Datenbank jedes Mal beim Hochfahren aus der Anwendung heraus zu initialisieren ist natürlich für einfache Beispiele valide. In realistischen Projekten will man sich diesen Aufwand allerdings sparen. Früher hatte jeder Entwickler seine eigene lokale Datenbank, die auch beim Neustart ihren Zustand beibehielt. Genau diesen Ansatz wollen wir aber durch die Verwendung von Docker vermeiden. Daher ist unser Zielzustand, eine bereits initialisierte Datenbank als fertiges Image verteilen zu können, die jeder Entwickler nur noch hochfahren muss.
Auf Basis dieser Idee ist es möglich, Standard-Entwicklungs-Datensätze, komplexe Spezialfälle oder auch Produktions-Abzüge versioniert in einer Docker Registry abzulegen und leicht zugänglich zu machen. Wir müssen nur beim Start der Anwendung das gewünschte Image in unserem compose-File angeben bzw. beliebig austauschen.
github:b4e01d1af5fc9fba7b8aa7e85740e944
Bleibt nur die Frage: Woher kommen diese vorbereiteten Images? Ein Standard-Werkzeug für die Initialisierung und Evolution relationaler Datenbanken ist Flyway. Damit wollen wir exemplarisch einen Datenstand für die Petclinic erzeugen, natürlich ebenfalls komplett dockerized ohne lokale Installation beim Entwickler. Unser Ausgangspunkt ist auch hier wieder Docker Compose, die nötigen Flyway-Skripte mounten wir als Volume.
github:d98049b41c11be6b2421d976b9642f84
Den so erzeugten Datenstand packen wir in ein neues Image. Dabei hilft uns ein bash-Skript, das ein paar dabei auftretende Fallstricke umgeht.
github:1ddb45b2be419daa0364e43936cc5243
Das zugehörige Dockerfile ist absolut minimal, der einzig spannende Aspekt ist die Verwendung von zur Build-Zeit definierten Argumenten.
github:db82d8eece57c5f507f10e7f360aa6eb
GRADLE BUILD
Docker Compose nimmt dem Entwickler beim Erstellen von Images und Containern bereits einiges an Arbeit ab. Trotzdem sind noch diverse Aufrufe auf der Kommandozeile nötig, um einen kompletten Build durchzuführen. Beispielsweise soll die Beispielanwendung heruntergeladen und übersetzt werden, bevor mit dem Erstellen der Testdatenbank überhaupt begonnen werden kann.
Zunächst haben wir die einzelnen Aufrufe in Unix-Shellskripte verpackt. Das funktioniert soweit ausgezeichnet und erspart bereits einiges an Arbeit. Allerdings vermisst man als Entwickler schnell einige Möglichkeiten, die man von weiter entwickelten Buildwerkzeugen gewohnt ist - zum Beispiel die Möglichkeit, den Prozess nach einem Fehler an einer bestimmten Stelle fortzusetzen bzw. auf die Ausführung bereits fertiger Aufgaben zu verzichten.
Das Spring Petclinic Beispielprojekt stellt bereits ein fertiges Maven-Buildscript zur Verfügung. Dieses kompiliert den Quellcode, erzeugt die Spring Boot-Applikation und führt einige Integrationstests aus. Sollte man Maven also auch für den Bau der Docker-Artefakte verwenden?
Aufgrund früherer Erfahrungen mit Maven haben wir uns gegen diese Möglichkeit entschieden. Grund dafür ist, dass Maven als rein deklaratives Buildwerkzeug nur eingeschränkte Möglichkeiten anbietet, die Abarbeitung einzelner Aufgaben zu orchestrieren (also z.B. mehrere Kommandos in einer bestimmten Reihenfolge aufzurufen). Dies ist zwar mit Hilfe diverser Plugins durchaus möglich, führt aber schnell zu extrem unübersichtlichen und schwierig zu wartenden Build-Dateien.
Stattdessen haben wir uns für einen hybriden Ansatz entschieden. Als Werkzeug zur Orchestrierung des Builds und der Docker-Umgebung haben wir Gradle gewählt, das für die meisten Aufgaben ebenso mächtig und einfach zu verwenden ist wie Maven, jedoch auch einfach um projektspezifische Aktionen wie die Erzeugung der Docker-Umgebung erweitert werden kann.
Für das Beispielprojekt führt dies zu einem Setup, in dem Gradle das Maven-Build des Petclinic-Beispiels aufruft - zwar wäre es durchaus möglich, auch dieses durch ein Gradle-Build zu ersetzen, doch das würde den den Rahmen dieses Beitrags sprengen. Zudem handelt es sich dabei eher um eine Fleißarbeit, die keinen echten Mehrwert bietet. Für eine reale Entwicklungsumgebung, in der der Code der tatsächlichen Anwendung unter unserer eigenen Kontrolle liegt, würden wir natürlich einen homogenen Ansatz (in Form eines Multi-module Gradle Builds) bevorzugen.
DAS GRADLE-SKRIPT
Das Gradle-Skript stellt die folgenden Tasks zur Verfügung:
- clean: Räumt alle gebauten Artefakte auf
- build: Baut das Spring Petclinic-Beispiel und die Testumgebung
- run: Startet die Testumgebung
- bundle: Creates a docker-compose bundle (.dab) [build]
- createPetclinicDb: Creates the MySQL Petclinic database as a Docker image
github:45e709fddf0dead2ec6866c3d541385f
Beim Schreiben des Buildskripts haben wir uns auf die Verwendung der bereits zur Verfügung stehenden Gradle- und Ant-Tasks beschränkt und auf weitere 3rd-party Plugins verzichtet. Die Kommandos für Docker bzw. Docker Compose werden über die Shell ausgeführt - den Nachteil, dass unser Build deshalb nicht auf einer Windows-Umgebung ausgeführt werden kann, nehmen wir an dieser Stelle bewusst in Kauf.
Das Skript selbst sollte keine Überraschungen beinhalten. Wo nötig, haben wir die Standard-Tasks (clean, build, test) angepaßt, um jeweils einen angepassten ‘Exec’-Task zur Ausführung der entsprechenden Maven-Goals aufzurufen.
Inkrementelle Builds
Des weiteren verwenden wir Gradles Fähigkeit, über die Definition von Eingabe- und Ausgabeartefakten die Notwendigkeit eines erneuten Builds zu überprüfen und gegebenenfalls zu überspringen. Dies ist besonders wertvoll für den Task “createPetclinicDb”, der das Datenbank-Image neu erstellt und relativ viel Zeit in Anspruch nimmt, weshalb man ihn nicht unnötig ausführen möchte.
Gradle macht das relativ einfach, indem man die entsprechenden Sourcen als Eingabe- und die erzeugten Dateien als Ausgabe definiert:
Dieser Ansatz hat jedoch den Nachteil, dass bei Ausführung eines ‘clean’ auf jeden Fall ein neues Image erzeugt wird, da die Zieldateien danach nicht mehr vorhanden sind. Das ist nicht sinnvoll, da das eigentliche Produkt unserer Bemühungen, das Docker-Image, in der lokalen Registry liegt und deshalb nach wie vor zur Verfügung steht. Um dieses Problem zu lösen, kann man die UP-TO-DATE-Überprüfung selbst implementieren. In unserem Fall machen wir das in folgenden Schritten:
- Archivieren der Migrations-Dateien als ZIP-Datei
- Erstellung einer MD5-Checksumme mit Hilfe des entsprechenden Ant-Tasks
- Taggen des erzeugten Docker-Images mit der MD5-Checksumme
Bei jedem Build wird überprüft, ob es ein Docker-Image mit der entsprechenden Checksumme gibt. Ist das der Fall, kann der Schritt übersprungen werden.
DEV-ROUNDTRIP KOMPLETT
Jetzt, nachdem unsere Testumgebung komplett ist, wollen wir sie natürlich auch für die Entwicklung einsetzen. Ausführen lässt sich das Ganze mit einem einfachen Kommando:
shell:docker-compose up
Das alleine ist natürlich noch kein Roundtrip. Angenommen wir implementieren Änderungen im Code der Petclinic und wollen diese integrativ testen - wie sieht das konkret aus? Zunächst müssen wir über Maven oder mit der oben beschriebenen Gradle-Integration das JAR-File neu bauen. Dieses wird dann in ein ansonsten identisches Docker-Image gewrappt um den neuen Stand als Container hochfahren zu können. Die Datenbank muss man dabei nicht einmal neu starten, docker-compose erlaubt auch die selektive Anwendung auf einzelne Komponenten des Stacks.
Verhält sich der neue Code nicht so wie erwartet greifen viele Entwickler gern zum Debugger. Die native Integration der IDE ist mit unserem Setup nicht ausreichend, da die Applikation nicht aus dieser heraus gestartet wird. Wer Erfahrung mit Remote Debugging hat wird sich allerdings auch hier sofort wie zu Hause fühlen. Unsere Anwendung lauscht wie im Dockerfile gesehen auf den Debug-Port 14441 - wird dieser auch mit einem Port-Mapping versehen können wir darauf direkt auf localhost zugreifen. Exemplarisch zeigen wir hier die zugehörige IntelliJ Konfiguration.
FAZIT
Damit haben wir schon relativ viele Bedürfnisse eines Anwendungs-Entwicklers abgedeckt. Selbstverständlich hat der komplett von Docker getriebene Ansatz auch einige Nachteile, die man entweder akzeptieren oder mit Aufwand, den wir uns nicht gemacht haben, lösen muss:
- Hot Swaps - ob nativ in der IDE oder z.B. mit JRebel - gehören für viele Entwickler zum Alltag. Um so etwas mit Docker zu gewährleisten müsste man mit einem als Volume gemounteten exploded JAR arbeiten, was allerdings dem Container-Gedanken widerspricht. Einen ähnlichen Ansatz wählt Peter Rossbach eine Schicht höher, wenn er die WAR-Files als Volume in einen Tomcat-Container packt anstatt diese fest ins Image zu bauen.
- Die Images mit den fertigen Datenständen werden schnell relativ groß. So umfasst das mysql/5.7 Base Image bereits 400.2MB, durch die Hinzunahme der Petclinic-DB mit dem oben beschriebenen Ansatz landen wir bereits bei über 600MB. Unsere Vermutung: Da Docker Images in Layern gebaut werden ist das /var/lib/mysql-Verzeichnis effektiv doppelt enthalten. Natürlich könnte man das optimieren, indem nur einzelne Teile ersetzt werden - dafür wird es dann mit der Konsistenz schwieriger.
Alles in allem verhält es sich mit Docker wie mit jedem anderen Tool: Es ist wichtig die Funktionalitäten und Grenzen zu kennen um einen Einsatz im jeweiligen Kontext sorgfältig abwägen zu können. Für uns zählt Docker - nicht zuletzt aufgrund der Evaluierung in diversen Labs - zu unserem Werkzeugkasten und hat mittlerweile in vielen Projekten seinen Platz. Inwieweit Container auch als lokale Entwicklungs-Plattform eingesetzt werden ist dabei aber sehr unterschiedlich.