10 Best Practices zur Containerisierung von Node.js-Anwendungen mit Docker

wordpress-sync/feature-node.js-cheat-sheet

September 15, 2022

0 Min. Lesezeit

Hinweis der Redaktion:

14. September 2022: Sehen Sie sich unser neues, überarbeitetes Cheatsheet für die Containerisierung von Node.js-Webanwendungen mit Docker an.

Sie interessieren sich für Best Practices beim Aufbau von Node.js-Docker-Images für Ihre Webanwendungen? Dann sind Sie hier genau richtig!

Im folgenden Artikel geht es um Richtlinien für optimierte, sichere Node.js-Docker-Images für Prod-Umgebungen. Diese Tipps lassen sich auf jede Node.js-Anwendung übertragen, die Sie entwickeln möchten, und sind besonders hilfreich, wenn Sie:

  • eine Frontend-Anwendung mit SSR-Node.js-Funktionen für React (Server-Side Rendering) programmieren oder

  • wissen wollen, wie Sie ein Node.js-Docker-Image für Microservices auf Fastify, NestJS oder anderen Application-Frameworks erstellen.

Welchem Zweck dient dieser Leitfaden zur Containerisierung von node.js-Webanwendungen mit Docker?

Bei den meisten uns bekannten Blog-Beiträgen geht es um die simplen Grundlagen, wie Sie ein Node.js-Docker-Image zum Ausführen einer Anwendung anlegen. Oft kommt dabei jedoch die Sicherheit zu kurz und es fehlen durchdachte Best Practices für den Aufbau von Node.js-Docker-Images.

In diesem Beitrag erklären wir Schritt für Schritt, wie Sie Container für Node.js-Webanwendungen erstellen. Dafür zeigen wir an einer simplen, funktionierenden Dockerfile, worauf Sie bei jeder Dockerfile-Anweisung achten müssen, was schiefgehen kann und wie Sie solche Probleme beheben. Cheatsheet hier herunterladen.

wordpress-sync/NodeJS-cheat-sheet

Ein simples Node.js-Docker-Image

Die meisten Blogbeiträge, die wir kennen, beginnen und enden mit grundlegenden Dockerfile-Anweisungen zum Aufbau von Node.js-Docker-Images:

FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Legen wir los: Kopieren Sie das in eine Datei namens Dockerfile, erstellen Sie einen Build und führen Sie ihn aus.

$ docker build . -t nodejs-tutorial
$ docker run -p 3000:3000 nodejs-tutorial

Das ist einfach und funktioniert.

Es gibt nur ein Problem: Diese Methode steckt voller Fehler und schlechter Praktiken für den Aufbau von Node.js-Docker-Images. Sie sollten auf keinen Fall so wie im obigen Beispiel vorgehen.

Beginnen wir mit der Verbesserung dieser Dockerfile zur Optimierung von Node.js-Webanwendungen mit Docker.

Sie können dieses Tutorial selbst durcharbeiten, indem Sie dieses Repository klonen.

Befolgen Sie diese zehn Tipps, um Node.js-Webanwendungen mit Docker zu optimieren:

  1. Tags für explizite und deterministische Docker-Base-Images verwenden

  2. Nur produktionsrelevante Abhängigkeiten im Node.js-Docker-Image installieren

  3. Produktionsoptimierung von Node.js-Tools

  4. Container nicht als Root ausführen

  5. Node.js-Docker-Anwendungen sicher beenden

  6. Node.js-Webanwendungen ordnungsgemäß herunterfahren

  7. Sicherheitslücken im Node.js-Docker-Image aufspüren und schließen

  8. Mit mehrstufigen Builds arbeiten

  9. Unnötige Dateien in Node.js-Docker-Images vermeiden

  10. Secrets im Docker-Build-Image mounten

1. Tags für explizite und deterministische Docker-Base-Images verwenden

Der Aufbau eigener Images auf Grundlage des node-Docker-Images erscheint oft als einfachste Methode. Die Frage ist nur: Was wird dabei alles abgerufen und in das Image eingebunden? Docker-Images werden immer mit Tags referenziert. Geben Sie kein Tag an, wird standardmäßig das Tag :latest verwendet.

Wenn Sie also Folgendes in Ihrer Dockerfile angeben, basiert Ihr Docker-Image immer auf der neuesten Image-Version der Node.js Docker Working Group:

FROM node

Der Aufbau von Images, die auf dem Standard-Image node basieren, hat jedoch einige Nachteile:

  1. Die Docker-Image-Builds sind inkonsistent. Genau wie bei der Verwendung von lockfiles für ein deterministisches npm install-Verhalten bei jeder Installation von npm-Paketen möchten wir auch deterministische Docker-Image-Builds erstellen. Verwenden wir das Image vom Node – also mit dem Tag node:latest –, wird jeder Build ein neu erstelltes Docker-Image abrufen, das auf node basiert. Mit dieser Art von deterministischem Verhalten wollen wir erst gar nicht anfangen.

  2. Das node-Docker-Image basiert auf einem vollständigen Betriebssystem mit Bibliotheken und Tools, von denen Sie noch nicht wissen, ob Sie sie zum Ausführen Ihrer Node.js-Webanwendung überhaupt brauchen. Das hat zwei Nachteile: Erstens bedeutet ein größeres Image eine größere Download-Datei. Das erhöht nicht nur die Speicheranforderungen, sondern auch die Download- und Re-Build-Zeiten für ein Image. Zweitens schleusen Sie damit womöglich Sicherheitslücken in Ihr Image ein, die durch Sicherheitslücken in all diesen Bibliotheken und Tools verursacht werden.

Das nodeDocker-Image ist relativ groß und beinhaltet Hunderte von Sicherheitslücken unterschiedlichster Art und Gefährlichkeit – Stichwort „Schweregrad“. Mit diesem Image als Standard-Ausgangsbasis laden Sie also bei jedem Pull und Build mindestens 642 Sicherheitslücken und Hunderte Megabytes Image-Daten herunter.

wordpress-sync/blog-container-image-vulnerabilities

Für bessere Docker-Image-Builds empfehlen wir daher Folgendes:

  1. Verwenden Sie kleine Docker-Images. Damit verringern Sie den Software-Anteil auf dem Docker-Image, wodurch potenzielle Angriffsvektoren reduziert und der Image-Build-Prozess beschleunigt wird.

  2. Nutzen Sie den Docker-Image-Digest, also den statischen SHA256-Hash des Images. Damit sorgen Sie dafür, dass Sie deterministische Docker-Image-Builds vom Base-Image erhalten.

Wir haben auch einen umfassenden Artikel darüber, wie Sie Ihr optimales Node.js-Docker-Image auswählen. In diesem Beitrag werden die genauen Gründe erklärt, warum eine aktuelle slim-Distribution von Debian mit einer langfristig unterstützten Node.js-Laufzeitversion ideal ist.

Das empfohlene Node.js-Docker-Image wäre:

FROM node:16.17.0-bullseye-slim

Dieses Tag für das Node.js-Docker-Image verweist auf eine bestimmte Node.js-Laufzeitversion (`16.17.`0) mit Langzeitunterstützung (Long-Term Support, LTS). Diese basiert auf der Image-Variante `bullseye` – der aktuellen offiziellen (stable) Debian-11-Version mit einem End-of-Life-Datum in ausreichender Ferne. Zudem handelt es sich um eine `slim`-Image-Variante mit einem minimalen Betriebssystem unter 200 MB (einschließlich Node.js-Laufzeitumgebung und Tools).

Eine der gängigen – und wenig reflektierten – Praktiken in Tutorials oder Leitfäden ist die Empfehlung der folgenden Docker-Anweisung für ein Base-Image:

FROM node:alpine

Damit legen Sie verwenden Sie das Docker-Image Node.js-Alpine als Ausgangsbasis. Die Frage ist nur, ob das wirklich ideal ist. Das Node.js-Alpine-Docker-Image wird oft wegen seines kleineren Software-Anteils empfohlen, hat jedoch einige Besonderheiten, die es nicht gerade ideal als Prod-Base-Image für Node.js-Application-Laufzeitumgebungen machen.

Node Alpine kurz erklärt

Node.js Alpine ist ein inoffizieller Docker-Container-Image-Build, der vom Node.js-Docker-Team gepflegt wird. Das Node.js-Image kommt mit dem Alpine-Betriebssystem, das mit den minimalen busybox-Softwaretools und der Bibliotheksimplementierung musl C arbeitet. Nicht zuletzt wegen dieser beiden Eigenschaften des Node.js-Alpine-Image wird das Docker-Image inoffiziell vom Node.js-Team unterstützt. Zudem können viele Sicherheitslücken-Scanner Software-Artefakte oder Laufzeitumgebungen auf Node.js-Alpine-Images nicht leicht erkennen, was beim Schutz Ihrer Container-Images kontraproduktiv ist.

Unabhängig vom verwendeten Image-Tag für Node.js-Alpine können Sie mit einem Word-Alias als Base-Image-Anweisung weiterhin neue Builds dieses Tags abrufen, da Docker-Image-Tags veränderbar sind. Die dafür notwendige Hashfunktion SHA256 erhalten wir mit dem Docker Hub für dieses Node.js-Tag oder durch Ausführen des folgenden Befehls, nachdem wir dieses Image lokal per Pull abgerufen und das Feld Digest im Output ausfindig gemacht haben:

$ docker pull node:16.17.0-bullseye-slim
5b1423465504: Already exists
2f232a362cd9: Already exists
aa653d801310: Already exists
25750f98abe8: Already exists
476cb0003ed3: Already exists
Digest: sha256:18ae6567b623f8c1caada3fefcc8746f8e84ad5c832abd909e129f6b13df25b4
Status: Downloaded newer image for node:16.17.0-bullseye-slim
docker.io/library/node:16.17.0-bullseye-slim

Die Hashfunktion SHA256 lässt sich auch mit diesem Befehl finden:

$ docker images --digests
REPOSITORY                     TAG              DIGEST                                                                    IMAGE ID       CREATED             SIZE
node                                         16.17.0-bullseye-slim   sha256:18ae6567b623f8c1caada3fefcc8746f8e84ad5c832abd909e129f6b13df25b4   f8e42f13e99d   6 days ago     183MB

Jetzt können wir die Dockerfile für dieses Node.js-Docker-Image wie folgt aktualisieren:

FROM node@sha256:18ae6567b623f8c1caada3fefcc8746f8e84ad5c832abd909e129f6b13df25b4
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Die obige Dockerfile gibt allerdings nur den Namen des Node.js-Docker-Images ohne Image-Tag an. So bleibt unklar, welches Image-Tag genau verwendet wird. Und das wiederum beeinträchtigt Lesbarkeit, Wartung und Entwicklung.

Beheben wir nun dieses Problem mit einem Update der Dockerfile, damit diese das vollständige Base-Image-Tag für die Node.js-Version gemäß dem Hash SHA256 enthält:

FROM node:16.17.0-bullseye-slim@sha256:18ae6567b623f8c1caada3fefcc8746f8e84ad5c832abd909e129f6b13df25b4
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Mit dem Docker-Image-Digest erhalten Sie zwar sicher ein deterministisches Image, das jedoch für einige Image-Scanning-Tools verwirrend oder kontraproduktiv sein könnte, da diese nicht wissen, wie sie das Image interpretieren sollen. Aus diesem Grund empfehlen wir die Verwendung einer konkreten Node.js-Laufzeitversion wie `16.17.0`. Theoretisch kann diese zwar auch verändert oder überschrieben werden, in der Praxis wird aber bei Sicherheits- und anderen Updates eine neue Version (z. B. `16.17.1`) übernommen. Das reicht aus, damit wir bei dieser Version von deterministischen Builds ausgehen können.

Deshalb sollte unsere Dockerfile nun so aussehen:

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Weitere Tipps und Best Practices für den Aufbau sicherer Container-Images

2. Nur produktionsrelevante Abhängigkeiten im Node.js-Docker-Image installieren

Die folgende Dockerfile-Anweisung installiert alle Abhängigkeiten im Container, einschließlich überflüssiger devDependencies, die Sie für eine funktionierende Anwendung nicht brauchen. Dadurch entsteht ein vermeidbares Sicherheitsrisiko durch Pakete, die als Dev-Abhängigkeiten genutzt werden. Außerdem wird die Image-Größe unnötig aufgeblasen.

RUN npm install

Falls Sie meinen vorherigen Leitfaden zu den 10 Best-Practices für NPM-Sicherheit kennen, wollen Sie wahrscheinlich deterministische Builds mit npm ci durchsetzen. Dadurch vermeiden Sie Überraschungen in einem kontinuierlichen Integrationsablauf, da ein solcher CI-Flow bei Abweichungen von der Lockfile angehalten wird.

Bei einem Docker-Image für die Produktion möchten wir sicherstellen, dass wir produktionsbezogene Abhängigkeiten ausschließlich deterministisch anlegen. Aus diesem Grund empfehlen wir folgende Best Practice zum Installieren von npm-Abhängigkeiten in einem Container-Image:

RUN npm ci --only=production

Unsere Dockerfile enthält jetzt also Folgendes:

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

Mehr zum Thema erfahren Sie unseren Artikel über Softwareabhängigkeiten.

3. Produktionsoptimierung von Node.js-Tools

Bei einem Node.js-Docker-Image für die Produktion wollen Sie wahrscheinlich sicherstellen, dass alle Frameworks und Bibliotheken die optimalen Performance- und Security-Einstellungen haben.

Deshalb fügen Sie jetzt die folgende Dockerfile-Anweisung hinzu:

ENV NODE_ENV production

Vielleicht halten Sie das jetzt für überflüssig, weil wir uns ja in der npm install-Stufe schon auf produktionsbezogene Abhängigkeiten beschränkt haben. Warum also ist dieser Schritt trotzdem notwendig?

Entwicklerinnen und Entwickler verbinden die Umgebungsvariablen-Einstellung NODE_ENV=production häufig mit der Installation von produktionsbezogenen Abhängigkeiten. Diese Einstellung hat allerdings auch andere Auswirkungen, die wir beachten müssen.

Einige Frameworks und Bibliotheken aktivieren womöglich nur dann die optimierte, für die Produktion geeignete Konfiguration, wenn die Umgebungsvariable NODE_ENV auf production gesetzt ist. Abgesehen davon, ob wir das nun für eine gute oder schlechte Vorgehensweise für Frameworks halten, sollten Sie dies immer im Hinterkopf behalten.

Beispielsweise wird in der Dokumentation von Express darauf hingewiesen, wie wichtig die Festlegung dieser Umgebungsvariable für leistungs- und sicherheitsrelevante Optimierungen ist.

wordpress-sync/blog-snyk-docker-optimize-node.js-tooling

Die Auswirkungen der Variable NODE_ENV auf die Leistung kann erheblich sein.

Dynatrace beschreibt in einem Blog-Beitrag ausführlich die drastischen Folgen, wenn die NODE_ENV in Express-Anwendungen weggelassen wird.

Viele andere notwendige Bibliotheken erfordern möglicherweise die Festlegung dieser Variable, weshalb wir das auch in unserer Dockerfile tun sollten.

Die aktualisierte Dockerfile mit der festgelegten Umgebungsvariable NODE_ENV sollte nun so aussehen:

FROM node:16.17.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

4. Container nicht als Root ausführen

Das Prinzip der geringsten Rechte ist ein Sicherheitsgrundsatz aus den Anfängen von Unix, an den wir uns auch beim Ausführen unserer containerisierten Node.js-Webanwendungen halten sollten.

Die Bedrohungsbewertung ist recht simpel: Kann ein Angreifer die Webanwendung auf eine Weise kompromittieren, die eine Command-Injection oder Directory Path Traversal erlaubt, werden diese bei dem Benutzer aufgerufen, der als Owner für den Anwendungsprozess zuständig ist. Handelt es sich dabei um den Root-Prozess, kann dieser Benutzer praktisch alles mit dem Container-Inhalt anstellen, z. B. eine Container-Escape oder Privilege-Escalation versuchen. Warum sollte man das riskieren wollen?

Halten Sie sich IMMER an den Grundsatz: „Freunde lassen nicht zu, dass Freunde Container als Root ausführen!“

Beim offiziellen Docker-Image node sowie Varianten wie alpine gibt es immer einen Benutzer mit geringsten Rechten: den User node. Es reicht jedoch nicht aus, den Prozess einfach als node auszuführen. Folgendes ist z. B. für die Anwendungsfunktion nicht ideal:

USER node
CMD "npm" "start"

Der Grund dafür ist, dass die Dockerfile-Anweisung USER lediglich dafür sorgt, dass der Prozess-Owner der Benutzer node ist. Und was ist mit den ganzen Dateien, die wir zuvor mit der COPY-Anweisung kopiert haben? Deren Owner ist weiterhin der Benutzer „root“. So funktioniert Docker mit den Standardeinstellungen.

Die vollständige korrekte Methode zum Löschen von Berechtigungen finden Sie im Folgenden (einschließlich der bereits für die Dockerfile vorgestellten Tipps):

FROM node:16.17.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --only=production
USER node
CMD "npm" "start"

5. Node.js-Docker-Anwendungen sicher beenden

Einer der häufigsten Fehler, auf die ich immer wieder in Blogbeiträgen und Artikeln zum Ausführen von Node.js-Anwendungen in Docker-Containern stoße, ist die Art und Weise, wie der Prozess aufgerufen wird. Alle folgenden Einstellungen und deren Varianten sollten Sie unbedingt vermeiden:

  • CMD “npm” “start”

  • CMD [“yarn”, “start”]

  • CMD “node” “server.js”

  • CMD “start-app.sh”

