Node.js et rendu de serveur dans Airbnb

Le matériel, dont nous publions la traduction aujourd'hui, est consacré à la façon dont Airbnb optimise les parties serveur des applications Web en tenant compte de l'utilisation toujours croissante des technologies de rendu de serveur. Au cours de plusieurs années, la société a progressivement déplacé l'ensemble de son front-end vers une architecture uniforme , selon laquelle les pages Web sont des structures hiérarchiques de composants React remplies de données de leur API. En particulier, au cours de ce processus, il y a eu un abandon systématique de Ruby on Rails. En fait, Airbnb prévoit de passer à un nouveau service basé uniquement sur Node.js, grâce auquel les pages entièrement préparées rendues sur le serveur seront livrées aux navigateurs des utilisateurs. Ce service générera la plupart du code HTML pour tous les produits Airbnb. Le moteur de rendu en question diffère de la plupart des services backend utilisés par la société du fait qu'il n'est pas écrit en Ruby ou Java. Cependant, il diffère des services Node.js traditionnels très chargés, autour desquels les modèles mentaux et les outils auxiliaires utilisés dans Airbnb sont construits.



Plateforme Node.js


En pensant à la plate-forme Node.js, vous pouvez imaginer comment une certaine application, conçue en tenant compte des capacités de cette plate-forme pour le traitement de données asynchrones, sert rapidement et efficacement des centaines ou des milliers de connexions parallèles. Le service extrait les données dont il a besoin de partout et les traite un peu afin de répondre aux besoins d'un grand nombre de clients. Le propriétaire d'une telle application n'a aucune raison de se plaindre, il est confiant dans le modèle léger de traitement simultané des données qu'il utilise (dans ce document, nous utilisons le mot «simultané» pour exprimer le terme «simultané», pour le terme «parallèle» - «parallèle»). Elle résout parfaitement la tâche qui lui est confiée.

Le rendu côté serveur (SSR) modifie les idées de base conduisant à une vision similaire du problème. Ainsi, le rendu du serveur nécessite beaucoup de ressources informatiques. Le code dans l'environnement Node.js est exécuté dans un seul thread, par conséquent, pour résoudre les problèmes de calcul (contrairement aux tâches d'E / S), le code peut être exécuté simultanément, mais pas en parallèle. La plate-forme Node.js est capable de gérer un grand nombre d'opérations d'E / S parallèles, mais en ce qui concerne l'informatique, la situation change.

Étant donné que lors de l'application du rendu côté serveur, la partie informatique de la tâche de traitement des demandes augmente par rapport à la partie liée aux entrées / sorties, les demandes entrantes affecteront simultanément la vitesse de réponse du serveur car elles se disputent les ressources du processeur. Il convient de noter que lors de l'utilisation du rendu asynchrone, la concurrence pour les ressources est toujours présente. Le rendu asynchrone résout la réactivité d'un processus ou d'un navigateur, mais n'améliore pas la situation avec des retards ou des accès simultanés. Dans cet article, nous nous concentrerons sur un modèle simple qui inclut exclusivement des charges de calcul. Si nous parlons d'une charge mixte, qui comprend à la fois des opérations d'entrée / sortie et de calcul, les demandes entrantes simultanées augmenteront le délai, mais en tenant compte de l'avantage d'un débit système plus élevé.

Considérons une commande de la forme Promise.all([fn1, fn2]) . Si fn1 ou fn2 sont des promesses résolues par le sous-système d'E / S, alors pendant l'exécution de cette commande, il est possible de réaliser l'exécution parallèle des opérations. Cela ressemble à ceci:


Exécution parallèle d'opérations au moyen du sous-système d'entrée / sortie

Si fn1 et fn2 sont des tâches de calcul, elles seront exécutées comme suit:


Tâches informatiques

L'une des opérations devra attendre la fin de la deuxième opération, car il n'y a qu'un seul thread dans Node.js.

Dans le cas du rendu du serveur, ce problème se produit lorsque le processus serveur doit traiter plusieurs demandes simultanées. Le traitement de ces demandes sera retardé jusqu'à ce que les demandes reçues plus tôt soient traitées. Voici à quoi ça ressemble.


Traitement des demandes simultanées

En pratique, le traitement des demandes se compose souvent de nombreuses phases asynchrones, même si elles impliquent une charge de calcul importante sur le système. Cela peut conduire à une situation encore plus difficile avec l'alternance des tâches de traitement de telles demandes.

Supposons que nos requêtes soient composées d'une chaîne de tâches qui ressemble à celle-ci: renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body)) . Lorsqu'une paire de ces demandes arrive dans le système, avec un petit intervalle entre elles, nous pouvons observer l'image suivante.


Traitement des demandes qui arrivent à un petit intervalle, le problème de la lutte pour les ressources processeur

Dans ce cas, il faut environ deux fois plus de temps pour traiter chaque demande que pour traiter une demande individuelle. Avec une augmentation du nombre de demandes traitées simultanément, la situation devient encore pire.

De plus, l'un des objectifs typiques de l'implémentation SSR est la possibilité d'utiliser le même code ou un code très similaire sur le client et le serveur. La différence sérieuse entre ces environnements est que l'environnement client est essentiellement un environnement dans lequel un client fonctionne, et les environnements de serveur, par leur nature, sont des environnements multi-clients. Ce qui fonctionne bien sur le client, comme les singletones ou d'autres approches pour stocker l'état global de l'application, entraîne des erreurs, des fuites de données et, en général, de la confusion, lors du traitement de nombreuses demandes arrivant sur le serveur.

Ces fonctionnalités deviennent des problèmes dans une situation où vous devez traiter plusieurs demandes en même temps. Tout fonctionne généralement normalement sous des charges plus faibles dans un environnement confortable de l'environnement de développement, qui est utilisé par un client en la personne d'un programmeur.

Cela conduit à une situation très différente des exemples d'application Node.js classiques. Il convient de noter que nous utilisons le runtime JavaScript pour l'ensemble riche de bibliothèques disponibles, et en raison du fait qu'il est pris en charge par les navigateurs, et non pour le bien de son modèle pour le traitement simultané des données. Dans cette application, le modèle asynchrone de traitement simultané des données présente tous ses inconvénients, non compensés par des avantages, qui sont soit très peu nombreux soit pas du tout.

Tutoriels sur le projet Hypernova


Notre nouveau service de rendu, Hyperloop, sera le principal service avec lequel les utilisateurs d'Airbnb interagiront. Par conséquent, sa fiabilité et ses performances jouent un rôle crucial pour garantir la commodité de travailler avec une ressource. Lors de l'introduction d'Hyperloop en production, nous tenons compte de l'expérience que nous avons acquise en travaillant avec notre système de rendu de serveur antérieur - Hypernova .

Hypernova ne fonctionne pas comme notre nouveau service. Il s'agit d'un pur système de rendu. Il est appelé à partir de notre service ferroviaire monolithique, appelé Monorail, et renvoie uniquement des extraits HTML pour des composants de rendu spécifiques. Dans de nombreux cas, cet «extrait» représente la part du lion de la page, et Rails fournit uniquement la mise en page. Avec la technologie héritée, des parties d'une page peuvent être liées entre elles à l'aide d'ERB. Dans tous les cas, cependant, Hypernova ne charge pas les données nécessaires pour former la page. C'est la tâche de Rails.

Ainsi, Hyperloop et Hypernova ont des performances informatiques similaires. Dans le même temps, Hypernova, en tant que service de production et traitant d'importants volumes de trafic, fournit un bon terrain pour les tests, ce qui permet de comprendre comment le remplaçant Hypernova se comportera dans des conditions de combat.


Flux de travail Hypernova

Voici comment fonctionne Hypernova. Les demandes des utilisateurs proviennent de notre application principale Rails, Monorail, qui collecte les propriétés des composants React qui doivent être affichés sur une page et fait une demande à Hypernova, en passant ces propriétés et noms de composants. Hypernova rend les composants avec des propriétés afin de générer le code HTML qui doit être renvoyé à l'application Monorail, qui intègre ensuite ce code dans le modèle de page et le renvoie tout au client.


Envoi d'une page terminée à un client

En cas d'urgence (cela peut être une erreur ou le délai de réponse) dans Hypernova, il existe une option de secours, lors de l'utilisation des composants et de leurs propriétés qui sont incorporés dans la page sans le HTML généré sur le serveur, après quoi tout cela est envoyé au client et rendu là-bas espérons-le réussi. Cela nous a amenés à considérer que le service Hypernova n'était pas un élément essentiel du système. Par conséquent, nous pourrions permettre la survenance d'un certain nombre d'échecs et de situations dans lesquelles un délai d'attente est déclenché. En ajustant les délais d'expiration des demandes, nous, en nous basant sur les observations, les avons réglés à environ le niveau P95. Par conséquent, il n'est pas surprenant que le système ait fonctionné avec un taux de réponse de temporisation de base inférieur à 5%.

Dans les situations où le trafic a atteint des valeurs de pointe, nous avons pu constater que jusqu'à 40% des demandes à Hypernova étaient fermées par des délais d'attente dans le monorail. Du côté d'Hypernova, nous avons vu des pics de BadRequestError: Request aborted hauteur inférieure. De plus, ces erreurs existaient dans des conditions normales, tandis qu'en fonctionnement normal, en raison de l'architecture de la solution, les erreurs restantes n'étaient pas particulièrement visibles.


Valeurs de délai maximal (lignes rouges)

Comme notre système pouvait fonctionner sans Hypernova, nous n'avons pas prêté beaucoup d'attention à ces fonctionnalités, elles étaient perçues davantage comme des bagatelles gênantes que comme de graves problèmes. Nous avons expliqué ces problèmes par les fonctionnalités de la plate-forme, car le lancement de l'application est lent en raison de l'opération de récupération de place initiale assez difficile, en raison des particularités de la compilation de code et de la mise en cache des données, et pour d'autres raisons. Nous avions espéré que les nouvelles versions de React ou Node incluraient des améliorations de performances qui atténueraient les lacunes du lent lancement du service.

Je soupçonnais que ce qui se passait était très probablement le résultat d'un mauvais équilibrage de charge ou la conséquence de problèmes dans le déploiement de la solution, lorsque des retards croissants se manifestaient en raison d'une charge de calcul excessive sur les processus. J'ai ajouté une couche auxiliaire au système pour enregistrer des informations sur le nombre de demandes traitées simultanément par des processus individuels, ainsi que pour enregistrer les cas dans lesquels le processus a reçu plus d'une demande de traitement.


Résultats de recherche

Nous avons considéré que le démarrage lent du service était à l'origine des retards, mais en fait, le problème était dû à des demandes parallèles luttant pour le temps CPU. Selon les résultats des mesures, il s'est avéré que le temps passé par la demande en prévision de l'achèvement du traitement des autres demandes correspond au temps passé à traiter la demande. De plus, cela signifiait qu'une augmentation des retards due au traitement simultané des demandes ressemble à une augmentation des retards due à une augmentation de la complexité de calcul du code, ce qui entraîne une augmentation de la charge sur le système lors du traitement de chaque demande.

De plus, cela rendait plus évident que l' BadRequestError: Request aborted ne pouvait pas être expliquée en toute confiance par un démarrage lent du système. L'erreur provenait du code d'analyse du corps de la demande et s'est produite lorsque le client a annulé la demande avant que le serveur ne puisse lire entièrement le corps de la demande. Le client a cessé de fonctionner, a fermé la connexion, nous privant des données nécessaires pour poursuivre le traitement de la demande. Il est beaucoup plus probable que cela se soit produit car nous avons commencé à traiter la demande, après quoi la boucle d'événement s'est avérée être un rendu bloqué pour une autre demande, puis nous sommes revenus à la tâche interrompue pour la terminer, mais il s'est avéré que le client qui nous a envoyé cette demande s'est déjà déconnecté, abandonnant la demande. En outre, les données transmises dans les demandes adressées à Hypernova étaient assez volumineuses, en moyenne, de l'ordre de plusieurs centaines de kilo-octets, ce qui, bien entendu, n'a pas contribué à améliorer la situation.


Une erreur causée par la déconnexion d'un client qui n'a pas attendu de réponse

Nous avons décidé de résoudre ce problème en utilisant quelques outils standard avec lesquels nous avions une expérience considérable. Nous parlons d'un serveur proxy inverse ( nginx ) et d'un équilibreur de charge ( HAProxy ).

Proxy inverse et équilibrage de charge


Afin de tirer parti de l'architecture de processeur multicœur, nous exécutons plusieurs processus Hypernova à l'aide du module de cluster Node.js intégré. Étant donné que ces processus sont indépendants, nous pouvons traiter simultanément les demandes entrantes.


Traitement parallèle des demandes arrivant simultanément

Le problème ici est que chaque processus Node est complètement occupé tout le temps nécessaire pour traiter une demande, y compris la lecture du corps de la demande envoyée par le client (Monorail joue son rôle dans ce cas). Bien que nous puissions lire plusieurs requêtes en un seul processus en même temps, en ce qui concerne le rendu, cela conduit à une alternance d'opérations de calcul.

L'utilisation des ressources de processus Node est liée à la vitesse du client et du réseau.

Pour résoudre ce problème, nous pouvons envisager un serveur proxy inverse de mise en mémoire tampon, qui nous permettra de maintenir des sessions de communication avec les clients. Cette idée a été inspirée par le serveur Web licorne, que nous utilisons pour nos applications Rails. Les principes déclarés par la licorne expliquent parfaitement pourquoi il en est ainsi. Pour cela, nous avons utilisé nginx. Nginx lit la demande du client vers le tampon et transmet la demande au serveur Node uniquement après sa lecture complète. Cette session de transfert de données est effectuée sur la machine locale, via l'interface de bouclage ou à l'aide de sockets de domaine Unix, et cela est beaucoup plus rapide et plus fiable que le transfert de données entre des ordinateurs distincts.


Nginx met en mémoire tampon les demandes, puis les envoie au serveur Node

Étant donné que nginx est désormais engagé dans la lecture des demandes, nous avons pu obtenir un chargement plus uniforme des processus Node.

Chargement de processus uniforme Ă  l'aide de nginx

De plus, nous avons utilisé nginx pour gérer certaines requêtes qui ne nécessitent pas d'accès aux processus Node. La couche de détection et de routage de notre service utilise des requêtes /ping qui ne créent pas une charge importante sur le système pour vérifier la communication entre les hôtes. Le traitement de tout cela dans nginx élimine une source importante de charge de travail supplémentaire (quoique petite) pour Node.js.

La prochaine amélioration concerne l'équilibrage de charge. Nous devons prendre des décisions éclairées sur la répartition des demandes entre les processus Node. Le module de cluster distribue les requêtes conformément à l'algorithme round-robin, dans la plupart des cas avec des tentatives de contournement des processus qui ne répondent pas aux requêtes. Avec cette approche, chaque processus reçoit une demande par ordre de priorité.

Le module de cluster distribue les connexions, pas les requêtes, donc tout cela ne fonctionne pas comme nous en avons besoin. La situation devient encore pire lorsque des connexions persistantes sont utilisées. Toute connexion permanente du client est liée à un seul flux de travail spécifique, ce qui complique la distribution efficace des tâches.

L'algorithme à tour de rôle est bon lorsqu'il existe une faible variabilité dans les délais de demande. Par exemple, dans la situation illustrée ci-dessous.


Algorithme de tourniquet et connexions via lesquelles les demandes sont reçues de manière stable

Cet algorithme n'est déjà pas si bon lorsque vous devez traiter des demandes de différents types, pour le traitement desquelles des coûts de temps complètement différents peuvent être nécessaires. La demande la plus récente envoyée à un certain processus est obligée d'attendre la fin du traitement de toutes les demandes envoyées plus tôt, même s'il existe un autre processus qui a la capacité de traiter une telle demande.


Charge de processus inégale

Si vous distribuez les requêtes ci-dessus de manière plus rationnelle, vous obtenez quelque chose comme celle illustrée dans la figure ci-dessous.


Répartition rationnelle des demandes par threads

Avec cette approche, l'attente est minimisée et il devient possible d'envoyer des réponses aux demandes plus rapidement.

Cela peut être réalisé en plaçant les demandes dans une file d'attente et en les affectant à un processus uniquement lorsqu'il n'est pas occupé à traiter une autre demande. Pour cela, nous utilisons HAProxy.


HAProxy et équilibrage de charge de processus

Lorsque nous avons utilisé HAProxy pour équilibrer la charge sur Hypernova, nous avons complètement éliminé les pics de temporisation, ainsi que les erreurs BadRequestErrors .

Les demandes simultanées ont également été la principale cause de retards en fonctionnement normal; cette approche a réduit ces retards. L'une des conséquences de cela était que désormais seulement 2% des demandes étaient fermées par timeout, et non 5%, avec les mêmes paramètres de timeout. Le fait que nous soyons parvenus à passer d'une situation avec 40% d'erreurs à une situation avec un timeout déclenchant dans 2% des cas montre que nous allons dans la bonne direction. Par conséquent, nos utilisateurs voient aujourd'hui l'écran de chargement du site Web beaucoup moins fréquemment. Il convient de noter que la stabilité du système sera d'une importance particulière pour nous avec la transition attendue vers un nouveau système qui n'a pas le même mécanisme de sauvegarde qu'Hypernova.

Détails sur le système et ses paramètres


Pour que tout cela fonctionne, vous devez configurer l'application nginx, HAProxy et Node. Voici un exemple d'une application similaire utilisant nginx et HAProxy, analysant laquelle, vous pouvez comprendre le périphérique du système en question. Cet exemple est basé sur le système que nous utilisons en production, mais il est simplifié et modifié afin qu'il puisse être exécuté au premier plan pour le compte d'un utilisateur non privilégié. En production, tout doit être configuré à l'aide d'une sorte de superviseur (nous utilisons runit, ou, plus souvent, kubernetes).

La configuration nginx est assez standard, elle utilise un serveur qui écoute sur le port 9000, configuré pour des requêtes proxy au serveur HAProxy, qui écoute sur le port 9001 (dans notre configuration, nous utilisons des sockets de domaine Unix).

De plus, ce serveur intercepte les requêtes vers le point de terminaison /ping pour traiter directement les requêtes visant à vérifier la connectivité réseau. 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 .

Chers lecteurs! ?

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


All Articles