Node.js und Redis mit Docker Compose
Wie ich in Schulungen eine Zwei-Container-App aus Node.js und Redis mit Docker Compose beschreibe: eine YAML für den ganzen Stack, Service Discovery über den Servicenamen, Persistenz per Volume – und warum depends_on nicht auf Betriebsbereitschaft wartet.
Im Workshop kommt der Moment fast immer am zweiten Tag. Wir haben gerade ein einzelnes Node.js-Image gebaut, docker run funktioniert, alle sind zufrieden – und dann sage ich: "Diese App braucht eine Datenbank." In dem Fall Redis. Sofort steht die Frage im Raum, die ich seit Jahren in jeder Schulung höre: "Muss ich jetzt zwei Terminals aufmachen, zwei docker run-Kommandos merken und die Container irgendwie miteinander bekannt machen?"
Genau hier setzt Docker Compose an. Ich beschreibe die gesamte Anwendung – Webservice plus Redis – in einer einzigen Datei, und docker-compose up startet den kompletten Stack. In diesem Artikel gehe ich denselben Weg, den ich auch im Raum gehe: von der docker-compose.yml über Service Discovery und Persistenz bis zu dem Fallstrick, der die meisten Teilnehmer eine halbe Stunde kostet. Stand ist Sommer 2018, also Compose file v3 und die Bindestrich-CLI docker-compose.
Warum überhaupt Compose
Ein einzelner Container ist schnell erklärt. Sobald aber zwei Container zusammenspielen sollen, wird die imperative Variante mühsam: Netzwerk anlegen, beide Container darin starten, Ports publizieren, Umgebungsvariablen setzen, Startreihenfolge beachten. Das sind viele Kommandos, die niemand fehlerfrei aus dem Kopf reproduziert – schon gar nicht der Kollege, der das Projekt morgen zum ersten Mal klont.
Compose dreht das um. Statt einer Kommandofolge schreibe ich eine deklarative Beschreibung des Zielzustands. Die Datei sagt, welche Services es gibt, welche Images sie nutzen, welche Ports offen sind und wie sie zusammenhängen. Der Startbefehl ist danach immer derselbe. Wer schon einmal mit Docker gearbeitet hat, kennt das Prinzip aus dem Dockerfile – Compose hebt es nur eine Ebene höher, von einem Image auf eine ganze Anwendung.
Das Ganze folgt derselben Idee, die ich in Docker-Workshops isolieren beschrieben habe: reproduzierbare, gekapselte Umgebungen, die auf jedem Rechner gleich hochfahren.
Die docker-compose.yml
Hier ist die Datei, mit der ich im Workshop starte. Zwei Services, minimal gehalten, damit jede Zeile eine erkennbare Aufgabe hat.
version: '3'
services:
web:
build: .
environment:
NODE_ENV: production
REDIS_HOST: 'redis'
REDIS_PORT: '6379'
ports:
- '8080:8080'
depends_on:
- 'redis'
redis:
image: redis:4.0.11
Die erste Zeile ist 2018 Pflicht: version: '3'. Ohne diesen Key weigert sich Compose, die Datei zu lesen. Darunter kommen die Top-Level-Keys, die uns hier interessieren – services: auf jeden Fall, volumes: und networks: gleich noch.
Zwei Zeilen lohnt es, gezielt anzuschauen. Beim web-Service steht build: . – Compose baut das Image lokal aus dem Dockerfile im aktuellen Verzeichnis. Beim redis-Service steht image: redis:4.0.11 – hier wird ein fertiges Image aus der Registry gezogen. Das ist der zentrale Gegensatz, den ich immer betone:
build:nutze ich für meinen eigenen Code, den es als fertiges Image noch nicht gibt. Compose ruft im Hintergrund einendocker buildauf.image:nutze ich für alles, was ich unverändert übernehme – Datenbanken, Caches, Message-Broker. Redis pinne ich dabei auf eine konkrete Version (4.0.11) statt auflatest, damit die Umgebung reproduzierbar bleibt.
Für den Fall, dass mein Webservice bereits gebaut und in eine Registry geschoben wurde, tausche ich build: . einfach gegen image: mikebild/compose-nodejs-redis:1.0.0. Dieselbe Datei, ein anderer Bezugsweg – im Deploy-Kontext genau das, was man will.
Auffällig ist auch, was beim redis-Service fehlt: kein ports. Das ist Absicht. Nur web publiziert mit '8080:8080' einen Port auf den Host, weil nur der Webservice von außen erreichbar sein soll. Redis bleibt containerintern und braucht deshalb keine Host-Veröffentlichung. Auf diesen Isolationspunkt komme ich weiter unten zurück, er hat nämlich eine handfeste Sicherheitsseite.
Service Discovery: Redis heißt einfach "redis"
Jetzt der Teil, der im Raum regelmäßig für den größten Aha-Moment sorgt. Woher weiß der Node-Prozess, unter welcher Adresse er Redis findet? Es steht ja nirgends eine IP.
Compose legt beim Start automatisch ein Default-Netzwerk an, benannt nach dem Projekt, etwa <projekt>_default. Jeder Service hängt in diesem Netz und ist darin unter seinem Servicenamen per eingebautem DNS erreichbar. Anders gesagt: Jeder Service erreicht jeden anderen Service unter dessen Servicenamen. Der Name redis aus der YAML wird zum Hostnamen im Netzwerk.
Deshalb setze ich in der Datei REDIS_HOST: 'redis'. Der Node-Client verbindet sich zu redis:6379 – keine hartkodierte IP, keine --link-Akrobatik. Wenn Compose morgen dem Container eine andere interne Adresse gibt, ist das egal, weil über den Namen aufgelöst wird.
graph LR
Client["Client / Browser"] -->|"Host-Port 8080"| Web
subgraph net["Compose default network"]
Web["web-Container"] -->|"DNS: redis:6379"| Redis["redis-Container"]
end
Redis --> Vol[("volume<br/>redis-data (/data)")]
Die Kernbotschaft dieses Diagramms: Von außen führt genau ein Weg herein, über den publizierten Host-Port 8080 auf web. Intern spricht web mit redis über den Servicenamen. Und die Redis-Daten liegen in einem Volume, um das es gleich geht.
Wer das Default-Netz didaktisch sichtbar machen will, kann es auch explizit hinschreiben. Ich mache das im Workshop einmal, damit klar wird, dass hier kein Zauber im Spiel ist:
version: '3'
services:
web:
build: .
environment:
REDIS_HOST: 'redis'
REDIS_PORT: '6379'
ports:
- '8080:8080'
networks:
- app-net
depends_on:
- 'redis'
redis:
image: redis:4.0.11
networks:
- app-net
networks:
app-net:
Funktional ändert das gegenüber der ersten Datei nichts – Compose hätte ohnehin ein Netz gebaut. Aber es macht den Mechanismus explizit, und danach fragt niemand mehr, "wo" das Netzwerk herkommt.
Der Node-Client
Damit die Verbindung nicht abstrakt bleibt, hier der relevante Ausschnitt des Servers. Es ist ein schlanker Express-Server, der node_redis in der 2018 gängigen Version ^2.8 nutzt – also die Callback-API, noch nicht die späteren Promises.
const SERVICE_PORT = process.env.SERVICE_PORT || 8080;
const REDIS_HOST = process.env.REDIS_HOST || 'localhost';
const REDIS_PORT = process.env.REDIS_PORT || '6379';
const express = require('express');
const redis = require('redis');
const app = express();
const client = redis.createClient(REDIS_PORT, REDIS_HOST);
client.on('error', err => {
console.error(`Redis connection error.\n${err.message}`);
process.exit(1);
});
app.get('/redis', (req, res, next) => {
client.get('foo', (error, result) => {
if (error) return next(error);
res.send(result);
});
});
app.post('/redis', (req, res, next) => {
client.set('foo', 'bar', (error, result) => {
if (error) return next(error);
res.status(201).send('done');
});
});
app.listen(SERVICE_PORT, () => console.log(`Listen on ${SERVICE_PORT}`));
Die entscheidende Zeile ist redis.createClient(REDIS_PORT, REDIS_HOST). Host und Port kommen aus der Umgebung, und die Umgebung füllt Compose über den environment-Block. So bleibt der Code frei von Adressen. Auf meinem Laptop läuft er gegen localhost, im Compose-Netz gegen redis – ohne dass eine Zeile Anwendungscode sich ändert.
Ein Detail behalte ich im Hinterkopf: Der client.on('error', …)-Handler beendet den Prozess mit process.exit(1), sobald die Redis-Verbindung scheitert. Das ist bewusst hart und führt direkt zum wichtigsten Fallstrick.
Der Fallstrick: depends_on wartet nicht auf "ready"
depends_on: ['redis'] liest sich, als würde Compose warten, bis Redis einsatzbereit ist, bevor es web startet. Das tut es nicht. depends_on steuert ausschließlich die Start-Reihenfolge: Compose startet den Redis-Container vor dem Web-Container. Es wartet aber nicht, bis Redis Verbindungen annimmt – nur, bis der Container gestartet wurde.
Der Unterschied klingt akademisch, bis der harte Error-Handler oben zuschlägt. Redis braucht einen Moment, bis der Server im Container tatsächlich lauscht. Startet web in diesem Fenster und versucht sofort zu verbinden, feuert client.on('error'), der Prozess beendet sich mit Exit-Code 1 – und der frisch gestartete Stack ist schon wieder halb tot. Im Workshop passiert das reproduzierbar auf langsameren Rechnern, und es ist jedes Mal ein guter Lehrmoment.
Die saubere Lösung liegt nicht in der YAML, sondern in der Anwendung. Sie muss Verbindungsabbrüche vertragen und einen Reconnect mit Retry versuchen, statt beim ersten Fehler auszusteigen. Wo ich den Anwendungscode nicht anfassen will, schiebe ich in 2018 ein kleines Wartescript vor den Start – wait-for-it.sh, dockerize oder wait-for prüft, ob redis:6379 erreichbar ist, bevor der Node-Prozess startet.
Eine Sache gehört hier ausdrücklich verneint: Die Langform depends_on: {redis: {condition: service_healthy}}, die auf einen Healthcheck wartet, gibt es in Compose v3 im Jahr 2018 nicht. Wer sie in ein v3-File schreibt, bekommt sie schlicht nicht anerkannt. Die Wartelogik gehört also in Anwendung oder Wartescript, Punkt.
Persistenz: Redis braucht ein Volume
Der zweite Fallstrick ist stiller, dafür ärgerlicher. In der Startdatei hat Redis kein Volume. Solange der Container läuft, ist alles gut – die Daten liegen im Container-Dateisystem unter /data. Doch sobald der Container ersetzt wird, etwa nach docker-compose down und einem erneuten up, ist der Cache leer. Der Container ist eben flüchtig, und mit ihm sein Dateisystem.
Wer Redis nicht nur als Wegwerf-Cache nutzt, mountet ein benanntes Volume auf /data. Das redis-Image legt seine RDB-Snapshots genau dort ab.
version: '3'
services:
web:
build: .
environment:
REDIS_HOST: 'redis'
REDIS_PORT: '6379'
ports:
- '8080:8080'
depends_on:
- 'redis'
redis:
image: redis:4.0.11
volumes:
- 'redis-data:/data'
volumes:
redis-data:
Das benannte Volume redis-data überlebt den Lebenszyklus des Containers. Ich kann web neu bauen, den Stack herunterfahren und wieder hochziehen – die Redis-Daten sind noch da. Nur ein explizites docker-compose down -v räumt auch die Volumes ab.
Damit habe ich die vier Bausteine beisammen, die eine Compose-Datei ausmachen:
servicessind die Container der Anwendung, hierwebundredis.portslegt fest, welche Container-Ports auf den Host veröffentlicht werden; nurwebbekommt einen.volumeshält Daten in benannten Volumes, die den Container überleben sollen, hierredis-data.networksbeschreibt das meist automatische Netz, das Service Discovery per Servicename ermöglicht.
Der Zyklus: up, logs, down
Im Betrieb sind es nur eine Handvoll Kommandos, und die bleiben über Projekte hinweg gleich. Ich starte den Stack im Hintergrund, schaue in die Logs und fahre am Ende alles wieder herunter.
docker-compose up -d
docker-compose logs web
curl -X POST http://localhost:8080/redis
curl http://localhost:8080/redis
docker-compose down
Das up -d startet beide Container detached. logs web zeigt gezielt die Ausgabe des Webservice, was beim Debuggen des Startfensters oben hilft. Die beiden curl-Aufrufe schreiben zuerst den Wert nach Redis und lesen ihn dann zurück – der Beweis, dass web seinen Nachbarn unter dem Namen redis tatsächlich erreicht. down stoppt und entfernt Container und das Default-Netz; die benannten Volumes bleiben, sofern ich nicht -v ergänze.
Wer Docker Toolbox nutzt statt Docker for Mac oder Windows, erreicht den Stack übrigens nicht unter localhost, sondern unter der IP der docker-machine, in der Standardkonfiguration 192.168.99.100. Das ist 2018 auf vielen Rechnern noch die Realität, und es sorgt für den einen oder anderen ratlosen Blick, bis der Groschen fällt.
Ein Wort zur Sicherheit
Der fehlende ports-Eintrag bei Redis ist kein Versehen, sondern die eigentliche Pointe der Isolation. ports publiziert auf allen Host-Interfaces. Würde ich Redis mit '6379:6379' nach außen legen, stünde eine offene, standardmäßig ungesicherte Redis-Instanz im Netz – und Redis ist genau dafür immer wieder negativ aufgefallen. Innerhalb des Compose-Netzes erreicht web seinen Cache ohnehin über den Servicenamen. Von außen soll ihn niemand erreichen. Also lasse ich Redis unpubliziert. Die gleiche Zurücknahme auf das Nötige zeigt sich auch in Docker für .NET, wo dieselbe Frage – was gehört nach außen, was bleibt intern – über ein sauberes Setup entscheidet.
Am Rande: Der links-Eintrag, der einem in älteren Beispielen begegnet, ist Legacy. In v3 macht ihn das Default-Netz mit seinem DNS überflüssig. Wer ihn noch stehen hat, kann ihn streichen.
Fazit
Compose macht aus einer Handvoll docker run-Kommandos eine einzige, lesbare Datei, die den ganzen Stack beschreibt. image gegen build trennt übernommene von selbstgebauten Diensten. Service Discovery über den Servicenamen räumt hartkodierte IPs komplett aus dem Weg – REDIS_HOST: 'redis' genügt, und der Node-Client verbindet sich zu redis:6379. Ein benanntes Volume auf /data rettet die Redis-Daten über den Container-Lebenszyklus hinweg. Und ports publiziert nur, was wirklich nach außen soll.
Der eine Punkt, den ich jedem mitgebe: depends_on ordnet den Start, garantiert aber keine Betriebsbereitschaft. Wer das nicht weiß, jagt einem Race-Condition-Fehler hinterher, der sich nicht in der YAML beheben lässt, sondern in der Anwendung oder über ein kleines Wartescript. Wer es weiß, baut den Stack von Anfang an widerstandsfähig – und docker-compose up fühlt sich danach jedes Mal wie ein einziger, verlässlicher Handgriff an.
Kommentare