Tooling as Code: Einführung in das Erstellen isolierter, reproduzierbarer Gradle-Builds

Remo Meier

© Shutterstock / Jose Maria Ruiz Sanchez

Es kann frustrierend sein, einen Build Break zu haben, nur weil es einen winzigen Unterschied zwischen Entwicklungs- und Produktionsumgebung gibt. In diesem Tutorial führt Remo Meier, Senior Software Engineer bei der Adnovum Informatik AG, in den Aufbau und die Nutzung von wahrhaft isolierten Gradle-Builds ein.

„Works on My Machine“ ist ein Klassiker unter Software-Engineering-Problemen. Kleine Unterschiede im lokalen Aufbau von Entwickler-Maschinen können immer wieder zu Problemen führen. Das Einrichten und Pflegen eines Projekts und die Isolierung gegenüber anderen Projekten kann eine zeitaufwändige Herausforderung sein. Gleiches gilt für Unterschiede zwischen Entwicklung, CI/CD und Produktion. Für letzere können Probleme häufig erst spät in der Lieferkette erkannt werden und sind entsprechend zeitaufwändig in der Reparatur. In diesem Tutorial präsentieren wir Werkzeuge, um solche Probleme anzugehen und dauerhaft zu lösen – auf Basis eigenständiger, isolierter Builds, abgeschottet von jeglichen externen Einflüssen.

Einführung

Es gibt bereits eine breite Palette von Werkzeugen, die mit der Einrichtung von Projekten behilflich sind. Mit nvm können Benutzer zum Beispiel schnell zwischen Node.js-Versionen in der JavaScript-Welt wechseln. Dennoch müssen Entwickler zu jeder Zeit die passende Version wählen. Auf der Java-Seite profitieren Entwickler von der JVM. Diese abstrahiert einen Großteil des zugrundeliegenden Betriebssystems. Typischerweise laufen daher Java-Anwendungen gleich gut unter Linux, Windows und OS X.

Gradle bringt Projekte mit dem Konzept des Gradle Wrappers noch einen Schritt weiter. Ein Gradle-Build verwendet immer die im Projekte deklarierte Gradle-Version für alle Build-Operationen. Gradle lädt die deklarierte Version herunter und cached diese für zukünftige Builds. Damit addressiert das Tool eines der Hauptprobleme von nvm: alles geschieht transparent für den Entwickler und das Projekt kann selbst über dessen lokales Setup entscheiden. Man kann dies als Tooling as Code bezeichnen, analog zu Techniken, die für Infrastruktur as Code im Einsatz sind. In den nachfolgenden Abschnitten wird beschrieben, wie solche Techniken nicht nur für Gradle, sondern für alle beteiligten Werkzeuge angewandt werden können, zum Beispiel:

  • JDK-Installationen
  • kubectl, um mit Kubernetes Clustern zu interagieren
  • Helm, um Kubernetes-Anwendungen zu paketieren
  • Terraform zur Bereitstellung von Infrastruktur
  • gcloud, az & oc für die Arbeit mit Google Cloud, Azure und OpenShift
  • Die Installation von Node.js
  • Die lokale Ausführung von Datenbanken
  • Die Durchführung von UI-Tests mit Cypress

Es gibt mehrere Projekte, die hier behilflich sind:

Auf der GitHub-Seite des CRNK-Projekts gibt es eine Beispiel-Applikation, welche einige dieser Techniken anwendet.

Installieren einer JVM

Das Plug-in jdk-bootstrap erreicht für die JVM den gleichen Komfort, wie der Gradle Wrapper für Gradle. Das Plug-in kann als Abhängigkeit definiert werden:

plugins {
    id "com.github.rmee.jdk-bootstrap" version "1.0.20190725142159".
}

Anschließend kann man es gemeinsam mit dem Gradle Wrapper nutzen:

wrapper {
    gradleVersion ='5.5'.
}

Plugin anwenden: jdk-bootstrap'.
jdk {
    useAdoptOpenJdk8('8u202-b08')
}

Durch den Anruf von: ./gradlew wrapper wird das gradlew-Skript mit Logik angereichert, um das ausgewählte JDK herunterzuladen, zu cachen und die JAVA_HOME Umgebungsvariable einzurichten. Das Skript sieht wie folgt aus:

...
JDK_DOWNLOAD_URL="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/..."
JDK_VERSION="8u202-b08"
JDK_CACHE_DIR="${APP_HOME}/.gradle/jdk"
if [ -z "${JAVA_HOME}" ]; then
	JAVA_HOME="${JDK_CACHE_DIR}/jdk-${JDK_VERSION}";
fi
if ! [ -d "${JAVA_HOME}" ]; then
  mkdir -p "${JDK_CACHE_DIR}" || die "java: Fatal error while creating local cache directory: ${JDK_CACHE_DIR}"
  ...
fi
...

Von diesem Zeitpunkt an wird jeder Entwickler die gleiche JVM verwenden! Das JDK ist im lokalen .gradle-Verzeichnis verfügbar, ebenso wie Gradle selbst.

Eigenständiges Werkzeug von der Entwicklung bis zur Produktion

Eine stabile, konsistente JVM- und Gradle-Einrichtung ist ein guter Anfang, aber in der Regel sind diverse weitere Werkzeuge über den gesamten Projektlebenszyklus hinweg involviert, insbesondere für das Testing, die Provisionierung und das Deployment. Terraform, Helm und kubectl sind typische Werkezuge, um Anwendungen auf einem Managed-Kubernetes-Service wie GKE in der Google Cloud zu installieren. Alle diese Werkzeuge stehen als Binärdateien zum Download und zur Installation zur Verfügung. Aber dann wiederum stellen sich die obigen Herausforderungen – und mehr noch:

  • Die lokale Installation muss vom Entwickler gepflegt werden.
  • Binärdateien verschmutzen häufig das Home-Verzeichnis der Entwickler. Sofern Entwickler an mehreren Projekten arbeiten, kann es schnell zu Überschneidungen führen. So ist es beispielsweise relativ einfach, mit kubectl in den falschen Kubernetes Cluster zu deployen. All dies lässt sich mit weiteren Konfigurationen verhindern. Aber typischerweise ist es zu umständlich, solche Einstellungen durch den ganzen Entwicklungsablauf hindurch, sowohl innerhalb als auch ausserhalb der Builds, durchzusetzen.
  • Binärdateien müssen von einem der Mirror heruntergeladen werden. Obwohl dies für einzelne Entwickler einfach ist, kann dies zu erheblichen Problemen im Unternehmensumfeld führen. Es gibt keinen standardisierten, etablierten Mechanismus, um solche Dateien zu downloaden, cachen und aktualisieren. URLs sind manchmal kryptisch und erschweren eine automatische Aktualisierung. Package-Manager wie RPM arbeiten global mit der ganzen Maschine.
  • Entwickler, CI/CD-Manager und Operators verwenden häufig unterschiedliche Werkzeuge. Puppet und Ansible findet man häufig für Produktions-Rollouts, während diese aber nur selten lokal von Entwicklern genutzt werden. Mit Gradle gibt es bereits eine Task Execution Engine, die während des Entwicklungsprozesses verwendet wird. Warum nicht auch in späteren Phasen? Dabei lassen sich die Herausforderungen von oben angehen und ein einheitliches Setup von Entwicklung bis Produktion etablieren.

Der Elefant im Raum ist klar Docker. Dessen Registry ermöglicht den Download, Caching und Aktualisierung von Images. Container werden in isolierten Laufzeitumgebungen ausgeführt. Jedoch ist es alles andere als klar wie man am Besten von Docker während der Entwicklung profitieren kann. So gibt es zwar auch Docker Images für Terraform, Helm, Kubectl und gcloud, aber nur selten werden diese auch verwendet.

Der einfachste Ansatz, um mit Docker zu arbeiten, besteht darin, den gesamten Build in ein Docker Image zu packen. Ein beliebtes Projekt in diesem Bereich ist Source-to-Image von Red Hat. Aber ist dies der gewünschte Weg, um die Probleme von oben anzugehen? Es ist eher unwahrscheinlich, dass Entwickler dieses Modell für die lokale Entwicklung übernehmen. Es fehlt die Unterstützung von Gradle als auch von IDEs wie IntelliJ und Eclipse. Dann drängt sich auch die Frage auf, ob es überhaupt möglich ist, den ganzen Build in einem Image zu haben: Größere Projekte besehen nämlich aus vielen Einzelteilen.

Quarkus-Spickzettel

Quarkus – das Supersonic Subatomic Java Framework

