Beschleunigung von CI mit Docker
Das Entwicklungsteam von Driftrock ist bestrebt, neue Funktionen und Fehlerbehebungen so schnell und häufig wie möglich zu entwickeln und an unsere Kunden weiterzugeben. Eine wichtige Sache, die uns das ermöglicht, ist die kontinuierliche Integration.
Vor kurzem haben wir angefangen, ziemlich lange CI-Testzeiten zu erleben - etwa 10 Minuten. Wir führen in unserem Team Pull Request-Reviews durch und das bedeutet, dass wir jedes Mal, wenn wir während der PR-Diskussion ein kleines Update durchgeführt haben, 10 Minuten warten mussten, um die Änderungen zusammenzuführen oder den Reviewer zu bitten, die Änderungen zu akzeptieren und uns effektiv für andere Arbeiten freizugeben. Wenn wir das in die Perspektive eines Arbeitstages mit 10 Pull Requests setzen, macht das 20% der Arbeitszeit aus (1:40 Stunde bei einem 8-Stunden-Arbeitstag).
Das Problem
Wenn wir uns das Protokoll der Build-Ausgabe ansehen, können wir feststellen, dass die Installation der Gems- und Node-Pakete am längsten dauert:
In diesem Fall handelt es sich um eine gemischte Ruby on Rails- und Javascript (Node)-Anwendung, die die Installation von Gems-Bundles und NPM-Paketen erfordert. Dies wird jedes Mal ausgeführt, obwohl sich die Pakete meistens nicht ändern.
Snap braucht auch einige Zeit, um die Build-Umgebung zu initialisieren - daran können wir nichts ändern. Und dann ist da noch die eigentliche Testzeit, die vielleicht verbessert werden kann, aber die Paketierungszeit ist jetzt das größere Problem.
Die Dockerdatei
Wenn Sie mit dem Docker-Ökosystem nicht vertraut sind, lassen Sie mich ein wenig von der Haupthandlung abschweifen. In Docker beginnen Sie alles, was Sie tun, mit einem Docker-Image. Ein Docker-Image ist ein Schnappschuss eines Betriebssystems - mit seiner gesamten Dateisystemstruktur, den CLI-Tools, der Shell und den darin installierten Programmen sowie allen Abhängigkeiten - ohne den Kernel.
Dies ist ein unglaublich leistungsfähiges Konzept, mit dem Sie alles von der Ruby-Shell bis zum Postgresql-Server mit einem einzigen Befehl herunterladen und ausführen können. Sie müssen sich nicht mehr um die Installation aller Bibliotheken, das Lösen von Konflikten usw. kümmern, wie wir es gewohnt sind, wenn wir Software direkt auf unseren Betriebssystemen ausführen.
Um ein Docker-Image zu erstellen - oder zu bauen -, verwendet Docker Dateien namens Dockerfile. Diese Dateien enthalten Schritte - Befehle -, die innerhalb des Abbilds ausgeführt werden, um ein neues Abbild für den nächsten Schritt und schließlich das endgültige Abbild zu erstellen, dem Sie einen Namen geben und das Sie zur Ausführung Ihrer Anwendungen wiederverwenden können.
FROM ruby:latest
# Bundle-Installation nur wiederholen, wenn Gemfile geändert wird
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20
# Rest des Quellcodes kopieren
COPY . ./Das obige Beispiel erzeugt ein Image, das eine Ruby-Anwendung mit installiertem Gems-Bundle auf Basis des Gemfile enthält.
Das Schöne an Dockerfiles ist, dass jeder Schritt ein Zwischenabbild erzeugt, das Docker automatisch speichert und zwischenspeichert. Da jeder Schritt deterministisch durch seinen Befehl definiert ist, muss Docker ihn nicht erneut ausführen, solange sich der Schritt nicht ändert.
Bei den RUN-Schritten wird davon ausgegangen, dass, solange der Befehlstext gleich ist, auch die Ausgabe und alle Nebeneffekte gleich sind. Noch interessanter ist, dass es für die COPY-Schritte den Inhalt der angegebenen Dateien liest, einen Hash-Fingerabdruck davon berechnet und das zwischengespeicherte Bild verwendet, solange sich der Inhalt dieser Dateien nicht ändert.
Im obigen Beispiel wird dieses Verhalten ausgenutzt, um den Bundle-Installationsschritt nur dann auszuführen, wenn sich die Gemfile- und Gemfile.lock-Datei tatsächlich ändert. Wenn sich die Datei nicht ändert, können wir die gecachte Version verwenden und so den Build beschleunigen. Da es den Hash des Dateiinhalts verwendet - nicht die Änderungszeit der Datei, es macht keine Annahmen über den Inhalt, etc. - wird der Cache immer dann zuverlässig ungültig gemacht, wenn er benötigt wird, so dass man sich keine Sorgen mehr über die Verwendung eines ungültigen Cache-Status machen muss, den man oft befürchtet, wenn man von Caching hört.
Die Lösung
Snap CI hat kürzlich die Beta-Unterstützung für Docker in ihrem Build-Stack eingeführt. Da wir um die Vorteile des Docker-Caching von Images wussten, beschlossen wir, es auszuprobieren.
Der Plan ist:
- Erstellen eines Docker-Images mit Gems- und Node-Paketen und den aktuellen Build-Quelldateien unter Verwendung von Dockerfile, wobei nur die Gemfile und package.json kopiert werden, bevor bundle install && npm install ausgeführt werden
- Nutzung der Zwischenspeicherung, so dass bei jedem Build nur neue Quelldateien hinzugefügt werden, aber das Paket nicht erneut ausgeführt wird, wenn sich die Abhängigkeiten nicht ändern
- Führen Sie die Tests mit dem erstellten Image durch
Wir haben also dieses Dockerfile erstellt und verwenden es, um das Image innerhalb des Build-Skripts 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
Dabei haben wir jedoch festgestellt, dass der Docker-Build-Befehl auf Snap leider nicht den Cache verwendet, wie er es auf dem lokalen Rechner tut. Er führt einfach alle Befehle immer wieder aus. Vielleicht, weil der Build jedes Mal auf einem anderen Knoten ausgeführt wird oder Snap einfach die Docker-Dateien vor jedem Build-Lauf bereinigt.
Manuelles Hashing
Wenn wir den Caching-Build im Docker-Befehl nicht verwenden können, hindert uns nichts daran, ihn zu imitieren und selbst zu implementieren. Also haben wir beschlossen, dies zu tun und den Plan zu ändern:
- Das Basis-Image mit den Gems- und Node-Paketen (ohne Build-Quelldateien) nur einmal erstellen
- Speichern Sie dieses Bild in einem Bild-Repository, das mit einem Hash der Gemfile- und package.json-Dateien versehen ist.
- Versuchen Sie, das Bild in nachfolgenden Builds herunterzuladen
- Neues Image erstellen und die aktuellen Build-Quelldateien hinzufügen
- Führen Sie die Tests mit diesem neuen Image durch
Das ist das Skript, das wir am Ende bekommen haben:
# Hash von Dateien, die Abhängigkeiten beeinflussen 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
# download image with dependencies if exist, if not build it and push to repo
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \
# push to repository for next time
&& docker push $BASE_IMAGE
)
# tag local to constant name so it can be used in Dockerfile-test
docker tag -f $BASE_IMAGE repository.example/driftrock/app:base-current
# build test image from the app:base-current - it adds the current source files to the base image
docker build -f Dockerfile-test -t app-test .
# run tests within created image
docker run app-test ./scripts/run-tests.sh
Mit Dockerfile-package-base werden nur Abhängigkeiten installiert:
FROM ruby:2.3.1
COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install
Und der Dockerfile-Test fügt nur die aktuellen Quelldateien zum Basisimage hinzu:
FROM repository.example/driftrock/app:base-current
COPY . /usr/src/app
Inspiriert von der Art und Weise, wie Dockerfile COPY-Befehle basierend auf dem Hash des Dateiinhalts zwischenspeichert, berechnen wir denselben Hash manuell. Dann versuchen wir, das mit diesem Hash benannte Image herunterzuladen. Wenn es nicht existiert - d.h. wenn der Inhalt der jeweiligen Dateien neu ist - erstellen wir das Image und speichern es in unserem privaten Docker-Images-Repository unter einem Namen, der den Hash der Dateien enthält.
Wir erstellen dann ein zusätzliches Image app-test, indem wir die aktuellen Quelldateien zum Basisimage hinzufügen, das wir gerade heruntergeladen (oder erstellt) haben. Dies bleibt nur für den jeweiligen Build lokal - da wir noch keine Docker-Images bereitstellen. Schließlich führen wir die Tests innerhalb dieses Images aus.
Hier sind die Ergebnisse:
Im Teil "Gems und Node-Pakete" haben wir die eigentliche Installationszeit gegen die Zeit für das Herunterladen des gespeicherten Images aus dem Repository getauscht.
Durch die Umstellung unserer Snap CI-Builds für diese Anwendung auf Docker und die Nutzung des Image-Repository zur Speicherung von Images mit Bundle konnten wir die Build-Zeit von ~9 Minuten auf ~6 Minuten reduzieren.
Nächste Schritte
Das Herunterladen des Bildes nimmt noch einige Zeit in Anspruch. Diese Zeit hängt hauptsächlich von der Größe des Abbilds ab. Wenn wir in der Lage sind, die Größe des Images zu reduzieren, indem wir die Anzahl der Pakete bereinigen oder das Docker-Image durch Entfernen unnötiger Binärdateien optimieren, wird dies die Zeit weiter verkürzen.
Wir haben noch Spielraum, um die Tests selbst zu optimieren und die unabhängigen Testverfahren parallel laufen zu lassen, was mindestens weitere 1:30 Minuten sparen kann.
Schlussfolgerung
Auch wenn wir Docker nicht verwenden, um unsere Anwendungen in der Produktion laufen zu lassen, hat Docker unsere KI erheblich verbessert.
Durch die Verlagerung des größten Teils des Build-Laufs in die Docker-Umgebung haben wir auch die Abhängigkeit von der Build-Umgebung und mögliche Schwachstellen durch unterbrochene Abhängigkeiten usw. beseitigt. Dadurch sind wir weniger abhängig von einem bestimmten CI/CD-Tool, was uns einen schnelleren und weniger kostspieligen Wechsel zu einem besseren Anbieter oder einen Wechsel im Falle eines Ausfalls ermöglicht.
Wir haben bewiesen, dass die Verwendung von Docker in einer CI-Umgebung sowohl aus strategischer Sicht als auch in Bezug auf die Effizienz von Vorteil ist. Wir werden weitere Teile der Build-Pipeline auf Docker verlagern, um entweder den Zeitaufwand oder 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 auch auf die übrigen Driftrock-Anwendungen ausweiten.
Es ist erwähnenswert, dass unser Problem die lange Installation von Abhängigkeiten war. Wenn Ihre Anwendung weniger Abhängigkeiten hat oder ihre Installation im Vergleich zum Rest der Erstellungszeit nur wenig Zeit in Anspruch nimmt, ist dieser Ansatz für Sie vielleicht nicht von Vorteil.