Von Entwickler zu Entwickler: Dockerisierung von Open Source
In unserer neuen Reihe „Von Entwickler zu Entwickler“ findet ihr Erfahrungsberichte, Tipps und Tricks aus dem weiten
Feld der Softwareentwicklung. Atmina-Entwickler geben Euch einen kleinen Einblick in das was sie gerade beschäftigt und
nehmen euch mit auf diese Reise. Da ich
gerade Haskell (Wikipedia Artikel)
lerne und immer mehr Motivation habe, wenn ich das Gelernte direkt anwenden kann, habe ich mir mal angeschaut, welche
Web Frameworks Haskell bietet. Das erste und vielleicht aktivste Projekt, auf das ich im Bereich Frontend-Entwicklung
gestoßen bin, ist miso. Ich bin privat auf Windows unterwegs und leide häufiger
darunter, dass viele kleinere Open-Source-Projekte nur für Unix-artige Betriebssysteme gedacht sind. Aber kein Problem:
bei ATMINA nutzen wir Docker, docker-compose und Kubernetes, also habe ich mich in den Kaninchenbau
begeben und das Projekt dockerisiert. 😉 In diesem Artikel erläutere ich
anhand von miso meinen Gedankenprozess beim Dockerisieren – im inzwischen
akzeptierten Pull Request könnt ihr mit auf die Reise gehen. Die erste und
vielleicht wichtigste Entscheidung ist das Basis-Image. Dieses legt fest, auf welche Ausführungsumgebung
(vorinstallierte Programme, Paket-Manager, etc.) ich bei der weiteren Definition und bei der Nutzung zurückgreifen kann
und sollte gut durchdacht sein. Auf der anderen Seite muss man berücksichtigen, was das aktuelle Projekt voraussetzt.
Hier gilt es ein Image zu finden, das so viele vom Projekt benötigte Programme wie möglich zur Verfügung stellt, um bei
der weiteren Definition der Dockerfile Zeit zu sparen. Die Build-Anleitung im Repository setzt
einen nix-Paketmanager voraus, also habe ich das
image [lnl7/nix:2.3.3](https://github.com/LnL7/nix-docker)
verwendet. Ganz wichtig (egal in welchem Bereich der
Software-Entwicklung) ist es, die Version zu pinnen. Wenn ich ein Image baue, will ich sicherstellen, dass es nicht in
Zukunft kaputt geht und das geht nicht, wenn es dem Zufall überlassen ist, welche Paket-Versionen ich bekomme. 😱 Als
nächstes habe ich die Anwendungsdateien in den Container kopiert, um herauszufinden, welche Betriebssystem-Pakete zum
Bau nötig sind:
FROM lnl7/nix:2.3.3
# Hier ist der Platz für die Paketinstallationen
COPY ./sample-app /miso/sample-app
WORKDIR /miso/sample-app
RUN nix-build
In diesem Fall habe ich tatsächlich die komplette Beispielanwendung kopiert, aber beim Entwickeln kopiert man
normalerweise nur die Dateien, welche die benötigten Libraries angeben, z.B.
die package.json
, cargo.toml
und default.nix
(im Falle von nix-Builds). Der Bau dieses Images schlägt fehl und es
wird Zeit die Abhängigkeiten zu erfüllen, d.h. fehlende Pakete entsprechend der Fehlermeldungen nachzuinstallieren.
Um im nächsten Schritt Zeit zu sparen, ist es wichtig zu wissen, dass jede RUN
-Anweisung in einer
Dockerfile cached wird. D.h., wenn ich nach und nach herausfinde, welche Pakete ich brauche, ist es von Vorteil,
jedes Paket in eine eigene RUN
-Anweisung zu platzieren:
FROM lnl7/nix:2.3.3
RUN nix-env -iA nixpkgs.curl
RUN nix-env -iA nixpkgs.jq
RUN nix-env -iA nixpkgs.git
# ...
Wenn ein neues Paket mit RUN nix-env -iA
installiert wird, wird für dieses Paket eine
neue Schicht im Image angelegt. Beim nächsten Bau
wird diese Schicht wiederverwendet und es wird nichts neu heruntergeladen.
Ist der Bau des Images erfolgreich, lassen sich die RUN
-Anweisungen in eine einzelne zusammenfassen:
FROM lnl7/nix:2.3.3
RUN nix-env -iA \
nixpkgs.curl \
nixpkgs.jq \
nixpkgs.git \
nixpkgs.gnutar \
nixpkgs.gzip \
# get ca certificates for connecting to cachix
nixpkgs.libressl \
# install ag and entr for auto-rebuild
nixpkgs.silver-searcher \
nixpkgs.entr \
nixpkgs.cabal-install
Das sorgt dafür, dass man das finale Image nicht unnötig mit 8 zusätzlichen Schichten aufbläht.
Wenn man von Anfang an mit einer einzigen RUN
-Anweisung gearbeitet hätte, dann hätte man jedes mal, wenn
man ein neues Paket angehängt hätte, auf die Installation aller Pakete warten müssen.
Ein großes Problem bei der Erstellung dieses Images war, dass ein externer Service namens cachix verwendet wurde, zu dem eine HTTPS-Verbindung aufgebaut wurde, bei der die Zertifikate geprüft wurden. Ein Basis-Image enthält in der Regel keine Wurzel-Zertifikate, mit denen es andere Zertifikate prüfen könnte. In diesem Fall musste ich also ein nix-Paket finden, das Zertifikate installiert, um nix davon zu überzeugen, dass cachix vertrauenswürdig ist. 😉
RUN nix-env -iA cachix -f https://cachix.org/api/v1/install
RUN SYSTEM_CERTIFICATE_PATH=$NIX_SSL_CERT_FILE USER=miso cachix use miso-haskell
Hier zeigt sich auch, dass man beim Dockerisieren häufig tief. Hier galt es zum Beispiel herauszufinden, dass die Paketinstallation an Wurzelzertifikaten scheitert, wo nix nach Zertifikaten sucht und in welchen Paketen diese stecken.
Bei diesem Projekt kam ein interessantes neues Problem dazu, das sonst nicht auftritt: ab einer bestimmten Stelle wurde mein Container immer vom Docker-Dämon gekillt. Wie sich herausgestellt hat, ist das ein besonderes Problem, das nur unter Mac und Windows auftritt: Docker setzt harte Speicherbegrenzungen. Für manche Images muss man diese über die Oberfläche lockern:
Hier habe ich RAM und Swap auf 6 und 2 GB erhöht.
Als die Dockerfile fertig war, wollte ich mit der Beispielanwendung noch ein wenig experimentieren, also habe ich
eine docker-compose.yaml
geschrieben, die mir automatisch die Anwendung neu baut, sobald sich Dateien ändern.
version: "3.7"
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
working_dir: /miso/sample-app
command: [nix-shell, -A, env, default.nix, --run, "cabal configure --ghcjs; ag -l | (ENTR_INOTIFY_WORKAROUND=1 entr sh -c 'cabal build')"]
volumes:
- ../sample-app:/miso/sample-app/
Die Anwendung entr hat die schöne Eigenschaft, dass sie Änderungen an Dateien
wahrnehmen kann und dabei mit der unvollständigen inotify-Unterstützung auf Docker für Windows umgehen kann, daher die
Umgebungsvariable ENTR_INOTIFY_WORKAROUND
.
An dieser Stelle hat man alle für den Anwendungsbau benötigten System-Pakete und könnte theoretisch aus der
Dockerfile RUN nix-build
und die COPY
-Anweisungen entfernen, da man die Anwendung doch sowieso neu baut, wenn man
docker-compose nutzt. Warum also behalten?
Nun, auch hier wieder Caching. Wenn man die Anwendung interaktiv mit docker-compose startet und innerhalb des
Containers nix-build
aufruft, gibt es die benötigten Haskell-Pakete schon, weil das Haskell-Build-Tool bereits einmal
eine Version des Projekts kompiliert hat und in diesem Prozess sämtliche Haskell-Pakete heruntergeladen hat. Genau das
ist in RUN nix-build
passiert und dieses Caching kann man sich für zukünftige Builds zu Nutze machen. Außerdem bleibt
dadurch eine vollständig gebaute Anwendung im Image, sodass man es grundsätzlich auch als Produktionsimage nutzen
könnte.
Anmerkung: Hat sich irgendeine Datei in der COPY
-Anweisung geändert, so wird COPY
und alle folgenden Anweisungen
erneut ausgeführt. Daher sollte man zusehen, dass man nur Dateien in die Dockerfile einbindet, die sich selten ändern
und ausschlaggebend für die zu installierenden Pakete sind. Auch sollte man ein COPY
vor einem RUN
nur mit den für
den folgenden RUN
-Befehl benötigten Dateien durchführen
Fazit: Jede Anwendung hat ihre eigenen Abhängigkeiten und bei jeder Dockerisierung muss man abwägen, welche Dateien und Pakete zum Bau benötigt werden und welche nicht. Einmal abgeschlossen, ist eine Dockerisierung allerdings Gold wert, weil damit auch Open-Source-Anwendungen wie miso auf allen Docker-fähigen Betriebssystemen lauffähig werden. 🙂
Themen:
- Allgemein