Apache Kafka als Backend für Webanwendungen?

Kafka wird dort eingesetzt, wo viele Daten in kurzer Zeit anfallen und verarbeitet werden sollen. Also auch im Backend für Webanwendungen?

In Pocket speichern vorlesen Druckansicht 2 Kommentare lesen
Apache Kafka als Backend für Webanwendungen?
Lesezeit: 17 Min.
Von
  • Frank Goraus
Inhaltsverzeichnis

Üblicherweise kommt Kafka zum Einsatz, wenn es darum geht, massenhaft Daten von vielen Systemen zu sammeln und auszuwerten, beispielsweise Log-Einträge, Zustandsinformationen oder Fehler-Events. Es dagegen als Backend für Webanwendungen zu benutzen, klingt wie eine dumme Idee. Das ist es eigentlich auch – eine, die beim Gespräch mit Kollegen an der Kaffeemaschine aufkam. Sie blieb aber irgendwie hängen, und damit auch die Frage, ob es überhaupt möglich ist und ob man dabei nicht die Vorteile von Kafka für sich nutzen kann.

Inwiefern ist solch ein Einsatzzweck überhaupt denkbar? Kafka trennt lesende Zugriffe durch sogenannte Consumer vom Erzeugen neuer Nachrichten in Form von Producern. Durch diese lose Kupplung können beide Seite unabhängig bedient und auch skaliert werden. Da der Inhalt der verteilten Nachrichten für Kafka grundsätzlich egal ist, müssen die ausgetauschten Daten nur in ein entsprechendes Format gebracht werden, das sie später wieder verwertbar macht. Die Idee wäre, jede Benutzeraktion als Event abzubilden und mit anderen Clients auszutauschen. Durch die Art wie Kafka Informationen bekommt und verteilt, sollten theoretisch beliebig viele Clients damit arbeiten können. Da Kafka sich ebenfalls um die Persistenz der Nachrichten kümmert, sollte ein Client nach einem Crash oder bei einer späteren Anmeldung da weiter machen können, wo er aufgehört hat und sogar mitbekommen, was zwischendurch passiert ist. Einziger Nachteil: Die Persistenz ist von begrenzter Dauer. In der Theorie sollte die Idee jedenfalls umsetzbar sein. Ein Proof of Concept soll den Beweis erbringen.

Kafka ist ein verteiltes Nachrichtensystem und zeichnet sich durch hohe Skalierbarkeit und Redundanz aus, da es sich auf mehrere Instanzen verteilen kann. Dadurch ist es hoch verfügbar und gewappnet gegen den Ausfall einzelner Instanzen, zwischen denen sich Nachrichten synchronisieren. Allerdings gibt es keine Garantie, dass beim Crash einer Instanz alle Nachrichten synchronisiert sind. Datenverlust ist folglich nicht ganz ausgeschlossen, aber eher unwahrscheinlich.

Zookeeper, ein weiteres System, das mit Kafka ausgeliefert wird, startet ausgefallene Instanzen neu oder tauscht sie durch Ersatzinstanzen. Durch die Verteilung über mehrere Instanzen verkraftet Kafka eine große Zahl von Consumern, die sich ad-hoc oder permanent per Publish-Subscribe-Mechanismus verbinden. Nachrichten sammelt Kafka unter sogenannten Topics, die sich zwecks Clustering noch auf sogenannte Partitionen verteilen können.

Um neue Nachrichten zu kreieren, erstellt man Producer. Consumer sind notwendig, um Nachrichten lesen und verarbeitet zu können. Kafka kann ebenfalls Nachrichten verarbeiten und daraus neue Nachrichten generieren. Das erfolgt über Stream-Prozessoren. Die Nachrichten bestehen aus einem Zeitstempel, dem Topic und dem Inhalt, der beliebig ist und von einfachen Strings über JSON bis hin zu kompletten (serialisierten) Java-Klassen reichen kann. Kafka versieht eingegangene Nachrichten automatisch mit einem Offset und damit einer fortlaufenden Reihenfolge. Dadurch entsteht eine Art Log, in dem alle Nachrichten zeitlich geordnete Events beziehungsweise Zustände darstellen. Außerdem kümmert sich Kafka um Housekeeping, indem es je nach Konfiguration alte Nachrichten verwirft. Dabei greift eine sogenannte Retention Time, die bestimmt, ob die Nachrichten nur wenige Sekunden, Tage oder auch nahezu ewig erhalten bleiben. Der zur Verfügung stehende Speicherplatz ist natürlich ein entscheidender Faktor, sowie die Menge und Größe der anfallenden Nachrichten.

Das soll als grober Überblick zu Kafka reichen. Üblicherweise wird Kafka eben genutzt, um Informationen von verschiedenen Systemen wie Log-Einträge, aktuelle Zustände von IoT-Geräten, Tracking-Informationen und ähnliches zu sammeln und möglichst in Echtzeit zu verarbeiten und zu analysieren. Big Data und Machine Learning sind hier die Schlagworte. Einige Anwendungsfälle sehen auch vor, diese Daten in einer Oberfläche wie einem Dashboard anzuzeigen.

Inwiefern kann man dies nun als Backend für eine Webanwendung benutzen? Dafür muss man zunächst ein paar Annahmen über die Applikation treffen. Zum einen sollte jede Benutzerinteraktion als ein Event dargestellt werden können, die in ihrer Summe den Gesamtzustand der Anwendung repräsentieren. Des Weiteren sollte es möglich sein, dass diese in ihrer zeitlichen Abfolge geordnet ablaufen können, sodass sich theoretisch das Wiederherstellen älterer Zustände oder Replay-Möglichkeiten ergeben. Mehrere Benutzer sollten darüber hinaus auf demselben Zustand arbeiten können, ohne sich mit konkurrierenden Änderungen gegenseitig ins Gehege zu kommen.

Stellt man sich zum Beispiel einen einfachen Chatclient vor, fällt der Vergleich nicht schwer. Es gibt mehrere Benutzer, die Textnachrichten verfassen. Sie erstellen fortlaufend immer neue Nachrichten, und löschen oder ändern keine alten Nachrichten. Diese Eingaben dienen als Basis für das Generieren und Publizieren entsprechender Kafka-Nachrichten. Man könnte auch einzelne Chaträume als eigene Topics modellieren. Andere Clients brauchen sich im Gegenzug nur auf das jeweilige Topic einzuschreiben und bekommen so Nachrichten anderer Benutzer zu lesen. Dass rein technisch gesehen Nachrichten nicht in Echtzeit ankommen müssen und mal ein kleiner Zeitversatz entstehen kann, ist dabei nicht schlimm. Da jeder Consumer seinen Lesefortschritt sowie -richtung der Nachrichten selbst verwaltet, ist das Wegbrechen einer Instanz nicht fatal und er fängt einfach später wieder da an, wo er aufgehört hat. Zwischendurch eingehende Nachrichten verarbeitet Kafka ebenso mit, ähnlich wie bei Messengern wie Whatsapp, wenn man eine Zeit lang offline war. Auch dass eventuell ganz alte Nachrichten, die länger als die konfigurierte Retention zurück liegen, nicht mehr vorhanden sein können, ist bei einem Chat nicht ganz so tragisch, da es ja um die zeitnahe direkte Kommunikation geht. Im Zweifelfall merkt sich der Client die lokale Historie, und es geht eher um die Überbrückung von Ausfallzeiten.

In diesem simplen Anwendungsbeispiel fungiert Kafka als zentraler Nachrichtenverteiler sowie als Persistenzschicht. Entwickler müssen theoretisch nur eine Client-App erstellen und die Anwendung ist fertig. In der Praxis ist es aus Sicherheitsgründen sinnvoll, noch eine weitere Schicht als Middleware zwischen Clients und Kafka einzubinden. Da Kafka einfach jegliche eingehenden Nachrichten entgegennimmt und in seinen Log schreibt, wäre es ziemlich anfällig für DDoS-Angriffe und auch Datenmüll wäre sofort persistent. Da Kafka jedoch keine Poison Message oder Dead Letter Queues wie JMS kennt, ist weniger tragisch. Dennoch bietet es sich an, die an Kafka gegebenen Nachrichten durch eine Middleware zu moderieren.

Neben der grundsätzlichen Umsetzbarkeit bietet dieser Ansatz auf den ersten Blick ein paar zusätzliche Vorteile. Durch das entkoppelte Schreiben (Producer) und Lesen (Consumer) erfolgt die Verarbeitung von Ende zu Ende komplett asynchron beziehungsweise Event-getrieben, sodass kein Client blockiert ist, oder auf das Ergebnis einer Nachricht warten muss. Da Kafka ein eigenes System ist und es Bibliotheken für verschiedene Programmiersprachen gibt, können Anwender verschiedene Clients auch mit unterschiedlichen Programmiersprachen schreiben. Als zusätzliche Option bietet sich auch die Nutzung eines Kafka REST Proxy an.

Im Folgenden wird ein vorhandenes kleines Chat-Frontend als React-App mit einer simplen Middleware auf Node-Basis angebunden. Die Kommunikation zum Frontend geschieht per Websocket. In die andere Richtung wird Kafka direkt angebunden. Das gesamte Beispiel ist im GitHub-Repository des Autors zu finden.

Kafka ist schnell aufgesetzt: Herunterladen, Auspacken, Zookeeper starten, Kafka starten, fertig. Schon läuft eine Instanz, mit der man entwickeln kann. Um sie aus einem Node-Projekt anzusprechen, bietet sich die Bibliothek kafka-node an, die aktiv weiterentwickelt wird und alle wichtigen Features in der Kommunikation mit Kafka anbietet.

const kafka         = require('kafka-node');
const Producer = kafka.Producer;
const Client = kafka.Client;

let client = new Client('localhost:2181');
let producer = new Producer(client);

let kafkaTopic = 'ADD_USER';
let kafkaMessage = '{"type":"USERS_LIST","users":[{"name":"Alejandro","id":1}]}';

producer.on('ready', function () {

let payload = [{
topic: kafkaTopic,
partition: 0,
messages: [JSON.stringify(kafkaMessage)],
attributes: 0
}];

producer.send(payload, function(error,result) {
if (error) {
console.error(error);
} else {
console.log('result: ', result)
}
});
});

producer.on('error', function (err) {
console.error(err);
});

Im Listing sieht man bespielhaft, was nötig ist, um einen Producer für Kafka-Nachrichten zu schreiben. Zunächst initalisiert man einen Client für den verwendeten Kafka-Server und den Producer. Sobald er bereit ist (ready-Event), können Nutzer Kafka-Nachrichten senden. Neben Topic und Message wird die Partition angegeben und dass die Nachricht nicht gzip-komprimiert (attribute: 0) abgelegt wird. Kafka-node kennt HighLevelProducer und "normale" Producer. Bei ersteren muss man sich nicht um das Management der Partitionen eines Topics kümmern, bei Letzteren hingegen schon. Fehler, die beim Senden auftreten, gibt die Konsole aus, ansonsten das Ergebnis beim erfolgreichen Ablegen der Nachricht.

result:  { ADD_USER: { '0': 76 } }

Die Zeile liefert statistische Informationen darüber, wie viele Nachrichten zum Topic (ADD_MESSAGE) auf den einzelnen Partitionen liegen. Da der Einfachheit halber mit einer Partition gearbeitet wurde, liegen alle bisherigen 76 Nachrichten dort. Sollte der Producer auf technische Fehler stoßen (error-Event), kann man darauf reagieren.

const kafka                 = require('kafka-node');
const HighLevelProducer = kafka.HighLevelProducer;
const Client = kafka.Client;

let client = new Client('localhost:2181');
let producer = new HighLevelProducer(client);
let topics = ['ADD_USER', 'ADD_MESSAGE'];

producer.on('ready', function (errProducer, dataProducer) {
producer.createTopics(topics, true, function (err, data) {
if (err) {
console.error('Error create Topics', err);
}
});
})

Bevor Nutzer jedoch das erste Mal Nachrichten zu einem neuen Topic erzeugen, sollte man sie Kafka mit createTopics bekannt machen. Das sollte beim Starten der Middleware, oder bevor man die Consumer initalisiert, passieren.

const kafka                 = require('kafka-node');
const HighLevelConsumer = kafka.HighLevelConsumer;
const Client = kafka.Client;

let client = new Client('localhost:2181');
let consumerTopics = [{ topic: 'ADD_USER', partition: 0 },
{ topic: 'ADD_MESSAGE', partition: 0 }];
let consumer = new HighLevelConsumer(client, consumerTopics,
{ autoCommit: true });

consumer.on('message', function (data) {
console.log(JSON.parse(data.value));
// später: ws.emit('broadcast', JSON.parse(data.value));
});

consumer.on('error', function (err) {
console.log('error', err);
});

process.on('SIGINT', function () {
consumer.close(true, function () {
process.exit();
});
});

Das Listing zeigt zum Schluss die nötigen Schritte, um einen Consumer zu erzeugen. Kafka-node unterscheidet erneut HighLevelConsumer und "normale" Consumer. Bei Verwendung von ersterem hat man keine Kontrolle über die Offsets beim Lesen der Nachrichten und es werden einfach nur fortlaufende neue Nachrichten konsumiert. Braucht man Features wie Replay oder besondere Leserichtungen, bleibt nur der zweite Weg über den Consumer. Beim Definieren der gelesenen Topics können Entwickler wiederum die Partitionen mit angeben, was im Beispiel aber nur pro forma ist, da es ja nur eine gibt. Um auf neue Nachrichten reagieren zu können, hängt man sich einfach an das message-Event. Als Übergabeparameter bekommen Nutzer die komplette Kafka-Nachricht an die Hand.

{ topic: 'ADD_USER',
value:
'{"type":"USERS_LIST","users":[{"name":"Alejandro","id":1}]}',
offset: 77,
partition: 0,
highWaterOffset: 79,
key: -1 }

Der eigentliche Nachrichteninhalt befindet sich im Attribut value, das im Beispiel vorerst auf der Konsole geloggt wird. Ebenso wie beim Producer können Anwender mit dem error-Event auf technische Fehler reagieren.

Falls der Serverprozess stoppt, sollten laufende Consumer ebenfalls geschlossen werden. Das gehört zum guten Ton, da Kafka minimale Informationen zu aktiven Consumern behält und andernfalls nicht sofort mitbekommen würde, dass sie geschlossen sind und deshalb unnötige Informationen erhalten bleiben. Bei einer großen Anzahl von Consumern wäre das ein Problem

Für die Anbindung der Clients sollen Websockets dienen. Sie erlauben eine bidirektionale Kommunikation zwischen Client und Server, die mit Events gesteuert werden. Somit ist keine der beiden Seiten blockiert oder muss auf das Gegenüber warten. Hierfür kommt die Bibliothek ws zum Einsatz. Sie bietet eine reine Serverimplementation. Am Client wird eine native Websocket-Implementierung vorausgesetzt, was ältere Browser ausschließt. Für den Test mit Kafka ist das ausreichend, für richtige Anwendungen sind jedoch mehrere Alternativen abzuwägen und je nach Voraussetzungen auszuwählen.

const WebSocket         = require('ws')
const kafkaProducer = require('./controllers/kafka.producer');
const kafkaConsumer = require('./controllers/kafka.consumer');

const wss = new WebSocket.Server({ port: 8989 })

const broadcast = (data, ws) => {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN && client !== ws) {
client.send(JSON.stringify(data))
}
})
}

wss.on('connection', (ws) => {

kafkaConsumer.subscribeToTopics(['ADD_USER', 'ADD_MESSAGE'], ws);

ws.on('message', (message) => {
const data = JSON.parse(message)
console.log('send message', message)

...

kafkaProducer.createMessage(...)

...
})

ws.on('broadcast', (broadcastData) => {
broadcast(broadcastData, ws)
})

ws.on('close', () => {
...
})
})

Zunächst wird eine Instanz des Websocket-Servers erzeugt, die auf Port 8989 zu erreichen ist. Sobald sie zur Verfügung steht, kann man mit dem connection-Event darauf reagieren. Zunächst wird die Verbindung Richtung Kafka aufgebaut, indem die subscribeToTopics-Methode die relevanten Topics anlegt und darauf einen Consumer initialisiert (siehe die bisherigen Codebeispiele). Schicken die Clients neue Nachrichten via Websocket an den Server, kann man, wie bei der kafka-node-API, mit einem message-Event reagieren. An dieser Stelle wird auch der Producer aufgerufen, womit die Nachrichten in Kafka abgelegt werden. Damit auch der umgekehrte Weg funktioniert, gibt man beim Eintreffen neuer Nachrichten am Consumer diese nicht wie zuvor auf die Konsole aus, sondern löst am Websocket den broadcast-Event aus. Die Verbindung zum Socket wurde, zuvor durch die Übergabe an die subscribeToTopics-Methode hergestellt, sodass der Consumer Zugriff auf diesen hat.

ws.emit('broadcast', JSON.parse(data.value));

Dadurch wird im Anschluss ein Broadcast an alle bekannten (Websocket-)Clients ausgelöst, die dann ihrerseits die Nachricht verarbeiten können. Meldet sich einer der Clients ab, können Anwender mit dem close-Event auf das Abbauen der Websocket-Verbindung reagieren, was im Falle des Chats dazu benutzt wird, die Liste der aktiven Benutzer zu aktualisieren.

const socket = new WebSocket('ws://localhost:8989')

socket.onopen = () => {
socket.send( ... )
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
...
}

Der hier gezeigte Clientcode ist das letzte Verbindungstück, um das Frontend mit der Middleware zu verbinden. Der Websocket verbindet sich mit dem konfigurierten Serverport, und beim Aufbau der Verbindung (socket.onopen) wird direkt eine Nachricht mit der Anmeldung des neuen Nutzers gesendet (socket.send). Wenn vom Server eine Nachricht ausgeht, kann man mit socket.onmessage darauf reagieren. Beim Chat selbst Redux-Actions erzeugt, sodass die Reducer den State aktualisieren können. Für den Rückweg kommt Redux-Saga zum Einsatz, das auf Neue-Nachrichten-Aktionen reagiert und sie per socket.send an den Server weitergibt. Die Abbildung zeigt das fertige Ergebnis des laufenden Chats mit Kafka.

Weiterführend wäre es jetzt denkbar, mit Stream-Prozessoren zu arbeiten, und statt wie bisher vom Client eine aktualisierte Userliste zu schicken, diese über Kafka zusammenbauen zu lassen. Doch eins ist längst bewiesen: Es geht! Bleibt jedoch die Frage: Sollte man es tun?

Bis auf ein paar sehr spezielle Anwendungsfälle ist die Antwort allerdings ein generelles Nein. Wie man bei der Konstruktion des Beispiels gemerkt hat, wird hier die Kafka-eigene Persistenz für etwas wofür sie nicht gedacht ist, ausgenutzt. Sie soll nur sicherstellen, dass Consumer genügend Zeit haben, eine Nachricht definitiv zu verarbeiten, und dass Crashes einer Kafka-Instanz zu möglichst wenig Datenverlust führen. Ebenfalls sehr konstruiert ist der Fakt, dass Kafka hauptsächlich als Nachrichtenverteiler fungiert. Echte Backend-Logik ist oft ungemein komplexer und mit Stream-Prozessoren nur schwer abdeckbar. Dass es überhaupt funktionierte, unterstreicht jedoch die Vielseitigkeit von Kafka.

Betrachtet man nochmal die Daten und deren Weg fallen zwei Dinge auf. Erstens war die Verbindung, weil die Kommunikation komplett über Events ablief, jeweils lose und nicht blockierend gewährleistet. Zweitens hat jede Nachricht denselben Weg genommen, um von Client zu Client zu gelangen. Der Datenfluss erfolgte also undirektional – ein Konzept, das Facebook bei seiner Flux-Architektur aufgegriffen hat. Beides sind gute Ansätze in der Umsetzung von Webanwendungen, sollten unabhängig von Kafka auch in anderen Anwendungen eingesetzt werden: Dann allerdings nicht bloß als ein Versuch, eine dumme Idee in die Tat umzusetzen.

Frank Goraus
ist Lead Developer bei der MATHEMA Software GmbH. Seit mehr als einem Jahrzehnt beschäftigt er sich mit der Entwicklung von JEE- und Webanwendungen. Dabei begleitet er die Projekte der Kunden von der Datenbank übers Backend bis hin zum Frontend. Sein Steckenpferd sind Frontend-Technologien und die damit verbundenen Herausforderungen und Chancen für die Zukunft der Webentwicklung.
(bbo)