Bereits Kunde? Jetzt einloggen.
Lesezeit ca. 7 Min.

Nachhaltig sichere Container bauen: Backen nach Rezept


Linux Magazin - epaper ⋅ Ausgabe 1/2021 vom 03.12.2020

Beim Container-Bau gerät oft die Aktualität der Basis-Images, auf die der Developer aufsetzt, unter die Räder. Der Autor hat ein System entwickelt, das dieses Problem löst und nebenbei deutlich schlankere Container erzeugt.


Artikelbild für den Artikel "Nachhaltig sichere Container bauen: Backen nach Rezept" aus der Ausgabe 1/2021 von Linux Magazin. Dieses epaper sofort kaufen oder online lesen mit der Zeitschriften-Flatrate United Kiosk NEWS.

Bildquelle: Linux Magazin, Ausgabe 1/2021

© Aleksey Popov,123RF

Beruflich beschäftigt sich der Autor unter anderem mit der Entwicklung von Anwendungen im Bereich Netzwerkautomatisierung. Sie basieren auf der Springboot-Umgebung, benötigen also ein lauffähiges Java. Gleichzeitig sind einige Infrastruktur-Applikationen erforderlich, wie etwa DNS-Server.

Bevor es Container gab, liefen die Infrastrukturdienste in minimalistisch ...

Weiterlesen
epaper-Einzelheft 6,99€
NEWS 14 Tage gratis testen
Bereits gekauft?Anmelden & Lesen
Leseprobe: Abdruck mit freundlicher Genehmigung von Linux Magazin. Alle Rechte vorbehalten.

Mehr aus dieser Ausgabe

Titelbild der Ausgabe 1/2021 von README. Zeitschriften als Abo oder epaper bei United Kiosk online kaufen.
README
Titelbild der Ausgabe 1/2021 von Librem Mini rüstet mit „Comet Lake“ auf. Zeitschriften als Abo oder epaper bei United Kiosk online kaufen.
Librem Mini rüstet mit „Comet Lake“ auf
Titelbild der Ausgabe 1/2021 von Pappl: CUPS-Erfinder arbeitet an neuem Drucker-Framework. Zeitschriften als Abo oder epaper bei United Kiosk online kaufen.
Pappl: CUPS-Erfinder arbeitet an neuem Drucker-Framework
Titelbild der Ausgabe 1/2021 von Zahlen & Trends. Zeitschriften als Abo oder epaper bei United Kiosk online kaufen.
Zahlen & Trends
Titelbild der Ausgabe 1/2021 von Ausbruch aus Docker-Containern: Nicht ganz dicht. Zeitschriften als Abo oder epaper bei United Kiosk online kaufen.
Ausbruch aus Docker-Containern: Nicht ganz dicht
Titelbild der Ausgabe 1/2021 von Wo Admins in Sachen Container- und Kubernetes-Security oft daneben liegen: Im Gruselkabinett. Zeitschriften als Abo oder epaper bei United Kiosk online kaufen.
Wo Admins in Sachen Container- und Kubernetes-Security oft daneben liegen: Im Gruselkabinett
Vorheriger Artikel
Zahlen & Trends
aus dieser Ausgabe
Nächster Artikel Ausbruch aus Docker-Containern: Nicht ganz dicht
aus dieser Ausgabe

... konstruierten Changeroot-Umgebungen. Das bedeutet, dass sie nur die notwendigen Binaries (etwa named), Konfigurationsdateien und Bibliotheken enthielten.

Das verringerte bei exponierten Diensten die Angriffsfläche. Beispielsweise würde ein Versuch des Angreifers, /bin/ sh aufzurufen, fehlschlagen, weil es in der Umgebung gar keine Shell gibt.

Klassische Docker-Build-Files, die mit FROM ubuntu eine komplette Ubuntu-Umgebung einbeziehen, stellen das exakte Gegenteil des eben beschriebenen Ansatzes dar. Der entstehende Container lässt sich zwar besser debuggen, weil hier zum Beispiel eine Shell zur Verfügung steht. Aber er ist auch weitaus größer und zudem unsicherer, weil auch der Angreifer das Shell-Binary finden und benutzen kann.

