Die Continuous Integration-Pipeline ist eines der wichtigsten Artefakte im Rahmen der Softwareentwicklung. Sie weist uns sehr früh darauf hin, dass Unit-Tests fehlschlagen, baut für uns unsere Anwendungen, triggert das Deployment und sagt uns zeitnah, dass Dinge bei der Integration schiefgegangen sind. Sie ist der Startpunkt für alle weiteren Schritte nach dem Commit in das Repository.

Diese Auflistung deckt sogar nur den Standard ab und wird mit Sicherheit nicht der Vielfalt der Aufgaben in den einzelnen Projekten gerecht, die die CI-Pipeline tagtäglich mehrmals für die Entwickelnden übernimmt. Umso verwunderlicher ist es für mich, dass ein so wichtiger Bestandteil der Softwareentwicklung immer noch verhältnismäßig stiefmütterlich behandelt wird. Am Ende des Tages ist die Pipeline ein Softwareprodukt des Teams, welches genauso laufend weiterentwickelt und gewartet werden muss!

Pipeline as Code und Docker top, Shell flop

Besonders mit Software wie Circle CI, Drone CI oder auch GitHub-Actions ist dies etwas einfacher geworden. Aufwendige Initialinstallationen wie beim Jenkins bleiben aus, einzelne Schritte der Pipeline können nun sehr gut durch Docker-Images isoliert werden und die ganze Pipeline kann deklarativ definiert werden.

Während dies alles zur Wartbarkeit einer Pipeline beiträgt, sieht man erstaunlicherweise immer noch Unmengen an Shell-Skripten in den Pipelines herumfliegen, die bezüglich der Wartbarkeit in den meisten Fällen der Horror sind. Eine Aneinanderreihung von Befehlen ist noch beherrschbar. Was ist aber, wenn wir kompliziertere Dinge tun wollen? Zum Beispiel über einen Remote Endpunkt überprüfen, ob alle Services deployed worden sind bevor wir den E2E-Test starten? Natürlich mit Timeout, sodass im Fehlerfall irgendwann die Pipeline abbricht?

Um diese Dinge zu tun, reicht es nicht mehr Befehle aneinanderzureihen, sondern jemand muss tatsächlich ein "richtiges" Shell-Skript schreiben. Und nun wirklich mal Hand aufs Herz: Wie hoch ist der Anteil in eurem Java-EntwicklerInnen- Team, der sich wirklich mit Shell-Scripting auskennen? Und damit meine ich Personen, die den Unterschied zwischen sh und bash kennen? Personen, die wissen, wie man in Bash auf eine Variable mit Wert prüft? Personen, die wissen, wo welche Leerzeichen rein müssen, wie man in welcher Shell ein Array anlegt? Personen, die folgende Zeilen lesbar finden?

IFS=$'\n' read -r -d '' -a services < <( jq -r 'keys[]' versions.json && printf '\0' )

Trotzdem werden dennoch gerne Shell-Skripte für die Pipelines benutzt, da Sie sehr performant sind und kaum Ressourcen benötigen. In einer Cloud-Welt, die ihre Preise nach Ausführungsdauer und Ressourcenverbrauch (Rechenkapazität + übertragene MB) kalkuliert, hat Java bis jetzt immer den kürzeren gezogen. Nur um Java ausführen zu können, sind wir bereits auf ein wesentlich größeres Docker Image angewiesen.

Docker Basis Image Größe
adoptopenjdk/openjdk11:jdk-11.0.8_10-alpine 267 MB
node:lts-slim 48 MB
alpine 2,6 MB

GraalVM Native Images to the rescue...

Während GraalVM generell mit verbesserter Performance und Ressourcenverbrauch wirbt, ist für mich das spannendste Feature, die Möglichkeit native Binaries mit Java/Kotlin zu erzeugen. Diese Applikationen benötigen keine extra JVM, da diese mit in der Binary in einer abgespeckten Version gepackt ist.

Für unsere Pipeline bedeutet dies, dass wir kein Docker-Image mit vorinstallierter JVM mehr brauchen, sondern ein schlankes Basisimage wie Alpine nutzen können, welches nur unsere Binary on top hat.

Zum Glück gibt es bereits einige Frameworks, die bereits sehr gut das Native Image Feature unterstützen. Wenn wir z.B. Micronaut mit dem Picocli-Framework nutzen und daraus eine CLI-App bauen, die http-client-fähig ist, kommen wir etwa zu folgender Tabelle.

Docker Image Größe
alpine + native binary 80 MB
node:lts-slim 48 MB
alpine 2,6 MB

Im Vergleich zu "Classic"-Java haben wir nicht nur die Startzeiten massiv verbessert, sondern auch die Größe des Docker-Images gedrittelt, wobei die Binary ca. 70 MB ausmacht. Dadurch wird Java wesentlich attraktiver, auch wenn es immer noch deutlich größer ist als ein Shell-Skript oder NodeJs-Skript.

Im Gegenzug kann nun aber ein Java-Team die Applikation warten. Nicht nur, weil der Code lesbarer (für eine Java-Team) wird, sondern auch weil ein bekanntes Ökosystem zur Verfügung steht, allen voran bekannte Testframeworks wie JUnit (wer kann mir ohne Googeln erzählen, wie man eigentlich Shell-Skripts testet?)!

...mit einigen Nachteilen

Native Images sind noch im "Early Adaptor Technology"-Stadium. Es gibt bekannte Restriktionen, wobei die fehlende Unterstützung von Java Reflections in dem CI-Kontext am meisten Kopfschmerzen verursachen wird. Oft ist es nicht sofort ersichtlich, ob die Library Reflection nutzt und wenn Sie darauf angewiesen ist, fällt der Fehler auch erst teilweise zur Laufzeit der Binary auf.

Ebenso ist das Bauen der Native Binaries immer noch sehr ressourcenintensiv. Ihr solltet dafür entweder eine Maschine mit mindestens 8 GB RAM haben oder sehr viel Geduld mitbringen.

Fazit

Unsere CI-Pipeline ist ebenfalls ein Softwareartefakt, welches entwickelt und gewartet werden muss. Mit GraalVM Native Images kann ein Java-Team dies deutlich einfacher tun. Während es bis jetzt ein ziemliches "Overkill" war mit Java einzelne Schritte auszuführen, ist es mit dem Native Image Feature deutlich attraktiver geworden. Der CI-Kontext eignet sich dabei besonders eine neue Technologie kennenzulernen, bevor man diese an einemProdukt ausprobiert.