Node.js: Skalierbarer Websocketserver mit Redis Pub/Sub

18. Mrz. 2017 // Moritz Kanzler //

Im Beitrag Node.js: Eigener Websocket Server wird der Aufbau eines Chatservers auf Websocketbasis beschrieben. Der folgende Artikel baut auf dem eben genannten auf und zeigt wie der vorher erstellte Chatserver so umgebaut werden kann, dass dieser skalierbar und synchron auf mehreren Prozessen oder Knoten laufen kann. Damit ist es möglich auf verschiedene Mengen von Nutzerzahlen dynamisch zu reagieren.


Problematik

Das Ziel von Skalierbarkeit ist es, auf unterschiedlich viele Zugriffe dynamisch zu reagieren. In der Praxis bedeutet das, dass die Zahl der laufenden Instanzen eines Programmes je nach Bedarf erhöht oder reduziert werden können müssen. Diese Instanzen können entweder auf einem System oder verteilt auf mehreren Systemen laufen. Je nachdem wie groß der Ressourcenbedarf ist. Soll ein Programm also in einer skalierbaren Umgebung laufen, so muss dieses darauf vorbereitet werden. Damit genau gemeint ist, dass sich im Vorfeld Gedanken darum gemacht werden muss, welche Informationen in dem Programm pro Instanz vorliegen können, und welche Informationen in allen Instanzen vorliegen müssen.

Im Bezug auf den bisher vorliegenden Chatserver erkennt man schnell folgende Aufteilung:

Ressourcen pro Instanz 
Verbindungsaufbau und -abbauAusgehend von einem Loadbalancing auf die verschiedenen Instanzen kann der reine Verbindungsaufbau und -abbau über die einzelnen Instanzen gehandelt werden.
NachrichtübermittlungSteht die Verbindung vom Client zu einer beliebigen Instanz schickt der Client seine ausgehenden Nachrichten immer an diese Instanz. Auch das versenden der Nachrichten von Server zu Client erfolgt über die vorhandene Verbindungen der einzelnen Instanzen.
NachrichtenauswertungNachdem eine Nachricht eingetroffen ist werden die relevanten Informationen aus dem geschickten JSON Objekt herausgelesen. Diese Informationsermittlung kann ebenfalls pro Instanz geschehen.
AuthentifizierungDie Ermittlung ob ein Nutzer berechtigt ist den Chatserver zu nutzen geht mit der Anmeldung einher und kann lokal pro Instanz ausgeführt werden.

 

Ressourcen für alle Instanzen
VerbindungslisteIn der bisherigen Umsetzung des Chatservers wird eine Verbindungsliste aufgebaut, welche pro Instanz verwaltet wird. Lässt man den Chatserver aber in mehr als einer Instanz laufen ist nicht gewährleistet, dass alle Nutzer auf der selben Instanz versammelt sind. Hier muss also eine Kommunikation zwischen den einzelnen Instanzen ablaufen, welche einen Instanzübergreifenden abgleich der angemeldeten Nutzer ermöglicht.

 

Wie zu erkennen ist, können fast alle Aufgaben des Chatservers innerhalb der Instanzen abgearbeitet werden. Lediglich der Abgleich der Verbindungslisten ist Instanzübergreifend nötig. Gerade dieser ist aber entscheidender Faktor für die Funktion der Nachrichtenübermittlung von Benutzer A zu B.

Idee & Konzept

Ausgehend von der festgestellten Problematik im vorherigen Abschnitt muss jetzt eine Lösung gefunden werden, wie eine eingehende Nachricht von Nutzer A auf Instanz 1 zu Nutzer B auf Instanz 2 übermittelt werden kann.

Lässt man die bisherige Implementierung in mehreren Instanzen laufen, so findet weder ein Austausch der angemeldeten Nutzer zwischen den Verbindungen statt, noch gibt es eine gemeinsame überliegende Verbindungsliste.

Als erste Idee kam mir der Gedanke die eingehenden Nachrichten in einer SQL Datenbank abzulegen. Damit wären diese sicher gespeichert und überall abrufbar. Im Falle meiner Serverumgebung ist sogar schon eine REST API vorhanden, welche für das Speichern und Abrufen von Nachrichten die nötige Infrastruktur bietet. Durch dieses Verfahren wäre die Nachricht also aufjedenfall für die spätere Verwendung gesichert.

Was offen bleibt ist, wie erreicht die Nachricht den entsprechenden Nutzer auf einer anderen Instanz und wird dann übertragen? Ist der andere Nutzer ebenfalls auf der eigenen Instanz würden die bisherigen Mechanismen greifen, nicht aber im Fall des Clusterbetriebs. Ebenfalls ist die Nachricht zwar von allen Instanzen zugänglich in einer MySQL Tabelle gespeichert, allerdings werden die anderen Instanzen davon nicht informiert und fragen entsprechend diese Datenbanktabelle auch nicht an. Darüber hinaus würde diese Vorgehensweise die bisherige Einfachheit des Konzepts aufbrechen, da dann sändige SQL Abfragen stattfinden müssten, welche die gespeicherten Nachrichten mit der eigenen Verbindungsliste gegenprüfen, sowie unterschieden werden müsste welche Nachrichten schon gesendet wurden und welche nicht. Als offene Punkte bleiben also weiterhin:

  • die Benachrichtigung der anderen Instanzen über eine neue Nachricht,
  • bzw das Wissen einer Instanz auf welcher anderen Instanz der entsprechende Empfänger angemeldet ist.

Redis und das Publish-Subscribe-Pattern

Eine mögliche Lösung um Daten zwischen verteilten Systemen auszutauschen ist das Publish-Subscribe-Pattern. Es ermöglicht, dass ein einzelnes System einen Kanal abonnieren (channel subscribe) kann. Fortan lauscht das System auf diesem Kanal und erhält alle Nachrichten die in diesem Kanal veröffentlicht werden.

Der Gegenpart zu den Subscribern sind die Publisher, welche die Nachrichten in den Channeln zur Verfügung stellen. Weder die Publisher noch die Subscriber wissen, welchen anderen Systemen Sie ihre Nachrichten zur Verfügung stellen. Auch das ausgetauschte Nachrichtenformat ist frei wählbar.

Großer Vorteil dieses Pantern ist, das es vom Verwaltungsaufwand her sehr einfach gehalten ist. Durch Abonnierfunktion und die Unwissenheit der Systeme darüber, wer auf den einzelnen Kanälen lauscht kann es hervorragend in dynamischen Kontexten mit unterschiedlicher Anzahl an teilnehmenden Systemen eingesetzt werden. Eignet sich also hervorragend für die gesuchten Spezifikationen. Ein weiterer Vorteil ist ebenfalls, dass das Datenformat zu Austausch frei wählbar ist. Somit kann das System direkt an die Bedürfnisse der bisherigen Infrastruktur angepasst werden und auch eine spätere Übertragung von binären Daten ist möglich.

Durch die leichtgewichtigen Vorteile des Patterns ergibt sich aber auch ein Nachteil: Da keine Bestätigung oder Kentniss der teilnehmenden Systeme implementiert ist, kann es vorkommen, dass bei Überlast Nachrichten verloren gehen. Zu diesem Problem wird später in diesem Artikel noch nach Lösungsansätzen gesucht.

Grundsätzlich zeigt sich aber, dass der gängige Einsatz des PubSub-Patterns und die Vorteile des einfachen Verbindungsprotokolls für den Einsatz dieses Patterns in dem Chatserver Projekt sprechen und eine gute Lösung für die Verteilung von Informationen auf eine dynamische Anzahl von Knoten darstellt.

Um das PubSub-Pattern auch wirklich produktiv einzusetzen gibt es verschiedene Lösungen. Die NoSQL In-Memory Datenbank redis bietet hier eine fertig implementierte Lösung an. Neben redis gibt es noch viele weitere vorgefertigte Lösungen, allerdings hat redis für das Projekt weitere Vorteile:

  • redis steht unter der BSD Open-Source-Lizenz und ist somit kostenlos einsetzbar,
  • die Node.js Unterstützung für redis ist breit aufgestellt und auf die aktuellen Features abgestimmt,
  • redis bietet seit Version 3.0 auch die Möglichkeit selbst verteilt im Clusterbetrieb zu laufen und ist somit bei einer möglichen Überlast selbst fähig sich skalieren zu lassen.

Von Redis und dem PubSub-Pattern zur Verteilung von Chatnachrichten

Nachdem über redis und die PubSub-Queue ein Weg gefunden worden ist, durch den die einzelnen Instanzen der Chatserver in einem Clusterverband miteinander kommunizieren können, muss noch ein spezifischer Kommunikationsablauf festgelegt werden. Der bisherige Stand des Projekts beinhaltet ein JSON Objekt welches alle relevanten Informationen für eine erfolgreiche Nachrichtenübermittlung bietet. Es reicht also dieses JSON Objekt über die Message-Queue den anderen Knoten zur Verfügung zu stellen. Diese können dann mit der Funktionalität die bisher schon zur Verfügung steht das JSON Objekt auswerten und gegebenenfalls an den einzelnen Chatteilnehmer weiterleiten.

Es steht nun fest mit welcher Technik und in welchem Format Nachrichten zwischen den einzelnen Chatserver Instanzen ausgetauscht werden können. Wie schon im Abschnitt Problematik beschrieben bleibt die Frage offen, wie die Instanzen nun bei einer eingehenden Nachricht reagieren, also wie die Nachricht zu eigentlichen Empfänger gelangt. Zusammen mit der Annahme, dass einer oder beliebig viele Instanzen des Chatservers laufen auf denen die Chatteilnehmer nach der Anmeldung verteilt werden, kann man zu folgendem Konzept bzw. Ablauf gelangen:

  1. Eingehende Nachricht (JSON Objekt) auf der Instanz, auf der sich der Sender vorher angemeldet hat.
  2. Auswertung der Informationen im JSON Objekt.
  3. Überprüfung ob der gesuchte Empfänger in der Verbindungsliste der Instanz vorhanden ist.
  • Wenn ja: Direktes verschicken der Nachricht über den bisherigen Mechanismus.
  • Wenn nein: Senden des JSON Objekts an den redis Kanal auf welchem alle Instanzen horchen.

Nach diesem Kommunikationsablauf ist es also nötig, dass jede Instanz als Publisher und Subscriber zugleich ist. Dazu muss die Instanz also zwei Verbindungen zu dem redis Server aufrecht erhalten.

Umsetzung

Im oberen Abschnitt sind alle Ideen aufgeführt, welche es ermöglichen den bisher entwickelten Chatserver auf verteilten Systemen mit mehreren Instanzen zu betreiben. Nun muss dieses Konzept in Code umgesetzt werden. Zunächst sind aber einige Voraussetzungen zu erfüllen.

Voraussetzungen

Der Betrieb des Chat Services nach obigem Konzept erfordert folgende Komponenten:

  • Redis Server, zur Kommunikation der Chatserver Instanzen über eine Pub/Sub Queue.
  • Eine/Mehrere Instanzen des Chatservers.
  • Loadbalancing für mehrere Instanzen.

Mit diesem Setting wären die Anforderungen zur Umsetzung des Konzeptes schon erfüllt. In der Praxis ist es allerdings genauso wichtig auch die gegebenen Ressourcen (die Chatserver Instanzen) vernünftig zu verteilen. Genauso ist eine Überwachung der Services Notwending. Gesucht ist also ein Service der die Node.js Instanzen verwaltet, bei Absturz neu startet und die eingehenden Anfragen sinnvoll verteilt. Hierzu gibt es eine in Node.js selbst geschriebene Lösung namens pm2.

pm2 bietet alle gesuchten Funktionen und lässt zu mehrere Instanzen sowohl auf einem System laufen zu lassen, als auch auf mehreren Systemen parallel. Ebenso bieten es praktische Features wie ein automatisiertes Deployment aus einem Git Repository oder die Fernüberwachung der laufenden Services.

Zusätzlich agiert pm2 beim Einsatz mit mehreren Instanzen auch als Loadbalancer für alle HTTP/TCP/UDP Verbindugen und kann so eingehende Verbindugnen sinnvoll auf die zur Verfügung stehenden Knoten verteilen.

Zunächst sollten also die benötigten Services installiert werden.

Redis Server

Zunächst sollte Redis installiert werden, eine allgemeine Anleitung findet sich hier.

Nachdem Redis installiert wurde, startet man den Redis Server über den Aufruf:

Um die Lauffähigkeit zu testen, kann man folgenden Befehl absetzen:

Kommt ein  zurück, bedeutet das, dass der Redis Server erfolgreich gestartet wurde und fortan läuft.

pm2

Hinweis: Um pm2 zu installieren muss zunächst Node.js und npm auf dem System installiert werden. Auf die Installation dieser Services wird hier nicht eingegangen. Desweiteren wird hier nur die einfache Ausführung von mehreren Instanzen eines Programmes über pm2 beschrieben, ohne auf Wartung oder Deploymentoptionen von pm2 einzugehen.

Zunächst muss pm2 installiert werden:

Danach kann man pm2 über verschiedene Aufrufe steuern. Eine Übersicht der Befehle findet man in der Dokumentation.

Verbindungen zum redis Server

Nachdem die Vorbereitung für den Umbau des Chatservers fertiggestellt wurde, werden nun die eigentlichen Erweiterungen in den Code eingebaut. Begonnen wird mit dem Verbindungsaufbau zur Pub/Sub Queue von Redis. Wie schon oben beschrieben, benötigt jede Chatserver Instanz jeweils eine Verbindung als Abonnent, sowie eine als Publisher.

Um einen Redis Server in Node.js anzusteuern muss zunächst das entsprechende Paket dafür über npm heruntergeladen werden:

Nun kann Redis in den bisherigen Serveraufbau (server.js) eingebaut werden und über   jeweils eine Verbindug als Publisher und eine als Subscriber aufgebaut werden:

 

Auswertung der Nachrichteninformationen

Die Anwendung soll nun konstant auf einem vereinbarten Channel auf Nachrichten aus der Pub/Sub Queue horchen. Dies lässt sich über die Events ready und message realisieren. Hinzu kommt noch ein einfaches Error Handling. Die grundlegende Funktionsweise soll bei einer eingehenden Nachricht von Redis prüfen, ob der gesuchte Empfänger in der Verbindungsliste (userMap) der Instanz vorhanden ist.

Ist dies der Fall, soll er die erhaltene Nachricht an den entsprechenden Empfänger weiterleiten.

Falls nicht muss der Server nichts weiter tun, da der gesuchte Empfänger entweder nicht am Service angemeldet ist, oder aber mit einer anderen Instanz verbunden ist, die nach dem gleichen Schema agiert.

Die Funktion   übernimmt die oben erklärte Prüfung und das eventuelle Verschicken und gibt je nach Ergebnis true oder false zurück. Sie fungiert also als Auswertungsmechanismus für die Instanz.

Verteilung von Informationen zwischen den Instanzen

An diesem Punkt kann der Chatserver also eingegangene Nachrichten auswerten (über  ) und solche über Redis von anderen Instanzen empfangen. Was noch fehlt ist die Behandlung von eingehenden Nachrichten der Benutzer die an einer Instanz angemeldet sind. Grundsätzlich geschieht das über die Pub-Queue, allerdings muss auch hier zunächst ausgewertet werden, ob der empfangende Nutzer direkt auf der Instanz angemeldet ist, oder aber die Nachricht per Redis an die anderen Instanzen weitergegeben werden muss.

Ohne noch einmal den Aufbau der Websocket Verbindung genauer zu erklären zeigt der nächste Abschnitt, wie obige Überlegung umgesetzt wird. Damit ist der dargelegte Grundgedanke über die mögliche Verteilung des Chatservers auf mehrere Instanzen umgesetzt.


Nützliche Links

Weitere Kapitel