Next.js und imgproxy

TL;DR Eigene Images bauen und mit allen Paketen aktuell zu halten ist nicht einfach. Reproduzierbare Builds sind eine Möglichkeit regelmäßig Updates von Base-Images und Paketen in das eigene Image zu übernehmen - ohne bei jedem Build umfangreiche Updates der Layer im Image und ständig wachsende Images in der Registry zu verursachen. Mit Kaniko und dem --reproducible Flag, so wie einer sorgfältigen Anpassung des Builds, werden ohne Änderungen stets die gleichen Hashes für Images erzeugt.

Docker kann viele Probleme beim Betrieb unterschiedlichster Software lösen - wie so häufig kommen jedoch ganz neue Herausforderungen dazu. Neben dem Einsatz während der Entwicklung kann der Einsatz von Containern auch beim Betrieb der Software erfolgen. Häufig ist die Rede von skalierbaren Umgebungen, bei denen eine große Applikation auf verschiedenen (Cloud-)Servern verteilt wird (z.B. mit Kubernetes). Für uns ist ein Anwendungsfall aber auch die Abhlösung bisheriger Shared-Hosting Umgebungen (vor allem im Bereich PHP, aber der Ansatz hier ist übetragbar auf alle anderen Sprachen), denn dort laufen üblicherweise viele verschiedene kleinere Anwendungen auf den Serveren und Teilen sich die Ressourcen. Docker bietet hier neben den bisher verwendeten Möglichkeiten zur Isolation (eine Anwendung darf keinen Zugriff auf andere Anwendungen haben) wie z.B. Chroot viele Vorteile, da die Isolation von Prozessen, dem Dateisystem und Netzwerk ja gerade das Hauptmerkmal der Container-Lösung ist. Auch ist der gleichzeitige Betrieb verschiedener Versionen einer Software (z.B. PHP Versionen) wesentlich einfacher.

Eine Scriptsprache wie PHP, aber auch Python oder Node.js verwenden viele Abhängigkeiten als Shared-Libraries, welche die Distribution mitliefert. Auf einem klassischen Linux Server werden regelmäßig Updates der Distributions Pakete über einen Paketmanager (Apt, Yum, usw.) eingespielt. Eine Distribution wie Debian/Ubuntu stellt regelmäßig neue Versionen von Paketen mit Bugfixes oder Sicherheitsupdates bereit. Nach einem Neustart von davon betroffenen Prozessen profitieren alle installierten Anwendungen auf dem Server von den eingespielten Updates.

Doch beim Einsatz von Containern sieht dies anders aus, denn die Dateien in den gebauten Images (also auch die Shared-Libraries als Abhängigkeiten) sollten unbedingt als immutable, also unveränderbar angesehen werden. Beim Einsatz von offiziellen Images vom Docker-Hub steht generell für aktuelle Software-Versionen eine regelmäßige Aktualisierung zur Verfügung - doch all zu häufig benötigen Applikationen noch zusätzliche Pakete wie z.B. ImageMagick und angepasste Einstellungen oder sonstige Ergänzungen (gerade bei PHP trifft dies häufig zu und ist in den Base-Images auch so vorgesehen). Dies kann über eigene Docker Images, welche von den offiziellen Images per FROM abgeleitet sind, erreicht werden.

Viele Images z.B. auf Docker Hub werden nicht laufend aktualisiert und beinhalten dementsprechend auch keine Sicherheitsupdates. Beim Einsatz von nicht-offiziellen Images ist immer Vorsicht geboten!

Was sollte ein Image beinhalten?

