Blog
/
Beschleunigung der CI mit Docker

Beschleunigung der CI mit Docker

4. Januar 2017

Wir vom Driftrock-Entwicklungsteam sind bestrebt, unseren Kunden so schnell und so oft wie möglich neue Funktionen und Fehlerbehebungen zur Verfügung zu stellen. Ein wichtiger Faktor, der uns dies ermöglicht, ist die kontinuierliche Integration.

Seit Kurzem haben wir mit ziemlich langen CI-Testzeiten zu kämpfen – etwa 10 Minuten. In unserem Team führen wir Pull-Request-Prüfungen durch, und das bedeutet, dass wir jedes Mal, wenn wir während der PR-Diskussion eine kleine Aktualisierung vornahmen, 10 Minuten warten mussten, um die Änderungen zusammenführen zu können oder den Prüfer zu bitten, die Änderungen zu akzeptieren und uns so effektiv die Möglichkeit zu geben, andere Arbeiten zu erledigen. Wenn wir das auf einen Arbeitstag mit 10 Pull-Requests hochrechnen, macht das 20 % der Arbeitszeit aus (1:40 Stunde bei einem 8-Stunden-Arbeitstag).

Das Problem

Ein Blick auf das Build-Protokoll zeigt, dass die Installation der Gems und Node-Pakete am meisten Zeit in Anspruch nimmt:

original-test-times.png

Bei der Anwendung handelt es sich in diesem Fall um eine gemischte Ruby-on-Rails- und JavaScript-Anwendung (Node), für die sowohl Gems-Bundles als auch NPM-Pakete installiert werden müssen. Dies geschieht jedes Mal, obwohl sich die Pakete in den meisten Fällen nicht ändern.

Außerdem benötigt Snap etwas Zeit, um die Build-Umgebung zu initialisieren – daran können wir nichts ändern. Hinzu kommt die eigentliche Testdauer, die vielleicht verkürzt werden könnte, doch derzeit ist die Zeit für die Paketierung das größere Problem.

Die Dockerfile-Datei

Falls Sie mit dem Docker-Ökosystem noch nicht vertraut sind, möchte ich kurz vom Hauptthema abschweifen und darauf eingehen. Bei Docker beginnt alles mit einem Docker-Image. Ein Docker-Image ist eine Momentaufnahme eines Betriebssystems – einschließlich seiner gesamten Dateisystemstruktur, der CLI-Tools, der Shell und der darauf installierten Programme samt aller Abhängigkeiten –, jedoch ohne den Kernel.

Das ist ein unglaublich leistungsstarkes Konzept, mit dem man mit einem einzigen Befehl alles von der Ruby-Shell bis zum PostgreSQL-Server herunterladen und ausführen kann. Man muss sich keine Gedanken mehr über die Installation aller Bibliotheken, die Behebung von Konflikten und all das machen, was wir gewohnt waren, wenn wir Software direkt auf unseren Betriebssystemen ausgeführt haben.

Um ein Docker-Image zu erstellen – oder zu bauen –, verwendet Docker Dateien, die als „Dockerfile“ bezeichnet werden. Diese Dateien enthalten Schritte – Befehle –, die innerhalb des Images ausgeführt werden, um ein neues Image für den nächsten Schritt zu erstellen und schließlich das endgültige Image zu erzeugen, dem Sie einen Namen geben und das Sie zur Ausführung Ihrer Anwendungen wiederverwenden können.

FROM ruby:latest

# „bundle install“ nur erneut ausführen, wenn sich die Gemfile geändert hat
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20

# Rest der Quelle kopieren
COPY . ./Das obige Beispiel erstellt ein Image, das eine Ruby-Anwendung mit installierten Gems enthält, basierend auf der Gemfile.

Das Schöne an Dockerfiles ist, dass bei jedem Schritt ein Zwischen-Image erstellt wird, das Docker automatisch speichert und zwischenspeichert. Da jeder Schritt durch seinen Befehl deterministisch definiert ist, muss Docker ihn nicht erneut ausführen, solange sich der Schritt nicht ändert.

Bei RUN-Schritten wird davon ausgegangen, dass die Ausgabe und alle damit verbundenen Nebenwirkungen identisch sind, solange der Befehlstext unverändert bleibt. Interessanterweise liest das Programm bei COPY-Schritten den Inhalt der angegebenen Dateien, berechnet deren Hash-Fingerabdruck und verwendet das zwischengespeicherte Abbild, solange sich der Inhalt dieser Dateien nicht ändert.

Im obigen Beispiel wird dieses Verhalten genutzt, um den Schritt „bundle install“ nur dann auszuführen, wenn sich die Dateien „Gemfile“ und „Gemfile.lock“ tatsächlich ändern. Wenn sich nichts ändert, können wir die zwischengespeicherte Version verwenden und so den Build beschleunigen. Da der Hash des Dateiinhalts verwendet wird – und nicht das Änderungsdatum der Datei – werden keine Annahmen über den Inhalt usw. getroffen. Der Cache wird daher bei Bedarf stets zuverlässig ungültig gemacht, wodurch die Sorge um die Verwendung eines ungültigen Cache-Zustands entfällt, die wir oft haben, wenn wir von Caching hören.

Die Lösung

Snap CI hat kürzlich Beta-Unterstützung für Docker in seinem Build-Stack eingeführt. Da wir die Vorteile des Docker-Cachings von Images kennen, haben wir beschlossen, es auszuprobieren.

Der Plan sieht folgendermaßen aus:

  • Erstelle ein Docker-Image, das Gems, Node-Pakete und die aktuellen Quelldateien enthält, indem du in der Dockerfile-Datei zunächst nur die Dateien „Gemfile“ und „package.json“ kopierst, bevor du „bundle install && npm install“ ausführst.
  • Nutzen Sie das Caching, damit bei jedem Build nur neue Quelldateien hinzugefügt werden, das Bundle jedoch nicht erneut ausgeführt wird, wenn sich die Abhängigkeiten nicht ändern
  • Führen Sie die Tests mit dem erstellten Image aus

Also haben wir dieses Dockerfile erstellt und verwenden es, um das Image im Build-Skript zu erstellen:

FROM ruby:2.3.1

COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install

COPY . /usr/src/app

Als wir dies jedoch ausprobierten, stellten wir fest, dass der Befehl „docker build“ auf Snap leider nicht den Cache nutzt, wie dies auf dem lokalen Rechner der Fall ist. Er führt einfach immer wieder alle Befehle von Neuem aus. Möglicherweise liegt das daran, dass der Build jedes Mal auf einem anderen Knoten ausgeführt wird, oder daran, dass Snap die Docker-Dateien vor jedem Build-Lauf einfach löscht.

Manuelles Hashing

Wenn wir die in Docker integrierte Caching-Funktion nicht nutzen können, spricht nichts dagegen, diese nachzubilden und selbst zu implementieren. Deshalb haben wir uns dazu entschlossen und den Plan wie folgt geändert:

  • Erstelle das Basis-Image, das die Gems- und Node-Pakete enthält (ohne Quelldateien), nur einmal
  • Speichere dieses Bild im Bild-Repository und versehe es mit einem Tag, der den Hash der Dateien „Gemfile“ und „package.json“ enthält
  • Versuchen Sie, das Image in den nächsten Builds herunterzuladen
  • Neues Image erstellen und die aktuellen Build-Quelldateien hinzufügen
  • Führe die Tests mit diesem neuen Image durch

Das ist das Skript, bei dem wir schließlich gelandet sind:

# Hash der Dateien, die sich auf Abhängigkeiten auswirken können
PACKAGE_SHA=$( cat Dockerfile-package-base Gemfile Gemfile.lock package.json | sha256sum | cut -d" " -f1 )

BASE_IMAGE=repository.example/driftrock/app:base-$PACKAGE_SHA

# Image mit Abhängigkeiten herunterladen, falls vorhanden; andernfalls erstellen und ins Repo pushen
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \
# Für das nächste Mal ins Repository pushen
&& docker push $BASE_IMAGE
)

# Lokal mit einem konstanten Namen taggen, damit es in Dockerfile-test verwendet werden kann
docker tag -f $BASE_IMAGE repository.example/driftrock/app:base-current

# Test-Image aus app:base-current erstellen – es fügt die aktuellen Quelldateien zum Basis-Image hinzu
docker build -f Dockerfile-test -t app-test .

# Tests innerhalb des erstellten Images ausführen
docker run app-test ./scripts/run-tests.sh

Wenn mit „Dockerfile-package-base“ nur Abhängigkeiten installiert werden:

FROM ruby:2.3.1

COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install

Und das Dockerfile-test, das lediglich die aktuellen Quelldateien auf das Basis-Image aufsetzt:

FROM repository.example/driftrock/app:base-current

COPY . /usr/src/app

Inspiriert davon, wie Dockerfile COPY-Befehle anhand des Hashwerts des Dateiinhalts zwischenspeichert, berechnen wir diesen Hashwert manuell. Anschließend versuchen wir, ein Image mit diesem Hashwert herunterzuladen. Falls es nicht existiert – d. h. falls der Inhalt der entsprechenden Dateien neu ist –, erstellen wir das Image und speichern es in unserem privaten Docker-Image-Repository unter einem Namen, der den Hashwert der Dateien enthält.

Anschließend erstellen wir ein zusätzliches App-Test-Image, indem wir die aktuellen Quelldateien zu dem Basis-Image hinzufügen, das wir gerade heruntergeladen (oder erstellt) haben. Dieses bleibt nur lokal für den jeweiligen Build erhalten – da wir noch keine Docker-Images bereitstellen. Schließlich führen wir die Tests innerhalb dieses Images durch.

Hier sind die Ergebnisse:

after-test-times.png

Im Abschnitt „Gems und Node-Pakete“ haben wir die eigentliche Installationszeit gegen die Zeit eingetauscht, die für das Herunterladen des gespeicherten Images aus dem Repository benötigt wird.

Durch die Umstellung unserer Snap-CI-Builds für diese Anwendung auf Docker und die Nutzung eines Image-Repositorys zur Speicherung von Images mit Bundles konnten wir die Build-Zeit von ca. 9 Minuten auf ca. 6 Minuten verkürzen.

Nächste Schritte

Das Herunterladen des Images dauert noch eine Weile. Diese Dauer hängt hauptsächlich von der Größe des Images ab. Wenn wir die Bildgröße durch das Bereinigen der Paketanzahl oder die Optimierung des Docker-Images durch das Entfernen unnötiger Binärdateien reduzieren können, wird der Vorgang weiter beschleunigt.

Wir haben noch Spielraum, die Tests selbst zu optimieren und die unabhängigen Testsuiten parallel auszuführen, wodurch sich mindestens weitere 1:30 Minuten einsparen lassen.

Fazit

Auch wenn wir Docker nicht nutzen, um unsere Anwendungen tatsächlich in der Produktion auszuführen, hat Docker unsere CI erheblich verbessert.

Durch die Verlagerung des Großteils des Build-Prozesses in eine Docker-Umgebung haben wir auch die Abhängigkeit von der Build-Umgebung sowie mögliche Schwachstellen durch fehlerhafte Abhängigkeiten usw. beseitigt. Dadurch sind wir weniger von einem bestimmten CI/CD-Tool abhängig, was uns einen schnelleren und kostengünstigeren Wechsel zu besseren Anbietern oder einen Ausweichanbieter im Falle eines Ausfalls ermöglicht.

Wir haben gezeigt, dass der Einsatz von Docker in einer CI-Umgebung sowohl aus strategischer Sicht als auch im Hinblick auf die Effizienz von Vorteil ist. Wir werden weitere Teile der Build-Pipeline auf Docker umstellen, um den Zeitaufwand zu reduzieren und die Abhängigkeit von den Besonderheiten des von uns verwendeten Build-Tools – in unserem Fall Snap CI – zu verringern.

Insgesamt stellt diese Änderung eine erhebliche Verbesserung unseres CI-Ansatzes dar, und wir werden sie auf die übrigen Driftrock-Apps ausweiten.

Es ist erwähnenswert, dass unser Problem in der langwierigen Installation von Abhängigkeiten lag. Falls Ihre Anwendung weniger Abhängigkeiten hat oder deren Installation im Vergleich zur übrigen Build-Zeit nur wenig Zeit in Anspruch nimmt, ist dieser Ansatz für Sie möglicherweise nicht von Vorteil.