Wollen Sie zeitgemäße Anwendungen mit Quarkus entwickeln? In unserem brandaktuellen Quarkus-Spickzettel finden Sie alles, was Sie zum Loslegen brauchen.

Häufig entscheiden sich Projekte für einen Mono-Repository-Ansatz, um Einfachheit beim Bau, der Versionierung und dem Testing zu erreichen. Entsprechend werden alle Teile im gleichen Git Repository gehosted, zusammengebaut und mittels mehrerer Artefakte veröffentlicht. Verschiedene Werkzeuge können hier unterschiedliche Anforderungen an das zugrundeliegende Betriebssystem haben. Allenfalls kommen mehrere Versionen des gleichen Tools oder derselben Komponente zum Einsatz, etwa für die Prüfung der Kompatibilität. Aus diesen Gründen wünscht man sich vielleicht eher eine Orchestrierung von entwicklerspezifischen Docker Images. Die verschiedenen Docker Images sollten nahtlos zusammenarbeiten und den Entwicklern im Arbeitsablauf nicht im Wege sehen. Hilfe bekommt man hier von Plug-ins wie cli-base, kubectl, oc, terraform, az und glcoud aus dem Fundus von Gradle Plugins oder von Testcontainers.

Beide Projekte verfolgen das gleiche Ziel in unterschiedlichen Situationen. Testcontainers vereinfacht die Verwendung von Docker Images für Unit Testing, während die Gradle-Plug-ins eine Verwendung von Docker Images innerhalb von Builds erleichtert. Zusammen haben sie das Potential, all die gewünschten Ziele von oben zu erreichen. Ein entsprechendes Beispiel wird im nächsten Abschnitt vorgestellt.

Deployment mit Helm in die Google Cloud

Die kubectl-, oc-, terraform-, az– und glcoud-Plug-ins sind so konzipiert, dass sie eine minimale Gradle-Integrationsschicht über deren nativen Pendants bieten. Es ist ausdrücklich nicht das Ziel, ein neues API in Gradle zu etablieren: ein solches würde eine kontinuierliche Wartung erfordern und von Entwicklern das „Kennenlernen“ des APIs verlangen. Stattdessen bieten die Plug-ins etwas ähnliches wie den Exec-Task von Gradle, der aber zusätzlich die Komplexität von Docker verbirgt. Die Plug-ins können wie folgt eingebunden werden:

plugins {
    id "com.github.rmee.kubectl" version "1.0.20190725142159"
    id "com.github.rmee.helm" version "1.0.20190725142159"
    id "com.github.rmee.gcloud" version "1.0.20190725142159"
}

Ein Zugriff auf GKE in Google Cloud erfolgt dann via

gcloud {
    keyFile = file("$projectDir/secrets/gcloud.key")
    region = 'my-region'
    project = 'my-project'
    gke {
        clusterName = 'my-cluster'
    }
    cli {
        imageName = 'google/cloud-sdk'
        version = '224.0.0'
    }
}
gcloudSetProject.dependsOn gcloudActivateServiceAccount
gcloudGetKubernetesCredentials.dependsOn gcloudSetProject

Der konfigurierte Schlüssel gcloud.key bietet technischen Zugriff auf den Cluster. Für gcloud wird auch explizit die verwendete Version definiert, während für Helm die Defaults zum Tragen kommen. Um nun Befehle mit helm und kubectl abzusetzen, verwendet man:

task deploy() {
    dependsOn gcloudGetKubernetesCredentials, helmPackage, tasks.jib
    doFirst {
        File yamlFile = file("build/helm/crnk-example.yaml")
        String imageId = file("build/jib-image.id").text
        helm.exec({
            commandLine = "helm template --name=crnk --set image.tag=${imageId} ${helmPackageCrnkExample.outputs.files.singleFile} --namespace=default"
            stdoutFile = yamlFile
        })
        kubectl.exec({
            commandLine = "kubectl apply -f=${yamlFile} -n=default"
        })
    }
}