Was genau in ein Image hineingehört hängt natürlich vom Anwendungsfall ab. Bei uns gibt es grundlegend verschiedene Szenarien, bei denen wir unterschiedliche Builds erzeugen:

  • Dev Image Z.B. beim lokalen Entwicklung mit verschiedenen Tools in den Images zur Entwicklung. Der Source-Code von Anwendungen wird per Volume-Mounts / Synchronisation in die Images hineingebracht. Üblicherweise beinhalten diese Images sehr viele Abhängigkeiten - auch solche, die später zur Laufzeit nicht notwendig sind und eine unnötige Angriffsoberfläche bieten.
  • App Builds Wenn Anwendungen gleich mit dem Ziel zum Betrieb als Container entwickelt wurden, ist die Auslieferung der Anwendung als Image mit spezifischer Version (z.B. auf Basis eines Git Tags über einen CI Build) die beste Wahl. Das immutable Deployment und die einfache Nachvollziebarkeit der Laufzeitumgebung spielen hier voll die Stärke der Container-Technologie aus. In diesem Szenario setzen wir z.B. je nach Programmiersprache ein eigenes Base-Image ein, auf welchem dann ein Applikations-Image mit dem Source-Code / Binary der Anwendung basiert. (Randnotiz: Insbesondere native Sprachen wie z.B. Go mit statisch gelinkten Binaries sind hier super einfach in der Handhabung und benötigen meistens auch kein eigenes Base-Image)
  • Runtime Image Wird der Code der Applikation erst zur Laufzeit installiert oder liegt dieser auf einem (persistenten) Volume, dann reicht ein Image, welches nur die notwendigen Laufzeitumgebung (also Interpreter und notwendige Erweiterungen und Abhängigkeiten) enthält.

Runtime und App Builds kombinieren

Für den Einsatz in unserer neuen Shared-Hosting Lösung auf Basis von Kubernetes haben wir viel über die optimale Aufteilung der Images nachgedacht. Für viele Anwendungen, die auf einer Standardlösung wie einem CMS basieren, ist es eher hinderlich komplette App Builds vorzunehmen und den Anwendungscode zusammen mit der Laufzeitumgebung als Image auszulieferen. Warum? Jedes Update des Base-Images würde auch einen kompletten App Build verursachen. Bei vielen Dutzend Anwendungen ist dies durchaus ein Problem und sorgt für viele unnötige Build-Vorgänge. Eine bessere Alternative war es für uns allgemeine Runtime Images für die Laufzeitumgebung zu erstellen und den Applikations-Code als Docker Image nur mit den Daten zu releasen. Zur Laufzeit in Kubernetes wird dann über einen initContainer dann der Applikations-Code in ein Volume des Runtime Images kopiert. Somit lassen sich beide Images quasi unabhängig voneinander releasen.

Idealerweise könnte eine Registry die Layer über virtuelle Image-Namen zur Laufzeit kombinieren, denn die Layer von Images lassen sich nach OCI Spezifikation tatsächlich kombinieren (jeder Layer wird durch den SHA256 Hash seines Tar-Archivs beschrieben) und nur das schlanke Manifest muss jeweils geänert werden.

Hier ein Beispiel für ein Runtime Image für PHP 7.4 mit allerhand Erweiterungen für das Hosting verschiedener Systeme:

Dockerfile
FROM php:7.4-fpm

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && \
    # gd
    apt install --yes --no-install-recommends libpng-dev libjpeg-dev zlib1g-dev libgmp3-dev libfreetype6-dev libwebp-dev \
    # graphicsmagick
    graphicsmagick ghostscript libgraphicsmagick1-dev \
    # intl
    libicu-dev zlib1g-dev \
    # zip build
    libzip-dev \
    # zip runtime
    libzip4 \
    # soap
    libxml2-dev \
    # mcrypt \
    libmcrypt-dev libmcrypt4 \
    # mcrypt: After adding libgraphicsmagick1-dev this is needed too
    libltdl7 \
    # postgresql
    libpq-dev libpq5 \
    # Locales for international date formats (~200MB)
    locales-all \
    # Editor
    vim \
    # Healthcheck requirement
    libfcgi-bin \
    # Install PHP Extensions
    docker-php-ext-configure gd --enable-gd --with-libdir=/usr/include/ --with-jpeg --with-webp --with-freetype && \
    docker-php-ext-install -j$(nproc) gd && \
    docker-php-ext-configure opcache --enable-opcache && \
    docker-php-ext-install -j$(nproc) opcache && \
    docker-php-ext-install -j$(nproc) intl && docker-php-ext-enable intl && \
    docker-php-ext-install -j$(nproc) zip && docker-php-ext-enable zip  && \
    docker-php-ext-install -j$(nproc) soap && docker-php-ext-enable soap  && \
    docker-php-ext-install -j$(nproc) pdo && docker-php-ext-enable pdo  && \
    docker-php-ext-install -j$(nproc) pdo_mysql && docker-php-ext-enable pdo_mysql  && \
    docker-php-ext-install -j$(nproc) mysqli && docker-php-ext-enable mysqli  && \
    docker-php-ext-install -j$(nproc) exif && docker-php-ext-enable exif  && \
    docker-php-ext-install -j$(nproc) iconv && \
    docker-php-ext-install -j$(nproc) pdo_pgsql && docker-php-ext-enable pdo_pgsql  && \
    # Make reproducible builds
    CFLAGS="-Wl,--build-id=none" pecl install redis && docker-php-ext-enable redis && \
    CFLAGS="-Wl,--build-id=none" pecl install mcrypt && docker-php-ext-enable mcrypt && \
    CFLAGS="-Wl,--build-id=none" pecl install gmagick-2.0.5RC1 && docker-php-ext-enable gmagick && \
    # Cleanup
    rm -rf /tmp/pear /usr/local/lib/php/.registry && \
    apt purge gcc gcc-8 make libpng-dev libjpeg-dev zlib1g-dev libgmp3-dev libfreetype6-dev libwebp-dev \
    libzip-dev libxml2-dev libicu-dev libmcrypt-dev libpq-dev libgraphicsmagick1-dev --yes && \
    apt autoremove --yes && \
    rm -rf /var/lib/apt/lists/* /usr/src/* && \
    # Remove logs and linker cache for reproducible builds
    find /var/log -name "*.log" -delete && rm /var/cache/ldconfig/aux-cache

COPY ./assets/php/ /usr/local/etc/php/conf.d/
COPY ./assets/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
COPY ./assets/php-fpm-healthcheck /usr/local/bin/php-fpm-healthcheck
COPY ./assets/wait-for-it.sh /usr/local/bin/wait-for-it.sh
COPY ./assets/entrypoint.sh /usr/local/bin/docker-php-entrypoint

RUN chmod +x /usr/local/bin/*

WORKDIR /var/www/html
VOLUME /var/www/html

USER www-data

CMD ["php-fpm"]

Warum einige Kommandos hier notwendig sind, erkläre ich später noch genauer. Wichtig ist erst einmal zu sehen, wie viele Pakete und Erweiterungen so in der Praxis zusammenkommen.

Wie häufig sollte ein Image aktualisiert werden?

Mindestens, wenn sich das Base-Image geändert hat oder Pakete der Distribution Sicherheitsupdates erhalten, muss auch ein eigenes Image aktualisiert werden. Wann ist das jeweils? Das ist schwer zu generalisieren. Wie in diesem Artikel erwähnt, werden OS Images ungefähr einmal im Monat aktualisiert. Die Runtimes für PHP oder Node bekommen jedoch wesentlich häufiger Updates und das mitunter sogar mehrfach pro Woche. Dann kommen noch nachträglich installierte Pakete der Distribution und andere externe Abhängigkeiten dazu. Es kann also jeden Tag ein erneuter Build des Images notwendig sein, um rechtzeitig alle Patches zu erhalten.

Wie können das Base Image und Pakete in eigenen Images aktuell gehalten werden?

Man könnte jetzt denken, dass sei z.B. bei Docker Hub einfach durch Automated Builds und einer Einstellung wie Repository Links > Enable for Base Image anzustellen. Doch relativ unerwartet funktioniert dies nicht für offizielle Base-Images - welche ja gerade gut als Basis für eigene Applikations-Images dienen könnten.

Mit einem täglichen Trigger könnte man nun einfach regelmäßig das Image neu bauen lassen und dabei die Updates des Base-Images und der Pakete der Distribution mitnehmen. Doch dieser Ansatz fordert leider seinen Tribut:

  • Der Hash des Images wird sich bei jedem Build ändern (vor allem durch Timestamps der Dateien z.B. beim Entpacken)
  • Je nach Aufteilung der Layer im Image ist ein größerer Up-/Download notwendig (bei unseren PHP Images sind das häufiger mal mehrere 100 MB)
  • Es wird also jeden Tag ein neues Release des Base-Images erstellt und alle davon abhängigen Images sowie Deployments müssen aktualisiert werden
  • Die Images können nicht ohne weiteres nach wirklichen Änderungen unterschieden werden

Auch eine andere Lösung zur Continous-Integration wie GitHub Actions, GitLab CI oder CircleCI wird mit den normalen Docker Builds (auch buildkit) auf ähnliche Probleme stoßen.

Die Lösung: reproduzierbare Builds mit Kaniko

Kaniko Logo

Wir verwenden schon seit einiger Zeit Kaniko: ein Tool für Docker-Builds, welches unprivilegiert läuft und die meisten Features in Dockerfiles unterstützt. Besonders spannend für unseren Use-Case ist das --reproducible Flag, welches Timestamps entfernt und somit - mit ein bisschen Sorgfalt - tatsächlich reproduzierbare Hashes für ein mehrfach gebautes Image erzeugt.

Und so kann man ein Image mit Kaniko bauen (in einem Docker-Container z.B. in einem Kubernetes Cluster):

docker run \
    -v /path/to/project:/workspace
    gcr.io/kaniko-project/executor:latest \
    --reproducible \
    --cache=false \
    --context=dir:///workspace \
    --dockerfile=/workspace/Dockerfile \
    --destination my-registry.example.com/my/image:latest

Doch auf dem Weg dahin haben wir noch einige kleiner und größere Hürden gehabt. Denn mehrere Builds hintereinander haben trotz --reproducible Flag immer noch unterschiedliche Hashes gehabt. Wie kann es dazu kommen und wie kann man das einfach erkennen?

Probleme, die zu unterschiedlichen Hashes führen

Immer, wenn in den Images Daten landen, die sich unterscheiden, führt dies zwangsweise zu verschiedenen Hashes, da sich der Inhalt der Layer unterscheidet. Recht einfach erkennen kann man das mit dem Tool container-diff, welches für zwei Images die Unterschiede im Dateisystem oder auf Paketebene (z.B. APT, NPM, Pip) anzeigen kann.

Nachdem die Unterschiede bekannt sind, können diese einfach im gleichen RUN Befehl gelöscht werden. Somit wird kein Layer mit abweichenden Daten zwischen zwei ansonsten gleichen Builds geschrieben.

Mit diesem Tool haben wir im Beispiel des PHP Runtime-Images schrittweise folgende Quellen identifiziert, die zu unterschiedlichen Hashes führen:

1. Logs von Paketinstallationen

Die Zeile rm -rf /var/lib/apt/lists/* ist in vielen Debian-basierten Images zu finden, da hiermit die von APT heruntergeladenen Paketlisten gelöscht werden, was zu kleineren Layern führt. Jedoch werden mitunter auch noch zusätzliche Dateien geschrieben. Eine gute Idee ist, alle Logs zu löschen:

find /var/log -name "*.log" -delete

2. Logs und Informationen von installierten Erweiterungen

Erweiterungen für PHP können mit pecl installiert werden. Dieses setzt im Hintergrund auf Pear auf. Beide hinterlassen Spuren (wie Informationen zu den Quellen) und benötigen ein paar zusätzliche Kommandos zum Aufräumen:

rm -rf /tmp/pear /usr/local/lib/php/.registry

Werden Tools oder Libraries kompiliert, so kann dies einen jeweils abweichenden Linker-Cache bedeuten. Dieser kann auch entfernt werden:

 rm /var/cache/ldconfig/aux-cache

3. Kompilierte Binaries

Die durch pecl kompilierten Shared-Libraries haben sich bei jedem Build des Images unterschieden. Eine Analyse der Binaries (erst mit dem Hex-Editor, dann mit readelf zum besseren Verständnis der Binärdaten) hat gezeigt, dass für jeden Compile-Vorgang eine neue GCC Build Id erzeugt wird. Zum Glück kann man pecl Linker-Flags mitgeben und nach ein paar Experimenten haben wir die richtige Kombination gefunden:

CFLAGS="-Wl,--build-id=none" pecl install redis

Hiermit wird mit dem gleichen Quellcode (also der gleichen Version der Erweiterung) auch immer das gleiche Binary erzeugt. Dies kann aber je nach Programm durchaus recht komplex werden.

Unvorhergesehen Bugs

Nachdem wir all diese durch container-diff sichtbaren Unterschiede beseitigt haben, hat sich immer noch nicht derselbe Hash ergeben. Dies hat uns erst etwas ratlos zurückgelassen. Aber da die Sopezifikation für die Erzeugung der Hashes recht klar ist und somit ein Unterschied in einer der Tar-Dateien der Layer des Images dafür verantwortlich sein musste - haben wir diese noch einmal genauer untersucht.

Das Problem: Der Inhalt der Tar-Dateien schien komplett gleich zu sein. Erst die Reihenfolge der Dateien im Tar-Archiv offenbarte ein Problem mit Kaniko, welches eine fehlende Sortierung von sogenannten Whiteouts (gelöschten Dateien zwischen Layern) hatte. Glücklicherweise ist das Tool Open-Source und mit etwas Go Kenntnissen konnte man sich im Quellcode zurechtfinden und den Bug beheben. Der Pull-Request wurde auch bereits gemerged und sollte in der nächsten veröffentlichten Version (nach v0.24.0) enthalten sein. Der Fix sollte auch in gcr.io/kaniko-project/executor:latest vorhanden sein - dieses Image ist aber nicht dauerhaft stabil und kann somit (nach unserer Erfahrung) immer wieder zu Probleme in CI Builds führen.

Et Voilà!

GitLab CI Registry with version showing the same hash

Nach all diesen Hindernissen haben unsere Builds für das Image den gleichen Hash! Doch wollen wir natürlich eigentlich gar keine neuen Version taggen, wenn sich der Hash nicht geändert hat.

Ein automatisierter Workflow

Die Grundidee ist, den reproduzierbaren Build nun regelmäßig z.B. Nachts auszuführen und über ein kleines Tool den erzeugten Image Hash mit bereits vorhandenen SemVer Tags abzugleichen und zu schauen, ob wir ein neues Tag erzeugen müssen. Unser Ansatz hier ist, dass wir nur Minor-Versionen in Git taggen und alle Patch-Versionen durch den automatischen Image-Build (eine Scheduled Pipeline in GitLab CI) erzeugt werden.

Da dies mit Bash-Scripten doch relativ schwierig zu bewerkstelligen ist und für Technologien im Container- / DevOps-Umfeld im Allgemeinen sehr gute Go Pakete vorhanden sind, haben wir das ganze Verfahren als CLI Tool mit Go entwickelt.

Doch hierzu später mehr - dieser Blog-Post ist schon lang genug geworden.

Fazit

Nach all diesen Schritten haben wir endlich eine (in der dauerhaften Nutzung) einfache Lösung, um Docker Images regelmäßig neu zu bauen und nur bei tatsächlichen Änderungen neue Versionen zu taggen.