arrow arrow--cut calendar callback check chevron chevron--large cross cross--large download filter kununu linkedin magnifier mail marker menu minus phone play plus quote share xing

Buildpacks: eine kritische Betrachtung

Die klassische Beraterantwort auf viele Fragen lautet „it depends“. Es gibt jedoch immer wieder neue Technologien und Lösungen, die zunächst bedenkenlos als „perfekt und alternativlos“ verkauft werden – die Blockchain oder Machine Learning seien hier als Beispiele angeführt. Ich möchte in diesem Artikel über Cloud Native Buildpacks (CNB) sprechen und etwas überspitzt hinterfragen, ob sie wirklich die Lösung sind, die wir immer gesucht haben. Oder ob nicht auch hier ein vorsichtiges „it depends“ die angemessenere Haltung ist. Ich habe dabei ein paar Punkte herausgegriffen, die mir wichtig erscheinen. Diese stellen eine subjektive Betrachtung und Gewichtung dar. Ich bin gerne an anderen Meinungen und Argumenten interessiert, die ich auch gern hier veröffentlichen werde. Meine E-Mail-Adresse ist weiter unten zu finden.

Cloud Native Buildpacks – was ist das?

Buildpacks sind eigentlich nicht neu. Sie stammen von Heroku, einem der Cloud-Pioniere schlechthin. Neu(er) jedoch ist, dass Buildpacks nun losgelöst von Heroku zur Verfügung stehen und an immer mehr Stellen integriert werden. 

Doch was sind Buildpacks eigentlich? Die Idee ist simpel wie genial: mit einem einzigen Kommando vom Source Code zum Docker-Container! Im Hintergrund werkelt dabei eine Spezifikation namens „Cloud Native Buildpacks Platform Specification“. Das zentrale Element ist die „Plattform“. Hier gibt es gleich mehrere Alternativen. Die bekannteste ist „pack“, ein Kommandozeilen-Tool. Es gibt mit „kpack“ aber beispielsweise auch eine Kubernetes-native Implementierung, welche sich in Tekton integriert.

Das erste, was die Plattform heranzieht, ist ein „Builder“. Dieser kann vom Benutzer gewählt werden, falls der Standard der Plattform nicht gefällt. Der Builder referenziert eine Reihe von Buildpacks. Das sind – simplifiziert – eine Reihe von Scripten, die sich auf Basis des Dateisystems und der Ausgabe anderer Builder am Build beteiligen können. Die Plattform delegiert den Build dann an eine Reihe von Buildpacks. Diese schreiben schließlich gemeinsam ein Docker-Image.

In den Buildpacks liegt die eigentliche Magie, die in zwei Phasen unterteilt ist. Die erste Phase lautet „detect“: hier kann das Buildpack entscheiden, ob es in den Build einbezogen werden will. Typischerweise wird dabei geprüft, ob bestimmte Dateien vorhanden sind (das Buildpack für Maven prüft logischerweise, ob eine pom.xml vorhanden ist). Die zweite Phase lautet „build“: Nachdem die Plattform ermittelt hat, welche Buildpacks partizipieren, werden diese der Reihe nach beauftragt, ihre Arbeit zu verrichten. Die Ergebnisse werden – Layer für Layer – in ein Docker-Image gegossen.

Es gibt Buildpacks, die in der Lage sind eine JRE oder eine JDK zu installieren. Andere compilieren Maven-Projekte. Dritte können War-Files verarbeiten. Es ist normal, dass eine niedrige zweistellige Anzahl von Buildpacks an einem Build teilnimmt.

Die Buildpacks können über Umgebungsvariablen konfiguriert werden. Außerdem bieten sie zahlreiche Features wie Caching (und das wesentlich besser als Docker-Builds das tun!) oder die Kommunikation miteinander, auf die ich hier im Detail nicht eingehen möchte, um den Rahmen nicht zu sprengen. An dieser Stelle sei auf die Dokumentation verwiesen.

Die Buildpacks laufen in einer eigenen Umgebung, die von der Plattform bereitgestellt wird. In dieser Umgebung können praktische Tools schon vorinstalliert sein. Ebenso nutzt das resultierende Artefakt ein vorher definiertes Basis-Image. Diese beiden Images definieren gemeinsam den „Stack“.

Wird von „Buildpacks“ im Plural gesprochen, sind in der Regel gar nicht die Buildpacks selbst gemeint, sondern die gesamte Maschinerie.

Ein einfaches Beispiel

Wir beginnen mit einem einfachen Beispiel. Eine einzige Java-Klasse in src/main/java/buildpacks/Main.java genügt:

package buildpacks;

import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;