Offizielle Container halten deren Hersteller aktuell, was bedeutet, dass beim Neubau des Containers auch ein aktualisiertes Ubuntu berücksichtigt würde. Allerdings es gibt keinen Mechanismus, der einen solchen Neuaufbau automatisch auslöst. Ein Ziel des Autors war es deshalb, alle Container automatisch neu zu bauen, die Komponenten enthalten, für die Patches vorliegen. Gleichzeitig sollten die Container schlanker werden.

Dockerfiles

Docker unterstützt zwar den Import eines komprimierten Tar-Archivs einer Changeroot-Umgebung, das ergibt aber einen schlecht wartbaren Build-Prozess. Der bessere Weg führt über die Verwendung eines Dockerfiles, in dem die Komponenten des Images hinterlegt sind und das es auch ermöglicht, einzelne Dateien aus anderen Images zu importieren. Darüber hinaus können der Aufruf von Skripten oder ganze Installationen möglich sein. Um einen derartigen Container zu erstellen, verwendet der Entwickler docker build. Hierzu kopiert er ein Archiv (in der Regel .tar.gz) in einen Ordner und legt dazu eine Datei mit dem Namen Dockerfile an. Listing 1 zeigt ein einfaches Beispiel.

Die erste Zeile beschreibt ein Basis-Image, dessen Dateisystem in den aktuellen Container eingefügt wird. In diesem Fall handelt es sich um ein auf Gentoo Linux basierendes Image (dazu später mehr), das eine lauffähige Java-Umgebung bereitstellt. Zeile 2 fügt dem Wurzelverzeichnis des Containers den Inhalt von webapp.tar.gz hinzu. Die dritte Zeile sorgt dafür, dass der Aufruf java ‑jar mywebapp.jar automatisch ausgeführt wird, falls der Container mit docker run ohne Argumente gestartet wird. Die letzte Zeile schließlich exponiert Port 8080, sodass sich der Admin die Option ‑p 8080:8080 im Docker-Aufruf spart. Der Build-Prozess von Docker ist hierarchisch organisiert. Die Images, die die Binaries im Container zur Verfügung stellen, bauen aufeinander auf. Beginnend bei einem Basis-Image, das mit FROM scratch zunächst leer angelegt wird, können mehrere Images jeweils ein anderes komplett importieren. Dies erzeugt die Layer, die einzeln aus der Registry heruntergeladen werden. Bleibt ein Layer unverändert, entfällt der Download, was Zeit und Bandbreite spart. Das referenzierte Image gentoo‑java umfasst das Glibc-Image sowie (weil die Java-Binaries das benötigen) die Bibliothek Zlib und einige GCC-Libraries. Hier werden aber jeweils nicht die vollständigen Images eingebunden, sondern nur die notwendigen Shared Libraries.

Das Glibc-Image schließlich verwendet in seiner FROM-Zeile noch ein Basis-Image, das ein minimales Dateisystem mit /etc‑, /dev- und /tmp-Verzeichnis enthält. Dank des hierarchischen Aufbaus kann das weiter unten beschriebene Build-System einzelne Layer des Images separat aktualisieren.

Die Quelldateien für die Images liegen als Tar.gz-Archive vor, die aus bereinigten Dateilisten von Paketen erzeugt werden. Im Container braucht man beispielsweise weder Manpages noch Beispielkonfigurationen. Der Aufbau mit einem Image pro Paket klingt zwar aufwendig, ist aber nur im ersten Schritt mit mehr Arbeit verbunden. Die am Ende der Kette stehenden Applikationsabbilder kann man als eine Datei exportieren und bei Bedarf in andere Registries integrieren.

Warum Gentoo?

Das vorgestellte System würde auch mit anderen Distributionen funktionieren. Gentoo wählte der Autor, weil diese Distribution Anwendungen lokal aus Quelldateien kompiliert. Somit kann man für einen späteren Audit zu jeder Version jedes Containers einfach die Quellen der Binaries archivieren und dokumentieren. Da der Admin selbst kompiliert und auch die Quellen der Compiler vorliegen, lässt sich die Kette der Dokumentation bis in den Quellcode nachvollziehen. Einzig eine Infektion des Build-Hosts böte hier eine Angriffsmöglichkeit, die man durch eine entsprechende Absicherung aber auch minimieren kann.

Praktische Umsetzung

Der erste Arbeitsschritt in der Erstellung eines Container-Images aus einem Paket besteht im Einsammeln der Dateien aus der Betriebssystemumgebung. Um dabei nicht die Übersicht zu verlieren, legte der Autor als erstes eine Ordnerstruktur fest. Für jeden Container gibt es einen Ordner, dessen Name dem Schema Distribution-Paketname folgt. Das ergibt dann Ordner in der Form gentoo‑glibc oder gentoo‑gcc. In jedem dieser Ordner befinden sich dann das jeweilige Dockerfile sowie das eingesammelte Tar.gz-Archiv.

Als Werkzeug zum Bauen kommt GNU Make zum Einsatz, da sich damit die Abhängigkeiten relativ einfach über Zeitstempel in Dateien abbilden lassen. Wurde ein Paket seit dem letzten Erstellen des Tar.gz-Archives aktualisiert, so ist der Zeitstempel der Dateien neuer, und Make löst eine Aktion aus.

Zum Erstellen des Archivs ist eine Liste der Dateien notwendig. Diese Liste erhält der Admin unter Gentoo am einfachsten mit dem Kommando q files Paket. Mittels Grep-Filtern sortiert er dann unnötige Dateien aus und leitet schließlich diese Liste in ein Tar-Kommando, das die Liste der zu archivierenden Dateien von der Standardeingabe liest. Für die meisten Pakete, die nur Shared Libraries zuliefern, sieht der Ausschnitt aus dem Makefile dann so aus, wie es Listing 2 am Beispiel des Paketes Libuv zeigt.

Einige Pakete benötigen mehr Dateien, weswegen hier geeignete Grep-Filter mehr oder weniger aus- oder einsortieren.

Im Beispiel lässt sich auch die Abhängigkeit erkennen: Das Archiv wird nur neu gebaut, wenn sich die Datei /usr/ lib64/ libuv.so.1 geändert hat. Die Handarbeit besteht jetzt für jedes Paket darin, eine Datei zu identifizieren, die als Indikator für einen Patch dienen kann, und auszusortieren, welche Dateien im Archiv am Ende notwendig sind.

In der Umgebung des Autors gibt es zwei Makefiles: eines, um die Tar.gz-Archive zu erstellen, und eines, das dann die Docker-Build-Prozesse anstößt. Listing 3 zeigt das Makefile für die Archive.

Bei den GCC- und Java-Paketen übernimmt ein kleines Shell-Skript das Zusammenstellen der Pakete, da hier noch Softlinks eine Rolle spielen, die sonst fehlen würden. Der Basis-Container ist nicht im Makefile enthalten. Er wird nicht statisch erzeugt, sondern aus Paketen. Nach einem Upgrade genügt nun ein Aufruf von Make, um – wo notwendig – die Archive neu zu erzeugen. Anschließend steht noch der Neubau der eigentlichen Container an. Die werden nach dem Bauen gleich mit dem Tag latest in die lokale Registry hochgeladen.

Der Knackpunkt war hier das Änderungsdatum. Man kann zwar per API-Aufruf die Änderungsdaten der existierenden Container in der Registry beziehungsweise auf dem lokalen Host abfragen, doch das lässt sich nur schwer im Makefile abbilden.

Daher beschloss der Autor, zu schummeln und den Aufruf von docker build einfach um && touch builddate zu ergänzen sowie nach dem docker push ein && touch pushtime anzuhängen. Die beiden Dateien werden nur erzeugt, wenn der Arbeitsschritt erfolgreich war, und pushtime dient im Makefile als Ziel. Um die Hierarchie der Container im Makefile abzubilden, kommen auch die Pushtime-Dateien aller Images in die Abhängigkeiten, die zum Bauen des Containers notwendig sind. Der Makefile-Ausschnitt in Listing 4 illustriert das.

Das Java-Image basiert auf dem Glibc-Image, kopiert aber auch Dateien von Zlib und GCC. Das bedeutet, dass man diese Images bauen und hochladen muss, bevor das Java-Image erzeugt werden kann. Listing 5 zeigt (gekürzt) den Aufruf von Make und dessen Bildschirmausgaben, nachdem es Patches für Glibc gab, was den Neubau aller Container triggerte.

Der größte Aufwand bei diesem Ansatz besteht darin, alle Abhängigkeiten aufzulösen. Den Container zu minimieren bedeutet, alle notwendigen Shared Libraries zu finden. Das erste Werkzeug, das dabei zum Einsatz kommen sollte, ist Ldd, das die referenzierten Shared Libraries eines Binaries auflistet.

Statt das Binary gleich im Container in der so erstellten Umgebung laufen zu lassen, sollte man in einer Changeroot-Umgebung anfangen: Hier lässt sich einfacher nachvollziehen, welche Bibliothek fehlt. Auch ein Lauf mit Strace, der etwa fehlende Konfigurationsdateien identifiziert, gelingt so einfacher. Kommen mehrere Binaries zum Einsatz, startet manchmal das Programm zwar, erzeugt aber beim Aufruf einer bestimmten Funktion erst einen Fehler.

Ebenso muss der Entwickler in Bezug auf die Abhängigkeiten im Auge behalten, dass Shared Libraries gelegentlich die Version wechseln. Ist die Datei, anhand derer erkannt wird, ob das Archiv neu gebaut werden muss, beispielsweise /usr/lib64/libdb‑5.3.so und liegt nach den Updates die Version 5.4 vor, dann fehlt die Indikatordatei, und das Makefile schlägt fehl. Dies gilt es, bei der Auswahl der Indikator-Dateien zu berücksichtigen.

Debugging der Container?

Funktioniert der Container nicht, obwohl alle Bibliotheken vorliegen, so wäre es möglich, den Fehler zu suchen, indem man eine Shell im Container startet. In diesem schlanken Ansatz fehlt diese Möglichkeit. Stattdessen lässt sich aber sehr einfach ein Debug-Container bauen. Im ersten Schritt erzeugt der Admin einen Container für das Busybox-Paket, dann den Debug-Container mit Dockerfile aus Listing 6.

Im Busybox-Container sollte ein Softlink von /bin/busybox auf /bin/sh zeigen: Auf diese Weise bekommt der Entwickler eine Version des Containers mit einer interaktiven Shell. Dabei handelt es sich aber um einen separaten Debug-Container, was es unwahrscheinlicher macht, dass er versehentlich in der Produktion landet.

Fazit

Der vorgestellte Ansatz erlaubt es nach einem anfänglich höheren Aufwand, die Container vollautomatisch auf dem aktuellen Stand zu halten. Im Sinne einer CI/CD-Pipeline sollte man auch die Anwendungen mit jedem Neubau einem Test unterziehen, um auszuschließen, dass es durch Änderungen in den Bibliotheken zu Inkompatibilitäten kommt.

Die Menge der Abhängigkeiten hält sich beim Java-Container für Springboot-Applikationen in Grenzen. Der Autor hat aber auch Infrastrukturdienste wie DNS auf diese Art und Weise containerisiert. Hier sind mehr Container nötig. Im Betrieb hat sich das automatische Aktualisieren der Container mit dieser Methode trotzdem voll bewährt. (jcb) ■

Der Autor

Konstantin Agouros arbeitet als Head of Open Source & AWS Projects bei der Matrix Technology AG und berät dort mit seinem Team Kunden zu Open-Source-, Sicherheits- und Cloud-Themen. Sein Buch „Software Defined Networking: Praxis mit Controllern und OpenFlow“ ist bei de Gruyter erschienen.