Sehen wir uns das genauer an und lassen Sie mich kurz erklären, warum jeder dieser Aufrufprozesse nicht verwendet werden sollte.

Um den Kontext für das richtige Ausführen und Beenden von Node.js-Docker-Anwendungen zu verstehen, muss man sich folgender Probleme bewusst sein:

  1. Eine Orchestrierungs-Engine wie Docker Swarm, Kubernetes oder sogar die Docker-Engine selbst müssen Signale an den Prozess im Container senden können. Meistens handelt es sich dabei um Signale wie SIGTERM oder SIGKILL, um die Anwendung zu beenden.

  2. Wird der Prozess indirekt ausgeführt, ist nicht immer gewährleistet, dass er diese Signale empfängt.

  3. Der Linux-Kernel behandelt Prozesse, die als Process ID 1 (PID) ausgeführt werden, anders als andere Prozess-IDs.

Sehen wir uns vor diesem Hintergrund einmal die Optionen zum Aufrufen des Container-Prozesses an, angefangen mit dem Beispiel aus der Dockerfile, die wir gerade erstellen:

CMD "npm" "start"

Hier gibt es eine doppelte Einschränkung: Erstens führen wir die Node-Anwendung direkt durch Aufrufen des npm-Clients aus. Woher wissen wir, ob die npm-CLI alle Events an die Node-Laufzeitumgebung weiterleitet? Tatsächlich ist das nicht der Fall, was sich einfach herausfinden lässt.

Legen Sie dafür in Ihrer Node.js-Anwendung einen Event-Handler für das SIGHUP-Signal fest, das bei jedem Ihrer gesendeten Events in der Konsole protokolliert wird. Hier ein einfaches Code-Beispiel, wie das aussehen könnte:

function handle(signal) {
   console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)

Führen Sie dann den Container aus. Sobald er hochgefahren ist, senden Sie das SIGHUP-Signal über die docker-CLI mit dem Befehlszeilen-Flag --signal:

$ docker kill --signal=SIGHUP elastic_archimedes

Nichts passiert. Das liegt daran, dass der npm-Client keine Signale an den Node-Prozess weiterleitet, den er erzeugt hat.

Die andere Einschränkung hat mit den verschiedenen Formen zu tun, wie Sie die CMD-Anweisung in die Dockerfile schreiben können. Dafür gibt es zwei Optionen:

  1. die Shellform-Notation, bei der der Container einen Shell-Interpreter für den Prozess erzeugt. In diesem Fall leitet die Shell die Signale womöglich nicht korrekt an Ihre Prozesse weiter.

  2. die Execform-Notation, die einen Prozess erzeugt, ohne ihn in eine Shell zu verpacken. Diese wird mit der JSON-Array-Notation angegeben und sieht z. B. so aus: CMD [“npm”, “start”]. Alle an den Container gesendeten Signale werden direkt an den Prozess gesendet.

Da wir das nun wissen, verbessern jetzt wir unsere Dockerfile-Anweisung zur Prozessausführung wie folgt:

CMD ["node", "server.js"]

Wir rufen jetzt den Node-Prozess direkt auf und stellen sicher, dass er alle an ihn gesendeten Signale auch ohne Shell-Interpreter empfängt.

Dadurch entsteht jedoch ein weiteres Problem:

Werden Prozesse als PID 1 ausgeführt, übernehmen sie einige Aufgaben eines Init-Systems, das normalerweise für die Initialisierung eines Betriebssystems und der Prozesse zuständig ist. Der Kernel behandelt PID 1 anders als andere Prozess-Identifier. Diese Sonderbehandlung durch den Kernel bedeutet, dass die Verarbeitung eines SIGTERM-Signals für einen laufenden Prozess kein standardmäßiges Fallback-Verhalten zum Beenden des Prozesses liefert, sofern der Prozess nicht bereits einen Handler dafür festgelegt hat.

Entsprechend lautet die Empfehlung der Node.js Docker Working Group:  „Node.js wurde nicht für die Ausführung als PID 1 entwickelt, was beim Ausführen in Docker zu unerwartetem Verhalten führt. Beispielsweise reagiert ein als PID 1 ausgeführter Node.js-Prozess nicht auf SIGINT (CTRL-C) und ähnliche Signale.“

Das lässt sich mit einem Tool vermeiden, das sich wie ein Init-Prozess verhält: Es wird mit PID 1 aufgerufen, erzeugt unsere Node.js-Anwendung als weiteren Prozess und sorgt zugleich dafür, dass alle Signale zu diesem Node.js-Prozess weitergeleitet werden. Grundsätzlich sollten Tools möglichst abgespeckt sein (geringer Tools-Footprint), damit wir nicht weitere Sicherheitslücken in unserem Container-Image riskieren.

Bei Snyk verwenden wir dafür u. a. das Tool dumb-init, weil es statisch verlinkt ist und einen kleinen Footprint hat. So wird es eingerichtet:

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
CMD ["dumb-init", "node", "server.js"]

Das Ergebnis ist die folgende Dockerfile. Wie Sie sehen, haben wir die dumb-init-Paketinstallation direkt hinter der Image-Deklaration eingefügt, damit wir das Layer-Caching von Docker verwenden können:

FROM node:16.17.0-bullseye-slim
RUN RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Wenn wir die Docker-Anweisung RUN wie bei RUN apt-get update && apt-get install zum Hinzufügen von Software verwenden, hinterlassen wir einige Informationen über das Docker-Image. Um solche Informationen nach diesem Befehl zu bereinigen, können wir ihn folgendermaßen erweitern und so ein schlankeres Docker-Image erhalten:

FROM node:16.17.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Tipp: Noch besser ist es, wenn Sie das dumb-init-Tool in einem früheren Build-Stage-Image installieren und dann die resultierende /usr/bin/dumb-init zum Schluss in das fertige Container-Image kopieren, um das Image möglichst sauber zu halten. Auf mehrstufige Docker-Builds gehen wir später noch in diesem Leitfaden ein.

Gut zu wissen: Die Befehle docker kill und docker stop senden nur Signale zum Container-Prozess mit der PID 1. Wenn Sie Ihre Node.js-Anwendung mit einem Shell-Skript ausführen, sollten Sie beachten, dass eine Shell-Instanz – wie z. B. /bin/sh – keine Signale an Child-Prozesse weiterleitet. Das bedeutet, dass Ihre App niemals ein SIGTERM erhält.

6. Node.js-Webanwendungen ordnungsgemäß herunterfahren

Apropos Prozess-Signale zum Beenden von Anwendungen: Wichtig ist, dass Anwendungen korrekt und ohne Probleme für Benutzer heruntergefahren werden.

Empfängt eine Node.js-Anwendung ein Interrupt-Signal (wie SIGINT oder CTRL+C), verursacht das einen abrupten Prozessabbruch – es sei denn, Sie haben Event-Handler für ein anderes Verhalten festgelegt. Das bedeutet, dass mit einer Webanwendung verbundene Clients sofort getrennt werden. Stellen Sie sich nun Hunderte von Kubernetes-orchestrierten Node.js-Webcontainer vor, die je nach Bedarf zur Skalierung oder für das Fehler-Management gestartet und beendet werden. Das wäre ein eher suboptimales Benutzererlebnis.

Dieses Problem lässt sich einfach simulieren. Nehmen wir z. B. eine Fastify-Webanwendung mit einer inhärenten verzögerten Antwort von 60 Sekunden für einen Endpunkt:

fastify.get('/delayed', async (request, reply) => {
 const SECONDS_DELAY = 60000
 await new Promise(resolve => {
     setTimeout(() => resolve(), SECONDS_DELAY)
 })
 return { hello: 'delayed world' }
})

const start = async () => {
 try {
   await fastify.listen(PORT, HOST)
   console.log(`*^!@4=> Process id: ${process.pid}`)
 } catch (err) {
   fastify.log.error(err)
   process.exit(1)
 }
}

start()

Führen Sie diese Anwendung aus und senden Sie nach dem Ausführen eine einfache HTTP-Anforderung an diesen Endpunkt:

$ time curl https://localhost:3000/delayed

Verwenden Sie im Konsolenfenster während der Node.js-Ausführung den Tastaturbefehl CTRL+C, wird der Curl-Request abrupt beendet. Genau das würden Ihre Benutzer erleben, wenn Container einfach beendet werden.

Ein besseres Benutzererlebnis erreichen Sie so:

  1. Legen Sie einen Event-Handler für die verschiedenen Terminierungssignale wie SIGINT und SIGTERM fest.

  2. Der Handler wartet auf Clean-up-Operationen wie Datenbankverbindungen oder laufende HTTP-Requests.

  3. Dann beendet der Handler den Node.js-Prozess.

Insbesondere bei Fastify können wir unseren Handler-Aufruf auf fastify.close() setzen, worauf uns ein Promise zurückgegeben wird, auf das wir warten werden. Fastify übernimmt auch die Reaktion auf jede neue Verbindung mit dem HTTP-Statuscode 503, um die Nichtverfügbarkeit der Anwendung zu signalisieren.

Fügen wir nun unseren Event-Handler hinzu:

async function closeGracefully(signal) {
   console.log(`*^!@4=> Received signal to terminate: ${signal}`)

   await fastify.close()
   // await db.close() if we have a db connection in this app
   // await other things we should cleanup nicely
   process.kill(process.pid, signal);
}
process.once('SIGINT', closeGracefully)
process.once('SIGTERM', closeGracefully)

Auch wenn dies zugegebenermaßen eher ein allgemeines Problem für Webanwendungen als für Dockerfiles darstellt, ist dies besonders für orchestrierte Umgebungen wichtig.

7. Sicherheitslücken im Node.js-Docker-Image aufspüren und schließen

Erinnern Sie sich noch, dass wir erwähnt haben, wie wichtig kleine Docker-Base-Images für unsere Node.js-Anwendungen sind? Setzen wir das nun in die Praxis um.

Ich werde jetzt unser Docker-Image mit der Snyk CLI testen. Sie können sich hier für ein kostenloses Snyk-Konto registrieren.

$ npm install -g snyk
$ snyk auth
$ snyk container test node:16.17.0-bullseye-slim --file=Dockerfile

Der erste Befehl installiert die Snyk-CLI, gefolgt von einer schnellen Anmeldung über die Befehlszeile, um einen API-Schlüssel abzurufen. Dann können wir den Container auf Sicherheitsprobleme überprüfen. Das Ergebnis:

Organization:      lirantal
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:16.17.0-bullseye-slim
Platform:          linux/arm64
Base image:        node:lts-bullseye-slim
Licenses:          enabled

Tested 97 dependencies for known issues, found 44 issues.

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

Snyk hat 97 Betriebssystem-Abhängigkeiten erkannt, einschließlich unserer ausführbaren Node.js-Laufzeitdatei, fand aber keine anfälligen Laufzeitversionen. Allerdings gibt es 44 Sicherheitslücken bei der Software im Container-Image. 43 dieser Abhängigkeiten sind Schwachstellen mit geringem Schweregrad. Aber es gibt eine Sicherheitslücke mit kritischen Schwergrad in einer zlib-Bibliothek:

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:lts-bullseye-slim)

Critical severity vulnerability found in zlib/zlib1g
  Description: Out-of-bounds Write
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-ZLIB-2976151
  Introduced through: meta-common-packages@meta
  From: meta-common-packages@meta > zlib/zlib1g@1:1.2.11.dfsg-2+deb11u1
  Image layer: Introduced by your base image (node:lts-bullseye-slim)
  Fixed in: 1:1.2.11.dfsg-2+deb11u2

Beheben von Schwachstellen in Docker-Images

Ein schneller und effektiver Weg zu sicherer Software im Docker-Image ist der Neuaufbau des Docker-Images. Dafür brauchen Sie das zuvor erstellte Docker-Base-Image, das diese Updates für Sie abruft. Eine andere Möglichkeit ist die Installation ausgewählter Betriebssystem-Updates für Pakete, einschließlich Sicherheitsfixes.

Beim offiziellen Node.js-Docker-Image braucht es vielleicht länger, bis Image-Updates verfügbar sind. Dann wäre der Rebuild des Node.js-Docker-Images 16.17.0-bullseye-slim oder lts-bullseye-slim keine effektive Lösung. Alternativ können Sie auch Ihr eigenes Base-Image mit aktueller Software von Debian pflegen. In unserer Dockerfile setzen wir das so um:

RUN apt-get update && apt-get upgrade -y

Nach dem Aufbau des Node.js-Docker-Images führen wir den Snyk-Security-Scan mit der neu hinzugefügten RUN-Anweisung aus:

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:16.17.0-bullseye-slim)
Tested 98 dependencies for known issues, found 43 issues.
According to our scan, you are currently using the most secure version of the selected base image

Dies führte dazu, dass eine weitere Betriebssystemabhängigkeit hinzugefügt wurde (98 gegenüber 97 zuvor). Allerdings sind jetzt alle 43 Sicherheitslücken, die sich auf dieses Node.js-Docker-Image auswirken, von geringem Schweregrad. Außerdem haben wir die kritische zlib-Sicherheitslücke geschlossen. Das ist ein ausgezeichnetes Ergebnis!

Was wäre passiert, hätten wir die Base-Image-Anweisung FROM node verwendet? Oder noch besser: Nehmen wir an, Sie hätten auf ein bestimmtes Node.js-Docker-Base-Image wie dieses verwiesen:

FROM node:14.2.0-slim

✗ High severity vulnerability found in node
  Description: Memory Corruption
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-570870
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.4.0

High severity vulnerability found in node
  Description: Denial of Service (DoS)
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-674659
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.11.0

Organization:      snyk-demo-567
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:14.2.0-slim
Platform:          linux/amd64
Base image:        node:14.2.0-slim

Tested 78 dependencies for known issues, found 82 issues.

Base Image        Vulnerabilities  Severity
node:14.2.0-slim  82               23 high, 11 medium, 48 low

Recommendations for base image upgrade:

Minor upgrades
Base Image         Vulnerabilities  Severity
node:14.15.1-slim  71               17 high, 7 medium, 47 low

Major upgrades
Base Image        Vulnerabilities  Severity
node:15.4.0-slim  71               17 high, 7 medium, 47 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:14.15.1-buster-slim   55               12 high, 4 medium, 39 low
node:14.15.3-stretch-slim  71               17 high, 7 medium, 47 low

Auch wenn eine spezielle Node.js-Laufzeitversion wie FROM node:14.2.0-slim auszureichen scheint, weil Sie eine bestimmte Version (`14.2.0`) angegeben und außerdem ein kleines Container-Image (mit dem `slim`-Image-Tag) verwendet haben, kann Snyk zwei Sicherheitslücken in zwei primären Quellen finden:

  1. In der Node.js-Laufzeitumgebung selbst – haben Sie oben in dem Bericht die beiden gravierendsten Sicherheitslücken gesehen? Dabei handelt es sich um allgemein bekannte Sicherheitsprobleme der Node.js-Laufzeitumgebung. Diese lassen sich mit einem sofortigen Upgrade auf eine neuere Node.js-Version beheben. Snyk gibt aber nicht nur diesen Hinweis, sondern zeigt Ihnen auch, welche Version Sie für den Fix brauchen: 14.11.0, wie Sie in den Ergebnissen sehen können.

  2. Auf diesem Debian-Base-Image installierte Tools und Bibliotheken, wie glibc, bzip2, gcc, perl, bash, tar oder libcrypt. Auch wenn diese anfälligen Versionen in dem Container keine unmittelbare Bedrohung darstellen, warum sollten wir sie behalten, wenn wir sie nicht brauchen?

Und das Beste an diesem Snyk-CLI-Report? Snyk empfiehlt Ihnen andere Base-Images, auf die Sie ausweichen können. Sie müssen das also nicht selbst herausfinden. Die Suche nach alternativen Images kann viel Zeit in Anspruch nehmen und Snyk erspart Ihnen diesen Aufwand.

Mein Tipp:

  1. Sollten Sie Docker-Images in einer Registry wie Docker Hub oder Artifactory verwalten, können Sie sie einfach in Snyk importieren, damit die Plattform diese Schwachstellen für Sie findet. Dadurch profitieren Sie auch von Empfehlungen in der Snyk-Benutzeroberfläche und einem ständigen Monitoring Ihrer Docker-Images auf neu entdeckte Sicherheitslücken.

  2. Verwenden Sie die Snyk-CLI in Ihrer CI-Automatisierung. Die CLI ist sehr flexibel und genau dafür haben wir sie entwickelt – damit Sie sie für all Ihre benutzerdefinierten Workflows anwenden können. Wir bieten auch Snyk für GitHub Actions an, falls Ihnen das lieber ist.

Weitere Methoden für das Schwachstellen-Management in Container-Images finden Sie in unserem Leitfaden zur Container-Sicherheit.

8. Mit mehrstufigen Builds arbeiten

Mehrstufige Builds sind ideal, wenn Sie statt einer simplen, aber womöglich fehlerhaften Dockerfile Ihr Docker-Image in einzelnen Schritten aufbauen wollen, um keine vertraulichen Informationen preiszugeben. Das bietet auch weitere Vorteile. Zum Beispiel können wir ein größeres Docker-Base-Image verwenden, um unsere Abhängigkeiten zu installieren und beliebige native npm-Pakete zu kompilieren – und dann kopieren wir all diese Artefakte in ein kleines Prod-Base-Image (wie in unserem Alpine-Beispiel).

Vertrauliche Informationen schützen

Dass keine vertraulichen Daten preisgegeben werden, ist ein häufigerer Anwendungsfall, als man vermuten mag.

Bei Docker-Images für geschäftliche Zwecke ist die Wahrscheinlichkeit hoch, dass auch private npm-Pakete gepflegt werden müssen. Also müssen Sie wahrscheinlich eine Möglichkeit finden, um diesen geheimen NPM_TOKEN für die npm-Installation verfügbar zu machen.

Hier ein Beispiel:

FROM node:16.17.0-bullseye-slim

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
#RUN npm ci --only=production
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Auf diese Weise bleibt die .npmrc-Datei beim geheimen npm-Token im Docker-Image. Eine mögliche Verbesserung wäre, sie nachher zu löschen, z. B. so:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
RUN rm -rf .npmrc

Jetzt ist die .npmrc-Datei jedoch auf einem anderen Layer des Docker-Images verfügbar. Wird dieses Docker-Image öffentlich bereitgestellt oder jemand kann irgendwie darauf zugreifen, ist Ihr Token kompromittiert. Besser wäre Folgendes:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

Jetzt haben wir das Problem, dass die Dockerfile selbst als Secret Asset behandelt werden muss, weil sie das geheime npm-Token enthält.

Docker bietet zum Glück die praktische Möglichkeit, Argumente an den Build-Prozess zu übergeben.

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

Dann bauen wir den Build wie folgt auf:

$ docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234

Wahrscheinlich haben Sie gedacht, dass wir jetzt fertig sind. Aber da muss ich Sie leider enttäuschen.

So ist das nun mal beim Thema Sicherheit: Einige offensichtliche Dinge stellen sich manchmal als problematisch heraus.

Sie fragen sich jetzt, was das Problem sei? Build-Argumente, die auf diese Weise an Docker übergeben werden, werden im History Log gespeichert. Sehen wir uns das einmal genauer an. Führen Sie bitte diesen Befehl aus:

$ docker history nodejs-tutorial

Darauf wird Folgendes ausgegeben:

IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
b4c2c78acaba   About a minute ago   CMD ["dumb-init" "node" "server.js"]            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   USER node                                       0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg…   5.71MB    buildkit.dockerfile.v0
<missing>      About a minute ago   ARG NPM_TOKEN                                   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY . . # buildkit                             15.3kB    buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /usr/src/app                            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ENV NODE_ENV=production                         0B        buildkit.dockerfile.v0

Haben Sie das geheime npm-Token entdeckt? Genau das meine ich.

Das ist eine tolle Möglichkeit, um Secrets für das Container-Image zu verwalten. Aber noch besser sind mehrstufige Builds, damit dieses Problem erst gar nicht entsteht, wie ich gleich erklären werde. Auch möchte ich Ihnen zeigen, wie Sie minimale Images anlegen.

Mehrstufige Builds für node.js-Docker-Images kurz erklärt

Für den Aufbau unserer Node.js-Docker-Images können wir einen bekannten Grundsatz aus der Softwareentwicklung anwenden: „Separations of Concerns“, also die Trennung der Belange. Wir verwenden ein Image, um alles zu erstellen, was wir zum Ausführen der Node.js-Anwendung brauchen. Für eine Node.js-Umgebung bedeutet das die Installation von npm-Paketen und ggf. die Kompilierung nativer npm-Module. Das ist unsere erste Stufe.

Das zweite Docker-Image, das die zweite Stufe des Docker-Builds darstellt, wird das Docker-Prod-Image sein. Diese zweite und letzte Stufe ist das Image, das wir de facto optimieren und zum Veröffentlichen an die Registry geben (sofern wir eine haben). Das erste Image (unser build-Image) wird verworfen und bleibt bis zur Bereinigung als Image in dem Docker-Host erhalten, der es erstellt hat.

Hier nun das Update unserer Dockerfile, das unseren bisherigen Fortschritt zeigt, aber in zwei Stufen unterteilt ist:

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production && \
   rm -f .npmrc

__# --------------> The production image__
FROM node:16.17.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

Wie Sie sehen, habe ich ein größeres Image für die build-Stufe verwendet, da ich eventuell Tools wie gcc (GNU Compiler Collection) zum Kompilieren nativer npm-Pakete oder für andere Anforderungen brauche.

Bei der zweiten Stufe gibt es eine besondere Notation für die COPY-Anweisung, die den Ordner node_modules/ vom Build-Docker-Image in dieses neue Prod-Base-Image kopiert.

Ist Ihnen aufgefallen, dass NPM_TOKEN als Build-Argument an das Intermediary-Docker-Imagebuild übergeben wurde? Es ist in der Befehlsausgabe des docker history nodejs-tutorial nicht mehr zu sehen, weil es nicht mehr in unserem Prod-Docker-Image existiert.

9. Unnötige Dateien in Node.js-Docker-Images vermeiden

Mit einer .gitignore-Datei vermeiden Sie, dass unnötige oder womöglich vertrauliche Daten im Git-Repository abgelegt werden. Gleiches gilt für Docker-Images.

Was ist eine Docker-Ignore-Datei?

Bei Docker sorgt die Datei .dockerignore dafür, dass das Senden von darin enthaltenen Glob-Pattern-Matches an den Docker-Daemon übersprungen wird. Hier ist eine Liste von Dateien, damit Sie eine Vorstellung davon bekommen, was Sie womöglich in Ihr Docker-Image aufnehmen, aber idealerweise vermeiden sollten: .dockerignore-node_modules-npm-debug.log-Dockerfile-.git-.gitignore

Wie Sie sehen, ist es ziemlich wichtig, node_modules/ zu überspringen. Hätten wir nämlich dies nicht ignoriert, hätte die vereinfachte Dockerfile-Version, mit der wir begonnen haben, dazu geführt, dass der lokale Ordner node_modules/ unverändert in den Container kopiert wird.

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Für mehrstufige Docker-Builds ist eine .dockerignore-Datei sogar noch wichtiger. Zur Erinnerung, wie der Docker-Build der zweiten Stufe aussieht:

__# --------------> The production image__
FROM node:16.17.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

Warum ist es so wichtig, dass wir eine .dockerignore haben? Ganz einfach: Wenn wir in der zweiten Dockerfile-Stufe den Befehl COPY . /usr/src/app ausführen, kopieren wir damit auch alle lokalen node_modules/ in das Docker-Image. Das muss unbedingt vermieden werden, weil wir so auch modifizierten Quellcode im node_modules/ kopieren könnten.

Dazu kommt, dass wir beim Verwenden des Platzhalters COPY . Gefahr laufen, vertrauliche Dateien in das Docker-Image zu übernehmen, die z. B. Anmeldedaten oder lokale Konfigurationen enthalten.

Meine wichtigen Tipps zur .dockerignore-Datei:

  • Überspringen Sie womöglich geänderte Kopien von node_modules/ im Docker-Image.

  • Die Datei bewahrt Sie vor der Preisgabe von vertraulichen Daten wie Anmeldeinformationen, die in .env oder aws.json enthalten sein können und so in das Node.js-Docker-Image gelangen.

  • Damit können Sie schneller Docker-Builds entwickeln, weil Dateien ignoriert werden, die ansonsten eine Cache-Invalidierung verursachen. Wurde z. B. eine Log-File oder eine Konfigurationsdatei für eine lokale Umgebung modifiziert, würde dies den Docker-Image-Cache beim Kopieren über das lokale Verzeichnis ungültig machen.

10. Secrets im Docker-Build-Image mounten

Zur .dockerignore-Datei ist anzumerken, dass dies ein Alles-oder-Nichts-Ansatz ist. Das bedeutet: Bei einem mehrstufigen Docker-Build können Sie die Datei nicht für jede Build-Stufe aktivieren oder deaktivieren.

Warum ist das wichtig? Idealerweise möchten wir die Datei .npmrc in der Build-Stufe verwenden, da wir sie vielleicht brauchen werden, weil sie ein geheimes npm-Token für den Zugriff auf vertrauliche npm-Pakete enthält. Eventuell ist auch eine spezielle Proxy- oder Registry-Konfiguration notwendig, um Pakete abzurufen.

Daher ist es sinnvoll, in der build-Stufe Zugriff auf die Datei .npmrc zu haben. Allerdings können wir sie in der zweiten Stufe des Prod-Images überhaupt nicht gebrauchen, da sie vertrauliche Daten wie den geheimen npm-Token enthalten kann.

Um dieses Problems mit der.dockerignore zu vermeiden, könnten wir ein lokales Dateisystem für die Build-Stufe mounten. Es gibt aber eine bessere Lösung.

Docker unterstützt eine relativ neue Funktion namens „Docker-Secrets“, die für unsere Anforderungen an die .npmrc ideal ist. Und so funktioniert’s:

  • Wenn wir den Befehl docker build ausführen, geben wir bestimmte Befehlszeilen-Argumente an, die eine neue Secret-ID definieren und eine Datei als Secret-Quelle referenzieren.

  • In der Dockerfile fügen wir Flags zur RUN-Anweisung hinzu, um die Prod-npm zu installieren. Diese bindet die Datei, auf die die Secret-ID referenziert, im Zielort ein: in der lokalen Verzeichnisdatei .npmrc – also genau dort, wo wir sie haben wollen.

  • Auf die .npmrc-Datei wird als Secret verwiesen, sie wird niemals in das Docker-Image kopiert.

  • Zum Schluss müssen wir noch daran denken, die .npmrc-Datei zum Inhalt der Datei .dockerignore hinzuzufügen, damit sie in kein Image – weder die Build- noch die Prod-Images – gelangt.

Prüfen wir einmal, wie das alles zusammen funktioniert. Sehen wir uns zuerst die Datei .dockerignore an:

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc

Betrachten wir nun die vollständige Dockerfile mit der aktualisierten RUN-Anweisung zur Installation der npm-Pakete, wenn der .npmrc-Mount-Punkt angegeben wird:

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --only=production

__# --------------> The production image__
FROM node:16.17.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

Und schließlich der Befehl, der das Node.js-Docker-Image aufbaut:

$ docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc

Hinweis: Secrets sind eine neue Docker-Funktion. Sollten Sie eine ältere Version verwenden, müssen Sie ggf. das Buildkit wie folgt aktivieren:

$ DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc

Zusammenfassung

Geschafft! Sie haben jetzt ein optimiertes Node.js-Docker-Base-Image erstellt. Gut gemacht!

In diesem letzten Abschnitt fassen wir noch einmal den gesamten Leitfaden zur Containerisierung von Node.js-Docker-Webanwendungen zusammen. Dabei berücksichtigen wir auch leistungs- und sicherheitsbezogene Optimierungen, die für den Aufbau von produktionstauglichen Node.js-Docker-Images notwendig sind.

Weiterführende Ressourcen, die ich Ihnen zu diesem Thema empfehlen kann:

Nachdem Sie sichere, leistungsstarke Docker-Base-Images für Ihre Node.js-Anwendungen erstellt haben, sollten Sie mit dem kostenlosen Snyk-Konto Ihre Container auf Schwachstellen überprüfen.

Patch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo Segment

Snyk ist eine Developer Security Plattform. Integrieren Sie Snyk in Ihre Tools, Workflows und Pipelines im Dev-Prozess – und Ihre Teams identifizieren, priorisieren und beheben Schwachstellen in Code, Abhängigkeiten, Containern, Cloud-Ressourcen und IaC nahtlos. Snyk bringt branchenführende Application & Security Intelligence in jede IDE.

Kostenlos startenLive-Demo buchen