Aus Linux-Magazin 01/2021

Nachhaltig sichere Container bauen

© Aleksey Popov, 123RF

Beim Container-Bau gerät häufig 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.

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 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 gibt es 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.

Listing 1

Einfaches Dockerfile

FROM dockerrepo.matrix.dev/gentoo-java:latest-amd64
ADD webapp.tar.gz /
ENTRYPOINT ["java", "-jar", "mywebapp.jar"]
EXPOSE 8080/tcp

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 befindet 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.

Listing 2

Makefile-Ausschnitt

gentoo-libuv/gentoo-libuv.tar.gz: /usr/lib64/libuv.so.1
    q files dev-libs/libuv | grep /usr/lib | tar -c -T - -v -z -f $@

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.

Listing 3

Makefile für die Archive

all: gentoo-glibc/gentoo-glibc.tar.gz gentoo-gcc/gentoo-gcc.tar.gz gentoo-java/gentoo-java.tar.gz  gentoo-gmp/gentoo-gmp.tar.gz gentoo-mpc/gentoo-mpc.tar.gz gentoo-mpfr/gentoo-mpfr.tar.gz
gentoo-glibc/gentoo-glibc.tar.gz: /usr/include/libintl.h
    sh createglibctar.sh
gentoo-gcc/gentoo-gcc.tar.gz: /usr/bin/gcc
    sh creategcctar.sh
gentoo-java/gentoo-java.tar.gz: /usr/lib/jvm/icedtea-bin-8 createjavatar.sh
        sh createjavatar.sh
gentoo-gmp/gentoo-gmp.tar.gz: /usr/lib64/pkgconfig/gmp.pc
    q files dev-libs/gmp |grep usr/lib|tar czvf $@ -T -
gentoo-mpc/gentoo-mpc.tar.gz: /usr/lib64/libmpc.so
    q files dev-libs/mpc |grep lib|grep -v doc|tar czvf $@ -T -
gentoo-mpfr/gentoo-mpfr.tar.gz: /usr/lib64/libmpfr.so
    q files dev-libs/mpfr |grep lib|grep -v doc|tar czvf $@ -T -
gentoo-zlib/gentoo-zlib.tar.gz: /usr/lib64/pkgconfig/zlib.pc
    q files sys-libs/zlib | grep /lib64 | tar cvzf $@ -T -

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.

Listing 4

Management der Abhängigkeiten

gentoo-java/pushtime: gentoo-java/gentoo-java.tar.gz gentoo-glibc/pushtime gentoo-zlib/pushtime gentoo-gcc/pushtime
    cd gentoo-java; docker build -t dockerrepo.matrix.dev:gentoo-java:latest-amd64 . && touch buildtime && docker push dockerrepo.matrix.dev/gentoo-java:latest-amd64 && touch pushtime

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.

Listing 5

Make-Lauf nach Glibc-Update

# make -f Makefile.docker
cd gentoo-glibc; docker build -t dockerrepo.matrix.dev/gentoo-glibc:latest-amd64 . && touch buildtime && docker push dockerrepo.matrix.dev/gentoo-glibc:latest-amd64 && touch pushtime
Sending build context to Docker daemon  21.12MB
Step 1/2 : FROM dockerrepo.matrix.dev/gentoo-base:latest
 ---> 22fe37b24ebe
Step 2/2 : ADD gentoo-glibc.tar.gz /
 ---> 4e800333acbd
Successfully built 4e800333acbd
Successfully tagged dockerrepo.matrix.dev/gentoo-glibc:latest-amd64
The push refers to repository [dockerrepo.matrix.dev/gentoo-glibc]
22bac475857f: Pushed
636634f1308a: Layer already exists
[...]
Step 2/8 : FROM dockerrepo.matrix.dev/gentoo-glibc:latest-amd64
[...]
Step 8/8 : ADD gentoo-gcc.tar.gz / ---> b89e1b4ab2ba
Successfully built b89e1b4ab2ba
Successfully tagged dockerrepo.matrix.dev/gentoo-gcc:latest-amd64
The push refers to repository [dockerrepo.matrix.dev/gentoo-gcc]
794c152bde4c: Pushed
[...]
22bac475857f: Mounted from gentoo-bind
636634f1308a: Layer already exists
latest-amd64: digest: sha256:667609580127bd14d287204eaa00f4844d9a5fd2847118a6025e386969fc88d5 size: 1996
cd gentoo-java; docker build -t dockerrepo.matrix.dev/gentoo-java:latest-amd64 . && touch buildtime && docker push dockerrepo.matrix.dev/gentoo-java:latest-amd64 && touch pushtime
Sending build context to Docker daemon  66.12MB
Step 1/6 : FROM dockerrepo.matrix.dev/gentoo-glibc:latest-amd64
 ---> 4e800333acbd
Step 2/6 : COPY --from=dockerrepo.matrix.dev/gentoo-zlib:latest-amd64 /lib64/* /lib64/
 ---> aaf3f557c027
Step 3/6 : COPY --from=dockerrepo.matrix.dev/gentoo-gcc:latest-amd64 /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/lib* /lib64/
 ---> 6f7d7264921c
Step 4/6 : ADD gentoo-java.tar.gz /
 ---> afb2d5612109
Step 5/6 : ENV JAVA_HOME /opt/icedtea-bin-3.16.0
[...]
441dec54d0dd: Pushed
22bac475857f: Mounted from gentoo-glibc
636634f1308a: Layer already exists
latest-amd64: digest: sha256:965aeac1b1cd78cde11aec58d6077f69190954ff59f5064900ae12285e170836 size: 1371

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, etwa »/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.

Listing 6

Dockerfile für Debug-Container

FROM dockerrepo/applikationscontainer:latest-amd64
COPY --from=dockerrepo/gentoo-busybox:latest-amd64 /bin/ /bin/

Im Busybox-Container sollte ein Softlink von »/bin/busybox« auf »/bin/sh« zeigen: Damit 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, vollautomatisch die Container 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 liegt die Menge der notwendigen Container höher. 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.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben