Von Entwickler zu Entwickler: Dockeri­sierung von Open Source

Logo Docker
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 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 gecached 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:

Einstellungen

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: