Auf das richtige Node.js-Docker-Image kommt es an: Entscheidungsfaktoren
30. September 2022
0 Min. LesezeitHinweis der Redaktion:
21. Oktober 2022 – Dieser Beitrag wurde aktualisiert, um den Unterschied zwischen Node.js-Alpine-Linux-Images und anderen Node.js-Container-Images für die Produktion besser zu erklären.
Die Auswahl eines Node.js-Docker-Images hört sich nicht wie eine große Sache an. Allerdings können die Image-Größe und mögliche Schwachstellen weitreichende Folgen für Ihre CI/CD-Pipeline und Ihr Sicherheitsprofil haben. Wie finden Sie also das richtige Node.js-Docker-Image für Ihre Zwecke?
Bei Verwendung von FROM node:latest
oder nur FROM node
(einem Alias des ersten Befehls) kann man leicht potenzielle Risiken übersehen. Das gilt umso mehr, wenn Sie sich weder der allgemeinen Sicherheitsrisiken bewusst sind noch die Dateigröße abschätzen können, mit der die CI/CD-Pipeline dann arbeiten muss.
Das folgende Referenzbeispiel einer Node.js-Dockerfile findet sich in vielen Tutorials und Blogbeiträgen zum Erstellen von Node.js-Docker-Images, ist aber wegen der vielen darin enthaltenen Fehler nicht zu empfehlen:
FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
Im Beitrag 10 Best Practices zur Containerisierung von Node.js-Webanwendungen mit Docker erkläre ich Schritt für Schritt, mit welchen Builds und Verbesserungen Sie ein produktionsreifes Node.js-Docker-Image erstellen können.
Für diesen Beitrag verwenden wir das obige fiktive Beispiel als Inhalt einer Dockerfile
, um gemeinsam ein ideales Node.js-Docker-Image zu finden.
Ihre Optionen für ein Node.js-Docker-Image
Für Ihre Vorgehensweise beim Aufbau eines Node.js-Images gibt es einige Optionen. Diese reichen vom offiziellen Node.js-Docker-Image, das vom Node.js-Kernteam gepflegt wird, bis hin zu speziellen Node.js-Image-Tags, die Sie in bestimmten Docker-Base-Images auswählen können. Zudem gibt es weitere Optionen, wie die Entwicklung Ihrer Node.js-Anwendung auf der Grundlage von Googles Distroless
-Projekts oder des rudimentären Scratch
-Images vom Docker-Team.
Die Frage ist: Welche dieser Optionen ist für Ihr Node.js-Docker-Image ideal?
Sehen wir uns die verschiedenen Möglichkeiten genauer an, um mehr über ihre Vorteile und potenziellen Risiken zu erfahren.
Hinweis des Autors: In diesem Artikel beziehe ich mich auf eine Node.js-Version vom Juni 2022 – Node.js 18.2.0.
Das Node-Standardimage
Beginnen wir mit dem gepflegten node
-Image. Dieses wird offiziell vom Node.js-Docker-Team gepflegt und umfasst mehrere Docker-Base-Image-Tags, die auf verschiedene zugrunde liegende Distributionen (Debian, Ubuntu oder Alpine) sowie auf unterschiedliche Versionen der Node.js-Laufzeitumgebung verweisen. Zudem gibt es spezielle Versions-Tags für CPU-Architekturen wie amd64
oder arm64x8
(der neue Apple M1).
Die am häufigsten verwendeten node
-Image-Tags für die Debian-Distribution – wie z. B. bullseye
oder buster
– basieren auf buildpack-deps
, die von einem anderen Team gepflegt werden.
Was passiert, wenn Sie Ihr Node.js-Docker-Image auf diesem node
-Standardimage nur mit der fastify
-npm-Abhängigkeit aufbauen?
FROM node
WORKDIR /app
RUN npm install fastify
Wenn Sie das Image mit der docker build --no-cache -f Dockerfile1 -t dockerfile1
erstellen, erhalten Sie Folgendes:
Da wir keine Node.js-Laufzeitversion angegeben haben, ist
node
ein Alias fürnode:latest
und verweist auf die Node.js-Version 18.2.0.Die Größe des Node.js-Docker-Images beträgt 952 MB.
Wie sieht es mit den Abhängigkeiten und den Sicherheitslücken bei diesem neuesten Node.js-Image aus? Ein Snyk-Container-Scan mit docker scan dockerfile1
liefert folgendes Ergebnis:
Insgesamt gibt es 409 Abhängigkeiten, bei denen es sich ausnahmslos um Open-Source-Bibliotheken handelt, die bei Verwendung des System-Package-Managers erkannt wurden (z. B.
curl/libcurl4
,git/git-man
oderimagemagick/imagemagick-6-common
).Bei diesen Abhängigkeiten wurden insgesamt 289 Sicherheitsprobleme gefunden, wie Buffer-Overflows, Use-After-Free-Fehler oder Out-of-Bounds-Write.
Die Node.js-Laufzeitversion 18.2.0 ist anfällig für sieben Sicherheitsprobleme wie DNS Rebinding, HTTP Request Smuggling und Configuration Hijacking.
Brauchen Sie in Ihrem Node.js-Image für Ihre Anwendung wirklich Dinge wie wget
, git
oder curl
? Insgesamt mach dieses Base-Image keinen guten Eindruck angesichts von Hunderten Abhängigkeiten und Tools im Node.js-Docker-Image und ebenso vielen Abhängigkeiten, die darauf zurückgehen. Kurz: Die sieben verschiedenen Sicherheitslücken bei der Node.js-Laufzeitumgebung stellen eine massive Angriffsfläche dar.
Tag-Optionen im Node.js Docker Hub: node:buster oder node:bullseye?
Bei den verfügbaren Tags im Node.js-Docker-Hub-Repository gibt es zwei Optionen für alternative Node.js-Image-Tags: node:buster
und node:bullseye
.
Diese beiden Docker-Image-Tags basieren auf Debian-Distribution-Versionen. Das Image-Tag buster
verweist auf Debian 10 mit dem End-of-Life-Datum von August 2022 bis 2024 – was daher keine gute Wahl ist. Das Image-Tag bullseye
referenziert auf Debian 11, die derzeit offizielle Version (stable) mit Unterstützung bis Juni 2026 (End of Life).
Hinweis des Autors: Aus diesem Grund wird dringend empfohlen, dass Sie bei allen neuen und vorhandenen Node.js-Docker-Images das Image-Tag node:buster
durch das Image-Tag node:bullseye
oder ein anderes geeignetes Tag ersetzen.
Erstellen wir nun ein neues Node.js-Docker-Image basierend auf:
FROM node:bookworm
Wenn Sie dieses Node.js-Docker-Image-Tag als Grundlage verwenden und die obigen Ergebnisse vergleichen, erhalten Sie die gleiche Größe sowie die identische Anzahl an Abhängigkeiten und Schwachstellen. Das liegt daran, dassnode
, node:latest
und node:bullseye
beim Erstellen alle auf das gleiche Node.js-Image-Tag verweisen.
Node.js-Image-Tag für kleinere Images
Das offizielle Node.js-Docker-Team pflegt auch ein Image-Tag, das lediglich auf die für eine funktionierende Node.js-Umgebung absolut notwendigen Tools verweist.
Diese Node.js-Image-Tags werden mit einer Variante des Image-Tags slim
referenziert, wie z. B. node:bullseye-slim
oder mit einem Tag für bestimmte Node.js-Versionen wie node:14.19.2-slim
.
Erstellen wir jetzt ein Node.js-slim
-Image, das auf Debians aktueller stable-Release bullseye
basiert:
FROM node:bookworm-slim
Die Image-Größe ist bereits erheblich kleiner: Ursprünglich hatten wir ein Container-Image von fast einem Gigabyte, jetzt ist das Image nur noch 246 MB groß. Überprüfen wir dessen Inhalt, sehen wir, dass das Image deutlich weniger Software enthält und somit lediglich 97 Abhängigkeiten und 56 Schwachstellen aufweist.
Allein bei der Container-Image-Größe und beim Sicherheitsprofil erhalten wir mit node:bullseye-slim
eine bessere Ausgangsbasis.
Node.js-Docker-Image mit Langzeitunterstützung (LTS)
Bisher basierten unsere Node.js-Docker-Images auf Node.js 18, der aktuellen Version von Node.js. Nach dem Node.js Releases Schedule beginnt der Active LTS
-Status – also der Long-Term Support, zu deutsch die Langzeitunterstützung – dieser Version jedoch erst im Oktober 2022.
Wäre es nicht praktisch, beim Erstellen von Node.js-Docker-Images immer auf die LTS-Versionen zu verweisen? Aktualisieren wir jetzt das Docker-Image-Tag entsprechend und legen ein neues Node.js-Image an:
FROM node:lts-bookworm-slim
Die schlankere Node.js-LTS-Version (16.15.0) erstellt ein Image mit ähnlich vielen Abhängigkeiten und Sicherheitslücken, das aber nur 188 MB groß ist.
Unabhängig von Ihren konkreten Anforderungen und Ihrer Entscheidung für eine LTS
oder Current
-Version der Node.js-Laufzeitumgebung bleibt also der Software-Anteil auf dem Node.js-Image mehr oder weniger gleich.
node:alpine – die bessere Wahl für ein Node.js-Image?
Das Node.js-Docker-Team pflegt ein node:alpine
-Image-Tag und Varianten davon, um bestimmte Versionen der Alpine-Linux-Distributionen mit einer Node.js-Laufzeitumgebung zu unterstützen.
Auf das Alpine-Linux-Projet wird häufig wegen der besonders kleinen Image-Größe verwiesen, da dies einen geringeren Software-Anteil und somit eine geringeren Angriffsfläche durch Abhängigkeiten bedeutet. Mit dem folgenden Befehl wird die Dockerfile angewiesen, einen Node zu erstellen, der die Größe des unkomprimierten Images erhöht:
FROM node:alpine
...
Das Ergebnis ist eine Docker-Image-Größe von 178 MB, die damit in etwa genauso groß wie ein slim
-Node.js-Image mit seinen 188 MB ist, aber wegen des Alpine-Image-Tags bei der Überprüfung nur 16 Betriebssystem-Abhängigkeiten und zwei Sicherheitslücken insgesamt aufweist. Das legt nahe, dass das Image-Tag alpine
eine gute Wahl für ein kleines Image und möglichst wenige Schwachstellen ist.
Ist node:alpine
die bessere Wahl für ein Node.js Docker-Image?
Die Alpine-Image-Variante für Node.js bietet womöglich eine insgesamt kleinere Image-Größe und sehr wenige Schwachstellen. Dazu müssen Sie aber wissen, dass das Alpine-Projekt musl zur Implementierung der C-Standardbibliothek verwendet, wohingegen Debians Node.js-Image-Tags wie bullseye
oder slim
auf der glibc
-Implementierung basieren. Diese Unterschiede bei der zugrunde liegenden C-Bibliothek können Leistungsprobleme, funktionale Bugs und Anwendungsabstürze verursachen. So berichtet z. B. Itamar Turner-Trauring in diesem Beitrag über unerwartete Laufzeitprobleme im Zusammenhang mit Alpine-Image-Tags für Python-Docker-Images.
Mit einem Node.js-alpine
-Image-Tag entscheiden Sie sich also für eine inoffizielle Node.js-Laufzeitumgebung. Das Node.js-Docker-Team unterstützt offiziell keine Container-Image-Builds, die auf Alpine basieren. Entsprechend wird darauf hingewiesen, dass Alpine-basierte Image-Tags experimentell und möglicherweise uneinheitlich sind. Die Bereitstellung erfolgt deshalb über folgende inoffizielle Builds. So steht es auch Image-Tag-Repository für „Unofficial Builds“:
Unofficial Builds bemüht sich um die Bereitstellung grundlegender Node.js-Binärpakete für einige Plattformen, die gar nicht oder nur teilweise von Node.js unterstützt werden. Dieses Projekt bietet keine Garantien und seine Ergebnisse werden nicht gründlich getestet. Auf nodejs.org bereitgestellte Builds haben sehr hohe Qualitätsstandards hinsichtlich der Code-Qualität, der Unterstützung durch relevante Plattformen sowie der termingerechten Bereitstellung und Bereitstellungsmethoden. Unter unofficial-builds bereitgestellte Builds wurden nur minimal oder gar nicht getestet; die Plattformen werden u. U. nicht von der offiziellen Node.js-Testinfrastruktur abgedeckt. Diese Builds werden als Service für die User Community bereitgestellt, wobei von diesen Communitys erwartet wird, dass sie an der Pflege der Builds mitwirken.
Bei der Kompatibilität mit dem Node.js-alpine
-Image-Tag gibt es einige bemerkenswerte Besonderheiten:
Yarn ist inkompatibel (Problem-Nr. 1716).
Sollten Sie
node-gyp
für die Cross-Compilation von nativen C-Bindings benötigen, ist Python (das in diesem Prozess eine Abhängigkeit darstellt) im Alpine-Image nicht verfügbar und Sie müssen eine eigene Lösung finden (Problem-Nr. 1706).
Wollen Sie ein Node.js-Docker-Image auf Basis von Alpine verwenden, müssen Sie wissen, dass Docker-Security-Tools (wie Trivy oder Snyk) derzeit keine laufzeitbezogenen Schwachstellen in Alpine-basierten Images erkennen können. Das kann sich zwar künftig ändern, aber bis dahin können Sie das Node.js 18.2.0 alpine
-Base-Image-Tag nicht auf Sicherheitslücken überprüfen – und die Node.js-Laufzeitumgebung 18.2.0 selbst ist de facto anfällig. Das liegt zwar an den Security-Tools und nicht am Alpine-Base-Image, sollte aber dennoch bedacht werden.
Distroless-Docker-Images für Node.js
Sehen wir uns für unseren Vergleich zuletzt noch die Distroless-Container-Images von Google an.
Was ist ein Distroless-Docker-Image?
Diese Images sind sogar noch kleiner als Builds mit dem Node.js-Image-Tag slim
, da sie auf die Anwendung und deren Laufzeitabhängigkeiten lediglich verweisen. Folglich hat ein Distroless-Docker-Image keinen Container-Package-Manager, keine Shell und keine sonstigen allgemeinen Tool-Abhängigkeiten – daher die kleine Größe und die geringe Angriffsfläche.
Praktischerweise pflegt das Distroless-Projekt ein laufzeitspezifisches Distroless-Docker-Image für Node.js, das anhand seines vollständigen Namespaces gcr.io/distroless/nodejs-debian11
identifiziert wird und in der Container-Registry von Google verfügbar ist (wie Sie an dem Teil gcr.io
erkennen können).
Da Distroless-Container-Images keine Software enthalten, können wir mit einem mehrstufigen Docker-Workflow Abhängigkeiten für unseren Container installieren und diese in das Distroless-Image kopieren:
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY . /app
RUN npm install
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=build /app /usr/src/app
WORKDIR /usr/src/app
CMD ["server.js"]
Dieses Distroless-Docker-Image hat nur 112 MB, was deutlich kleiner ist als die Dateigröße der Varianten mit den Image-Tags slim
oder alpine
.
Sollten Sie mit Distroless-Docker-Images liebäugeln, gibt es einige wichtige Punkte zu bedenken:
Die Images basieren auf aktuellen offiziellen Release-Versionen von Debian (stable). Das bedeutet, dass sie bis weit in die Zukunft unterstützt werden – Stichwort End of Life.
Da sie auf Debian basieren, verwenden sie die glibc-Implementierung, was weniger Überraschungen in der Produktion bedeutet.
Sie werden bald feststellen, dass das Distroless-Team keine speziellen Node.js-Laufzeitversionen unterstützt. Das bedeutet, dass Sie mit dem häufig aktualisierten Universal-Tag
nodejs:16
arbeiten oder sich bei der Installation irgendwann nach dem SHA256-Hash richten müssen.
Node.js-Docker-Image-Tags im Vergleich
In der folgenden Tabelle vergleichen wir die verschiedenen Node.js-Docker-Image-Tags noch einmal zur Übersicht:
Image-Tag | Node.js-Laufzeitversion | Betriebssystem-Abhängigkeiten | Betriebssystem-Sicherheitslücken | Kritische Schwachstellen mit hohem Risiko | Mittlere Schwachstellen | Geringe Schwachstellen | Node.js-Laufzeit-Schwachstellen | Image-Größe | Yarn verfügbar |
---|---|---|---|---|---|---|---|---|---|
Node | 18.2.0 | 409 | 289 | 54 | 18 | 217 | 7 | 952 MB | Ja |
node:bullseye | 18.2.0 | 409 | 289 | 54 | 18 | 217 | 7 | 952 MB | Ja |
node:bullseye-slim | 18.2.0 | 97 | 56 | 4 | 8 | 44 | 7 | 246 MB | Ja |
node:lts-bullseye-slim | 16.15.0 | 97 | 55 | 4 | 7 | 44 | 6 | 188 MB | Ja |
node:alpine | 18.2.0 | 16 | 2 | 2 | 0 | 0 | 0 | 178 MB | Ja |
gcr.io/distroless/nodejs:16 | 16.17.0 | 9 | 11 | 0 | 0 | 11 | 0 | 112 MB | Nein |
Betrachten wir noch einmal die Daten und Erkenntnisse zu den verschiedenen Node.js-Image-Tags, um unsere ideale Lösung zu finden.
Entwicklungsparität
Falls Sie sich wegen der Entwicklungsparität für ein Node.js-Image-Tag entscheiden – weil Sie also die gleiche Umgebungsparität für die Entwicklung und Produktion optimieren wollen –, dann ist Ihr Vorhaben wahrscheinlich schon zum Scheitern verurteilt. In den meisten Fällen verwendet jedes der drei wichtigsten Betriebssysteme eine andere C-Bibliotheksimplementierung: Linux arbeitet mit glibc, Alpine mit musl und macOS hat seine eigene BSD-libc-Implementierung.
Größe des Docker-Images
Manchmal kommt es tatsächlich auf die Größe an. Genauer gesagt: Es geht nicht darum, die kleinste Dateigröße hinzubekommen, sondern den insgesamt kleinsten Software-Footprint. In diesem Fall gibt es kaum einen Größenunterschied bei dem Image, das Sie mit dem Image-Tag slim
oder dem Image-Tag alpine
erhalten: Beide erzeugen ein Container-Image mit um die 200 MB. Allerdings ist der Software-Anteil der slim
-Images immer noch recht hoch (97 gegenüber 16 beim alpine
) und entsprechend größer ist die Angriffsfläche (56 Schwachstellen beim `slim`-Image gegenüber `2` Schwachstellen beim `alpine`-Image).
Sicherheitslücken
Schwachstellen sind ein wichtiger Aspekt, weshalb in vielen Artikeln dazu geraten wird, Container-Images möglichst klein zu halten. Dabei muss aber auch die Schwere der Sicherheitslücken berücksichtigt werden.
Die node
\- und node:bullseye
-Images können wir hier außen vor lassen, da sie wegen ihres umfassenderen Software-Anteils auch mehr Sicherheitslücken aufweisen. Konzentrieren wir uns also auf die kleineren Image-Typen. Vergleicht man slim
-, alpine
\- und distroless
-Images, liegen die hohen und kritischen Sicherheitsrisiken in absoluten Zahlen bei 0 bis 4 Sicherheitslücken – ein überschaubares Risiko, das womöglich für Ihren Anwendungsfall irrelevant ist.
Support und Widerstandsfähigkeit
Ob das Node.js-Docker-Team Probleme bei Ihren Container-Image-Builds mit Priorität bearbeitet und zeitnah behebt, ist schwer zu sagen. Sofern es sich nicht um die offiziellen Debian-basierten Image-Tags handelt, bleibt das ein offener Punkt auf Ihrer Checkliste.
Mit den Image-Tags node
oder node:bullseye-slim
bekommen Sie auf jeden Fall die neueste Version der Node.js-Laufzeitumgebung, ob Sie nun ein vollständiges Betriebssystem einbinden oder sich für die abgespeckte slim-Version mit weniger Abhängigkeiten entscheiden. Obwohl es eine gerade Versionsnummer (Node.js 18.2.0) gibt, als dieser Artikel geschrieben wurde, ist diese noch nicht in die Langzeitunterstützung (LTS) aufgenommen. Das bedeutet, dass auch neuere Versionen anderer abhängiger Komponenten - wie die letzten npm
-Versionen selbst (die für einige neue Bugs bekannt sind, die noch ausgebügelt werden müssen) – in dieser Laufzeitumgebung eingebunden werden.
Das Fazit?
Das ideale Node.js-Docker-Image wäre eine schlanke Version des Betriebssystems, das auf einem modernen Debian OS basiert und mit einer offiziellen (stable) und aktiven Langzeitunterstützung (LTS) von Node.js kommt.
Damit bleibt praktisch nur das Node.js-Image-Tag node:lts-bullseye-slim
übrig. Mein Favorit sind deterministische Image-Tags mit einer kleinen Änderung: Ich würde die tatsächlich zugrunde liegende Versionsnummer statt dem lts
-Alias verwenden.
Das ideale Node.js-Docker-Image-Tag ist node:16.17.0-bullseye-slim
.
Wenn Sie mit einem erfahrenen DevOps-Team arbeiten, das mit benutzerdefinierten Base-Images zurechtkommt, wäre meine zweite Wahl der distroless-Image-Tag von Google, da er die glibc-Kompatibilität für offizielle Node.js-Laufzeitversionen gewährleistet. Da dieser Workflow gepflegt werden muss, empfehle ich dieses Tag nur, wenn Sie es unterstützen können.
Container-Schwachstellen automatisch beheben
Container-Schwachstellen mit Snyk kostenlos finden und beheben