10 Best Practices für die Containerisierung von Node.js-Webanwendungen mit Docker
15. September 2022
0 Min. LesezeitSuchen Sie nach Best Practices für das Erstellen von Node.js-Docker-Images für Ihre Webanwendungen? Dann sind Sie hier genau richtig!
Der folgende Artikel bietet produktionsreife Richtlinien für das Erstellen optimierter und sicherer Node.js-Docker-Images. Er ist unabhängig davon hilfreich, welche Node.js-Anwendung Sie entwickeln möchten. Dieser Artikel ist für Sie hilfreich, wenn:
Sie mithilfe der serverseitigen Rendering-Funktionen (SSR) von Node.js eine Frontend-Anwendung für React entwickeln möchten.
Sie nach Tipps suchen, wie Sie ein Node.js-Docker-Image für Ihre Microservices erstellen, die Fastify, NestJS oder andere Anwendungs-Frameworks verwenden.
Warum haben wir diesen Leitfaden zur Containerisierung von Node.js-Docker-Webanwendungen geschrieben?
Vielleicht wirkt es wie ein weiterer Artikel zum Erstellen von Docker-Images für Node.js-Anwendungen. Doch viele Beispiele, die wir in Blogs gesehen haben, sind sehr oberflächlich und erklären lediglich die Grundlagen dafür, wie ein Node.js-Docker-Image eine Anwendung ausführt – ohne Sicherheitsaspekte und Best Practices für das Erstellen von Node.js-Docker-Images sorgfältig zu berücksichtigen.
Wir lernen Schritt für Schritt, wie Sie Node.js-Webanwendungen containerisieren: angefangen bei einer einfachen, funktionierenden Dockerfile über das Verständnis der Fallstricke und Sicherheitslücken jeder Dockerfile-Anweisung bis hin zu deren Behebung.
Ein einfaches Node.js-Docker-Image
Die meisten Blogartikel, die wir gesehen haben, beginnen und enden mit grundlegenden Dockerfile-Anweisungen wie den folgenden zum Erstellen von Node.js-Docker-Images:
Kopieren Sie den Inhalt in eine Datei namens Dockerfile und erstellen Sie anschließend das Image und starten Sie es.
Es ist einfach und funktioniert.
Das einzige Problem? Es steckt voller Fehler und schlechter Praktiken für das Erstellen von Node.js-Docker-Images. Vermeiden Sie es unbedingt.
Verbessern wir diese Dockerfile, damit wir optimierte Node.js-Webanwendungen mit Docker erstellen können.
Sie können diesem Tutorial folgen, indem Sie dieses Repository klonen.
Befolgen Sie diese 10 Schritte, um optimierte Node.js-Webanwendungen mit Docker zu erstellen:
Explizite und deterministische Docker-Basis-Image-Tags verwenden
Nur Produktionsabhängigkeiten im Node.js-Docker-Image installieren
Node.js-Tools für die Produktion optimieren
Container nicht als Root ausführen
Node.js-Docker-Webanwendungen sicher beenden
Graceful Shutdown für Ihre Node.js-Webanwendungen
Sicherheitslücken in Ihrem Node.js-Docker-Image finden und beheben
Multi-Stage-Builds verwenden
Unnötige Dateien aus Ihren Node.js-Docker-Images heraushalten
Secrets in das Docker-Build-Image einbinden
1. Explizite und deterministische Docker-Basis-Image-Tags verwenden
Es mag naheliegend erscheinen, Ihr Image auf dem node-Docker-Image aufzubauen. Doch was ziehen Sie beim Erstellen des Images tatsächlich herunter? Docker-Images werden immer über Tags referenziert. Wenn Sie kein Tag angeben, wird standardmäßig das Tag :latest verwendet.
Wenn Sie also Folgendes in Ihrer Dockerfile angeben, erstellen Sie immer die neueste Version des Docker-Images, das von der Node.js Docker Working Group erstellt wurde:
Das Erstellen auf Grundlage des standardmäßigen node-Images hat folgende Nachteile:
Docker-Image-Builds sind inkonsistent. So wie wir
lockfilesverwenden, um bei jeder Installation von npm-Paketen ein deterministisches Verhalten vonnpm installzu erzielen, möchten wir auch deterministische Docker-Image-Builds erhalten. Wenn wir das Image auf Grundlage von node erstellen – was effektiv dem Tagnode:latestentspricht –, lädt jeder Build ein neu erstelltes Docker-Image vonnodeherunter. Ein solches nicht deterministisches Verhalten möchten wir vermeiden.Das node-Docker-Image basiert auf einem vollwertigen Betriebssystem mit zahlreichen Bibliotheken und Tools, die Sie möglicherweise für den Betrieb Ihrer Node.js-Webanwendung benötigen oder auch nicht. Das hat zwei Nachteile. Erstens bedeutet ein größeres Image eine größere Downloadmenge. Dadurch steigt nicht nur der Speicherbedarf, sondern es dauert auch länger, das Image herunterzuladen und neu zu erstellen. Zweitens können Sie damit Sicherheitslücken, die in diesen Bibliotheken und Tools vorhanden sein können, in Ihr Image einführen.
Tatsächlich ist das node-Docker-Image ziemlich groß und enthält Hunderte von Sicherheitslücken verschiedener Arten und Schweregrade. Wenn Sie es verwenden, starten Sie standardmäßig mit einer Basis von 642 Sicherheitslücken und Hunderten Megabyte an Image-Daten, die bei jedem Abruf und Build heruntergeladen werden.