@SpringBootApplication
@RestController
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
 
    @GetMapping("/")
    public Map<String, String> hello() {
        return System.getenv();
    }
}

Dazu benötigen wir eine pom.xml:

<?xml version="1.0" encoding="UTF-8"?> 
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 
    <modelVersion>4.0.0</modelVersion> 
    <parent> 
        <groupId>org.springframework.boot</groupId> 
        <artifactId>spring-boot-starter-parent</artifactId> 
        <version>2.5.1</version> 
        <relativePath/> 
    </parent> 
  
    <groupId>com.example</groupId> 
    <artifactId>demo</artifactId> 
    <version>0.0.1-SNAPSHOT</version> 
  
    <properties> 
        <java.version>16</java.version> 
    </properties> 
  
    <dependencies> 
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-web</artifactId> 
        </dependency> 
  
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-test</artifactId> 
            <scope>test</scope> 
        </dependency> 
    </dependencies> 
  
    <build> 
        <plugins> 
            <plugin> 
                <groupId>org.springframework.boot</groupId> 
                <artifactId>spring-boot-maven-plugin</artifactId> 
  
                <configuration> 
                    <layers><enabled>true</enabled></layers> 
                </configuration> 
            </plugin> 
        </plugins> 
    </build> 
</project>

Obwohl es nicht nötig ist, wollen wir tippfaul sein und legen noch eine project.toml daneben:

[build]
exclude = [ "target" ]

[[build.env]]
name = "BP_JVM_VERSION"
value = "16.0.1"

Nun können wir einen Build starten.

pack config default-builder paketobuildpacks/builder:base
pack build myapp

Die Ausgabe ist relativ ausführlich, insbesondere beim ersten Start. Neben dem Download von allerlei Buildpacks wird auch Java heruntergeladen und der eigentliche Build ausgeführt. Am Ende erhalten wir eine lauffähige Java-Applikation als Docker-Image. Einfacher geht es kaum, lokal muss nicht einmal Java oder Maven installiert sein. Gleiches lässt sich mit neueren Spring-Boot-Versionen auch über das Maven-Goal „spring-boot:build-image“ erreichen.

Das Beispiel zeigt deutlich, wie praktisch und einfach Buildpacks sind. Schaut man sich an, was das liberica-buildpack für eine JRE beisteuert, fallen viele Dinge auf, die in anderen Docker-Images fehlen. So wird die JVM bei einem OutOfMemoryError automatisch neugestartet, DNS-Caching wird bei lokal laufendem DNS-Server deaktiviert und die Speicher-Einstellungen werden dem Container angepasst. Außerdem können SSL-Zertifikate bequem dem Java-Keystore hinzugefügt werden. Selbstverständlich läuft der Prozess im Container nicht als root. Einige der JVM-Optimierungen fanden in neuer Java-Versionen bereits Einzug, dennoch sind diese Vorgaben sehr sinnvoll und werden in der „händischen Lösung“ oft vernachlässigt.

Das Image ist mit 289 MB relativ groß. Schon das Basis-Image ist fast 90 MB, die JVM liegt bei 170 MB, die Anwendung liegt bei 17 MB. Der Rest geht auf Konfigurationen und kleine Helfer der Buildpacks zurück.

Rebase

Ein oft beworbenes Feature ist die „rebase“-Funktionalität von CNBs. Dabei wird im Docker-Image des Artefakts die Referenz auf das zugrundeliegende Basis-Image aktualisiert, ohne dass das Image neu gebaut werden muss. Zunächst klingt diese Funktionalität wie ein Segen, wo doch Updates bei Containern oft irgendwo auf einer Todo-Liste versauern. Jedoch hat diese Funktionalität für mich einen Haken: es wird lediglich das Basis-Image aktualisiert.

In den Buildern, die standardmäßig verwendet werden, sind dies grundsätzliche Komponenten wie die Bash. In Sachen Containern bin ich aber Purist: nach meinem Verständnis gehört in ein gutes Container-Image nicht viel mehr als die Anwendung und ihre Laufzeitumgebung. Das bedeutet bei Java, dass eine C-Laufzeitumgebung (libc: gnu oder musl), zlib, die JRE und die Anwendung in Form von jar-Dateien völlig genügen. Die JRE ist nicht Teil des Basis-Images. Aktualisiert wird bei einem Rebase also lediglich die libc. Nun enthalten Basis-Images oft noch einiges mehr: bash,  curl/wget, apt/apk/yum und der vi sind keine Seltenheit. Je mehr Funktionalität im Basis-Image vorhanden ist, desto mehr kann ein Rebase aktualisieren. Je mehr Funktionalität, desto häufiger wird sich diese ändern. Und desto größer ist die Chance, dass auch Sicherheitslücken und Angriffsvektoren verbaut sind. Mir ist bewusst, dass es gute Gründe gibt, eine Reihe von Tools in sein Container-Image zu paketieren. Ich möchte jedoch provokativ die Frage stellen, was der bessere Default ist und ob CNBs hier nicht dazu verleiten, einige Vorteile von Containern über Bord zu werfen.

Um die JRE zu aktualisieren ist bei den aktuell vorhandenen Buildpacks übrigens weiterhin ein kompletter Rebuild erforderlich. Es ist möglich, eigene Basis-Images zu bauen, die weniger Komponenten beinhalten. Ferner können eigene Buildpacks geschrieben werden, sodass es theoretisch möglich ist, minimale Basis-Images zu erstellen, die meiner Vorstellung eines guten Containers entsprechen. Der Aufwand ist allerdings nicht zu unterschätzen.

Als letztes bleibt ein Problem, welches mir immer wieder begegnet – und das ist kein Argument für oder gegen Buildpacks: Obwohl Updates möglich sind, muss ein Prozess geschaffen werden, diese auch regelmäßig bis in die Produktion zu bringen.

Builder erweitern

Manchmal mag es vorkommen, dass die vorhandenen Builder nicht ausreichen. In einem Experiment wollte ich keine komplette JRE paketieren, sondern mit JLink eine eigene erstellen. Außerdem wollte ich ein schlankeres Basis-Image verwenden. Dies gestaltete sich jedoch als unerwartet komplex. Eigene Buildpacks sind einfach geschrieben. Sie verweisen jedoch auf eine Liste von kompatiblen Stacks. Das macht es einfach, ein eigenes Buildpack zu ergänzen. Möchte man jedoch einen eigenen Stack bereitstellen, ist dieser zunächst nicht mehr mit bestehenden Buildpacks kompatibel. Als Lösung bleibt, bestehende IDs wiederzuverwenden oder Buildpacks zu duplizieren – gut fühlt sich beides nicht an.

Möchte man bei seinem Buildpack nicht bei null anfangen, sondern ein bestehendes Buildpack abändern, kann es auch schnell kompliziert werden: die offiziellen Buildpacks sind meist in go geschrieben und enthalten relativ wenig Code. Die eigentliche Arbeit wird von Bibliotheken erledigt. So verweist das „bellsoft-liberica“-Buildpack auf „paketo-buildpacks/libjvm“. Das vermeidet auf der einen Seite duplizierten Content, ist aber weniger zugänglich als ein Shell-Script, in dem man einzelne Zeilen austauschen kann.

Da die einzelnen Buildpacks gut durchdacht sind, wird man nicht oft in die Situation geraten, dass man eigene schreiben muss. Falls doch, sollte man ein bisschen Geduld einplanen.

Buildbacks und Continuous Delivery

Continuous Delivery (CD) ist in meinen Augen immer noch eine Königsdisziplin in der modernen Softwareentwicklung. Gerne wird behauptet, dass Projekte CD betreiben, wobei damit oft nur gemeint ist, dass irgendwo ein Jenkins zum Einsatz kommt. Ich möchte an dieser Stelle nicht erschöpfend über die Philosophie von CD schreiben, jedoch muss ich kurz auf mein Verständnis von CD-Pipelines eingehen. Die Grundidee von Pipelines ist ausführlich im aktuellen Buch „Continuous Delivery Pipelines“ von Dave Farley beschrieben. 

Nach jedem Commit startet die erste Stage der Pipeline. Ihre Aufgabe ist es, ein paar grundsätzliche und schnelle Tests auszuführen und dann ein Release Candidate zu erstellen. Der Entwickler soll innerhalb weniger Minuten ein erstes Feedback bekommen, ob mit seinem Commit alles in Ordnung war. Der Rest der Pipeline arbeitet mit diesem Release Candidate. Er wird analysiert, getestet, möglicherweise irgendwo installiert. Wenn alle definierten Kriterien erfüllt sind, wird der Release Candidate zu einem echten Release, welches potentiell seinen Weg in die Produktion finden kann.

Der Versuch, eine solche Pipeline im Java-Umfeld zu bauen, ist nicht trivial. Das Problem beginnt damit, dass das Artefakt, welches den Release Candidate darstellt, nur einmal gebaut werden sollte. Somit wird sichergestellt, dass es keinen Unterschied zwischen dem Artefakt in Produktion und dem getesteten Artefakt gibt. Dies ist insbesondere bei komplexeren Builds interessant, bei denen z.B. Code automatisch generiert wird oder der Build nicht 100% reproduzierbar ist, etwa weil während des Bauens auf externe Ressourcen wie WSDLs zugegriffen wird. Tools wie Maven machen es noch schwerer: hier muss bereits vor dem Bauen die Versionsnummer bekannt sein, obwohl zu diesem Zeitpunkt noch nicht klar ist, ob der Release Candidate schlussendlich zu einem Release wird. All diese Probleme sind lösbar und möglicherweise teilweise akademischer Natur, jedoch erfordert die saubere Implementierung einer solchen Pipeline oft einigen Aufwand.

Wie passen nun Buildpacks in solch eine Pipeline? Zunächst muss hier die Frage geklärt werden, was eigentlich das Artefakt ist. Ist die Anwendung am Ende der Docker-Container selbst? Oder ist der Docker-Container nur eine Paketierung, wohingegen die Anwendung selbst eher als eine Sammlung von Dateien angesehen werden muss? Dies kann im Kontext von CD einen Unterschied machen: Betrachte ich mein Artefakt als eine Sammlung von Jar-Dateien, so sollte im ersten Schritt der Pipeline gar nicht viel mehr gebaut werden. Zur Erinnerung: dieser Schritt sollte möglichst schnell gehen. Es genügt, wenn das Artefakt gebaut und ein paar grundsätzliche (Unit-)Tests ausgeführt werden. Die Paketierung des Artefakts würde tendenziell erst am Ende passieren, wenn wir zuversichtlich sind, dass der Release Candidate alle an ihn gestellten Anforderungen erfüllt.

Buildpacks stehen dem ein wenig entgegen: vom Quellcode zum Docker-Container ist es ein automatischer Schritt. Dieser Schritt sollte so schnell wie möglich laufen. Der Release Candidate muss dann als Docker-Container betrachtet werden. Es gilt, diesen zu starten und weitere Tests auszuführen. Diese haben dann einen ziemlichen Blackbox-Character. Werkzeuge zur Messung der Testabdeckung oder Tools wie PMD, Spotbugs oder Sonarqube sind in diesem Kontext schwer zu verorten. Sie bereits beim Erstellen des Release Candidates laufen zu lassen, verlängert die Laufzeit der ersten Stage unnötig. Danach gibt es aber keinen direkten Zugriff mehr auf das Kompilat. Möchte man die Ausgaben dieser Tools weiterverwenden (z.B. um sie in Jenkins zu visualisieren), gerät man in das nächste Problem.

Eine grundsätzliche Lösung könnte sein, zunächst mit klassischen jar-Dateien weiterzuarbeiten und erst später das Docker-Image zu erstellen. CNBs werden dann nur noch genutzt, um die Dateien zu paketieren, was problemlos möglich ist. Diese Thematik wird in meinen Augen aber viel zu wenig diskutiert. Außerdem nutzt diese Lösung einen Großteil des Potentials von CNBs nicht mehr aus.

Entkräftend sei hierbei noch ergänzt, dass bei Microservices die Pipelines insgesamt oft schnell laufen. Dies löst aber nur ein Teilproblem.

Fazit: Gute Lösungen sind (meist) keine Einzeiler

Was ist nun mit Cloud Native Buildpacks? Ist der Hype übertrieben? Ich komme für mich zu dem Schluss, dass sie ein wertvolles Werkzeug in meinem Koffer darstellen können. Die einzelnen Buildpacks lassen sich flexibel kombinieren und nehmen dem Entwickler viel Arbeit ab. Bei genauerem Hinsehen entdecke ich jedoch viele Aspekte, die mich vorsichtig stimmen. Eigene Buildpacks und eigene Builder lassen sich mit einigem Aufwand erstellen. Auch das oft gelobte Rebase-Feature ist in meinen Augen zwar praktisch, verliert aber an Wert, wenn der Inhalt der Images wirklich minimal ist; für regelmäßige Aktualisierungen sollte so oder so ein Prozess existieren.

Die meisten Beispiele legen nahe, dass es vom Source Code zum fertigen Artefakt nur eines einzelnen Befehls bedarf. Dieses Versprechen halten Buildpacks grundsätzlich. Bei einer Integration in eine Build-Pipeline möchte man viele der Automatismen aber vielleicht gar nicht nutzen. In komplexeren Projekten – so fürchte ich – sind die Stärken von Buildpacks nicht mehr so groß. Das spricht nicht gegen ihre Verwendung, bringt aber auch weniger der versprochenen Vorteile mit.

Buildpacks einzusetzen ist in meinen Augen trotzdem kein Fehler. Ich möchte dennoch dazu ermutigen, den gesamten Build-Prozess stets kritisch zu beleuchten. In vielen Situationen sind gute Lösungen leider nicht unbedingt ein Einzeiler ...