Das Bauen von Container-Images ist nach wie vor ein zeitaufwendiges Unterfangen, mit dem sich niemand mehr rumschlagen möchte, wenn erstmal alles reibungslos funktioniert. Schaue ich als Dienstleister auf unterschiedlichste Projekte, sehe ich immer wieder, dass Kunden nach wie vor dieselben Build-Prozesse verwenden, wie sie seit der Multi-Stage „Revolution“ etabliert sind.
Die Gründe dafür sind vielfältig: „Keine Zeit“, fehlende Ressourcen, Betriebsblindheit oder die persönliche Einstellung „Funktioniert doch auch so“.
Daher möchte ich mit diesem Artikel zeigen, wie Images heute gebaut werden können. Der Fokus dabei soll auf Geschwindigkeit der Builds liegen, unabhängig vom Dockerfile.
Eine Einführung in Buildkit
docker build ist tot! Lang lebe docker buildx!
Andreas R., erfahrener Baumeister
Moment, was denn nun? Buildkit oder buildx?
Buildkit ist die eigentliche Engine, mit der wir den Container bauen. Damit löste Docker ab Version 23 den alten internen Builder ab. Aus Kompatibilitätsgründen ist er aber nach wie vor vorhanden.
Buildx hingegen ist ein Plugin für Docker, um Buildkit verwenden zu können. Damit ersetzen wir den üblichen Aufruf docker build
mit docker buildx build
. Der Einfachheit halber bieten viele Distributionen einen Patch bzw. Alias an, um wie gewohnt docker build
nutzen zu können.
Was kann Buildkit?
Buildkit kann die Build-Zeit durch Parallelisierung verkürzen. Vorausgesetzt, man hat ein entsprechendes Multi-Stage Setup.
Apropos: neben Multi-Stage ermöglicht Buildkit auch Multi-Platform Builds. Also die Möglichkeit, z. B. x86 und Arm zu bauen. Natürlich auch parallel.
Buildkit verfügt aber auch über erweiterte Caching-Möglichkeiten für den Bau von Images. So kann nicht nur der lokale Cache effizienter verwendet werden, sondern auch remote können Daten für den Bau einbezogen werden.
„Oh, damit kann man also Dinge sicher schneller machen?“
Ja, generell hat – je nach Setup – auch der Entwickler was davon. Dazu jedoch später mehr.
Time for craftership!
Es ist an der Zeit, dass wir uns ein wenig die Hände schmutzig machen. Also fangen wir mit einem einfachen Beispiel an …
Level 1: Wir parallelisieren den Bau
Zu Beginn gibt’s ein wenig Quellcode. So sieht unser Dockerfile aus:
FROM alpine AS p1 RUN echo p1 is building, time is date > /p1_time.txt && sleep 5 FROM alpine AS p2 RUN echo p2 is building, time is date > /p2_time.txt && sleep 5 FROM alpine AS p3 RUN echo p3 is building, time is date > /p3_time.txt && sleep 5 FROM p3 AS final-stage COPY --from=p1 /p1_time.txt / COPY --from=p2 /p2_time.txt / ENTRYPOINT ["cat", "/p1_time.txt"]
Der Bau der einzelnen Stages ist ganz offensichtlich nicht voneinander abhängig. Lediglich die „Final-Stage“ benötigt die vorherigen Stages.
Testen wir doch auf die Schnelle, ob bei der Installation Buildkit aktiv ist. Wenn nicht, sollte die Ausgabe so aussehen wie in Abbildung 1. Mit dem Ergebnis, dass die Builds nicht zeitgleich gelaufen sind (Abb. 2).
Zeit für den Gegentest mit aktiviertem Buildkit: DOCKER_BUILDKIT=1 docker build ...
Und was sagt die Laufzeit?
Legacy Build: 18 Sekunden …
vs. Buildkit: 12 Sekunden
Ca. 6 Sekunden Unterschied bei einem 12MB-Image; na wenn sich das nicht schon lohnt? Den Benchmark habe ich 8 Mal mit der Option --no-Cache
durchlaufen lassen, um das Ergebnis sicherstellen zu können.
Level 2: Remote Cache for the Win!
Ja, richtig gehört, wir können auch einen Cache aus der Entfernung nutzen. Genau genommen versucht der Builder nur die Layer aus dem Remote Image zu holen, die in unserem Lokalen unverändert sind. Es wird also nicht erst das gesamte Image heruntergeladen, das spart Zeit und Bandbreite.
Remote Cache? 6 Sekunden.
Zugegeben, hier ist ein wenig Pfusch am Bau. Ich habe in diesem Beispiel kein bestehendes Image, sondern ich muss sowieso das gesamte Remote Image laden. Dennoch ist es über diesen Weg schneller, als wenn ich ohne Remote Cache arbeite.
Der Parameter --Cache-to type=inline
sorgt dafür, dass die Layer direkt im Ouput Image landen und nicht zusätzlich im Builder Cache. Außerdem werden nur die neu exportierten Zeilen in den Cache geschrieben. Dazu später mehr.
Ein Vorteil, der bei Remote Caches sofort ins Auge fällt, ist natürlich der, dass der Cache von überall verwendet werden kann. Egal ob ich ein Image für lokale Entwicklung bauen will oder in der CI.
Nun zum Nachteil dieser Cache-Variante: er kann nicht gut mit komplexen Multi-Stage Builds skalieren, da diese Cache-Option nicht zwischen output artifacts und Cache output
differenzieren kann.
Zusammengefasst
- Der Cache ist für jeden im Team verfügbar
- Type
inline
verhält sich in erster Linie wie der Legacy Builder Cache und ergibt nur bei Single-Stage-Containern Sinn - Die Option
--no-Cache
stellt sicher, dass kein lokaler Cache verwendet wird - Wenn der Remote Cache verwendet wird, muss auch dieser aktualisiert werden:
--Cache-from
und--Cache-to
. Mehr dazu im nächsten Abschnitt. - Zu beachten: Der Remote Cache ist natürlich immer von der Internet-Anbindung abhängig!
Level 3: Komplexe Builds cachen
Komplex bedeutet in diesem Fall sowohl Multi-Stage als auch Multi-Platform. Hier eignet sich der Type registry
für die Docker Registry, es werden aber auch Caches von diversen Cloud- und Git-Hostern unterstützt.
Wichtig ist es, den Unterschied zu inline
zu verstehen. In beiden Caching-Varianten kann der Remote Cache verwendet und gleichzeitig mit neuen Artifacts geupdated werden. Wie im ersten Beispiel jedoch schon angedeutet, werden nur die Layer exportiert, die auch am Ende im finalen Image sind.
Der Type registry
hingegen exportiert alle Layer und Metainformationen in den Cache; also auch die, die nicht in das endgültige Image kommen. Somit kann der Builder dann auch bei Multistage die Daten besser unterscheiden und damit auch Layer zur Verfügung stellen, die evtl. in einem ähnlichen Image benötigt werden.
Kurzer Service-Hinweis: mode=[min|max]
min
ist der Default und der Weg, wie der Builder Cache schon immer funktioniert hat. max
ist der neue Mode, der alle Image-Informationen exportiert und damit die bereits erwähnten Vorteile mit sich bringt.
Ein Häppchen für die Massen!
Ein Beispiel, dass auch mit dem Type inline
funktionieren würde:
docker buildx build -t foobar:latest \ --Cache-from=type=registry,ref=foobar-build-Cache \ --Cache-to=type=registry,ref=foobar-build-Cache,mode=max \ --push
--Cache-from=type=registry,ref=foobar-build-Cache
bedeutet: Nimm die Registry mit dem Image „foobar-build-Cache“ als Remote Cache für meinen aktuellen Build
--Cache-to=type=registry,ref=foobar-build-Cache,mode=max
sagt: Schreibe das neue Cache Artifact in das Image „foobar-build-Cache“ der Registry.
Wenn ich dann in meiner Pipeline den eigentlichen „foobar“ Build baue, zieht Buildkit von Remote nur die Layer, die ich benötige. Das hält das Image schön klein und bringt es schnell in die Welt. Damit im nächsten Build aber wieder ein Abgleich gemacht werden kann, muss das aktuell gebaute Image wieder zur Verfügung gestellt werden, siehe --Cache-to
.
Zusammengefasst
- Für Multi-Stage ist
mode=max
mit Typeregistry
ideal - Der Mode
min
exportiert nur die Layer aus dem finalen Stage (default),max
exportiert alle Layer aus allen Stages. - Zu beachten:
- Im Bestfall müssen in dem beschriebenen Szenario nur Metadaten/Referenzen hochgeladen werden, um das Cache Image zu aktualisieren
- Funktioniert u. U. in der Pipeline nicht optimal und schafft neue Herausforderungen
- Was passieren kann und wie man es behebt, verrate ich in einem meiner nächsten Artikel
Fazit
Docker entwickelt sich ständig weiter und bietet immer wieder neue Funktionen, um schneller und bessere Images zu produzieren.
Am Ende muss natürlich jedes Team selbst abwägen, ob der Aufwand die Mühe wert ist. Je größer die eigene Systemlandschaft ist, desto wahrscheinlicher ist es, dass es Sinn ergibt.
Let’s build it fast!
Schreibe einen Kommentar