Für bessere Docker-Images empfehlen wir Folgendes:
Verwenden Sie kleine Docker-Images. Das reduziert den Softwareumfang im Docker-Image und damit potenzielle Angriffsvektoren. Zudem fällt die Image-Größe geringer aus, was den Image-Build beschleunigt.
Verwenden Sie den Docker-Image-Digest, also den statischen SHA256-Hash des Images. So stellen Sie sicher, dass Docker-Image-Builds auf Grundlage des Basis-Images deterministisch sind.
Wir haben einen ausführlichen Artikel darüber geschrieben, wie Sie das beste Node.js-Docker-Image auswählen. Darin erfahren Sie, warum eine aktuelle Debian-Slim-Distribution mit einer Node.js-Runtime-Version mit Long-Term-Support die ideale Wahl ist.
Wir empfehlen folgendes Node.js-Docker-Image:
Dieses Node.js-Docker-Image-Tag verwendet eine bestimmte Version der Node.js-Runtime (20.9.0), die der aktuellsten Long-Term-Support-Version entspricht. Es verwendet die Image-Variante bullseye, die der aktuellen stabilen Version Debian 11 mit einem ausreichend weit in der Zukunft liegenden Support-Ende entspricht. Schließlich wird die Image-Variante slim verwendet, um den Softwareumfang des Betriebssystems zu reduzieren. So bleibt das Image einschließlich Node.js-Runtime und Tools kleiner als 200 MB.
Eine häufige, auf fehlendem Wissen beruhende Praxis in Tutorials und Leitfäden ist die Verwendung der folgenden Docker-Anweisung für ein Basis-Image:
In diesen Artikeln wird die Verwendung des Node.js-Alpine-Docker-Images empfohlen. Doch ist das wirklich ideal? Die Empfehlung beruht meist darauf, dass das Node.js-Alpine-Docker-Image einen geringeren Softwareumfang hat. Es unterscheidet sich jedoch in anderen Merkmalen erheblich und ist daher kein optimales Produktions-Basis-Image für Node.js-Anwendungs-Runtimes.
Was ist Node Alpine?
Node.js Alpine ist ein inoffizielles Docker-Container-Image, das vom Node.js-Docker-Team gepflegt wird. Das Node.js-Image bündelt das Alpine-Betriebssystem, das auf den minimalen Software-Tools busybox und der C-Bibliotheksimplementierung musl basiert. Diese beiden Merkmale des Node.js-Alpine-Images tragen dazu bei, dass es vom Node.js-Team inoffiziell unterstützt wird. Außerdem können viele Scanner für Sicherheitslücken Software-Artefakte oder Runtimes in Node.js-Alpine-Images nicht ohne Weiteres erkennen. Das wirkt den Bemühungen entgegen, Ihre Container-Images zu schützen.
Auch wenn Sie das Node.js-Alpine-Image-Tag verwenden: Eine Basis-Image-Anweisung in Form eines Alias-Worts kann weiterhin neue Builds dieses Tags abrufen, da Docker-Image-Tags veränderlich sind. Den SHA256-Hash finden Sie im Docker Hub für dieses Node.js-Tag oder, nachdem Sie dieses Image lokal abgerufen haben, mit dem folgenden Befehl im Ausgabefeld Digest:
Eine weitere Möglichkeit, den SHA256-Hash zu finden, ist die Ausführung des folgenden Befehls:
Nun können wir die Dockerfile für dieses Node.js-Docker-Image wie folgt aktualisieren:
Die obige Dockerfile gibt jedoch nur den Namen des Node.js-Docker-Images an, nicht aber ein Image-Tag. Dadurch ist unklar, welches Image-Tag genau verwendet wird. Die Angabe ist nicht lesbar, schwer zu warten und sorgt für keine gute Developer Experience.
Beheben wir das, indem wir das Dockerfile aktualisieren und das vollständige Basis-Image-Tag für die Node.js-Version angeben, die diesem SHA256-Hash entspricht:
Der Docker-Image-Digest gewährleistet ein deterministisches Image, kann jedoch für manche Image-Scanning-Tools verwirrend oder kontraproduktiv sein, wenn sie ihn nicht interpretieren können. Deshalb ist eine explizite Node.js-Runtime-Version wie 20.9.0 vorzuziehen. Auch wenn sie theoretisch veränderlich und überschreibbar ist, werden Sicherheitsupdates und andere Aktualisierungen in der Praxis in einer neuen Version wie 20.9.1 bereitgestellt. Daher können Sie von ausreichend deterministischen Builds ausgehen.
Unsere finale Dockerfile-Empfehlung für diesen Schritt sieht daher so aus:
Weitere Tipps und Best Practices für das Erstellen sicherer Container-Images.
2. Nur Produktionsabhängigkeiten im Node.js-Docker-Image installieren
Die folgende Dockerfile-Anweisung installiert alle Abhängigkeiten im Container, einschließlich devDependencies, die für den Betrieb einer funktionsfähigen Anwendung nicht benötigt werden. Dadurch entsteht ein unnötiges Sicherheitsrisiko durch Pakete, die als Entwicklungsabhängigkeiten verwendet werden, und das Image wird unnötig größer.
Wenn Sie meinem vorherigen Leitfaden zu 10 Best Practices für npm-Sicherheit gefolgt sind, wissen Sie, dass Sie mit npm ci deterministische Builds sicherstellen sollten. So vermeiden Sie Überraschungen in einem Continuous-Integration-(CI-)Ablauf, denn der Vorgang wird abgebrochen, wenn es Abweichungen von der Lockdatei gibt.
Beim Erstellen eines Docker-Images für die Produktion möchten wir sicherstellen, dass ausschließlich Produktionsabhängigkeiten auf deterministische Weise installiert werden. Daraus ergibt sich folgende Best Practice für die Installation von npm-Abhängigkeiten in einem Container-Image:
Die aktualisierte Dockerfile sieht in diesem Schritt wie folgt aus:
Weitere Informationen finden Sie in unserem Artikel über Softwareabhängigkeiten.
3. Node.js-Tools für die Produktion optimieren
Wenn Sie Ihr Node.js-Docker-Image für die Produktion erstellen, sollten Sie sicherstellen, dass alle Frameworks und Bibliotheken optimal für Leistung und Sicherheit konfiguriert sind.
Daher fügen wir die folgende Dockerfile-Anweisung hinzu:
Auf den ersten Blick wirkt das überflüssig, da wir bereits in der Phase npm install ausschließlich Produktionsabhängigkeiten angegeben haben. Warum ist das also nötig?
Entwicklerinnen und Entwickler bringen die Einstellung der Umgebungsvariablen NODE_ENV=production meist mit der Installation produktionsbezogener Abhängigkeiten in Verbindung. Diese Einstellung hat jedoch noch weitere Auswirkungen, die Sie kennen sollten.
Manche Frameworks und Bibliotheken aktivieren möglicherweise nur dann die für die Produktion optimierte Konfiguration, wenn die Umgebungsvariable NODE_ENV auf production gesetzt ist. Unabhängig davon, ob wir diese Praxis bei Frameworks für gut oder schlecht halten, ist es wichtig, sie zu kennen.
Die Express-Dokumentation erläutert beispielsweise, wie wichtig es ist, diese Umgebungsvariable zu setzen, um leistungs- und sicherheitsbezogene Optimierungen zu aktivieren:

Die Auswirkungen der Variablen NODE_ENV auf die Leistung können erheblich sein.
Die Fachleute von Dynatrace haben einen Blogbeitrag zu den drastischen Auswirkungen des Fehlens von NODE_ENV in Ihren Express-Anwendungen veröffentlicht.
Viele weitere Bibliotheken, auf die Sie sich verlassen, erwarten möglicherweise ebenfalls, dass diese Variable gesetzt ist. Daher sollten wir sie in unserer Dockerfile festlegen.
Die aktualisierte Dockerfile sollte nun wie folgt aussehen und die Einstellung der Umgebungsvariable NODE_ENV enthalten:
4. Container nicht als Root ausführen
Das Prinzip der geringsten Rechte ist ein Sicherheitsprinzip aus den frühen Tagen von Unix. Wir sollten es stets befolgen, wenn wir unsere containerisierten Node.js-Webanwendungen ausführen.
Die Bedrohungsbewertung ist ziemlich eindeutig: Kann ein Angreifer die Webanwendung so kompromittieren, dass Command Injection oder Directory Path Traversal möglich wird, werden diese Befehle mit den Rechten des Benutzers ausgeführt, dem der Anwendungsprozess gehört. Ist dieser Prozess Root, kann der Angreifer im Container praktisch alles tun – auch einen Container Escape versuchen oder eine Rechteausweitung durchführen. Warum sollten wir das riskieren? Sie haben recht: Das sollten wir nicht.
Sagen Sie es mir nach: „Freunde lassen Freunde Container nicht als Root ausführen!“
Das offizielle node-Docker-Image und seine Varianten wie alpine enthalten einen Benutzer mit minimalen Berechtigungen, der denselben Namen trägt: node. Es reicht jedoch nicht aus, den Prozess einfach als node auszuführen. Für die Funktionalität der Anwendung ist beispielsweise Folgendes nicht optimal:
Der Grund dafür ist, dass die Dockerfile-Anweisung USER lediglich sicherstellt, dass der Prozess dem Benutzer node gehört. Was ist mit all den Dateien, die wir zuvor mit der Anweisung COPY kopiert haben? Sie gehören root. So funktioniert Docker standardmäßig.
So entziehen Sie dem Prozess vollständig und korrekt seine Berechtigungen. Gleichzeitig sehen Sie hier unsere bis zu diesem Punkt aktualisierten Dockerfile-Best Practices:
5. Node.js-Webanwendungen in Docker sicher beenden
Einer der häufigsten Fehler, die mir in Blogs und Artikeln zum Containerisieren von Node.js-Anwendungen in Docker-Containern begegnen, betrifft den Aufruf des Prozesses. Die folgenden Muster und ihre Varianten sind ungeeignet und sollten vermieden werden:
CMD “npm” “start”CMD [“yarn”, “start”]CMD “node” “server.js”CMD “start-app.sh”
Sehen wir uns das genauer an! Ich führe Sie durch die fehlerhaften Methoden zum Aufruf des Prozesses und erkläre, warum Sie sie vermeiden sollten.
Die folgenden Punkte sind entscheidend, um zu verstehen, wie Node.js-Anwendungen in Docker-Containern korrekt ausgeführt und beendet werden:
Eine Orchestrierungs-Engine wie Docker Swarm, Kubernetes oder auch die Docker-Engine selbst muss Signale an den Prozess im Container senden können. Dabei handelt es sich meist um Signale zum Beenden einer Anwendung, etwa
SIGTERMundSIGKILL.Der Prozess wird möglicherweise indirekt ausgeführt. In diesem Fall ist nicht immer gewährleistet, dass er diese Signale empfängt.
Der Linux-Kernel behandelt Prozesse mit der Prozess-ID 1 (PID 1) anders als alle anderen Prozesse.
Mit diesem Wissen sehen wir uns nun an, wie sich ein Prozess für einen Container aufrufen lässt. Wir beginnen mit dem Beispiel aus der Dockerfile, die wir erstellen:
Dabei gibt es zwei Einschränkungen. Erstens führen wir die Node-Anwendung indirekt aus, indem wir direkt den npm-Client aufrufen. Wer sagt, dass die npm-CLI alle Ereignisse an die Node-Laufzeitumgebung weiterleitet? Das tut sie tatsächlich nicht – und das lässt sich leicht testen.
Richten Sie in Ihrer Node.js-Anwendung einen Event-Handler für das Signal SIGHUP ein, der jedes Mal eine Meldung in der Konsole protokolliert, wenn Sie ein Ereignis senden. Ein einfaches Codebeispiel könnte so aussehen:
Starten Sie dann den Container. Sobald er läuft, senden Sie ihm mit der docker-CLI und dem speziellen Befehlszeilen-Flag --signal gezielt das Signal SIGHUP:
Nichts passiert, oder? Das liegt daran, dass der npm-Client keine Signale an den Node-Prozess weiterleitet, den er gestartet hat.
Die zweite Einschränkung betrifft die verschiedenen Möglichkeiten, die CMD-Anweisung in der Dockerfile anzugeben. Es gibt zwei Varianten, die sich unterscheiden:
die Shell-Form, bei der der Container einen Shell-Interpreter startet, der den Prozess umschließt. In solchen Fällen leitet die Shell Signale möglicherweise nicht korrekt an Ihren Prozess weiter.
die Exec-Form, bei der ein Prozess direkt und ohne Shell gestartet wird. Sie wird in der JSON-Array-Notation angegeben, zum Beispiel:
CMD [“npm”, “start”]. Alle an den Container gesendeten Signale werden direkt an den Prozess weitergeleitet.
Mit diesem Wissen können wir die Anweisung zur Prozessausführung in unserer Dockerfile wie folgt verbessern:
Wir rufen den Node-Prozess jetzt direkt auf. So stellen wir sicher, dass er alle an ihn gesendeten Signale empfängt und nicht von einem Shell-Interpreter umschlossen wird.
Das birgt jedoch eine weitere Falle.
Wenn Prozesse als PID 1 ausgeführt werden, übernehmen sie faktisch einige Aufgaben eines Init-Systems, das normalerweise ein Betriebssystem und dessen Prozesse initialisiert. Der Kernel behandelt PID 1 anders als andere Prozess-IDs. Aufgrund dieser Sonderbehandlung löst ein SIGTERM-Signal an einen laufenden Prozess nicht das standardmäßige Verhalten aus, den Prozess zu beenden, wenn dieser keinen eigenen Handler dafür eingerichtet hat.
Dazu heißt es in der Empfehlung der Node.js Docker-Arbeitsgruppe: „Node.js wurde nicht für die Ausführung als PID 1 entwickelt. Das führt bei der Ausführung in Docker zu unerwartetem Verhalten. Ein Node.js-Prozess, der als PID 1 läuft, reagiert beispielsweise nicht auf SIGINT (STRG+C) und ähnliche Signale.“
Verwenden Sie dazu ein Tool, das wie ein Init-Prozess agiert: Es wird mit PID 1 aufgerufen und startet dann unsere Node.js-Anwendung als weiteren Prozess. Dabei leitet es alle Signale an diesen Node.js-Prozess weiter. Wenn möglich, sollte das Tool so klein wie möglich sein, damit das Sicherheitsrisiko durch zusätzliche Komponenten im Container-Image gering bleibt.
Ein solches Tool, das wir bei Snyk verwenden, ist dumb-init. Es ist statisch gelinkt und hat nur einen geringen Platzbedarf. So richten wir es ein:
Damit sieht unsere bis hierhin aktualisierte Dockerfile wie folgt aus. Sie werden sehen, dass wir dumb-init direkt nach der Image-Deklaration installieren. So nutzen wir das Caching der Docker-Layer:
Wenn wir Software mit der Docker-Anweisung RUN hinzufügen, wie mit RUN apt-get update && apt-get install, bleiben Informationen im Docker-Image zurück. Wir können den Befehl wie folgt erweitern, um aufzuräumen und das Docker-Image schlanker zu halten:
Wir können das noch verbessern, indem wir dumb-init über die Dockerfile-Anweisung ENTRYPOINT festlegen und ihm den auszuführenden Node.js-Prozess als Befehlszeilenargument übergeben. Aktualisieren Sie dazu den entsprechenden Eintrag in der Dockerfile wie folgt:
Tipp: Noch besser ist es, das Tool dumb-init in einer früheren Build-Stage zu installieren und anschließend die resultierende Datei /usr/bin/dumb-init in das finale Container-Image zu kopieren. So bleibt das Image sauber. Im weiteren Verlauf dieser Anleitung erfahren Sie mehr über Docker-Builds mit mehreren Stages.
Gut zu wissen: Die Befehle docker kill und docker stop senden Signale nur an den Container-Prozess mit PID 1. Wenn Sie ein Shell-Skript ausführen, das Ihre Node.js-Anwendung startet, beachten Sie: Eine Shell-Instanz wie /bin/sh leitet Signale nicht an untergeordnete Prozesse weiter. Ihre App erhält daher niemals ein SIGTERM-Signal.
6. Node.js-Webanwendungen kontrolliert herunterfahren
Wenn wir schon über Signale zum Beenden von Anwendungen sprechen, sollten wir auch sicherstellen, dass sie ordnungsgemäß und kontrolliert heruntergefahren werden, ohne Nutzerinnen und Nutzer zu beeinträchtigen.
Empfängt eine Node.js-Anwendung ein Unterbrechungssignal, auch SIGINT oder CTRL+C genannt, wird der Prozess abrupt beendet – sofern keine Event-Handler für ein anderes Verhalten eingerichtet wurden. Dadurch werden verbundene Clients der Webanwendung sofort getrennt. Stellen Sie sich nun Hunderte von Node.js-Webcontainern vor, die von Kubernetes orchestriert werden und je nach Skalierungsbedarf oder zur Fehlerbehebung gestartet und beendet werden. Das ist kein besonders gutes Nutzererlebnis.
Sie können dieses Problem ganz einfach simulieren. Hier sehen Sie eine einfache Fastify-Webanwendung mit einem Endpunkt, der absichtlich erst nach 60 Sekunden antwortet:
Starten Sie die Anwendung und senden Sie, sobald sie läuft, eine einfache HTTP-Anfrage an diesen Endpunkt:
Drücken Sie im laufenden Node.js-Konsolenfenster CTRL+C. Sie werden sehen, dass die curl-Anfrage abrupt beendet wird. Genau so erleben es Ihre Nutzerinnen und Nutzer, wenn Container heruntergefahren werden.
Für ein besseres Nutzererlebnis können wir Folgendes tun:
Richten Sie einen Event-Handler für die verschiedenen Beendigungssignale wie
SIGINTundSIGTERMein.Der Handler wartet, bis Aufräumarbeiten abgeschlossen sind, etwa das Schließen von Datenbankverbindungen und laufenden HTTP-Anfragen.
Anschließend beendet der Handler den Node.js-Prozess.
Bei Fastify kann unser Handler speziell fastify.close() aufrufen und auf das zurückgegebene Promise warten. Fastify sorgt außerdem dafür, dass neue Verbindungen mit dem HTTP-Statuscode 503 beantwortet werden, um zu signalisieren, dass die Anwendung nicht verfügbar ist.
Fügen wir unseren Event-Handler hinzu:
Zugegeben: Das betrifft eher Webanwendungen allgemein als Dockerfiles. In orchestrierten Umgebungen ist es jedoch umso wichtiger.
7. Sicherheitslücken in Ihrem Node.js-Docker-Image finden und beheben
Erinnern Sie sich daran, wie wichtig kleine Docker-Basis-Images für unsere Node.js-Anwendungen sind? Setzen wir das in die Praxis um.
Ich verwende die Snyk CLI, um unser Docker-Image zu testen. Hier können Sie sich kostenlos für ein Snyk-Konto registrieren.
Mit dem ersten Befehl wird die Snyk CLI installiert. Anschließend melden Sie sich über die Befehlszeile an, um einen API-Schlüssel abzurufen. Danach können wir den Container auf Sicherheitsprobleme prüfen. Das ist das Ergebnis:
Snyk hat 97 Betriebssystemabhängigkeiten erkannt, darunter die ausführbare Datei der Node.js-Laufzeitumgebung, und keine anfälligen Versionen der Laufzeitumgebung gefunden. Im Container-Image gibt es jedoch 44 Sicherheitslücken in einigen Softwarekomponenten. 43 dieser Abhängigkeiten weisen Sicherheitslücken mit niedrigem Schweregrad auf. Eine kritische Sicherheitslücke betrifft die zlib-Bibliothek:
Sicherheitslücken in Docker-Images beheben
Eine schnelle und wirksame Möglichkeit, die Software in Ihrem Docker-Image sicher zu halten, ist, das Image neu zu erstellen. Dabei sind Sie darauf angewiesen, dass das verwendete Docker-Basis-Image die entsprechenden Updates bereitstellt. Alternativ können Sie Betriebssystempakete explizit aktualisieren und so auch Sicherheitskorrekturen installieren.
Beim offiziellen Node.js-Docker-Image kann es etwas länger dauern, bis das Team Aktualisierungen bereitstellt. Daher bringt es nichts, das Node.js-Docker-Image 20.9.0-bullseye-slim oder lts-bullseye-slim neu zu erstellen. Alternativ können Sie Ihr eigenes Basis-Image mit aktueller Software von Debian verwalten. In unserer Dockerfile geht das so:
Führen wir nach dem Erstellen des Node.js-Docker-Images mit der neu hinzugefügten RUN-Anweisung den Snyk-Sicherheitsscan aus:
Das Ergebnis: eine zusätzliche Betriebssystemabhängigkeit (98 statt zuvor 97). Alle 43 Sicherheitslücken, die dieses Node.js-Docker-Image betreffen, weisen nun jedoch einen niedrigen Schweregrad auf. Außerdem haben wir die kritische Sicherheitslücke in zlib behoben. Ein großartiges Ergebnis!
Was wäre, wenn wir die Basis-Image-Anweisung FROM node verwendet hätten? Noch besser: Nehmen wir an, Sie hätten ein spezifischeres Node.js-Docker-Basis-Image verwendet, etwa dieses:
Eine bestimmte Node.js-Laufzeitversion wie FROM node:14.2.0-slim scheint vielleicht auszureichen, da Sie eine konkrete Version (14.2.0) und ein kleines Container-Image (durch das Image-Tag slim) angegeben haben. Snyk kann Sicherheitslücken jedoch anhand von zwei Hauptquellen erkennen:
Die Node.js-Laufzeitumgebung selbst: Sind Ihnen die beiden führenden Sicherheitslücken im obigen Bericht aufgefallen? Dabei handelt es sich um öffentlich bekannte Sicherheitsprobleme in der Node.js-Laufzeitumgebung. Die sofortige Abhilfe besteht darin, auf eine neuere Node.js-Version zu aktualisieren. Snyk nennt Ihnen sowohl die passende Version als auch die Version, in der die Lücke behoben wurde – hier 14.11.0, wie Sie in der Ausgabe sehen.
Die Tools und Bibliotheken, die in diesem Debian-Basis-Image installiert sind, etwa glibc, bzip2, gcc, perl, bash, tar, libcrypt und andere. Diese anfälligen Versionen im Container stellen zwar möglicherweise keine unmittelbare Gefahr dar. Aber warum sollten Sie sie behalten, wenn Sie sie nicht verwenden?
Das Beste an diesem Bericht der Snyk CLI? Snyk empfiehlt auch alternative Basis-Images, auf die Sie umsteigen können. Sie müssen also nicht selbst danach suchen. Die Suche nach Alternativen kann viel Zeit kosten – Snyk nimmt Ihnen diese Arbeit ab.
Meine Empfehlung an diesem Punkt lautet:
Wenn Sie Ihre Docker-Images in einer Registry wie Docker Hub oder Artifactory verwalten, können Sie sie ganz einfach in Snyk importieren. Die Plattform findet dann die Sicherheitslücken für Sie. Außerdem erhalten Sie in der Snyk-Oberfläche Empfehlungen und können Ihre Docker-Images fortlaufend auf neu entdeckte Sicherheitslücken überwachen.
Verwenden Sie die Snyk CLI in Ihrer CI-Automatisierung. Die CLI ist sehr flexibel – genau deshalb haben wir sie entwickelt: damit Sie sie in beliebige benutzerdefinierte Workflows integrieren können. Wenn Sie möchten, können Sie auch Snyk for GitHub Actions verwenden.
Weitere Möglichkeiten, Schwachstellen in Container-Images zu verwalten, finden Sie in unserem Leitfaden zu Container-Sicherheit.
8. Multi-Stage-Builds verwenden
Multi-Stage-Builds sind eine hervorragende Möglichkeit, von einer einfachen, aber potenziell fehleranfälligen Dockerfile zu getrennten Schritten beim Erstellen eines Docker-Images überzugehen. So vermeiden wir, dass vertrauliche Informationen offengelegt werden. Außerdem können wir ein größeres Docker-Basis-Image verwenden, um unsere Abhängigkeiten zu installieren und gegebenenfalls native npm-Pakete zu kompilieren, und anschließend alle diese Artefakte in ein kleines Produktions-Basis-Image wie unser Alpine-Beispiel kopieren.
Offenlegung vertraulicher Informationen verhindern
Der Anwendungsfall, bei dem es darum geht, die Offenlegung vertraulicher Informationen zu vermeiden, kommt häufiger vor, als Sie denken.
Wenn Sie Docker-Images für die Arbeit erstellen, verwalten Sie wahrscheinlich auch private npm-Pakete. In diesem Fall mussten Sie vermutlich einen Weg finden, das geheime NPM_TOKEN für npm install verfügbar zu machen.
Hier ein Beispiel dafür, was ich meine:
Dadurch bleibt die Datei .npmrc jedoch mit dem geheimen npm-Token im Docker-Image zurück. Sie könnten versuchen, das zu verbessern, indem Sie sie anschließend löschen, etwa so:
Jetzt ist die Datei .npmrc jedoch in einer anderen Ebene des Docker-Images verfügbar. Wenn dieses Docker-Image öffentlich ist oder jemand anderweitig darauf zugreifen kann, ist Ihr Token kompromittiert. Besser wäre es folgendermaßen:
Das Problem ist nun, dass die Dockerfile selbst als vertrauliche Ressource behandelt werden muss, da sie das geheime npm-Token enthält.
Zum Glück unterstützt Docker eine Möglichkeit, Argumente an den Build-Prozess zu übergeben:
Anschließend erstellen wir das Image wie folgt:
Ich weiß, Sie denken, dass wir an diesem Punkt fertig sind, aber leider muss ich Sie enttäuschen.
So ist das mit Sicherheit: Manchmal ist das Offensichtliche nur eine weitere Falle.
Was ist jetzt das Problem, fragen Sie sich? Auf diese Weise an Docker übergebene Build-Argumente werden im Verlaufsprotokoll gespeichert. Sehen wir es uns selbst an. Führen Sie diesen Befehl aus:
Die Ausgabe sieht so aus:
Haben Sie das geheime npm-Token entdeckt? Genau das meine ich.
Es gibt eine hervorragende Möglichkeit, Geheimnisse für das Container-Image zu verwalten. Doch jetzt ist der richtige Zeitpunkt, Multi-Stage-Builds als Abhilfe für dieses Problem einzuführen und zu zeigen, wie sich minimale Images erstellen lassen.
Multi-Stage-Builds für Node.js-Docker-Images
Wie beim Prinzip der Separation of Concerns in der Softwareentwicklung wenden wir dieselben Ideen an, um unsere Node.js-Docker-Images zu erstellen. Wir verwenden ein Image, um alles zu erstellen, was die Node.js-Anwendung zum Ausführen benötigt. In der Node.js-Welt bedeutet das, npm-Pakete zu installieren und bei Bedarf native npm-Module zu kompilieren. Das ist unsere erste Build-Stufe.
Das zweite Docker-Image, das die zweite Build-Stufe darstellt, ist das Docker-Produktions-Image. Diese zweite und letzte Stufe ist das Image, das wir tatsächlich optimieren und – sofern vorhanden – in einer Registry veröffentlichen. Das erste Image, das wir als build-Image bezeichnen, wird verworfen und bleibt als verwaistes Image auf dem Docker-Host zurück, auf dem es erstellt wurde, bis es bereinigt wird.
Hier sehen Sie die aktualisierte Dockerfile, die unseren bisherigen Fortschritt zeigt und in zwei Stufen unterteilt ist:
Wie Sie sehen, habe ich für die build-Stufe ein größeres Image gewählt, da ich möglicherweise Tools wie gcc (die GNU Compiler Collection) zum Kompilieren nativer npm-Pakete oder für andere Aufgaben benötige.
In der zweiten Stufe gibt es eine spezielle Syntax für die COPY-Anweisung, mit der der Ordner node_modules/ aus dem Build-Docker-Image in dieses neue Produktions-Basis-Image kopiert wird.
Sehen Sie jetzt auch, dass NPM_TOKEN als Build-Argument an das zwischengeschaltete Docker-Image build übergeben wird? In der Ausgabe des Befehls docker history nodejs-tutorial ist es nicht mehr zu sehen, da es in unserem Docker-Produktions-Image nicht vorhanden ist.
9. Unnötige Dateien aus Ihren Node.js-Docker-Images heraushalten
Sie haben eine .gitignore-Datei, damit keine unnötigen und möglicherweise vertraulichen Dateien in Ihr Git-Repository gelangen, richtig? Dasselbe gilt für Docker-Images.
Was ist eine Docker-Ignore-Datei?
Docker verfügt über eine .dockerignore-Datei. Damit werden alle darin aufgeführten Glob-Muster vom Senden an den Docker-Daemon ausgeschlossen. Die folgende Dateiliste zeigt, was Sie möglicherweise in Ihr Docker-Image aufnehmen und besser vermeiden sollten:- .dockerignore- node_modules- npm-debug.log- Dockerfile- .git- .gitignore
Wie Sie sehen, ist es besonders wichtig, node_modules/ auszuschließen. Andernfalls hätte die einfache Dockerfile, mit der wir begonnen haben, den lokalen Ordner node_modules/ unverändert in den Container kopiert.
Eine .dockerignore-Datei ist sogar noch wichtiger, wenn Sie Multi-Stage-Docker-Builds verwenden. Zur Erinnerung: So sieht die Docker-Build-Datei der zweiten Stufe aus:
Eine .dockerignore-Datei ist wichtig, weil wir mit COPY . /usr/src/app in der zweiten Dockerfile-Stufe auch alle lokalen node_modules/ in das Docker-Image kopieren. Das sollten Sie unbedingt vermeiden, denn dabei könnte geänderter Quellcode aus node_modules/ kopiert werden.
Außerdem könnten wir mit dem Platzhalter COPY . vertrauliche Dateien mit Anmeldedaten oder lokaler Konfiguration in das Docker-Image kopieren.
Das Wichtigste zur .dockerignore-Datei:
Verhindert, dass potenziell geänderte Kopien von
node_modules/im Docker-Image landen.Schützt vor der Offenlegung von Geheimnissen, etwa wenn Anmeldedaten aus Dateien wie
.envoderaws.jsonin das Node.js-Docker-Image gelangen.Beschleunigt Docker-Builds, da Dateien ignoriert werden, die andernfalls eine Cache-Invalidierung verursachen würden. Eine geänderte Protokolldatei oder lokale Umgebungskonfigurationsdatei hätte beispielsweise dazu geführt, dass der Docker-Image-Cache auf der Ebene ungültig wird, auf der das lokale Verzeichnis kopiert wird.
10. Geheimnisse in das Docker-Build-Image einbinden
Wichtig zu wissen: Die .dockerignore-Datei verfolgt einen Alles-oder-nichts-Ansatz und lässt sich bei einem Multi-Stage-Docker-Build nicht für einzelne Build-Stufen aktivieren oder deaktivieren.
Warum ist das wichtig? Idealerweise würden wir die Datei .npmrc in der Build-Stufe verwenden, da wir sie möglicherweise benötigen, um mit einem geheimen npm-Token auf private npm-Pakete zuzugreifen. Vielleicht enthält sie auch eine bestimmte Proxy- oder Registry-Konfiguration, über die Pakete bezogen werden.
Das bedeutet, dass die Datei .npmrc in der build-Stufe verfügbar sein sollte. In der zweiten Stufe für das Produktions-Image benötigen wir sie jedoch überhaupt nicht und möchten sie dort auch nicht haben, da sie vertrauliche Informationen wie das geheime npm-Token enthalten kann.
Eine Möglichkeit, diese Einschränkung der .dockerignore-Datei zu umgehen, besteht darin, ein lokales Dateisystem einzubinden, das in der Build-Stufe verfügbar ist. Es gibt jedoch eine bessere Lösung.
Docker unterstützt eine relativ neue Funktion namens Docker Secrets, die sich für unseren Anwendungsfall mit .npmrc bestens eignet. So funktioniert sie:
Beim Ausführen des Befehls
docker buildgeben wir Befehlszeilenargumente an, die eine neue Secret-ID definieren und eine Datei als Quelle des Geheimnisses festlegen.In der Dockerfile fügen wir der
RUN-Anweisung Flags hinzu, um die Produktionsversion von npm zu installieren. Dabei wird die Datei, auf die die Secret-ID verweist, am gewünschten Speicherort eingebunden – in diesem Fall als lokale Datei.npmrc.Die Datei
.npmrcwird als Geheimnis eingebunden und niemals in das Docker-Image kopiert.Vergessen Sie zuletzt nicht, die Datei
.npmrcin die.dockerignore-Datei aufzunehmen, damit sie weder im Build- noch im Produktions-Image landet.
Sehen wir uns an, wie alles zusammenspielt. Zuerst die aktualisierte .dockerignore-Datei:
Dann die vollständige Dockerfile mit der aktualisierten RUN-Anweisung zur Installation von npm-Paketen und dem Einbindungspunkt für .npmrc:
Und schließlich der Befehl zum Erstellen des Node.js-Docker-Images:
Hinweis: Secrets sind eine neue Docker-Funktion. Wenn Sie eine ältere Version verwenden, müssen Sie möglicherweise Buildkit wie folgt aktivieren:
Zusammenfassung
Sie haben es geschafft und ein optimiertes Node.js-Docker-Basis-Image erstellt. Hervorragend!
Damit ist dieser Leitfaden zum Containerisieren von Node.js-Docker-Webanwendungen abgeschlossen. Dabei haben wir Leistungs- und Sicherheitsoptimierungen berücksichtigt, damit produktionsreife Node.js-Docker-Images entstehen!
Diese weiterführenden Ressourcen empfehle ich Ihnen besonders:
Docker für Java-Entwickler: 5 Dinge, die Sie wissen müssen, um Ihre Sicherheit nicht zu gefährden
Best Practices für das Containerisieren von Python-Anwendungen mit Docker
Nachdem Sie sichere und leistungsstarke Docker-Basis-Images für Ihre Node.js-Anwendungen erstellt haben, können Sie mit einem kostenlosen Snyk-Konto Schwachstellen in Ihren Containern finden und beheben.
Container-Sicherheit mit Fokus auf Entwickler
Snyk findet und behebt automatisch Schwachstellen in Container-Images und Kubernetes-Workloads.