Dieses Beispiel zeigt diverse wichtige Eigenschaften:

  • Der Gradle-Code deklariert die verwendeten Werkzeuge und deren Versionen. Eine lokale Installation ist nicht erforderlich.
  • Die abgesetzten Befehle stimmen 1:1 mit denen überein, die Entwickler auch in der Befehlszeile ausführen kann. Es muss nichts Neues erlernt werden. Es ist zudem einfach, außerhalb von Gradle manuell zu experimentieren, wenn man sich neuen Aufgaben und Problemen stellt.
  • Die Aktualisierung einer Image-Version ermöglicht den sofortigen Zugriff auf die neusten Funktionen. Die Plug-ins sind minimalistisch und weitgehend unabhängig von den verwendeten Image-Versionen.
  • Die Plug-ins schützen das Home-Verzeichnis des Benutzers! Ein neues Home-Verzeichnis wird im build-Verzeichnis angelegt. Es unterliegt dem gleichen Gradle-Lebenszyklus wie jede andere Projektdatei. gradlew clean entfernt auch das Home-Verzeichnis. Weitere Befehle werden dieses bei Bedarf wieder aufbauen.
  • Die exec-Methode ändert absolute Host-Pfade zu entsprechenden Docker-Pfaden.
  • Die Plug-ins sind nicht auf die Verwendung von Docker Images beschränkt. Sie können, sofern erwünscht, auch existierende Binärdateien der Host-Maschine zur Ausführung verwenden.
  • Alle Plug-ins verwenden ein gemeinsames cli-base-Projekt. Es bietet eine (frühe) Abstraktionsschicht, die die schnelle Implementierung weiterer solcher Integrationen erlaubt.
  • Die exec-Methode erlaubt auch den Zugriff auf Resultate. Beispielsweise wird das evaluierte Helm Template in die YAML-Datei geschrieben.
  • Entwicklern die Kontrolle zurückgeben

    Ähnlich wie bei jdk-bootstrap adaptieren die verschiedenen Gradle-Plug-ins aus dem vorherigen Abschnitt den wrapper-Task in Gradle. Für jedes angewandte Plug-in wird ein kleines Shell-Skript generiert. Diese bietet einen Ersatz für deren native Gegenstücke. So sieht das Skript beispielsweise für kubectl wie folgt aus:

    #!/usr/bin/env bash
    WORK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
    [ -x /bin/cygpath ] && WORK_DIR=$(/bin/cygpath -m "$WORK_DIR")
    
    ...
    
    exec docker run -i $TTY_PARAM $USER_PARAM --rm \
        "${HTTP_PROXY_PARAM[@]}" \
        ...
        -e HOME=/workdir/build/home \
        --workdir /workdir/build/home \
        -v $WORK_DIR:/workdir \
        google/cloud-sdk:224.0.0 kubectl "$@"
    

    Die Verwendung ist fast identisch zur ursprünglichen Binärdatei. Einschränkungen gibt es aktuell noch bei Pfad- und Port-Mappings. Ein Aufruf sieht wie folgt aus:

    $ ./kubectl get pods -n=kube-system
    
    NAME                                                         READY   STATUS    RESTARTS   AGE
    heapster-v1.6.0-beta.1-869f77bc95-n8h4c                      3/3     Running   0          35d
    kube-dns-76dbb796c5-2xhdw                                    4/4     Running   0          35d
    kube-dns-76dbb796c5-cr8wl                                    4/4     Running   0          35d
    kube-dns-autoscaler-67c97c87fb-nl9fv                         1/1     Running   0          35d
    kube-proxy-gke-sb4b-test-europe-west6-pool-1-0087b845-38fc   1/1     Running   0          35d
    kube-proxy-gke-sb4b-test-europe-west6-pool-1-0087b845-s21t   1/1     Running   0          35d
    

    Ausblick

    Das präsentierte Beispiel zeigt, wie man eine Applikation mit Helm und Kubernetes in die Google Cloud deployen kann. Damit ist das Setup vollständig in sich geschlossen und reproduzierbar. Alle Werkzeuge und deren Versionen sind durch das Projekt definiert. Die Pflege der lokalen Installation durch die Entwickler entfällt. Artefakte verlassen nicht die Grenzen des Projektverzeichnisses. Und Werkzeuge sowie Versionen lassen sich beliebig kombinieren, auch in den komplexesten Szenarien. Weitere Informationen finden Sie auf den Webseiten der jeweiligen Projekte.

Geschrieben von
Remo Meier

Remo Meier obtained a PhD in computer science from the ETH in Zurich. He works as a senior software engineer at Adnovum Informatik AG with a background in enterprise Java.  His main technical interests are software architecture, security and standardization resp. automation in Enterprise software landscapes.

Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: