Docker für NodeJS - ein Produktiv-Setup

Das Einrichten eines Docker Containers zum Ausführen einer NodeJS-Anwendung ist einfach und unkompliziert. Jedoch müssen für den Produktiveinsatz viele Aspekte bedacht werden. Ich beschreibe die notwendigen Schritte für ein Produktiv-Setup.

4 Minuten
Hero image

Im folgenden Docker-Build wende ich einige Best Practices an, die ich in verschiedenen Security Blogbeiträgen gefunden habe. Ich verlasse mich auf Docker Layering, damit ich das Image auf eine angemessene Größe minimieren kann.

Base image

Als Ausgangspunkt verwenden wir eine Node Alpine-Version. Wichtig ist, dass alle Nicht-NPM-Abhängigkeiten, die im endgültigen Image benötigt werden, installiert sind.

ARG NODE_VERSION=20.11.1
ARG GITHUB_NPM_TOKEN

FROM node:${NODE_VERSION}-alpine AS base

ENV npm_config_unsafe_perm true
ARG GITHUB_NPM_TOKEN

RUN apk add --update --no-cache curl-dev git tini

Build Image

Das Build-Image verwendet das Basis-Image. Du installierst nun alle zusätzlich benötigten Abhängigkeiten, um den Befehl npm build ausführen zu können. Sobald dies erledigt ist, bauen wir unser Projekt und entfernen noch im selben Schritt die dev-dependencies aus dem Verzeichnis node_modules. Dies spart enorme Menge an Speicherplatz.

Tokens für Private-Packages

Falls du Private-NPM-Packages in deinen Dependencies hast, müsst du ein Token für die Installation als .npmrc-Datei bereitstellen. Dieses Token ist ein Secret und muss als solches behandelt werden. Wenn es am Ende eines einzelnen Docker Schritts Teil des Docker-Dateisystems ist, kann das Secret aus dem endgültigen Image ausgelesen werden! Daher führen wir einen einzigen Befehl aus, um die Datei npmrc zu erstellen, alle Abhängigkeiten zu installieren und die Datei zu löschen, sodass das vertrauliche Token nicht Teil des Endergebnisses ist.

FROM base AS build

ENV npm_config_unsafe_perm true
ARG GITHUB_NPM_TOKEN

# Create app directory
WORKDIR /app/

# Copy app files
COPY . .

# Install all dependencies (dev + prod)
RUN echo '@konzentrik:registry=https://npm.pkg.github.com' > "$HOME/.npmrc" && \
    echo '//npm.pkg.github.com/:_authToken=${GITHUB_NPM_TOKEN}' >> "$HOME/.npmrc" && \
    echo 'always-auth=true' >> "$HOME/.npmrc" && \
    npm ci && \
    rm -f "$HOME/.npmrc"

# Build stuff and remove dev dependencies afterward so we copy less to the final image
RUN npm run build && npm prune --omit=dev

Production image

Falls du im ersten Schritt keine zusätzlichen Abhängigkeiten installiert hast, kannst du ein neues Image aus dem NodeJS-Bullseye-Slim-Image erstellen. Dadurch wird die Docker-Image-Größe noch weiter reduziert.

# Create the final image
FROM node:${NODE_VERSION}-bullseye-slim as final
RUN apt-get update && apt-get install -y --no-install-recommends tini

Wenn Abhängigkeiten bestehen, nutzen wir das Basis-Image.

# Create the final image
FROM base AS final

Non-root user

Wir möchten die Anwendung als Nicht-Root-Benutzer ausführen. Daher kopieren wir für einen neuen Benutzer die relevanten Dateien aus dem Build-Image und starten den Vorgang mit dem entsprechenden Benutzer.

ENV npm_config_unsafe_perm true
ENV NODE_ENV production

# Create app directory
WORKDIR /app/

# Copy build result
COPY --chown=node:node --from=build /app/package.json .
COPY --chown=node:node --from=build /app/node_modules/ ./node_modules
COPY --chown=node:node --from=build /app/dist/ ./dist
USER node

Kubernetes-friendly execution

Wenn du das Docker-Image in einem Kubernetes-Cluster ausführst, bist du sehr an einem ordnungsgemäßen Herunterfahren (graceful shutdown) der Container interessiert. Im Zusammenhang mit RapidStream habe ich ausführlich darüber geschrieben. Für Details ließ dir gerne den Artikel durch.

EXPOSE 8080
ENTRYPOINT ["tini", "-v", "-e", "143", "--", "node", "build/index.js"]

Abschließende Gedanken

Während wir die Effizienz und Sicherheit unseres Clusters weiter verbessern, ist es wichtig, einen entscheidenden Aspekt der Containerisierung noch einmal zu überdenken: Docker-Images. Durch die Implementierung von Best Practices können wir die Startzeit erheblich verkürzen, die Sicherheit erhöhen und den Speicherbedarf minimieren.

Um die Vorteile schnellerer Startzeit zu nutzen, stellst du sicher, dass dein Docker-Image so schlank wie möglich ist. Das heisst:

  • Nur relevante Daten im Container behalten
  • Vermeidung unnötiger Abhängigkeiten und Bibliotheken

Denk bei der Optimierung der Docker-Images daran folgendes zu berücksichtigen: Auch wenn du nicht mehr auf einen alten Layer zugreifst, kann dieser immer noch vertrauliche Informationen enthalten – etwa Token oder Anmeldeinformationen.

Keep optimizing, keep securing!

Abonniere meinen Newsletter

Erhalte einmal im Monat Nachrichten aus den Bereichen Softwareentwicklung und Kommunikation gespikt mit Buch- und Linkempfehlungen.