Entwicklergesteuerte Workflows: Dockerfile-Image-Scanning, Priorisierung und Behebung
26. März 2021
0 Min. LesezeitBeim Bereitstellen von Anwendungen in Containern müssen Entwickler inzwischen auch Verantwortung für Sicherheitsaspekte auf Betriebssystemebene übernehmen. Dabei geht es oft um Themen, mit denen sie nicht vertraut sind und die zuvor vielfach von Betriebs- und Sicherheitsteams betreut wurden. Dieser neue Bereich kann zunächst einschüchternd wirken. Es gibt jedoch verschiedene Tools und Vorgehensweisen, die Sie in Ihren Workflow integrieren können, um Probleme zu erkennen und zu beheben, bevor sie in die Produktion gelangen. In diesem Artikel erfahren Sie, wie Sie Probleme in den Container-Images Ihrer Anwendung mit verschiedenen Tools erkennen, priorisieren und beheben. Außerdem erhalten Sie einen besseren Einblick, wie sich diese Korrekturen bei der Bereitstellung auf Ihre Anwendungen auswirken können.
Die Beispielanwendung zu diesem Artikel können Sie gern parallel ausprobieren und damit experimentieren. Sie finden sie auf GitHub unter https://github.com/snyk-snippets/dev-driven-workflows.
Inhaltsverzeichnis
10 Möglichkeiten, Ihre containerisierte Anwendung zu optimieren und abzusichern
Gut strukturierte Container-Images sind entscheidend
Die Grundlage jedes von Ihnen bereitgestellten Containers ist größtenteils durch das Docker-/OCI-Image bestimmt, auf dem er basiert. Daher ist es wichtig zu verstehen, was ein Container-Image ist und wie es funktioniert.
Was ist ein Container-Image?
Im Kern ist ein Image eine Sammlung von Dateisystemarchiven und Metadatendefinitionen. Jedes davon enthält einen Teil des Dateisystems und Umgebungsparameter, die den Ausgangszustand eines Containers festlegen. Die Layer sind logisch übereinandergestapelt; der Inhalt jedes Layers wird auf den seines Vorgängers angewendet, der manchmal auch als übergeordneter Layer bezeichnet wird. Jeder Layer wird kryptografisch gehasht, um seine Integrität sicherzustellen. Das Image enthält ein Manifest der Layer sowie einen eigenen Hash, den sogenannten Image-Digest.

Beim Erstellen eines Containers wird ihm ein Dateisystem bereitgestellt, das die Image-Layer mit einer optionalen Lese-Schreib-Ebene darüber vereint. Alle vom Container geschriebenen Dateien landen in dieser Lese-Schreib-Ebene, da die Image-Layer selbst unveränderlich sind. Änderungen an Dateien, die bereits in den Image-Layern vorhanden sind, werden zunächst in die Lese-Schreib-Ebene kopiert und dort angepasst – dieses Verhalten wird als Copy-on-Write bezeichnet.
Was ist ein Dockerfile?
Ein Dockerfile ist eine einfache Liste von Anweisungen, die festlegt, was in die einzelnen Layer eines Container-Images aufgenommen wird.
Dockerfile-Beispiel
Im folgenden Beispiel sehen Sie, wie die Layer auf einem node:14.1.0-Basis-Image aufbauen. Jeder weitere Layer enthält entweder Metadaten oder Änderungen am Dateisystem, die durch die Dockerfile-Anweisungen in den einzelnen Zeilen verursacht werden. Dieses Beispiel finden Sie im oben genannten GitHub-Repository unter dem Namen Dockerfile-initial.
Wenn wir mit diesem Dockerfile den Befehl docker build ausführen, wird ein Layer erstellt, der das Verzeichnis /usr/src/goof hinzufügt. Darauf folgt ein weiterer Layer mit dem neuen Verzeichnis /tmp/extracted_files.
Im nächsten Layer wird der Inhalt des aktuellen Verzeichnisses auf dem Build-Rechner – durch . im Build-Befehl angegeben – einschließlich aller Unterverzeichnisse in das Verzeichnis /usr/src/goof im neuen Layer kopiert.
Der Build wird fortgesetzt, indem bis zum Ende der Datei weitere Layer hinzugefügt werden.
Jeder Layer eines Images baut auf seinem Vorgänger auf. Sobald ein Layer erstellt wurde, ist er jedoch unveränderlich. Selbst wenn wir in einem späteren Schritt eine Zeile zum Löschen einer Datei hinzufügen, bleibt deren Inhalt in der Hierarchie erhalten. Die Änderung zum Löschen bildet lediglich das Delta dieses neuen Layers. Das ist vergleichbar mit dem Verhalten von Quellcode-Repositories wie Git: Ein Commit, der eine Datei löscht, entfernt sie nicht aus früheren Commits. Wird also eine 1 MB große Datei zu einem Layer hinzugefügt und in einem späteren Layer entfernt, enthält das Image weiterhin die 1 MB, auch wenn der Container die Datei nicht sieht.
Kombinierte RUN-Anweisungen
Häufig werden kombinierte Anweisungen verwendet, bei denen der letzte Abschnitt die Bereinigung übernimmt, damit temporäre Dateien nicht im Dateisystem des Layers verbleiben. Unser Dockerfile enthält zwar keine solche Anweisung, aber hier sehen Sie ein typisches Beispiel für die Installation von Paketen auf Betriebssystemebene mit apt, yum usw.
Hier wird eine bestimmte Version von curl in einem Ubuntu-Image installiert. Außerdem wird sichergestellt, dass keine apt-Caches im Layer gespeichert werden.
Unser Dockerfile-Beispiel zeigt die imperativen Schritte, mit denen diese Anwendung vor dem Umstieg auf Container erstellt und ausgeführt wurde. Es ähnelt den Dockerfiles, die Sie möglicherweise bei älteren Anwendungen sehen, die einfach in Container migriert wurden. Es bietet viel Potenzial für Optimierungen und Härtung. Schauen wir uns das genauer an.
Neben dem Optimierungsbedarf enthält dieses Dockerfile tatsächlich einen Fehler, der dazu führt, dass ein docker build fehlschlägt. Sehen wir uns das Problem an und erfahren wir, wie Automatisierung bei der Behebung helfen kann.
10 Möglichkeiten, Ihre containerisierte Anwendung zu optimieren und abzusichern
1. Verwenden Sie einen Dockerfile-Linter
Ein üblicher erster Schritt zur Verbesserung unserer Dockerfiles ist der Einsatz eines Linters. Linters analysieren den Inhalt einer Datei statisch und schlagen mögliche Probleme vor, die wir beheben sollten. Wie oben erwähnt, enthält unser vorhandenes Dockerfile einen Fehler, der den Build fehlschlagen lässt.
Hadolint ist ein beliebter Open-Source-Linter für Dockerfiles. Er analysiert die Schritte und gibt konkrete Empfehlungen zu deren Struktur.
Sehen wir uns die gefundenen Probleme an:
Verwenden Sie für Dateien und Ordner COPY statt ADDDas ist beim lokalen Kopieren von Dateien, die keine Archive sind, laut Docker-Best Practices für Dockerfiles sinnvoll. Eine Behebung lohnt sich, sie verursacht jedoch nicht das Problem mit unserem Build.
Verwenden Sie „cd … || exit“ oder „cd … || return“, falls cd fehlschlägt.Technisch stimmt das, aber es ist hier nicht wirklich das Problem. Wir überspringen diesen Punkt vorerst.
Verwenden Sie WORKDIR, um in ein Verzeichnis zu wechselnGenau! Das ist die Ursache. Die Wirkung der Zeile
RUN cd /usr/src/goofist auf den Layer beschränkt, den diese Anweisung erstellt. Tatsächlich hat die Zeile für den Image-Build praktisch keine Wirkung. Stattdessen sollten wir den Dockerfile-BefehlWORKDIRverwenden, da er das aktuelle Arbeitsverzeichnis ab diesem Punkt ausdrücklich festlegt – auch zur Laufzeit. Weil dieser Befehl fehlt, kann die ZeileRUN npm…die Dateipackage.jsonnicht finden.Verwenden Sie für Argumente von CMD und ENTRYPOINT die JSON-NotationDiese Form wird auch als „exec“-Form bezeichnet. Zwar gibt es dazu unterschiedliche Meinungen, doch die Docker-Dokumentation empfiehlt die Verwendung der JSON-Notation.
Nachdem wir all diese Probleme behoben haben, weist unser neues Dockerfile – im Beispiel-Repository Dockerfile-hadolint-fixes genannt – keine Linting-Probleme mehr auf und lässt sich erfolgreich erstellen.
2. Führen Sie Ihren Linter als Commit-Hook aus, damit keine Dockerfile-Probleme in Ihre Codebasis gelangen
Leichtgewichtige Tools wie hadolint eignen sich hervorragend als Pre-Commit-Hooks und verhindern, dass Dockerfile-Probleme in Ihre Codebasis gelangen. Das folgende einfache Git-Skript können Sie unter folgendem Pfad zu Ihrem Repository hinzufügen: .git/hooks/pre-commit. Wenn Sie noch nicht mit Git-Hooks vertraut sind, finden Sie weitere Informationen in der Dokumentation von Git-SCM.
Hier sehen Sie ein Beispiel dafür, wie der Hook mit dem ursprünglichen Dockerfile ausgeführt wird, bevor wir Korrekturen vorgenommen haben.
Hinweis: Wenn Sie dies in unserem Beispiel-Repository testen, kopieren Sie Dockerfile-initial nach Dockerfile und stellen Sie die Datei in Ihrem Repository bereit, damit das Hook-Problem ausgelöst wird.
3. Testen Sie Ihr Image iterativ lokal
Wir haben bereits einiges getan, damit das Image erstellt werden kann, einen Build-Fehler behoben und dabei einige schlechte Vorgehensweisen korrigiert. Jetzt müssen wir sicherstellen, dass das Image tatsächlich alles enthält, was zum Ausführen der Anwendung erforderlich ist.
Das „goof“-Beispiel ist eine einfache zweischichtige Anwendung: ein Node.js-Frontend, das auf ein MongoDB-Backend zur Datenpersistenz angewiesen ist. Um diese Anwendung lokal auszuführen, gehen wir folgendermaßen vor:
Führen Sie mit
docker runeinen kurzen Plausibilitätstest des Images durch. (Das geschieht üblicherweise iterativ, während Sie an Ihrem Dockerfile arbeiten.) Die Anwendung schlägt fehl, weil sie keine Datenbank zum Herstellen einer Verbindung findet. Wenn Sie jedoch so weit kommen, wissen Sie zumindest, dass der Container gestartet wird.Starten Sie einen lokalen Kubernetes-Cluster, der auf das von Ihnen erstellte Image zugreifen kann. Dafür eignen sich verschiedene Optionen. Hier sind einige beliebte Möglichkeiten:
Kubernetes in Docker DesktopAktivieren Sie die Funktion einfach im Docker-Desktop-Dashboard und warten Sie, bis sie gestartet ist.
Kubernetes in Docker (KinD)Folgen Sie der Schnellstartanleitung von KinD, um loszulegen. Vergessen Sie nicht, Ihr Image in den Cluster zu laden. Wenn Sie KinD außerdem unter Docker Desktop ausführen, müssen Sie Ihren Cluster mit Host-Zuordnungen konfigurieren, die dem NodePort des goof-service entsprechen. Im Beispiel-Repository finden Sie eine Beispieldatei
kind-config.yaml, die Sie verwenden können.MiniKubeLesen Sie das Einstiegsdokument zu MiniKube, um loszulegen, und die Handbuchseite zum Caching Ihres Images im Cluster.
Ein Kubernetes-Remote-ClusterAuch jeder andere Cluster, der Ihnen zur Verfügung steht und in den Sie Images übertragen können, sollte funktionieren. Möglicherweise müssen Sie Ihr Image in eine Registry pushen, aus der der Cluster es abrufen kann. Wenn Sie nicht wissen, wie das geht, wenden Sie sich an Ihr Cluster-Betriebsteam.
Mit Docker testen
Während Sie am Dockerfile arbeiten, sollten Sie sicherstellen, dass das Image korrekt erstellt wird. Am einfachsten geht das, indem Sie gelegentlich einen Container ausführen und stichprobenartige Prüfungen vornehmen.
Wenn wir beispielsweise das zuvor erstellte Image mit dem Tag goof ausführen möchten, verwenden wir docker run --rm -it -p3001:3001 goof.
Dadurch wird ein Container auf Grundlage dieses Images erstellt und gestartet. Port 3001 auf Ihrem Rechner wird dabei so gebunden, dass Datenverkehr an Port 3001 im Container weitergeleitet wird. Mit --rm weisen Sie Docker einfach an, den Container nach dem Beenden zu entfernen. -it führt ihn im interaktiven Modus mit einem TTY aus, sodass wir die Ausgabe sehen und mit CTRL-C den Container beenden können.
Während die Node.js-Anwendung startet, sollten zahlreiche Ausgaben erscheinen, darunter eine Fehlermeldung, dass keine Verbindung zur Datenbank hergestellt werden kann. Danach wird der Container beendet und entfernt, sobald die Anwendung beendet ist. Wenn stattdessen andere Fehler auftreten, etwa dass npm nicht gefunden wird, wissen wir, dass das Image selbst Probleme hat.
Mit lokalem Kubernetes testen
Wir stellen die Anwendung mithilfe der Manifestdateien bereit, die sich im Beispiel-Repository im Ordner manifests befinden.
goof-deployment.yaml
Eine umfassende Einführung in die Kubernetes-APIs würde den Rahmen dieses Artikels sprengen. Im Großen und Ganzen deklarieren wir zwei Deployments, die Pods bereitstellen und verwalten. In diesen Pods werden unser Container und seine Datenbank ausgeführt.
Außerdem stellen wir die Datei goof-services.yaml bereit. Sie macht die Pods über Kubernetes-Services zugänglich und ermöglicht so die Service-Erkennung für diese MongoDB-Instanz. Wenn Sie Kubernetes lokal mit Docker Desktop, KinD oder MiniKube ausführen, müssen Sie der Spezifikation des goof-Containers imagePullPolicy: Never hinzufügen, wie hier gezeigt:
Wenn Sie einen Remote-Cluster verwenden, ist dies nicht erforderlich. Sie müssen Ihr Image jedoch in eine Registry pushen und die image:-Tags in diesen YAML-Dateien entsprechend ändern. Wenden Sie sich bei Fragen an Ihre Cluster-Administratoren.
Vorausgesetzt, Ihr Kubernetes-Cluster läuft und Ihre kubectl-Konfiguration ist eingerichtet, müssen Sie als Nächstes nur kubectl apply -f manifests/ ausführen und warten, bis die Pods den Status Running erreichen. Prüfen Sie dies mit kubectl get pods.
Zum Schluss testen wir die Anwendung, indem wir einen Browser öffnen und folgende Adresse aufrufen:
Docker Desktop oder KinD: http://localhost
MiniKube Führen Sie
minikube service goofaus und verwenden Sie die erste angezeigte URL.Andere Wenn
kubectl get service goofeine externe IP-Adresse anzeigt, verwenden Sie diese. Alternativ können Sie die IP-Adresse eines Ihrer Cluster-Knoten mit Port 32301 ausprobieren. Wenden Sie sich andernfalls an Ihre Cluster-Administration.
Im Browser sollte ungefähr Folgendes angezeigt werden:

Sie können eine TODO-Notiz hinzufügen und mit der Eingabetaste speichern. Wenn alles funktioniert, wird sie der Liste auf der Seite hinzugefügt.
Und immer wieder: lokal entwickeln und testen – Schritt für Schritt
Nachdem die Anwendung nun läuft, können Sie sie weiterentwickeln, das Image neu erstellen und in Ihren Cluster pushen. Je nach Art der Anwendung und verwendeten Kubernetes-Distribution gibt es wahrscheinlich Hunderte Möglichkeiten, dabei vorzugehen. Im Allgemeinen ähnelt der Ablauf jedoch diesem Workflow:
Änderungen am Code vornehmen
Mit dem Befehl
docker buildund/oder den Tools Ihrer Programmiersprache ein neues Image erstellenDas Image in den Cluster oder in eine Registry pushen (falls erforderlich)
Den laufenden Kubernetes-Pod so aktualisieren, dass er das neue Image verwendet. Dazu lösche ich normalerweise einfach den laufenden Pod und lasse den Cluster anhand des neuen Images einen neuen erstellen. Sie können aber auch
kubectl set imageverwenden, sofern das neue Image einen neuen Tag-Namen hat.Den neuen Pod testen, sobald er bereit ist
Images noch besser absichern
Abgesehen davon, dass wir einige Fehler in der ursprünglichen Dockerfile behoben haben, sind wir bisher kaum auf deren Sicherheit eingegangen. Jetzt, da wir unsere Anwendung ausführen und testen können, sehen wir uns einige der interessanteren Sicherheitsprobleme unseres Images an.
4. Verwenden Sie im Image nicht standardmäßig den Root-Benutzer
Wenn Sie sich in den Anwendungs-Container begeben und nachsehen, stellen Sie fest, dass der Node-Prozess tatsächlich als Root-Benutzer ausgeführt wird.
Auch wenn der Prozess innerhalb eines Containers mit einem Linux-Prozess-Namespace läuft, der ihn vom restlichen Host isoliert, ist die Ausführung mit UID 0 aus mehreren Gründen keine gute Idee. Häufige Probleme entstehen durch Fehlkonfigurationen beim Deployment, zum Beispiel wenn ein Host-Volume in den Container eingebunden wird und dadurch mehr Informationen als beabsichtigt offengelegt werden. Der Benutzer im Container hat auf diesem Dateisystem dieselben Berechtigungen wie ein Benutzer mit derselben UID auf dem Host. Wird beispielsweise das Host-Verzeichnis /etc in den Container eingebunden, kann ein Prozess mit Root-Berechtigungen im Container alle Dateien im Host-Ordner /etc lesen und bearbeiten.
Wissen Sie noch, dass Layer unveränderlich sind und das Löschen einer Datei in einem Layer deren Inhalt nicht tatsächlich aus den übergeordneten Layern entfernt? Das kann ausgenutzt werden, wenn ein Prozess im Container die Layer-Dateien des Host-Container-Images lesen kann, weil jemand den Pfad /var/lib/docker in den Container eingebunden hat. Unter diesem Pfad sind alle Layer verfügbar. Ein bösartiger Prozess müsste also nur alle Layer nach ausnutzbarer Software durchsuchen. (Oder /var/lib/containerd beziehungsweise den Pfad, unter dem Ihre Container-Laufzeitumgebung die Dateien speichert.) Zum Glück haben die Maintainer der offiziellen Node-Images bereits einen node-Benutzer mit der UID 1000 angelegt. Um ihn zu verwenden, müssen wir unserer Dockerfile lediglich eine Zeile mit USER 1000 hinzufügen. Wir verwenden die UID statt des Benutzernamens, da manche Tools, beispielsweise Kubernetes, den Standardbenutzernamen eines Images vor dem Start des Containers nicht seiner UID zuordnen können. Diese Zuordnung kann jedoch erforderlich sein, um Richtlinien für Root-Benutzer durchzusetzen. Auf eine solche Richtliniendurchsetzung kommen wir später in diesem Dokument zurück.
Wenn das von Ihnen gewählte Basis-Image keinen vorab angelegten Benutzer enthält, müssen Sie auch einen erstellen. Fügen Sie dazu die erforderliche RUN-Zeile hinzu, zum Beispiel adduser, und anschließend die USER-Zeile, um zu dieser UID zu wechseln. Achten Sie darauf, die Dateieigentümerschaft und Berechtigungen in Ihrem Image so festzulegen, dass Ihre Anwendung als dieser neue Benutzer ausgeführt werden kann. Häufig gehören ausführbare Dateien und andere Dateien Root, sind aber für Benutzer les- und ausführbar. So wird verhindert, dass ein fehlerhafter oder bösartiger Prozess sie zur Laufzeit verändern kann.
Nachdem wir das Image erstellt und den Pod aktualisiert haben, sieht die Situation schon deutlich besser aus.
5. Setzen Sie bei der Bereitstellung Kontrollen zur Einschränkung der Root-Benutzernutzung durch
Da das Image nun so geändert wurde, dass es als Nicht-Root-Benutzer ausgeführt wird, stellen wir sicher, dass dies auch so bleibt, indem wir es im Kubernetes-Deployment-Manifest durchsetzen. Zunächst lasse ich unsere Datei goof-deployment.yaml vom Snyk IaC-Scanner prüfen.
Wie Sie sehen, gibt es mehrere Probleme. Eines davon mit mittlerem Schweregrad: Der Container wird ohne Kontrolle über den Root-Benutzer ausgeführt. Die Behebung ist einfach: Fügen Sie runAsNonRoot: true zum securityContext von pod:spec oder container:spec hinzu. Ich bevorzuge die Pod-Ebene, da die Einstellung dann für alle Container im Pod gilt, sofern sie nicht ausdrücklich überschrieben wird. Wird diesem Pod ein weiterer Container hinzugefügt, gilt die Einstellung automatisch auch für ihn.
Damit verhindern wir, dass jemand das Image wieder so ändert, dass es als Root-Benutzer ausgeführt wird: Beim Test schlägt das Deployment dann fehl. Im Beispiel-Repository wurde diese Änderung in manifests/good-deployment.yaml-nonroot vorgenommen.
6. Legen Sie bei Bedarf zur Laufzeit einen Benutzer fest
Aufmerksamen Leserinnen und Lesern ist vielleicht aufgefallen, dass das zweite Deployment, goof-mongo, bereits den securityContext enthält und dort außerdem eine UID festgelegt ist.
Wir fügen dort das Feld runAsUser hinzu, weil wir das unveränderte offizielle mongo-Basis-Image von Docker Hub verwenden. Daher haben wir keine weitere Dockerfile, in der wir die Zeile USER festlegen könnten. Wir vertrauen darauf, dass sich die UID des offiziellen mongo-Images nicht ändert. Bei unserem Anwendungs-Image goof kontrollieren wir die UID über die Dockerfile. Sie im Deployment-Manifest erneut festzulegen, bringt daher keinen Vorteil und würde gegen das DRY-Softwareprinzip verstoßen.
Beachten Sie, dass die Image-Dokumentation von Docker Hub für mongo (zum Zeitpunkt der Erstellung dieses Artikels) diesen Benutzer oder seine UID nicht erwähnt. Um sie herauszufinden, habe ich docker run --rm -it mongo id ausgeführt und überprüft, dass der Prozess tatsächlich als root lief. Anschließend habe ich docker run --rm -it --entrypoint cat mongo /etc/passwd ausgeführt und am Ende der Datei mongodb:x:999:999 gefunden. Erste Tests zeigten, dass die Ausführung als dieser Benutzer funktioniert. In einer realen Situation sollten Sie jedoch gründlicher recherchieren, bevor Sie eine nicht dokumentierte Änderung wie diese verwenden.
7. Entfernen Sie nicht benötigte Capabilities Ihrer Anwendung
Bei diesem IaC-Scan wurde als weiteres Problem mittleren Schweregrads gemeldet, dass unser Container mit den standardmäßigen Capabilities läuft. Unsere Anwendung ist einfach und muss keine Funktionen auf Kernel-Ebene aufrufen, etwa um Dateieigentümer zu ändern oder Aspekte des Host-Netzwerks zu steuern. Wir können die Sicherheit erhöhen, indem wir der Container-Laufzeitumgebung vorgeben, alle diese Capabilities zu entfernen. Das macht es potenziellen Angreifern noch schwerer, falls sie in unseren Container eindringen. Dazu fügen wir unserer Container-Spezifikation einfach einen Block hinzu, der alle Capabilities entfernt.
Wenden Sie das neue Manifest an und testen Sie die Anwendung erneut. Im Beispiel-Repository enthält die Datei manifests/good-deployment.yaml-nonroot-dropcapabilities diese Änderungen.
Auch beim Deployment goof-mongo wurde dieselbe Änderung bereits vorgenommen, da auch die Datenbank diese Capabilities nicht benötigt.
Die Einstellungen für den Kubernetes-securityContext können komplex sein. Weitere Informationen finden Sie in unserem CheatSheet: 10 Kubernetes Security Context-Einstellungen, die Sie kennen sollten.
Der Snyk IaC-Scan hat außerdem mehrere Probleme mit niedrigem Schweregrad aufgedeckt, die oben nicht abgebildet sind. Auch diese sollten behoben werden, liegen jedoch außerhalb des Umfangs dieses Artikels.
An diesem Punkt committe ich meine Änderungen und pushe sie zu GitHub. Ich habe das Build-Problem behoben und möchte nicht noch mehr Änderungen in einem Commit bündeln. Da Snyk mein GitHub-Repository bereits überwacht, wird beim Erstellen eines Pull Requests von meinem Branch in den Branch main ein schneller Schwachstellen-Scan der von mir vorgenommenen Dockerfile-Änderungen ausgeführt. In diesem Fall haben meine Änderungen weder Sicherheitslücken noch Lizenzprobleme hinzugefügt, daher zeigen die Tests, dass sie bestanden wurden.

Wir mergen diese Änderungen jetzt. Damit kommen wir zum nächsten Thema auf unserem Weg zu einem sicheren Image: dem Scannen auf Schwachstellen.
8. Verwenden Sie einen Image-Schwachstellenscanner
Nachdem wir die noch offenen konfigurationsbasierten Sicherheitsprobleme behoben haben, wenden wir uns dem Teil des Images zu, den wir nicht direkt kontrollieren: den Paketen, die wir aus dem Basis-Image übernehmen, sowie den Paketen, die wir möglicherweise hinzufügen. Um herauszufinden, welche potenziellen Exploits unser Image betreffen könnten, lassen wir einen Schwachstellenscanner darüber laufen. Er sucht nach bekannten CVEs in den installierten Paketen.
Ein Snyk Container-Scan findet in diesem Image 825 Probleme mit unterschiedlichen Schweregraden.
Der Bericht zeigt, dass sie alle aus dem Basis-Image node:14.1.0 stammen, auf dem wir aufbauen. Das ist nachvollziehbar, da wir unserer Dockerfile keine zusätzlichen Pakete über apt-get oder ähnliche Wege hinzufügen.
Bei einer so großen Zahl von Problemen lassen sich Prioritäten oft am einfachsten in der entsprechenden Snyk-Webkonsole setzen. Da mein Repository bereits überwacht wird, sehen wir uns den Scan an, der durch das soeben durchgeführte Mergen des Pull Requests ausgelöst wurde.

Hier sehen Sie eine nach Priorität geordnete Tabelle der gefundenen Schwachstellen. Berücksichtigt werden dabei Kennzahlen wie der CVSS-Score, der Reifegrad von Exploits und die aktuelle Verfügbarkeit von Korrekturen. Sowohl die CLI- als auch die Web-Berichte empfehlen außerdem alternative Basis-Images mit weniger bekannten Schwachstellen.

In diesem Fall scheint ein Upgrade unseres Basis-Images auf node:fermium-buster-slim die beste und kompatibelste Option zu sein. Mit „slim“ gekennzeichnete Images sind deutlich reduziert. Testen Sie daher die Kompatibilität, falls Ihre Anwendung Pakete verwendet, die darin nicht enthalten sind.
Wir könnten diese Änderung in unserem Code vornehmen. Die Weboberfläche bietet jedoch neben jeder Empfehlung mit der Schaltfläche „Open a fix PR“ auch eine automatisierte Korrektur. Verwenden wir also diese Option.

Es wird eine Bestätigungsseite angezeigt, auf der die bevorstehende Änderung beschrieben wird. Anschließend gelangen Sie direkt zur GitHub-Pull-Request-Seite.

In der Praxis würde dieser Pull Request dieselben Code-Review- und Anwendungstests durchlaufen wie jede andere Änderung. Fürs Erste mergen wir diese Änderung.
Nachdem ich die Änderungen in mein lokales Git-Repository übernommen und mein Image neu erstellt und gescannt habe, ist die Zahl der Schwachstellen auf 58 gesunken! Durch den Wechsel zum kleineren „slim“-Basis-Image hat sich außerdem die Gesamtgröße von 1,03 GB auf nur noch 261 MB reduziert.
9. Ziehen Sie eine noch stärkere Verkleinerung des Images in Betracht
Ein zentrales Prinzip des Container-Paradigmas ist es, die Umgebung, in der ein Prozess ausgeführt wird, möglichst klein zu halten. Wir haben unser Image bereits deutlich verkleinert und abgesichert, packen für das Deployment in der Produktionsumgebung aber immer noch viel mehr ein, als nötig ist. Wir könnten unser Image Schritt für Schritt auseinandernehmen und alle überflüssigen Dateien und Pakete entfernen, die in der Debian-Basisdistribution enthalten sind. Es gibt jedoch einfachere Lösungen – mit gewissen Kompromissen.
Images auf Alpine-Basis
Eine der einfachsten Lösungen ist der Wechsel zu einem anderen Basisbetriebssystem, das auf Sicherheit und eine geringe Größe ausgelegt ist: Alpine. Images auf Alpine-Basis sind für ihren geringen Speicherbedarf bekannt, vor allem weil nur sehr wenige Pakete vorinstalliert sind. Aus Sicherheitssicht ist das gut, denn nicht vorhandene Dateien können nicht ausgenutzt werden. Passen wir unsere Dockerfile so an, dass sie das Basis-Image node:14-alpine verwendet.
Wie Sie sehen, habe ich einige Dinge umsortiert und die App in ein anderes Verzeichnis verschoben, damit sie den Konventionen des Alpine-Basis-Images entspricht. Wie erwartet, hat sich die Größe unseres Images durch diese Änderung beim Build drastisch verringert.
Sehen wir uns an, was der Snyk Container-Scan für unser neues Image ergibt:
Keine anfälligen Pfade gefunden! Besser geht es nicht!
Warum sollten wir also nicht immer Alpine-Basis-Images verwenden? Aus der Dokumentation zum Node-Image:
Der wichtigste Vorbehalt: Es verwendet musl libc anstelle von glibc und anderen Bibliotheken. Je nachdem, wie umfangreich die Anforderungen und Annahmen einer Software in Bezug auf libc sind, können daher Probleme auftreten. Weitere Informationen zu möglichen Problemen und einen Vergleich der Vor- und Nachteile von Images auf Alpine-Basis finden Sie in diesem Hacker-News-Kommentar-Thread.
In vielen Fällen ist das völlig unproblematisch, insbesondere bei Plattformen wie Node, für die eine Cross-Kompilierung möglich ist. Der Wechsel zu einer völlig anderen Sammlung von Low-Level-C-Bibliotheken kann Anwendungen jedoch mitunter beeinträchtigen. Aus diesem Grund empfehlen Schwachstellen-Scanner Alpine möglicherweise nicht, um die Anzahl der Schwachstellen zu senken, wenn sie ein Image auf glibc-Basis scannen. Häufig scheitert der Wechsel zu Alpine bei älteren Anwendungen, die auf vorkompilierte Bibliotheken angewiesen sind, etwa einer Java-Anwendung mit JNI-Aufrufen oder einer Go-Anwendung mit cgo. Wenn Sie von einem anderen Linux-basierten Image zu Alpine wechseln, sollten Sie Ihre Anwendung in jedem Fall umfassenden Funktions- und Leistungstests unterziehen – schließlich wechseln Sie gewissermaßen das Betriebssystem.
Distroless-Images
Wenn Alpine nicht infrage kommt, gibt es ein von Google betreutes Projekt namens Distroless. Es stellt Images bereit, die meist auf Debian basieren, aber auf das absolute Minimum reduziert sind – oft enthalten sie nicht einmal eine Shell, in der Befehle ausgeführt werden können. Diese Images werden vom Google-Distroless-Team gepflegt. Weitere Informationen finden Sie im GitHub-Repository.
Scratch-basierte Images selbst erstellen
Das Basis-Image scratch ist eigentlich gar kein Image. Es ist ein reserviertes Token in der Dockerfile-Spezifikation, mit dem der Image-Build mit einem vollständig leeren Dateisystem beginnt. Keine Shell, kein Paketmanager, überhaupt nichts. Alles, was Sie diesem Image hinzufügen möchten, müssen Sie selbst hineinkopieren. In der Regel kommt scratch als letzte Phase einer Dockerfile mit Multi-Stage-Builds zum Einsatz. Diese Funktion ermöglicht mehrere FROM-Zeilen. Die letzte davon bestimmt die Basis des fertigen Images. Bei der häufigsten Form eines Multi-Stage-Builds wird in einer „Build“-Phase kompiliert und anschließend das erzeugte Artefakt in die finale Phase kopiert. Bei einer finalen Phase mit scratch müssen Sie alles hineinkopieren, was für die Ausführung Ihrer ausführbaren Datei benötigt wird. Deshalb wird diese Methode selten für interpretierte Sprachen oder Sprachen verwendet, die eine Laufzeitumgebung benötigen, etwa Node.js, Java oder Python. Sie eignet sich besonders für Sprachen, deren Kompilierung eine einzelne oder nur wenige Dateien ergibt. C-, C++- und Go-Anwendungen verwenden häufig scratch-Images.
10. Image-Builder ohne Dockerfile untersuchen
In diesem Dokument haben wir uns auf den Image-Build mit Dockerfiles konzentriert – das mit Abstand am häufigsten verwendete Tool zum Erstellen von Images. Images lassen sich jedoch auch auf andere Weise und ohne Dockerfiles erstellen. Zu den bekannteren Optionen zählen Bazel, jib und skriptbasiertes Buildah. Auch Docker unterstützt über das Projekt BuildKit weitere Build-Skript-Techniken. BuildKit ist ein optionaler Builder, der mit dem Docker-Client bereitgestellt wird.
Fazit und weiterführende Links
Das Beispiel, das wir durchgegangen sind, ist recht einfach, da JavaScript eine interpretierte Sprache ist. Wenn Sie jedoch mit Sprachen arbeiten, bei denen Kompilierungs- und Laufzeitphasen klarer voneinander abgegrenzt sind, sollten Sie sich die oben erwähnten Multi-Stage-Dockerfiles ansehen. Damit können Sie weiterhin Builds in Ihrer Dockerfile durchführen und zugleich Compiler und Quellcode aus dem finalen Image heraushalten, das Sie bereitstellen.
Weitere Informationen zum Ausführen von Node.js in Containern finden Sie unter 10 Best Practices zum Containerisieren von Node.js-Webanwendungen mit Docker. Eine entsprechende Version für Java-Anwendungen finden Sie hier: 10 Best Practices zum Erstellen eines Java-Containers mit Docker.
Container-Sicherheit mit Fokus auf Entwickler
Snyk findet und behebt automatisch Schwachstellen in Container-Images und Kubernetes-Workloads.
Wenn Sie die alternativen Container-Tools von RedHat verwenden, lesen Sie unseren Blogbeitrag zu Befehlszeilen-Tools für Container – Snyk mit Buildah, Podman und Skopeo verwenden.
Wie bereits erwähnt, kann die Einrichtung der Security-Context-APIs für Kubernetes eine Herausforderung sein. Der Beitrag 10 Kubernetes-Security-Context-Einstellungen, die Sie kennen sollten erklärt diese Einstellungen und hilft Ihnen dabei, sichere Entscheidungen für Ihre Anwendung zu treffen. Eine leistungsstarke Option in diesen Einstellungen ist, Ihren Container im Nur-Lese-Modus auszuführen. Das kann einen ausgezeichneten Schutz vor böswilligen Änderungen am Dateisystem Ihres Containers bieten. Weitere Informationen dazu finden Sie in diesem Cheatsheet.
Möchten Sie mehr über Docker-Images erfahren? Adam Gordon Bell hat einen hervorragenden Blogbeitrag, der detailliert erklärt, wie Images aufgebaut sind. Außerdem hielten einige Docker Captains während des 2. Docker Community All-Hands-Webinars hervorragende Vorträge über Docker-Images:
Best Practices zum Erstellen von Images – Michael Irwin
Das Design von Images verstehen – Brandon Mitchel
Einführung in buildx bake --push – Kevin "CrazyMax" Alvarez
Wir hoffen, dieser Leitfaden hat Ihnen geholfen, besser zu verstehen, wie Container-Images funktionieren und wie Sie Tools einsetzen können, um Ihre containerisierten Anwendungen abzusichern und zu warten.
