Skip to main content

Entwicklergesteuerte Workflows: Dockerfile-Image-Scanning, Priorisierung und Behebung

Artikel von
Blog Design Developer Driven Workflows updated

26. März 2021

0 Min. Lesezeit

Beim 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


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.

Gestapeltes Diagramm mit einer Lese- und Schreibebene über mehreren Ebenen mit den Bezeichnungen „Layer“ und „Base“ sowie SHA-256-Kennungen.

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.

FROM node:14.1.0

RUN mkdir /usr/src/goof
RUN mkdir /tmp/extracted_files
ADD . /usr/src/goof
RUN cd /usr/src/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT npm start

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.

RUN apt-get update -y && \
    apt-get install -y --no-install-recommends curl=7.68.0-1ubuntu2.4 && \
    rm -rf /var/lib/apt/lists/*

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.

$ docker build -t goof -f Dockerfile-initial .
[+] Building 11.3s (10/10) FINISHED                                                                                                                                                
 => [internal] load build definition from Dockerfile      0.0s
...
 => ERROR [6/6] RUN npm ci --only=production              1.0s
------
 > [6/6] RUN npm ci --only=production:
#10 0.936 npm ERR! code ENOENT
#10 0.937 npm ERR! syscall open
#10 0.937 npm ERR! path /package.json
#10 0.938 npm ERR! errno -2
#10 0.939 npm ERR! enoent ENOENT: no such file or directory, open '/package.json'
#10 0.939 npm ERR! enoent This is related to npm not being able to find a file.
#10 0.939 npm ERR! enoent 
#10 0.948 
#10 0.948 npm ERR! A complete log of this run can be found in:
#10 0.949 npm ERR!     /root/.npm/_logs/2021-03-17T17_19_44_616Z-debug.log
------
executor failed running [/bin/sh -c npm ci --only=production]: exit code: 254

Hadolint ist ein beliebter Open-Source-Linter für Dockerfiles. Er analysiert die Schritte und gibt konkrete Empfehlungen zu deren Struktur.

$ hadolint Dockerfile-initial 
Dockerfile:5 DL3020 error: Use COPY instead of ADD for files and folders
Dockerfile:6 SC2164 warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Dockerfile:6 DL3003 warning: Use WORKDIR to switch to a directory
Dockerfile:11 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

Sehen wir uns die gefundenen Probleme an:

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

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

  3. Verwenden Sie WORKDIR, um in ein Verzeichnis zu wechselnGenau! Das ist die Ursache. Die Wirkung der Zeile RUN cd /usr/src/goof ist 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-Befehl WORKDIR verwenden, da er das aktuelle Arbeitsverzeichnis ab diesem Punkt ausdrücklich festlegt – auch zur Laufzeit. Weil dieser Befehl fehlt, kann die Zeile RUN npm… die Datei package.json nicht finden.

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

FROM node:14.1.0

RUN mkdir /usr/src/goof
RUN mkdir /tmp/extracted_files
COPY . /usr/src/goof
WORKDIR /usr/src/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT ["npm", "start"]

$ hadolint Dockerfile-hadolint-fixes 

$ docker build -t goof -f Dockerfile-hadolint-fixes .
[+] Building 26.5s (11/11) FINISHED                                                                                                                                                
 => [internal] load build definition from Dockerfile-hadolint-fixes                0.0s
...
 => => writing image sha256:c365dec0cfdf64d8cfd43ebd8f2bcd534cfe7b71849796dfb3030b4fe5ff7f93               0.0s 
 => => naming to docker.io/library/goof:hadolint-fixes

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.

#!/bin/sh
echo "Linting Dockerfile ...\c"
LINT=$(hadolint Dockerfile)
if [[ $? > 0 ]]
then
   echo " FAILED\n"
   echo "$LINT"
   echo "\nLinting failed, commit aborted."
   exit 1
fi
echo " PASSED"
exit 0

Hier sehen Sie ein Beispiel dafür, wie der Hook mit dem ursprünglichen Dockerfile ausgeführt wird, bevor wir Korrekturen vorgenommen haben.

$ git commit -m 'Test'
Linting Dockerfile ... FAILED

Dockerfile:5 DL3020 error: Use COPY instead of ADD for files and folders
Dockerfile:6 SC2164 warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Dockerfile:6 DL3003 warning: Use WORKDIR to switch to a directory
Dockerfile:11 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

Linting failed, commit aborted.

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:

  1. Führen Sie mit docker run einen 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.

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

---
apiVersion: apps/v1
kind: Deployment
metadata:
 name: goof
spec:
 replicas: 1
 selector:
   matchLabels:
     app: goof
     tier: frontend
 template:
   metadata:
     labels:
       app: goof
       tier: frontend
   spec:
     containers:
       - name: goof
         image: goof:latest
         resources:
           requests:
             cpu: 100m
             memory: 100Mi
         ports:
           - containerPort: 3001
           - containerPort: 9229
         env:
           - name: DOCKER
             value: "1"
---
apiVersion: apps/v1
kind: Deployment
metadata:
 name: goof-mongo
spec:
 replicas: 1
 selector:
   matchLabels:
     app: goof
     tier: backend
 template:
   metadata:
     labels:
       app: goof
       tier: backend
   spec:
     securityContext:
       runAsNonRoot: true
       runAsUser: 999
     containers:
       - name: goof-mongo
         image: mongo
         securityContext:
           capabilities:
             drop:
               - all
         ports:
           - containerPort: 27017

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:

...
     containers:
       - name: goof
         image: goof:latest
         imagePullPolicy: Never
...

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.

$ kubectl apply -f manifests/
deployment.apps/goof created                                                  deployment.apps/goof-mongo created
service/goof created
service/goof-mongo created

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
goof-6bf7cfb886-qjgdd         1/1     Running   0          6m7s
goof-mongo-66f98d594c-vsphr   1/1     Running   0          30m

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 goof aus und verwenden Sie die erste angezeigte URL.

  • Andere Wenn kubectl get service goof eine 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:

Goof-Überschrift über einem leeren Texteingabefeld

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:

  1. Änderungen am Code vornehmen

  2. Mit dem Befehl docker build und/oder den Tools Ihrer Programmiersprache ein neues Image erstellen

  3. Das Image in den Cluster oder in eine Registry pushen (falls erforderlich)

  4. 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 image verwenden, sofern das neue Image einen neuen Tag-Namen hat.

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

$ kubectl exec -it goof-6bf7cfb886-slkxm -- id
uid=0(root) gid=0(root) groups=0(root)

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.

USER 1000
ENTRYPOINT ["npm", "start"]

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.

$ kubectl exec -it goof-6bf7cfb886-qxb6w -- id
uid=1000(node) gid=1000(node) groups=1000(node)

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.

$ snyk iac test manifests/goof-deployment.yaml 

Testing manifests/goof-deployment.yaml...

Infrastructure as code issues:
  ✗ Container is running with default set of capabilities [Medium Severity] [SNYK-CC-K8S-6] in Deployment
    introduced by input > spec > template > spec > containers[goof] > securityContext > capabilities > drop

  ✗ Container is running without root user control [Medium Severity] [SNYK-CC-K8S-10] in Deployment
    introduced by input > spec > template > spec > containers[goof] > securityContext > runAsNonRoot
...

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.

...
 template:
   spec:
     securityContext:
       runAsNonRoot: true
     containers:
       - name: goof
...

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.

...
  securityContext:
    runAsNonRoot: true
    runAsUser: 999
...

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.

...
  containers:
    - name: goof
      image: goof:latest
      securityContext:
        capabilities:
          drop:
            - all
...

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.

GitHub-Pull-Request mit allen bestandenen Prüfungen, keinen Merge-Konflikten und einem grünen Button „Pull Request zusammenführen“

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.

$ snyk container test goof: --file=Dockerfile
...
Tested 412 dependencies for known issues, found 825 issues.

Base Image   Vulnerabilities  Severity
node:14.1.0  825              189 high, 178 medium, 458 low

Recommendations for base image upgrade:

Minor upgrades
Base Image  Vulnerabilities  Severity
node:14.15  571              63 high, 64 medium, 444 low

Major upgrades
Base Image  Vulnerabilities  Severity
node:15     562              58 high, 61 medium, 443 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:current-buster-slim   58               10 high, 6 medium, 42 low
node:fermium-stretch-slim  75               18 high, 9 medium, 48 low
node:15.10-slim            75               18 high, 9 medium, 48 low
node:14.16.0-buster        325              35 high, 49 medium, 241 low

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.

Liste von Schwachstellen mit hochgradigen Node.js-Problemen bei HTTP-Request-Smuggling, unzureichender Hostnamenverifizierung und Speicherkorruption samt Bewertungen.

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.

Tabelle zum Vergleich von Docker-Basis-Image-Upgrades, Schwachstellenanzahl, Schweregradbewertungen und Optionen zum „PR zur Behebung öffnen“.

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.

Seite „Fix-PR öffnen“ für ericsmalling/goof (main):Dockerfile mit der Option, einen Pull Request für Upgrades und Patches zu öffnen.

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

GitHub-Pull-Request von Snyk mit einem Sicherheitsupgrade für ein Docker-Basisimage, einer Schwachstellentabelle, bestandenen Prüfungen und einer Option zum Zusammenführen.

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.

REPOSITORY   TAG                   IMAGE ID       CREATED             SIZE
goof         old                   f2f1ca19cdc9   48 minutes ago      1.03GB
goof         slim                  865cee656a5f   8 seconds ago       261MB
node         fermium-buster-slim   d1bc1f4d4c5d   4 days ago          180MB
node         14.1.0                a511eb5c14ec   10 months ago       941MB

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.

FROM node:14-alpine

USER node
RUN mkdir /home/node/goof
RUN mkdir /tmp/extracted_files
COPY . /home/node/goof
WORKDIR /home/node/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT ["npm", "start"]

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.

$ docker images
REPOSITORY   TAG                   IMAGE ID       CREATED          SIZE
goof         alpine                3aa775448685   11 minutes ago   197MB
goof         slim                  8b4795653de3   3 hours ago      261MB

Sehen wir uns an, was der Snyk Container-Scan für unser neues Image ergibt:

$ snyk container test goof:alpine --file=Dockerfile

Testing goof:alpine...

Organization:      eric-smalling-snyk
Package manager:   apk
Target file:       Dockerfile
Project name:      docker-image|goof
Docker image:      goof:alpine
Platform:          linux/amd64
Base image:        node:14-alpine
Licenses:          enabled

✓ Tested 16 dependencies for known issues, no vulnerable paths found.

According to our scan, you are currently using the most secure version of the selected base image

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.

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:

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.