Quelle est la meilleure façon d'augmenter de façon transparente la concurrence d'accès au service Node.js utilisé en production? C'est une question à laquelle mon équipe devait répondre il y a quelques mois.
Nous avons lancé 4000 conteneurs Node (ou «travailleurs»), qui assurent le fonctionnement de notre service d'intégration avec les banques. Le service a été initialement conçu pour que chaque travailleur soit conçu pour traiter une seule demande à la fois. Cela a réduit l'impact sur le système de ces opérations qui pourraient
bloquer de manière inattendue
le cycle des événements et nous ont permis d'ignorer les différences dans l'utilisation des ressources par diverses opérations similaires. Mais, comme nos capacités se limitaient à l'exécution simultanée de seulement 4 000 demandes, le système n'a pas pu être correctement mis à l'échelle. La rapidité de réponse à la plupart des demandes ne dépend pas de la capacité de l'équipement, mais des capacités du réseau. Par conséquent, nous pourrions améliorer le système et réduire le coût de son support si nous pouvions trouver un moyen de traiter de manière fiable les demandes en parallèle.

Après avoir étudié cette question, nous n'avons pas pu trouver un bon guide qui discuterait de la transition du «manque de parallélisme» dans Node.js à un «haut niveau de parallélisme». En conséquence, nous avons développé notre propre stratégie de migration, qui était basée sur une planification minutieuse, de bons outils, des outils de surveillance et une bonne dose de débogage. En conséquence, nous avons réussi à augmenter le niveau de parallélisme de notre système de 30 fois. Cela équivaut à réduire le coût de maintenance du système d'environ 300 000 dollars par an.
Ce matériel est consacré à l'histoire de la façon dont nous avons augmenté la productivité et l'efficacité de nos employés Node.js, et à ce que nous avons appris en procédant de cette façon.
Pourquoi avons-nous décidé d'investir dans le parallélisme?
Il peut sembler surprenant que nous ayons atteint de telles dimensions sans recourir au parallélisme. Comment est-ce arrivé? Seulement 10% des opérations de traitement de données effectuées par les outils Plaid sont lancées par des utilisateurs assis devant un ordinateur et ayant connecté leurs comptes à l'application. Tout le reste est constitué de transactions pour mettre à jour périodiquement des transactions qui sont effectuées sans la présence de l'utilisateur. La logique a été ajoutée au système d'équilibrage de charge que nous utilisons, ce qui garantit la priorité des demandes faites par les utilisateurs sur les demandes de mise à jour des transactions. Cela nous a permis de gérer des rafales d'activité des opérations d'accès aux API à 1000% ou même plus. Cela a été fait grâce à des transactions visant à mettre à jour les données.
Bien que ce schéma de compromis fonctionne depuis longtemps, il a été possible d'y discerner plusieurs moments désagréables. Nous savions qu'en fin de compte, cela pourrait nuire à la fiabilité du service.
- Les pics de demandes d'API provenant des clients augmentaient de plus en plus. Nous craignions qu'une forte augmentation de l'activité puisse épuiser nos capacités de traitement des requêtes.
- L'augmentation soudaine des retards dans le traitement des demandes adressées aux banques a également entraîné une diminution de la capacité des travailleurs. Étant donné que les banques utilisent diverses solutions d'infrastructure, nous avons défini des délais d'expiration très prudents pour les demandes sortantes. Par conséquent, le chargement de certaines données peut prendre plusieurs minutes. S'il arrivait que les retards dans l'exécution de nombreuses demandes auprès des banques augmenteraient soudainement considérablement, il se révélerait que de nombreux travailleurs seraient simplement coincés dans l'attente de réponses.
- Le déploiement dans ECS est devenu trop lent et même si nous avons amélioré la vitesse de déploiement du système, nous ne voulions pas continuer à augmenter la taille du cluster.
Nous avons décidé que la meilleure façon de traiter les goulots d'étranglement des applications et d'augmenter la fiabilité du système était d'augmenter le niveau de parallélisme dans le traitement des demandes. De plus, nous espérions que, comme effet secondaire, cela nous permettrait de réduire les coûts d'infrastructure et d'aider à mettre en œuvre de meilleurs outils pour surveiller le système. Cela et un autre à l'avenir porteraient leurs fruits.
Comment nous avons introduit les mises à jour, en veillant à la fiabilité
â–ŤOutils et surveillance
Nous avons notre propre équilibreur de charge, qui redirige les demandes vers les employés de Node.js. Chaque travailleur exécute un serveur gRPC utilisé pour traiter les demandes. Worker utilise Redis pour indiquer à l'équilibreur de charge qu'il est disponible. Cela signifie que l'ajout de parallélisme au système revient à simplement changer quelques lignes de code. À savoir, le travailleur, au lieu de devenir inaccessible après que la demande lui a été faite, doit informer qu'il est disponible jusqu'à ce qu'il se trouve occupé à traiter les N demandes qui lui sont parvenues (chacune d'elles). représenté par son propre objet Promise).
Certes, en fait, tout n'est pas si simple. Lors du déploiement des mises à jour du système, nous considérons toujours que notre objectif principal est de maintenir sa fiabilité. Par conséquent, nous ne pouvions pas simplement prendre et, guidés par quelque chose comme le principe YOLO, mettre le système en mode de traitement de requête parallèle. Nous nous attendions à ce qu'une telle mise à niveau du système soit particulièrement risquée. Le fait est que cela aurait un effet imprévisible sur l'utilisation du processeur, de la mémoire et des retards dans l'exécution des tâches. Étant donné que le
moteur V8 utilisé dans Node.js gère les tâches dans la boucle d'événements, notre principale préoccupation était qu'il pourrait s'avérer que nous faisons trop de travail dans la boucle d'événements et ainsi réduire le débit du système.
Afin d'atténuer ces risques, nous avons, avant même la mise en production du premier collaborateur parallèle, assuré la disponibilité des outils de surveillance et des outils suivants dans le système:
- La pile ELK que nous avons déjà utilisée nous a fourni une quantité suffisante d'informations enregistrées, ce qui pourrait être utile pour comprendre rapidement ce qui se passait dans le système.
- Nous avons ajouté plusieurs métriques Prometheus au système. Y compris les éléments suivants:
- Taille de segment V8 obtenue Ă l'aide de
process.memoryUsage()
. - Informations sur les opérations de récupération de place à l'aide du package gc-stats .
- Données sur le temps nécessaire à la réalisation des tâches, regroupées par type d'opérations liées à l'intégration avec les banques et par niveau de simultanéité. Nous en avions besoin pour mesurer de manière fiable l'impact de la concurrence sur le débit du système.
- Nous avons créé le panneau de contrôle Grafana , conçu pour étudier le degré d'impact de la concurrence sur le système.
- Pour nous, la possibilité de modifier le comportement de l'application sans avoir à redéployer le service était extrêmement importante. Par conséquent, nous avons créé un ensemble de drapeaux LaunchDarkly conçus pour contrôler divers paramètres. Avec cette approche, la sélection des paramètres des travailleurs, calculés pour qu'ils atteignent le niveau maximal de parallélisme, nous a permis de mener rapidement des expériences et de trouver les meilleurs paramètres, en y consacrant quelques minutes.
- Afin de savoir comment différentes parties de l'application chargent le processeur, nous avons intégré les outils de collecte de données du service de production, sur la base desquels des diagrammes de flamme ont été construits.
- Nous avons utilisé le package 0x parce que les outils Node.js étaient faciles à intégrer dans notre service et parce que la visualisation finale des données HTML a soutenu la recherche et nous a donné un bon niveau de détail.
- Nous avons ajouté un mode de profilage au système lorsque le travailleur a commencé avec le package 0x activé et, à sa sortie, nous avons noté les données finales dans S3. Ensuite, nous pourrions télécharger les journaux dont nous avons besoin depuis S3 et les visualiser localement en utilisant une commande de la forme
0x --visualize-only ./flamegraph
. - Dans un certain laps de temps, nous avons commencé le profilage pour un seul travailleur. Le profilage augmente la consommation de ressources et réduit la productivité, nous aimerions donc limiter ces effets négatifs à un seul travailleur.
▍ Démarrer le déploiement
Après avoir terminé la préparation préliminaire, nous avons créé un nouveau cluster ECS pour les «travailleurs parallèles». Ce sont les travailleurs qui ont utilisé les drapeaux LaunchDarkly pour définir dynamiquement leur niveau maximal de parallélisme.
Notre plan de déploiement du système comprenait une redirection progressive du volume croissant de trafic de l'ancien cluster vers le nouveau. Pendant ce temps, nous allions surveiller de près les performances du nouveau cluster. À chaque niveau de charge, nous avons prévu d'augmenter le niveau de parallélisme de chaque travailleur, en le portant à la valeur maximale à laquelle il n'y a pas d'augmentation de la durée des tâches ou de dégradation d'autres indicateurs. Si nous étions en difficulté, nous pourrions, en quelques secondes, rediriger dynamiquement le trafic vers l'ancien cluster.
Comme prévu, nous avons rencontré des problèmes délicats. Nous devions les étudier et les éliminer afin d'assurer le bon fonctionnement du système mis à jour. C'est là que le plaisir a commencé.
Développez, explorez, répétez
â–ŤAugmentation de la taille de segment maximale de Node.js
Lorsque nous avons commencé à déployer le nouveau système, nous avons commencé à recevoir des notifications d'achèvement de tâches avec un code de sortie différent de zéro. Eh bien, que puis-je dire - un début prometteur. Ensuite, nous avons enterré à Kibana et trouvé le journal nécessaire:
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - Javascript heap out of memory 1: node::Abort() 2: node::FatalException(v8::Isolate*, v8::Local, v8::Local) 3: v8::internal::V8::FatalProcessOutOfMemory(char const*, bool) 4: v8::internal::Factory::NewFixedArray(int, v8::internal::PretenureFlag)
Cela rappelait les effets des fuites de mémoire que nous avions déjà rencontrées lorsque le processus s'est terminé de manière inattendue, donnant un message d'erreur similaire. Cela semblait tout à fait attendu: une augmentation du niveau de parallélisme conduit à une augmentation du niveau d'utilisation de la mémoire.
Nous avons suggéré que l'augmentation de la taille de segment de mémoire maximale de Node.js, qui est définie sur 1,7 Go par défaut, peut aider à résoudre ce problème. Ensuite, nous avons commencé à exécuter Node.js, en définissant la taille de
--max-old-space-size=6144
maximale Ă 6 Go (en utilisant l'indicateur de ligne de commande
--max-old-space-size=6144
). Il s'agissait de la plus grande valeur adaptée à nos instances EC2. Pour notre plus grand plaisir, une telle décision nous a permis de faire face à l'erreur ci-dessus qui se produit en production.
▍ Identification des goulots d'étranglement de la mémoire
Après avoir résolu le problème d'allocation de mémoire, nous avons commencé à rencontrer un faible débit de tâches sur des travailleurs parallèles. En même temps, l'un des graphiques sur le panneau de commande a immédiatement attiré notre attention. Il s'agissait d'un rapport sur la façon dont les processus de travail parallèles utilisent un groupe.
Utilisation du tasCertaines des courbes de ce graphique montaient continuellement - jusqu'à ce qu'elles se transforment, au niveau de la taille de tas maximale, en lignes presque horizontales. Nous ne l'avons vraiment pas aimé.
Nous avons utilisé des métriques système dans Prometheus afin d'éliminer les fuites d'un descripteur de fichier ou d'un socket réseau à cause des causes d'un tel comportement du système. Notre hypothèse la plus appropriée était que la collecte des ordures n'était pas effectuée assez souvent pour les objets anciens. Cela pourrait conduire au fait qu'au fur et à mesure du traitement des tâches, le travailleur accumulerait de plus en plus de mémoire allouée à des objets déjà inutiles. Nous avons supposé que le fonctionnement du système, pendant lequel son débit est dégradé, ressemble à ceci:
- Le travailleur reçoit une nouvelle tâche et exécute certaines actions.
- Au cours de l'exécution de la tâche, de la mémoire est allouée sur le tas pour les objets.
- Étant donné qu'une certaine opération avec laquelle ils travaillent sur le principe du «fait et oublié» (alors on ne savait pas encore laquelle) est incomplète, les références aux objets sont enregistrées même après la fin de la tâche.
- La récupération de place est ralentie du fait que le V8 doit analyser un nombre croissant d'objets dans le tas.
- Étant donné que V8 implémente un système de collecte des ordures qui fonctionne selon le schéma d'arrêt du monde (arrêt du programme pendant la durée de la collecte des ordures), les nouvelles tâches recevront inévitablement moins de temps processeur, ce qui réduit le débit du travailleur.
Nous avons commencé à rechercher dans notre code des opérations qui sont effectuées sur la base du principe «fait et oublié». Ils sont également appelés «promesses flottantes» («promesse flottante»). C'était simple - il suffisait de trouver les lignes dans lesquelles la règle de linter sans
promesses flottantes Ă©tait dĂ©sactivĂ©e. Une mĂ©thode a attirĂ© notre attention. Il a fait un appel Ă
compressAndUploadDebuggingPayload
sans attendre les résultats. Il semblait qu'un tel appel pouvait facilement se poursuivre longtemps même après la fin du traitement de la tâche.
const postTaskDebugging = async (data: TypedData) => { const payload = await generateDebuggingPayload(data);
Nous voulions tester l'hypothèse que de telles promesses flottantes étaient la principale source de problèmes. Si vous ne relevez pas ces défis, qui n'ont pas affecté le bon fonctionnement du système, pouvons-nous améliorer la vitesse des tâches? Voici à quoi ressemblaient les informations d'utilisation du tas après que nous nous soyons temporairement débarrassés des appels
postTaskDebugging
.
Utiliser le tas après avoir désactivé postTaskDebuggingÇa s'est avéré! Maintenant, le niveau d'utilisation du tas chez les travailleurs parallèles reste stable sur une longue période.
Il y avait le sentiment que dans le système, au fur et à mesure que les tâches étaient terminées, les "dettes" des appels
compressAndUploadDebuggingPayload
s'accumulaient progressivement. Si le travailleur a reçu des tâches plus rapidement qu'il n'a pu «rembourser» ces «dettes», alors les objets sous lesquels la mémoire a été allouée n'ont pas été soumis à des opérations de collecte des ordures. Cela a conduit à remplir le tas au sommet, que nous avons considéré ci-dessus, en analysant le graphique précédent.
Nous avons commencé à nous demander pourquoi ces promesses flottantes étaient si lentes. Nous ne voulions pas supprimer complètement
compressAndUploadDebuggingPayload
du code, car cet appel était extrêmement important pour que nos ingénieurs puissent déboguer les tâches de production sur leurs machines locales. D'un point de vue technique, nous pourrions résoudre le problème en attendant les résultats de cet appel et après avoir terminé la tâche, éliminant ainsi la promesse flottante. Mais cela augmenterait considérablement le temps d'exécution de chaque tâche que nous traitons.
Ayant décidé que nous n'utiliserions une telle solution au problème qu'en dernier recours, nous avons commencé à penser à optimiser le code. Comment accélérer cette opération?
▍Fix goulot d'étranglement S3
La logique de
compressAndUploadDebuggingPayload
facile à comprendre. Ici, nous compressons les données de débogage, et elles peuvent être assez importantes, car elles incluent le trafic réseau. Ensuite, nous téléchargeons les données compressées sur S3.
export const compressAndUploadDebuggingPayload = async ( logger: Logger, data: any, ) => { const compressionStart = Date.now(); const base64CompressedData = await streamToString( bfj.streamify(data) .pipe(zlib.createDeflate()) .pipe(new b64.Encoder()), ); logger.trace('finished compressing data', { compression_time_ms: Date.now() - compressionStart, ); const uploadStart = Date.now(); s3Client.upload({ Body: base64CompressedData, Bucket: bucket, Key: key, }); logger.trace('finished uploading data', { upload_time_ms: Date.now() - uploadStart, ); }
D'après les journaux de Kibana, il était clair que le téléchargement de données vers S3, même si son volume était petit, prenait beaucoup de temps. Nous ne pensions pas initialement que les sockets pourraient devenir un goulot d'étranglement dans le système, car l'agent HTTPS Node.js standard définit le paramètre
maxSockets sur
Infinity
. Cependant, à la fin, nous avons lu la documentation AWS sur Node.js et trouvé quelque chose de surprenant pour nous: le client S3 réduit la valeur du paramètre
maxSockets
Ă
50
. Inutile de dire que ce comportement ne peut pas être qualifié d’intuitif.
Depuis que nous avons amené le travailleur dans un état où, en mode compétitif, plus de 50 tâches ont été effectuées, l'étape de téléchargement est devenue un goulot d'étranglement: il prévoyait l'attente de la libération du socket pour télécharger les données vers S3. Nous avons amélioré le temps de chargement des données en apportant la modification suivante au code d'initialisation du client S3:
const s3Client = new AWS.S3({ httpOptions: { agent: new https.Agent({
▍ Accélération de la sérialisation JSON
Les améliorations du code S3 ont ralenti la croissance de la taille du segment de mémoire, mais elles n'ont pas conduit à une solution complète au problème. Il y avait une autre nuisance évidente: selon nos mesures, l'étape de compression des données dans le code ci-dessus a duré une fois 4 minutes. Il était beaucoup plus long que le temps de fin de tâche habituel, qui est de 4 secondes. Ne croyant pas nos yeux, ne comprenant pas comment cela peut prendre 4 minutes, nous avons décidé d'utiliser des benchmarks locaux et d'optimiser le bloc de code lent.
La compression des données se compose de trois étapes (ici, pour limiter l'utilisation de la mémoire, les
flux Node.js sont utilisés). À savoir, dans la première étape, les données de chaîne JSON sont générées, dans la seconde, les données sont compressées à l'aide de zlib, dans la troisième, elles sont converties en encodage base64. Nous pensions que la source des problèmes pourrait être la bibliothèque tierce que nous utilisons pour générer des chaînes JSON -
bfj . Nous avons écrit un script qui examine les performances de différentes bibliothèques pour générer des données de chaîne JSON à l'aide de flux (le code correspondant peut être trouvé
ici ). Il s'est avéré que le package Big Friendly JSON que nous utilisions n'était pas du tout convivial. Il suffit de regarder les résultats de quelques mesures obtenues au cours de l'expérience:
benchBFJ*100: 67652.616ms benchJSONStream*100: 14094.825ms
Des résultats étonnants. Même dans un test simple, le paquet bfj s'est avéré être 5 fois plus lent que l'autre paquet, JSONStream. En découvrant cela, nous avons rapidement changé bfj en
JSONStream et
avons immédiatement vu une augmentation significative des performances.
▍ Réduction du temps requis pour la collecte des ordures
Après avoir résolu les problèmes de mémoire, nous avons commencé à prêter attention à la différence de temps nécessaire pour traiter des tâches du même type entre les travailleurs réguliers et parallèles. Cette comparaison était tout à fait légitime, d'après ses résultats, nous avons pu juger de l'efficacité du nouveau système. Ainsi, si le rapport entre les travailleurs réguliers et parallèles était d'environ 1, cela nous donnerait l'assurance que nous pouvons rediriger le trafic vers ces travailleurs en toute sécurité. Mais lors des premiers lancements du système, le graphique correspondant dans le panneau de contrôle de Grafana ressemblait à celui illustré ci-dessous.
Le rapport du temps d'exécution des tâches par les travailleurs conventionnels et parallèlesVeuillez noter que parfois l'indicateur est de l'ordre de 8: 1, et cela malgré le fait que le niveau moyen de parallélisation des tâches est relativement faible et se situe aux alentours de 30. Nous savions que les tâches que nous résolvons concernant l'interaction avec les banques ne créent pas lourde charge sur les processeurs. Nous savions également que nos conteneurs «parallèles» n'étaient nullement limités. Ne sachant pas où chercher la cause du problème, nous sommes allés lire des documents sur l'optimisation des projets Node.js. Malgré le petit nombre de ces articles, nous sommes tombés sur
ce matériel, qui traite de la réalisation de 600 000 connexions de socket Web compétitives dans Node.js.
En particulier, notre attention a été attirée sur l'utilisation de l'
--nouse-idle-notification
. Nos processus Node.js peuvent-ils passer autant de temps à collecter les ordures? Ici, en passant, le package gc-stats nous a donné l'occasion de regarder le temps moyen consacré à la collecte des ordures.
Analyse du temps consacrĂ© Ă la collecte des orduresNous avions le sentiment que nos processus passaient environ 30% du temps Ă collecter les ordures Ă l'aide de l'algorithme Scavenge. Ici, nous n'allons pas dĂ©crire les dĂ©tails techniques concernant les diffĂ©rents types de collecte de dĂ©chets dans Node.js. Si ce sujet vous intĂ©resse - jetez un Ĺ“il Ă
ce matériel. L'essence de l'algorithme Scavenge est que la récupération de place est souvent lancée pour effacer la mémoire occupée par les petits objets dans le tas Node.js appelé «nouvel espace».
Il s'est donc avéré que dans nos processus Node.js, la collecte des ordures démarre trop souvent. Puis-je désactiver le garbage collection V8 et l'exécuter moi-même? Existe-t-il un moyen de
réduire la fréquence des appels
de récupération de place? Il s'est avéré que le premier de ce qui précède ne peut pas être fait, mais le dernier - c'est possible! Nous pouvons simplement augmenter la taille de la zone "nouvel espace" en augmentant la limite de la zone "semi-espace" dans Node.js en utilisant l'indicateur de ligne de commande
--max-semi-space-size=1024
. Cela vous permet d'effectuer plus d'opérations d'allocation de mémoire pour les objets de courte durée jusqu'à ce que le V8 démarre le garbage collection. En conséquence, la fréquence de lancement de telles opérations est réduite.
Résultats d'optimisation de la récupération de placeEncore une victoire! L'augmentation de la zone «nouvel espace» a entraîné une réduction significative du temps consacré à la collecte des ordures à l'aide de l'algorithme de récupération - de 30% à 2%.
â–ŤOptimisez l'utilisation du processeur
Après tout ce travail, le résultat nous convenait. Les tâches exécutées chez des travailleurs parallèles, avec une parallélisation de 20 fois le travail, fonctionnaient presque aussi rapidement que celles qui étaient effectuées séparément chez des travailleurs séparés. Il nous a semblé que nous avions surmonté tous les goulets d'étranglement, mais nous ne savions toujours pas exactement quelles opérations ralentissaient le système en production. Puisqu'il n'y avait plus d'endroits dans le système qui nécessitaient évidemment une optimisation, nous avons décidé d'étudier comment les travailleurs utilisent les ressources du processeur.
Sur la base des données collectées sur l'un de nos collaborateurs parallèles, un calendrier fougueux a été créé. Nous avions une visualisation claire à notre disposition, avec laquelle nous pouvions travailler sur la machine locale. Oui, voici un détail intéressant: la taille de ces données était de 60 Mo. C'est ce que nous avons vu en recherchant l'
logger
mots dans le graphique fougueux 0x.
Analyse des données avec les outils 0xLes zones bleu-vert mises en évidence dans les colonnes indiquent qu'au moins 15% du temps processeur a été consacré à la génération du journal de travail. En conséquence, nous avons pu réduire ce temps de 75%. Certes, l'histoire de la façon dont nous avons fait cela fait l'objet d'un article séparé. (Astuce: nous avons utilisé des expressions régulières et fait beaucoup de travail avec les propriétés).
Après cette optimisation, nous avons pu traiter simultanément jusqu'à 30 tâches en un seul travailleur sans nuire aux performances du système.
Résumé
Le passage à des travailleurs parallèles a réduit les coûts annuels pour EC2 d'environ 300 000 dollars et a considérablement simplifié l'architecture du système. Maintenant, nous utilisons dans la production environ 30 fois moins de conteneurs qu'auparavant. Notre système est plus résistant aux retards de traitement des demandes sortantes et aux pics de demandes d'API provenant des utilisateurs.
Tout en parallélisant notre service d'intégration avec les banques, nous avons appris beaucoup de nouvelles choses:
- Ne sous-estimez jamais l'importance d'avoir des mesures système de bas niveau. La capacité de surveiller les données liées à la collecte des ordures et à l'utilisation de la mémoire nous a fourni une aide considérable pour déployer le système et le finaliser.
- Les graphiques flamboyants sont un excellent outil. Maintenant que nous avons appris à les utiliser, nous pouvons facilement identifier de nouveaux goulots d'étranglement dans le système avec leur aide.
- La compréhension des mécanismes d'exécution de Node.js nous a permis d'écrire un meilleur code. Par exemple, sachant comment V8 alloue de la mémoire aux objets et comment fonctionne le ramasse-miettes, nous avons vu l'intérêt d'utiliser la technique de réutilisation des objets aussi largement que possible. Parfois, pour mieux comprendre tout cela, vous devez travailler directement avec V8 ou expérimenter avec les indicateurs de ligne de commande Node.js.
- , .
maxSocket
, Node.js, , , , AWS Node.js . , , , .
Chers lecteurs! Node.js-?
