Docker ist eine immens leistungsfähige Technologie, die das Leben eines Entwicklers in vielerlei Hinsicht erleichtern kann.
Docker ist eine Plattform zur Entwicklung, Bereitstellung und Ausführung von Anwendungen in Containern. Containern ermöglichen es, Anwendungen und ihre Abhängigkeiten in isolierten Umgebungen auszuführen, die sich einfach verschieben und skalieren lassen. Mit Docker können Entwickler ihre Anwendungen in Containern bereitstellen, die auf jeder Umgebung laufen, die Docker unterstützt.
Heute werden wir unsere NodeJS-Anwendung dockerisieren. Das bedeutet, eine Anwendung in einen Docker-Container zu „packen“, um sie einfacher bereitstellen und ausführen zu können. Das Dockerisieren beinhaltet das Erstellen eines Dockerfiles, das die Schritte beschreibt, die zur Erstellung des Containers erforderlich sind, sowie das Erstellen des Containers selbst mithilfe von Docker-Befehlen.
Ein Vorteil dieses Schritts ist, dass eine dockerisierte Anwendung auf jeder Umgebung ausgeführt werden kann, die Docker unterstützt, ohne dass sich die Umgebung auf die Anwendung auswirkt oder umgekehrt.
Wir gehen davon aus, dass wir bereits eine NodeJS-Anwendung haben und diese läuft.
Bevor wir beginnen…
Auf meiner GitHub Page findest du einige NodeJS Projekte, welche du für dieses Wie geht das?
gerne verwenden kannst. Hier ist der Link: https://github.com/DenHerrRing
Warum Docker?
Wie bereits oben erwähnt, erschaffen wir durch das Dockerisieren eine Umgebung, hier eine Entwicklungsumgebung, die sich nicht auf unsere Anwendung auswirkt. Das bedeutet, dass wir immer davon ausgehen können, dass entstandene Fehler der Anwendung geschuldet sein müssen und nicht der Umgebung. Aber Achtung, auch das kann in einigen Ausnahmefällen passieren. Daher gehe bitte immer nur von 99% aus, dass der Fehler in der Anwendung liegt.
Ein weiterer Vorteil ist, dass wir Continuous Deployment (CI) nutzen können, da Container agnostisch zu ihren Umgebungen laufen. Somit stellen wir sicher, dass in unserem Fall die Entwicklungsumgebung das gleiche Laufzeitverhalten auf zum Beispiel einem Windows, Linux oder Apple Mac Computer hat.
Docker installieren
Wenn du Docker noch nicht installiert hast, gehe auf den folgenden Link und befolge die Installationsschritte.
Anschließend erstellst du eine Dockerdatei im Stammverzeichnis deinem Projekt:
touch Dockerfile
Die (Entwicklungs-)Umgebung definieren
Öffne das Dockerfile
und beginne mit der Erstellung der Konfiguration für Docker.
Als Erstes müssen wir die Umgebung definieren. Für unsere Anwendung werden wir NodeJS 16 verwenden. Du kannst die verfügbaren Versionen auf Docker Hub sehen:
FROM node:16
Meistens brauchen wir aber nicht die gesamte NodeJS-Umgebung. Wir können eine leichtgewichtige Alternative verwenden.
FROM node:lts-alpine
In der Regel sind die alpinen Versionen „abgespeckte“ Versionen, welche aber genug Funktionalität für deine Anwendung bieten.
Erstelle ein Arbeitsverzeichnis
Das Docker-Image wird eine eigene Verzeichnisstruktur haben. Wir brauchen also einen Platz innerhalb des Images, um unseren Anwendungscode zu speichern.
Erstellen wir ein Verzeichnis für unsere Anwendung.
WORKDIR /usr/src/app
Abhängigkeiten installieren
In dem oben definierten Image sind NodeJS und NPM bereits installiert, sodass wir uns darüber keine Gedanken machen müssen. Als Nächstes müssen wir unsere package.json
-Datei in das Arbeitsverzeichnis kopieren, das wir oben definiert haben.
# Wir verwenden einen Platzhalter, um sicherzustellen, dass sowohl package.json
# als auch package-lock.json in unserem Arbeitsverzeichnis abgelegt werden.
COPY package*.json ./
Dann installiere die Abhängigkeiten. Wir können den RUN
-Befehl von Docker nutzen.
RUN npm install
Wenn du den Code für die Produktion bauen willst, können wir den folgenden Befehl verwenden. Die Erklärung zum Befehl npm ci findest du hier.
RUN npm ci --only=production
Den Anwendungscode kopieren
Du fragst dich vielleicht, warum wir den Anwendungscode im vorherigen Schritt nicht in das Arbeitsverzeichnis kopiert haben?! Das liegt daran, dass Docker für jede Schicht ein Zwischenabbild erstellt. Und für jeden Befehl wird ein Layer erstellt.
Da wir die Anwendungscodes in den vorherigen Schritten nicht brauchten, wäre es Platzverschwendung und der Prozess wäre weniger effizient, wenn wir den gesamten Code in den vorherigen Schritten kopieren würden.
Wenn du mehr darüber erfahren möchtest, besuche diesen Blog-Beitrag: bitjudo.com
Holen wir uns jetzt deinen Anwendung – Code:
COPY . .
Jetzt müssen wir den internen Anwendungsport freigeben. Denn standardmäßig geben Docker-Images aus Sicherheitsgründen keinen Port frei.
EXPOSE 3000
Die Anwendung ausführen
Jetzt definieren wir den Befehl zum Starten unseres Servers.
Da wir unser Bundle im dist
-Ordner erstellen (den wir in der tsconfig.json-Datei definiert haben), müssen wir das erstellte Image ausführen. Solltest du einen anderen Ordner gewählt haben, musst du diesen hier abwandeln.
CMD [ "node", "dist/index.js" ]
Beachte, dass wir nicht npm start
verwendet haben, um die Anwendung zu starten, weil die Ausführung mit NodeJS
effizienter ist.
NodeJS, bzw. der Befehl node
startet die Anwendung direkt aus der JavaScript-Datei. Es werden keine weiteren Konfigurationen geladen, es sei denn, wir haben sie explizit definiert. Dazu kommen wir aber weiter unten. Bei npm start
wird die Anwendung mithilfe der Konfigurationen und Abhängigkeiten in der package.json gestartet. Dies ermöglicht es auch die Anwendungen zu starten, indem Konfigurationsdateien, Umgebungsvariablen und andere Abhängigkeiten geladen werden, die in der package.json definiert sind aber wir verlieren wichtige Exit-Signale wie SIGTERM
und SIGINIT
, welche npm nicht „verwendet“.
Wenn wir uns die folgenden Definitionen einmal anschauen, sollten die Möglichkeiten klar sein, wieso wir nicht auf solche Signale verzichten wollen:
SIGTERM (Terminate) ist ein Signal, das an ein Prozess gesendet wird, um ihn sanft zum Beenden aufzufordern. Es ist das übliche Signal, das verwendet wird, um einen Prozess auf einem Unix-ähnlichen Betriebssystem sauber zu beenden. Wenn ein Prozess das SIGTERM-Signal empfängt, hat es normalerweise Zeit, um saubere Ressourcen freizugeben, Daten zu speichern und andere notwendige Aufgaben auszuführen, bevor es beendet wird. Wenn ein Prozess das Signal nicht ordnungsgemäß handhabt, kann es zu Datenverlust oder anderen Problemen führen.
SIGINT (Interrupt) ist ein Signal, das an ein Prozess gesendet wird, um ihn zum sofortigen Beenden aufzufordern. Es wird typischerweise verwendet, um einen Prozess zu beenden, wenn er unerwartet hängt oder nicht auf ein SIGTERM-Signal reagiert. Wenn ein Prozess das SIGINT-Signal empfängt, hat es normalerweise keine Zeit, um saubere Ressourcen freizugeben oder Daten zu speichern. Es wird sofort beendet. Es ist auch das Signal das gesendet wird, wenn man ein Programm mittels „Strg + C“ in der Kommandozeile oder Terminal unterbricht.
Jetzt könnte man behaupten, dass npm start
es uns ermöglicht mehrere Prozesse einfacher zu starten zum Beispiel mit PM2 oder anderen Tools. Dies ist auch richtig, aber wir arbeiten in einem Container. Dieser braucht diese Exit-Signale um darauf reagieren zu können, ob unsere Anwendung läuft oder nicht.
Finale Docker-Datei
FROM node:lts-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
RUN npm run build
CMD [ "node", "dist/index.js" ]
Eine .dockerignore
Datei erstellen
Wir können unser Docker-Image weiter optimieren, indem wir das Kopieren einiger Ressourcen in die Anwendung vermeiden. Erstelle eine .dockerignore
-Datei und füge dort den folgenden Code ein
Dockerfile
docker*.yml
.git
.gitignore
.config
.npm
.vscode
node_modules
README.md
Unser Docker Image bauen
Erstelle das Image, indem du den folgenden Befehl ausführst.
docker build . -t nodejs-docker
Beachte den .
(Punkt) im Befehl.
Der -t
-Flag (oder --tag
) wird verwendet, um einen Tag oder einen Namen für das erstellte Docker-Image zu definieren. Der Tag wird verwendet, um auf das Image zu verweisen, wenn es in einem Repository gespeichert oder gestartet wird.
Das Tag-Format ist in der Regel <repository-name>:<tag>
oder <username>/<repository-name>:<tag>
z.B: docker build -t myapp:v1.0 .
Du kannst das erstellte Bild sehen, indem du den folgenden Befehl ausführst:
docker images
Das ergibt eine Ausgabe wie diese:
REPOSITORY TAG IMAGE ID CREATED SIZE
nodejs-docker latest 11b3fd2ab6d4 29 seconds 262MB
Unser Image ausführen
Führen wir unser Image in einem Container aus, indem wir den folgenden Befehl ausführen.
docker run -p 3000:3000 -d nodejs-docker:latest
Was passiert bei diesem aufruf?
Wir benutzen das -p
-Flag ( oder --publish
) um die Portweiterleitung zwischen dem Host-Computer, also deinem Computer und dem Docker-Container zu konfigurieren. Mit diesem Flag kannst Du angeben, welche Ports des Host-Computers auf welche Ports im Container weitergeleitet werden sollen.
Die Syntax für den -p-Flag lautet -p <host-port>:<container-port>
.
Beispiel: docker run -p 8080:3000 nodejs-docker
In diesem Beispiel wird der Port 3000 im Container auf den Port 8080 des Host-Computers weitergeleitet. Das bedeutet, dass jeder Zugriff auf den Port 8080 des Host-Computers auf den Port 3000 des Containers weitergeleitet wird, und dass die Anwendung im Container über den Port 3000 erreichbar sein wird.
Es ist auch möglich mehrere Ports gleichzeitig weiterzuleiten, indem man -p mehrere male verwendet oder die Ports kommagetrennt an den Flag übergibt.
z.B. docker run -p 8080:3000 -p 9090:80 nodejs-docker
Hier wird Port 3000 des Containers auf Port 8080 des Hosts und Port 80 des Containers auf Port 9090 des Hosts weitergeleitet.
Der -d
-Flag (oder --detach
) wird verwendet, um den Docker-Container im Hintergrund laufen zu lassen. Dies bedeutet, dass der Container im Hintergrund ausgeführt wird und die Kontrolle über die Konsole zurückgegeben wird, sobald der Container gestartet wurde.
Es ist auch möglich, die Ausgabe des Containers mit dem Flag -f
(oder --follow
) zu sehen.
docker run -d -f nodejs-docker
In diesem Fall wird der container ebenfalls im Hintergrund gestartet, aber die Ausgabe der Anwendung wird in der Konsole angezeigt.
Der -d
-Flag ist besonders nützlich, wenn Du den Container als Dienst oder zum Beispiel in einem Cluster starten möchtest, der im Hintergrund läuft und unabhängig von Ihrer Konsole weiterläuft.
Du kannst auch Umgebungsvariablen an den Container übergeben, indem du -e "NODE_ENV=production"
in den Befehl einfügst.
Der -e
-Flag (oder --env
) wird verwendet, um Umgebungsvariablen an den Docker-Container zu übergeben. Dies ermöglicht es, dass der Container über bestimmte Eigenschaften verfügt, die für seine Ausführung erforderlich sind, z.B. Zugangsdaten für eine Datenbank, Konfigurationswerte oder Umgebungsvariablen die in der Anwendung verwendet werden.
Die Syntax für den -e-Flag lautet -e <variable>=<value>
.
Beispiel: docker run -e DB_USER=myuser -e DB_PASSWORD=mypassword
nodejs-docker
Der laufende Container
Wenn wir uns den (oder die) laufenden Container anschauen wollen führe folgenden Befehl aus:
docker ps
Das sollte die folgende Ausgabe ergeben:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9be24ac857bb
nodejs-docker:latest
"docker-entrypoint.s…"
8 seconds ago
Up 7 seconds
0.0.0.0:3000->3000/tcp,
8080/tcp
eager_bardeen
Den Endpunkt testen
Gehe nun in deinen Browser oder einen HTTP-Client wie Postman und rufe die folgende URL auf.
http://localhost:3000
Du solltest dann folgende Ausgabe erhalten, wenn alles korrekt funktioniert:
{ "message": "Hello World!" }
Du kannst auch die Logs innerhalb des Containers sehen, indem du den folgenden Befehl ausführst. Hole dir die container_id
aus dem Befehl docker ps
, den wir zuvor ausgeführt haben.
docker logs [Deine container_id]
Connected successfully on port 3000
{ message: 'Hello World'}
Stoppe den Container
Du kannst den laufenden Container mit folgendem Befehl anhalten:
docker stop [deine container_id]
Und überprüfe mit docker ps
, ob der Befehl erfolgreich war.
Entwicklungsumgebungen mit Docker definieren
Die obige Konfiguration kann für die Produktion funktionieren. Aber wir können es uns nicht leisten, das Image während der Entwicklung jedes mal neu zu erstellen, wenn wir unseren Code ändern. Um dieses Problem zu lösen, brauchen wir eine Entwicklungskonfiguration, die unsere Codeänderungen sofort widerspiegelt.
Dazu können wir eine separate Dockerfile.dev
erstellen und die folgende Konfiguration hinzufügen:
FROM node:lts-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
Damit wir nun nicht immer zwischen den Umgebungen hin und her springen müssen, gibt es Docker Compose. Docker Compose ist ein Werkzeug, das es ermöglicht, mehrere Docker-Container als eine einzige Anwendung zusammenzuführen und zu verwalten. Es ermöglicht es, die Container, ihre Abhängigkeiten und ihre Konfigurationen in einer einzigen Datei zu beschreiben, die als docker-compose.yml
bezeichnet wird. Mit Docker Compose kannst Du mehrere Container mit einem einzigen Befehl starten, stoppen und verwalten. Dies ist sehr nützlich, wenn wir zum Beispiel noch einen Datenbank Container hinzufügen würden oder Anwendung auf Basis einer Microservice-Architektur entwickeln wollen.
Wie du merkst, brauchen auch wir in unserem Projekt Docker Compose, da wir mit verschiedenen Umgebungsvariablen arbeiten wollen und nicht immer alles per Konsole einfügen wollen. Die Befehle wären dann zu unhandlich. Um das zu erreichen, erstellen wir eine Datei docker-compose.yml
im Stammverzeichnis und fügen dort die folgende Konfiguration hinzu:
version: '3'
services:
express-typescript-docker:
environment:
- NODE_ENV=development
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./:/usr/src/app
container_name: nodejs-docker
expose:
- '3000'
ports:
- '3000:3000'
command: npm run dev
Beachte, dass wir auf die Dockerfile.dev
verweisen, um docker-compose mitzuteilen, welche Datei verwendet werden soll.
Dieses Skript wird ein Image mit dem Namen nodejs-docker
erstellen und es im Entwicklungsmodus ausführen.
Wir können den folgenden Befehl ausführen, um es in Aktion zu sehen:
docker-compose up
Übrigends kannst du auch hier das -d
/ -f
-Flag benutzen. Es funktioniert wie bei dem Befehl docker
.
Jetzt kannst du versuchen, deinen Code an beliebiger Stelle zu ändern und auf Speichern klicken. Dein Code wird automatisch aktualisiert!
Du kannst die Container anhalten, indem du STRG+C drückst, solltest du nicht einen der -d
/ -f
-Flag benutzt haben.
Solltest du deinen Container mit docker-compose up -d
gestartet haben, kannst du ganz einfach den folgenden Befehl ausführen um den Container zu stoppen:
docker-compose down
Docker Compose in der Produktion verwenden
Wir können docker-compose
auch in der Produktion verwenden. Dazu erstellen wir eine separate Dockerfile.prod
FROM node:lts-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
Beachte, dass wir die Befehle EXPOSE
und CMD
nicht mehr benötigen, da docker-compose sich darum kümmert.
Jetzt erstellen wir eine Datei docker-compose.prod.yml
version: '3'
services:
ts-node-docker:
environment:
- NODE_ENV=production
build:
context: .
dockerfile: Dockerfile.prod
command: node dist/index.js
Dabei wird die Konfiguration aus der Datei Dockerfile.prod
übernommen und der Befehl node dist/index.js von dort ausgeführt. Die restliche Konfiguration wird aus der Standarddatei docker-compose.yml
übernommen.
Um den Container in der Produktion zu starten, führen wir den folgenden Befehl aus:
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Beachte das Flag -f
, das docker-compose
mitteilt, welche Dateien für die Konfiguration verwendet werden sollen.
Du kannst überprüfen, ob die Container laufen, indem du den folgenden Befehl ausführst:
docker ps
Oder indem du die http://localhost:3000 anklickst.
Verbesserungen
Das Eingeben all dieser Befehle kann zeitaufwändig sein, also lass uns ein Makefile im Stammverzeichnis des Projekts erstellen, um uns das Leben leichter zu machen!
up: docker-compose up -d
up-prod: docker-compose -f docker-compose.yml -f docker-compose.prod.yml
down: docker-compose down
Jetzt können wir einfach make up
oder make up-prod
ausführen, um die Container zu starten.
Ich hoffe ich konnte dir einen guten Einblick in der NodeJS Entwicklung mit Hilfe von Docker, bzw. Docker Compose geben.