Node.js und Server-Rendering in Airbnb

Das Material, dessen Übersetzung wir heute veröffentlichen, widmet sich der Geschichte, wie Airbnb die Serverteile von Webanwendungen optimiert, um den zunehmenden Einsatz von Server-Rendering-Technologien zu berücksichtigen. Im Laufe mehrerer Jahre hat das Unternehmen sein gesamtes Front-End schrittweise auf eine einheitliche Architektur umgestellt , nach der Webseiten hierarchische Strukturen von React-Komponenten sind, die mit Daten aus ihrer API gefüllt sind. Insbesondere während dieses Prozesses wurde Ruby on Rails systematisch aufgegeben. Tatsächlich plant Airbnb, auf einen neuen Dienst umzusteigen, der ausschließlich auf Node.js basiert. Dank dessen werden vollständig vorbereitete Seiten, die auf dem Server gerendert werden, an die Browser der Benutzer geliefert. Dieser Service generiert den größten Teil des HTML-Codes für alle Airbnb-Produkte. Die betreffende Rendering-Engine unterscheidet sich von den meisten Backend-Diensten des Unternehmens dadurch, dass sie nicht in Ruby oder Java geschrieben ist. Es unterscheidet sich jedoch von den traditionellen hoch geladenen Node.js-Diensten, auf denen die in Airbnb verwendeten mentalen Modelle und Hilfstools basieren.



Node.js Plattform


Wenn Sie an die Node.js-Plattform denken, können Sie sich vorstellen, wie eine bestimmte Anwendung, die unter Berücksichtigung der Funktionen dieser Plattform für die asynchrone Datenverarbeitung erstellt wurde, schnell und effizient Hunderte oder Tausende paralleler Verbindungen bedient. Der Service holt die benötigten Daten von überall her und verarbeitet sie ein wenig, damit sie den Anforderungen einer großen Anzahl von Kunden entsprechen. Der Inhaber einer solchen Anwendung hat keinen Grund, sich zu beschweren, er ist überzeugt von dem von ihm verwendeten leichten Modell der gleichzeitigen Datenverarbeitung (in diesem Material verwenden wir das Wort "simultan", um den Begriff "gleichzeitig" für den Begriff "parallel" - "parallel" zu vermitteln). Sie löst die für sie gestellte Aufgabe perfekt.

Server Side Rendering (SSR) ändert die Grundideen und führt zu einer ähnlichen Vision des Problems. Das Rendern von Servern erfordert daher viele Rechenressourcen. Der Code in der Node.js-Umgebung wird in einem einzelnen Thread ausgeführt. Daher kann der Code zur Lösung von Rechenproblemen (im Gegensatz zu E / A-Aufgaben) gleichzeitig, jedoch nicht parallel ausgeführt werden. Die Node.js-Plattform kann eine große Anzahl paralleler E / A-Vorgänge verarbeiten. Beim Rechnen ändert sich jedoch die Situation.

Da beim Anwenden von serverseitigem Rendering der rechnerische Teil der Anforderungsverarbeitungsaufgabe im Vergleich zu dem Teil, der sich auf Eingabe / Ausgabe bezieht, zunimmt, wirken sich gleichzeitig eingehende Anforderungen auf die Serverantwortgeschwindigkeit aus, da sie um Prozessorressourcen konkurrieren. Es ist zu beachten, dass bei Verwendung von asynchronem Rendering weiterhin ein Wettbewerb um Ressourcen besteht. Asynchrones Rendern löst die Reaktionsfähigkeit eines Prozesses oder Browsers, verbessert jedoch nicht die Situation durch Verzögerungen oder Parallelität. In diesem Artikel konzentrieren wir uns auf ein einfaches Modell, das ausschließlich Rechenlasten enthält. Wenn es sich um eine gemischte Last handelt, die sowohl Eingabe- / Ausgabe- als auch Berechnungsoperationen umfasst, erhöhen gleichzeitig eingehende Anforderungen die Verzögerung, berücksichtigen jedoch den Vorteil eines höheren Systemdurchsatzes.

Betrachten Sie einen Befehl der Form Promise.all([fn1, fn2]) . Wenn fn1 oder fn2 Versprechen sind, die vom E / A-Subsystem aufgelöst werden, ist es während der Ausführung dieses Befehls möglich, eine parallele Ausführung von Operationen zu erreichen. Es sieht so aus:


Parallele Ausführung von Operationen mittels des Eingabe / Ausgabe-Subsystems

Wenn fn1 und fn2 Rechenaufgaben sind, werden sie wie folgt ausgeführt:


Rechenaufgaben

Eine der Operationen muss auf den Abschluss der zweiten Operation warten, da in Node.js nur ein Thread vorhanden ist.

Beim Rendern von Servern tritt dieses Problem auf, wenn der Serverprozess mehrere gleichzeitige Anforderungen verarbeiten muss. Die Verarbeitung solcher Anfragen wird verzögert, bis früher eingegangene Anfragen bearbeitet werden. So sieht es aus.


Gleichzeitige Anforderungen verarbeiten

In der Praxis besteht die Anforderungsverarbeitung häufig aus vielen asynchronen Phasen, selbst wenn sie eine ernsthafte Rechenlast für das System bedeuten. Dies kann zu einer noch schwierigeren Situation führen, wenn Aufgaben für die Verarbeitung solcher Anforderungen gewechselt werden.

Angenommen, unsere Abfragen bestehen aus einer Task-Kette, die dieser ähnelt: renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body)) . Wenn ein Paar solcher Anforderungen mit einem kleinen Intervall zwischen ihnen im System eintrifft, können wir das folgende Bild beobachten.


Verarbeitungsanforderungen, die in einem kleinen Intervall eintreffen, sind das Problem des Kampfes um Prozessorressourcen

In diesem Fall dauert die Verarbeitung jeder Anforderung etwa doppelt so lange wie die Verarbeitung einer einzelnen Anforderung. Mit zunehmender Anzahl gleichzeitig verarbeiteter Anfragen wird die Situation noch schlimmer.

Darüber hinaus ist eines der typischen Ziele der SSR-Implementierung die Möglichkeit, denselben oder einen sehr ähnlichen Code sowohl auf dem Client als auch auf dem Server zu verwenden. Der wesentliche Unterschied zwischen diesen Umgebungen besteht darin, dass die Clientumgebung im Wesentlichen eine Umgebung ist, in der ein Client ausgeführt wird, und Serverumgebungen naturgemäß Multi-Client-Umgebungen sind. Was auf dem Client gut funktioniert, wie Singletones oder andere Ansätze zum Speichern des globalen Status der Anwendung, führt zu Fehlern, Datenlecks und im Allgemeinen zu Verwirrung, während viele auf dem Server eingehende Anforderungen verarbeitet werden.

Diese Funktionen werden zu Problemen in einer Situation, in der Sie mehrere Anforderungen gleichzeitig verarbeiten müssen. In einer gemütlichen Umgebung der Entwicklungsumgebung, die von einem Client in der Person eines Programmierers verwendet wird, funktioniert normalerweise alles unter normalen Belastungen ganz normal.

Dies führt zu einer Situation, die sich stark von den klassischen Anwendungsbeispielen für Node.j unterscheidet. Es sollte beachtet werden, dass wir die JavaScript-Laufzeit für die zahlreichen verfügbaren Bibliotheken verwenden, und zwar aufgrund der Tatsache, dass sie von Browsern unterstützt wird, und nicht aufgrund ihres Modells für die gleichzeitige Datenverarbeitung. In dieser Anwendung zeigt das asynchrone Modell der gleichzeitigen Datenverarbeitung alle seine Nachteile, die nicht durch Vorteile kompensiert werden, die entweder sehr gering oder überhaupt nicht sind.

Hypernova-Projekt-Tutorials


Unser neuer Rendering-Service Hyperloop wird der primäre Service sein, mit dem Airbnb-Benutzer interagieren. Daher spielen Zuverlässigkeit und Leistung eine entscheidende Rolle, um die Arbeit mit einer Ressource zu vereinfachen. Bei der Einführung von Hyperloop in die Produktion berücksichtigen wir die Erfahrungen, die wir bei der Arbeit mit unserem früheren Server-Rendering-System Hypernova gesammelt haben.

Hypernova funktioniert nicht wie unser neuer Service. Dies ist ein reines Rendering-System. Es wird von unserem monolithischen Rail-Dienst namens Monorail aufgerufen und gibt nur HTML-Snippets für bestimmte gerenderte Komponenten zurück. In vielen Fällen stellt dieses „Snippet“ den Löwenanteil der Seite dar, und Rails stellt nur das Seitenlayout bereit. Mit der Legacy-Technologie können Teile einer Seite mithilfe von ERB miteinander verknüpft werden. In jedem Fall lädt Hypernova jedoch keine Daten, die zum Erstellen der Seite erforderlich sind. Dies ist die Aufgabe von Rails.

Somit haben Hyperloop und Hypernova eine ähnliche Rechenleistung. Gleichzeitig bietet Hypernova als Produktionsdienstleister und für die Verarbeitung erheblicher Verkehrsmengen ein gutes Testfeld, um zu verstehen, wie sich der Hypernova-Ersatz unter Kampfbedingungen verhält.


Hypernova-Workflow

So funktioniert Hypernova Benutzeranfragen gehen an unsere Rails-Hauptanwendung Monorail, die die Eigenschaften der React-Komponenten sammelt, die auf einer Seite angezeigt werden müssen, und eine Anfrage an Hypernova sendet, wobei diese Eigenschaften und Komponentennamen übergeben werden. Hypernova rendert Komponenten mit Eigenschaften, um den HTML-Code zu generieren, der an die Monorail-Anwendung zurückgegeben werden muss, die diesen Code dann in die Seitenvorlage einbettet und alles an den Client zurücksendet.


Senden einer fertigen Seite an einen Client

Im Notfall (dies kann ein Fehler oder ein Antwortzeitlimit sein) in Hypernova gibt es eine Fallback-Option, bei der die Komponenten und ihre Eigenschaften ohne den auf dem Server generierten HTML-Code in die Seite eingebettet werden. Danach wird alles an den Client gesendet und dort gerendert hoffentlich erfolgreich. Dies führte uns zu der Tatsache, dass wir den Hypernova-Dienst nicht als kritischen Teil des Systems betrachteten. Infolgedessen könnten wir das Auftreten einer bestimmten Anzahl von Fehlern und Situationen zulassen, in denen eine Zeitüberschreitung ausgelöst wird. Durch Anpassen der Anforderungszeitlimits setzen wir sie basierend auf den Beobachtungen auf ungefähr Stufe P95. Daher ist es nicht überraschend, dass das System mit einer Basis-Timeout-Antwortrate von weniger als 5% arbeitete.

In Situationen, in denen der Verkehr Spitzenwerte erreichte, konnten wir feststellen, dass bis zu 40% der Anfragen an Hypernova durch Zeitüberschreitungen in der Einschienenbahn geschlossen wurden. Auf der Hypernova-Seite haben wir Spitzen von BadRequestError: Request aborted geringerer Höhe an. Diese Fehler traten außerdem unter normalen Bedingungen auf, während im normalen Betrieb aufgrund der Architektur der Lösung die verbleibenden Fehler nicht besonders auffällig waren.


Spitzenzeitlimitwerte (rote Linien)

Da unser System ohne Hypernova funktionieren könnte, haben wir diesen Funktionen nicht viel Aufmerksamkeit geschenkt. Sie wurden eher als störende Kleinigkeiten als als ernsthafte Probleme wahrgenommen. Wir haben diese Probleme durch die Funktionen der Plattform erklärt, da der Start der Anwendung aufgrund des ziemlich schwierigen anfänglichen Speicherbereinigungsvorgangs, aufgrund der Besonderheiten der Codekompilierung und des Daten-Caching und aus anderen Gründen langsam ist. Wir hatten gehofft, dass die neuen React- oder Node-Versionen Leistungsverbesserungen enthalten würden, die die Mängel des langsamen Starts des Dienstes abmildern würden.

Ich vermutete, dass das, was geschah, sehr wahrscheinlich auf einen schlechten Lastausgleich oder die Folge von Problemen bei der Bereitstellung der Lösung zurückzuführen war, als sich zunehmende Verzögerungen aufgrund einer übermäßigen Rechenlast für die Prozesse zeigten. Ich habe dem System eine zusätzliche Schicht hinzugefügt, um Informationen über die Anzahl der gleichzeitig von einzelnen Prozessen verarbeiteten Anforderungen zu protokollieren und Fälle aufzuzeichnen, in denen der Prozess mehr als eine Anforderung zur Verarbeitung erhalten hat.


Forschungsergebnisse

Wir betrachteten den langsamen Start des Dienstes als Schuld an den Verzögerungen, aber tatsächlich wurde das Problem durch parallele Anforderungen verursacht, die um die CPU-Zeit kämpften. Den Messergebnissen zufolge stellte sich heraus, dass die von der Anforderung im Vorgriff auf den Abschluss der Verarbeitung anderer Anforderungen aufgewendete Zeit der für die Verarbeitung der Anforderung aufgewendeten Zeit entspricht. Darüber hinaus bedeutete dies, dass eine Zunahme der Verzögerungen aufgrund der gleichzeitigen Verarbeitung von Anforderungen gleichbedeutend mit einer Zunahme der Verzögerungen aufgrund einer Zunahme der Rechenkomplexität des Codes ist, was zu einer Zunahme der Systemlast bei der Verarbeitung jeder Anfrage führt.

Dies machte außerdem deutlicher, dass der BadRequestError: Request aborted nicht sicher durch einen langsamen Systemstart erklärt werden konnte. Der Fehler ging vom Parsing-Code des Anforderungshauptteils aus und trat auf, als der Client die Anforderung abbrach, bevor der Server den Anforderungshauptteil vollständig lesen konnte. Der Client hat aufgehört zu arbeiten, die Verbindung geschlossen und uns die Daten entzogen, die erforderlich sind, um die Anforderung weiter zu verarbeiten. Es ist viel wahrscheinlicher, dass dies geschah, weil wir mit der Verarbeitung der Anforderung begonnen haben. Danach stellte sich heraus, dass die Ereignisschleife ein blockiertes Rendering für eine andere Anforderung war, und wir kehrten dann zur unterbrochenen Aufgabe zurück, um sie abzuschließen. Als Ergebnis stellte sich jedoch heraus, dass der Client Wer uns diese Anfrage gesendet hat, hat bereits die Verbindung getrennt und die Anfrage abgebrochen. Darüber hinaus waren die in Anfragen an Hypernova übermittelten Daten im Durchschnitt im Bereich von mehreren hundert Kilobyte recht umfangreich, und dies trug natürlich nicht zur Verbesserung der Situation bei.


Ein Fehler, der durch das Trennen eines Clients verursacht wurde, der nicht auf eine Antwort gewartet hat

Wir haben uns entschlossen, dieses Problem mit einigen Standardwerkzeugen zu lösen, mit denen wir beträchtliche Erfahrung hatten. Es handelt sich um einen Reverse-Proxy-Server ( Nginx ) und einen Load Balancer ( HAProxy ).

Reverse Proxy und Load Balancing


Um die Multi-Core-Prozessorarchitektur zu nutzen, führen wir mehrere Hypernova-Prozesse mit dem integrierten Node.js- Clustermodul aus . Da diese Prozesse unabhängig sind, können wir eingehende Anfragen gleichzeitig verarbeiten.


Parallele Verarbeitung von gleichzeitig eintreffenden Anfragen

Das Problem hierbei ist, dass jeder Knotenprozess die ganze Zeit über voll beschäftigt ist, um eine Anforderung zu verarbeiten, einschließlich des Lesens des Hauptteils der vom Client gesendeten Anforderung (Monorail spielt in diesem Fall seine Rolle). Obwohl wir viele Abfragen in einem einzigen Prozess gleichzeitig lesen können, führt dies beim Rendern zu einer Abwechslung der Rechenoperationen.

Die Verwendung von Knotenprozessressourcen ist an die Client- und Netzwerkgeschwindigkeit gebunden.

Als Lösung für dieses Problem können wir einen puffernden Reverse-Proxy-Server in Betracht ziehen, mit dem wir Kommunikationssitzungen mit Clients aufrechterhalten können. Die Inspiration für diese Idee war der Einhorn-Webserver, den wir für unsere Rails-Anwendungen verwenden. Die vom Einhorn erklärten Prinzipien erklären perfekt, warum dies so ist. Zu diesem Zweck haben wir Nginx verwendet. Nginx liest die Anforderung vom Client in den Puffer und leitet die Anforderung erst an den Knotenserver weiter, nachdem sie vollständig gelesen wurde. Diese Datenübertragungssitzung wird auf dem lokalen Computer über die Loopback-Schnittstelle oder mithilfe von Unix-Domänensockets durchgeführt. Dies ist viel schneller und zuverlässiger als die Datenübertragung zwischen separaten Computern.


Nginx puffert Anforderungen und sendet sie dann an den Knotenserver

Aufgrund der Tatsache, dass nginx jetzt Leseanfragen bearbeitet, konnten wir eine gleichmäßigere Beladung der Knotenprozesse erreichen.

Gleichmäßige Prozessbelastung mit Nginx

Darüber hinaus haben wir nginx verwendet, um einige Anforderungen zu verarbeiten, für die kein Zugriff auf Knotenprozesse erforderlich ist. Die Erkennungs- und Routingschicht unseres Dienstes verwendet /ping Anforderungen, die das System nicht stark belasten, um die Kommunikation zwischen Hosts zu überprüfen. Durch die Verarbeitung all dessen in Nginx wird eine erhebliche zusätzliche (wenn auch geringe) Arbeitslast für Node.js vermieden.

Die nächste Verbesserung betrifft den Lastausgleich. Wir müssen fundierte Entscheidungen über die Verteilung von Anforderungen zwischen Knotenprozessen treffen. Das cluster Modul verteilt Anforderungen gemäß dem Round-Robin-Algorithmus, in den meisten Fällen mit Versuchen, Prozesse zu umgehen, die nicht auf Anforderungen reagieren. Bei diesem Ansatz erhält jeder Prozess eine Anforderung in der Reihenfolge ihrer Priorität.

Das cluster Modul verteilt Verbindungen, keine Anforderungen, sodass dies alles nicht wie erforderlich funktioniert. Die Situation wird noch schlimmer, wenn dauerhafte Verbindungen verwendet werden. Jede permanente Verbindung vom Client ist an einen bestimmten Workflow gebunden, was die effiziente Verteilung von Aufgaben erschwert.

Der Round-Robin-Algorithmus ist gut, wenn die Anforderungsverzögerungen nur geringfügig variieren. Zum Beispiel in der unten dargestellten Situation.


Round-Robin-Algorithmus und Verbindungen, über die Anforderungen stabil empfangen werden

Dieser Algorithmus ist bereits nicht so gut, wenn Sie Anforderungen unterschiedlicher Art verarbeiten müssen, für deren Verarbeitung möglicherweise völlig unterschiedliche Zeitkosten erforderlich sind. Die letzte an einen bestimmten Prozess gesendete Anforderung muss warten, bis alle zuvor gesendeten Anforderungen verarbeitet wurden, selbst wenn ein anderer Prozess in der Lage ist, eine solche Anforderung zu verarbeiten.


Ungleichmäßige Prozesslast

Wenn Sie die oben gezeigten Abfragen rationaler verteilen, erhalten Sie so etwas wie die in der folgenden Abbildung gezeigte.


Rationale Verteilung von Anforderungen nach Threads

Mit diesem Ansatz wird das Warten minimiert und es wird möglich, Antworten auf Anfragen schneller zu senden.

Dies kann erreicht werden, indem Anforderungen in eine Warteschlange gestellt und nur dann einem Prozess zugewiesen werden, wenn keine andere Anforderung verarbeitet wird. Zu diesem Zweck verwenden wir HAProxy.


HAProxy und Prozesslastausgleich

Als wir HAProxy verwendet haben, um die Last auf Hypernova auszugleichen, haben wir Timeout-Peaks sowie BadRequestErrors Fehler vollständig eliminiert.

Gleichzeitige Anforderungen waren auch die Hauptursache für Verzögerungen während des normalen Betriebs, und dieser Ansatz reduzierte solche Verzögerungen. Eine der Konsequenzen davon war, dass jetzt nur 2% der Anforderungen durch Timeout geschlossen wurden und nicht 5% mit denselben Timeout-Einstellungen. Die Tatsache, dass wir es geschafft haben, von einer Situation mit 40% Fehlern zu einer Situation mit einer Zeitüberschreitung zu wechseln, die in 2% der Fälle ausgelöst wurde, hat gezeigt, dass wir uns in die richtige Richtung bewegen. Infolgedessen sehen unsere Benutzer heute den Ladebildschirm der Website viel seltener. Es sollte beachtet werden, dass die Systemstabilität für uns beim erwarteten Übergang zu einem neuen System von besonderer Bedeutung sein wird, das nicht über denselben Sicherungsmechanismus wie Hypernova verfügt.

Details zum System und seinen Einstellungen


Damit dies alles funktioniert, müssen Sie die Anwendungen nginx, HAProxy und Node konfigurieren. Hier ist ein Beispiel einer ähnlichen Anwendung, die Nginx und HAProxy verwendet und analysiert, welche Sie das Gerät des betreffenden Systems verstehen können. Dieses Beispiel basiert auf dem System, das wir in der Produktion verwenden, es ist jedoch vereinfacht und modifiziert, so dass es im Auftrag eines nicht privilegierten Benutzers im Vordergrund ausgeführt werden kann. In der Produktion sollte alles mit einem Supervisor konfiguriert werden (wir verwenden Runit oder häufiger Kubernetes).

Die Nginx-Konfiguration ist ziemlich Standard. Sie verwendet einen Server, der Port 9000 überwacht und für Proxy-Anforderungen an den HAProxy-Server konfiguriert ist, der Port 9001 überwacht (in unserer Konfiguration verwenden wir Unix-Domänensockets).

Darüber hinaus fängt dieser Server Anforderungen an den /ping Endpunkt ab, um Anforderungen zur Überprüfung der Netzwerkkonnektivität direkt zu bearbeiten. nginx , worker_processes 1, nginx — HAProxy Node-. , , , Hypernova, ( ). .

Node.js cluster . HAProxy, cluster , . pool-hall . — , , , cluster , . pool-hall , .

HAProxy , 9001 , 9002 9005. — maxconn 1 , . . HAProxy ( 8999).


HAProxy

HAProxy . , maxconn . static-rr (static round-robin), , , . , round-robin, , , , , . , , . .

, , . ( ). , , , , . , , .

HAProxy


HAProxy. , , , . , , ( ) . , , cluster . , .

ab (Apache Benchmark) 10000 . - . :

 ab -l -c <CONCURRENCY> -n 10000 http://<HOSTNAME>:9000/render 

15 4- -, ab , . ( concurrency=5 ), ( concurrency=13 ), , ( concurrency=20 ). , .

, -, . , . , , , , . , , , .

, — .

maxconn 1 , , .

HTTP TCP , , , . , maxconn , . , , (, , ).

, , , , , , .

— , . option redispatch retries 3 , , , , , , . .

, - , . , . , , . 100 , 10 , , . , . , accept .

, ( backlog ) , . SYN-ACK ( , , , ACK ). , , , , .

, , , , . , , 1. maxconn . 0 , , , , , . , . - , , . abortonclose , . , abortonclose . nginx.

, , . ( ) , , , , , . HAProxy , , ( ). , , , HTML. , , . , , ( , , ). , , . , , , . HAProxy, MAINT HAProxy.

, , , server.close Node.js , HAProxy , , , . , , , , , .

, , balance first , ( worker1 ) 15% , , , balance static-rr . , «» . . (12 ), , , - . , , , «» «». .

, , Node server.maxconnections , ( , ), , , , . , maxconnection , , , . JavaScript, ( ). , , , . , , , HAProxy Node , . , , .

, , , , .


Node.js . , , , -. Node.js . , , , , , , , nginx HAProxy.

, Airbnb , Node.js .

Liebe Leser! Verwenden Sie in Ihren Projekten serverseitiges Rendering?

Source: https://habr.com/ru/post/de418009/


All Articles