Reproduzierbare Builds - Was steckt dahinter und wieso ist das auch für mich gut?
Reproduzierbare oder deterministische Builds sind nichts Neues - oder etwas das sich ein hippes Start-Up aus dem Hut gezaubert, ein angesagter Technical Writer oder Architekt ins Feld bringt. Dieser Beitrag soll Licht ins Dunkel bringen und Gewohnheiten in Frage stellen.
Was ist deterministisch?
Wikipedia definiert Determinismus wie folgt:
Der Determinismus [...] ist die Auffassung, dass alle – insbesondere auch zukünftige – Ereignisse durch Vorbedingungen eindeutig festgelegt sind [...]
Auf Build übertragen bedeuted das, der selbe Input liefert das gleiche Ergebnis. Das macht diese reproduzierbar, somit kann ich auch die Gegenprobe durchführen. Wenn ich ein Artefakt habe und wissen will ob es wirklich aus den aktuellen Sourcen gemacht wurde, baue ich neu und vergleiche einfach die Prüfsummen der Artefakte.
Wieso sollte mich das kümmern?
In der klassischen Entwicklung released man zu einem spezfisichen Zeitpunkt mit einer definierten Version. Also zum 10.01 kommt die Version 102.3.0 raus. Dazu wird dann z. B. im Falle von Git ein Tag erstellt, zu diesem läuft eine Pipeline los.
Also warum sollte es mich interessieren ob mein Build das gleiche Ergebnis liefert? Nun für diesen Fall wäre es natürlich nicht erheblich, mag man meinen. Aber was passiert wenn die Pipeline neu ausgeführt wird? - Das Artefakt sollte sich ja nicht mehr verändern, schließlich wurde vielleicht schon eine Version mit definierter Prüfsumme verteilt und Systeme verweigern eine andere Version.
Das wird vor allem dann relevant, wenn die Pipeline neu ausgeführt wird, egal ob absichtlich oder nicht. Trotzdem sollte sich nichts am Release ändern.
Besonders wichtig wird das Ganze aber erst wenn wir Continuos Integration in seiner Reinform leben. Das heißt, dass jeder Commit auch wirklich in der Umgebung landet. Natürlich wollen wir aber das Deployment nur ausführen wenn es auch Änderungen gab. Eine Änderung an der README, die den Code nicht ändert soll ja nicht unsere Staging-Umgebung unnötig mit Deployments beschäftigen.
Auch will man nachträglich wissen, hat sich durch meine Änderung tatsächlich etwas geändert?
An diesem Punkt kommt man nicht mehr daran vorbei, sicherzustellen dass Builds mit dem selben Input exakt das selbe Ergebnis liefern.
Wie setze ich das um?
Erstaunlicherweise ist sehr vieles im Build-Kontext noch nicht reproduzierbar. Daher stelle ich nachfolgend meine persönliche Erfahrungen und allgemeine Beispiele an.
Go
Go erzeugt grundsätzlich reproduzierbare Builds. Natürlich solange man nicht zur Compile-Zeit andere Parameter wie etwa den Zeitpunkt mitliefert.
Ein weiterer Pitfall ist die Dependencies fest zu setzen via go modules oder einem Package Manager. Ansonsten läuft man schnell Gefahr andere Versionen bei jedem Build zu verwenden!
Container Images aka Docker
Docker ist ein weitverbreitetes Tool um OCI-Images zu erstellen. Wichtig ist hierbei: Container != Docker!
Um ein Image zu bauen lohnt es sich in vielen Fällen nicht Docker selbst zu nutzen. Hier gibt es z. B. Cloud Native Buildpacks, die reproduzierbare Images liefern. Hierbei werden Timestamps fixiert und Layer so gebaut das sie immer den gleichen Output bei selbem Input liefern.
Grundsätzlich ist jedes Image das RUN-Statements benutzt nicht mehr deterministisch, sofern man nicht Checksumen einbindet oder Build-Tools die deterministisch sind (wie Bazel) nutzt. Daher wird man in solchen Anwendungsfällen nie einen deterministischen Build erhalten.
(Zip-)Archive
Archive sind gerade in der Serverless-Welt sehr beliebt. Pack deinen Code in ein ZIP, lade es zum FaaS-Provider und los gehts!
Also einfach alles zippen - gleiche File-Liste - gleicher Output? Falsch!
Gerade zip und tar laden nicht nur den Inhalt der Files, sondern auch Metadaten wie Timestamps, Berechtigungen, Kommentare oder sogar andere spezifische Attribute. Das lässt sich mit diversen CommandLine-Flags bis zu einem gewissen Grad beeinflussen. So bietet das zip-Tool Flags um Metadaten einfach zu ignorieren. Eine gängige Lösung ist auch vorher alle Dateien einfach auf einen festen Timestamp zu setzen.
Doch dass ist noch nicht alles! Die Reihenfolge der Dateien darf sich auch zwischen den Builds nicht ändern. Gerade anmutig erscheinende Commands führen so zu Problemen, die man nicht immer kommen sieht:
zip build.zip dist/*
Ein Beispiel das man oft gesehen hat, doch das nicht so einfach ist wie es scheint. Denn die Expression dist/* wird nicht wie oft angenommen von zip interpretiert, sondern von der Shell. Es kann hier das Ergebnis also nicht nur von Shell zu Shell variieren, sondern in der selben Session andere Ergebnisse liefern.
Wenn man das Ganze in Anführungszeichen packt wird das Pattern von zip interpretiert. Allerdings ist die Reihenfolge hier je nach Implementierung sogar bei subsequenten Ausführungen unterschiedlich.
Ein Workaround der häufig genutzt wird ist hier eine vorsortierte Liste zu übergeben.
Dann gibt es auch noch Tools wie stripzip die im Nachhinein die zusätzlichen Informationen entfernen.
Vor kurzem kam ich mit Problemkontext serverless auch an dieses Thema. Aus der Not heraus entstand deterministic-zip. Ein Tool das man fast 1:1 als Ersatz für zip nutzen kann und das die Probleme einfach umgeht, indem es Metadaten aussortiert und die Dateireihenfolge sicherstellt.
Python
Bei Python sieht es ebenfalls schwierig aus. Hier ist es nur möglich reproduzierbare Artefakte zu erstellen wenn das Host-OS gleich ist. Am Besten eignet sich die Ausführung in einem (Docker-)Container. Das stellt sicher, dass die Pakete wirklich gleich sind, egal auf welchem OS oder Plattform man unterwegs ist.
Das funktioniert natürlich nur solange die Zielplattform überall containerisierbar ist ( hust Windows hust).
Weitere Infos und Plattformen
Das sind jetzt meine ersten Erfahrungen und Tipps die ich geben kann. Für mehr Infos zu dem Thema kann ich folgende Ressourcen empfehlen:
- https://reproducible-builds.org/
- https://www.chromium.org/developers/testing/isolated-testing/deterministic-builds
- https://blog.conan.io/2019/09/02/Deterministic-builds-with-C-C++.html
Abschließende Worte
Builds sollten reproduzierbar sein. Das bringt einige Vorteile und die Implementierung stellt sich oft leichter dar als man denkt.
Für mich war das ein Thema das lange zweitrangig erschien, aber um so mehr man mit CI/CD, Containern und Serverless arbeitet um so mehr merkt man das man hier nicht vorbeikommt.
Aber auch traditionelle Prozesse die auf Versionsnummern setzen können hier profitieren. Es schadet meiner Meinung nach nie die Integrität von Artefakten sicherzustellen.
Im Idealfall ist für dich etwas hängen geblieben und man wirft noch mal einen Blick auf bestehende Release-Prozesse. Eine Implementierung von reproduzierbaren Builds ist eine tolle Sache, die auf lange Sicht einige Vorteile mit sich bringt.