Débogage des fuites de mémoire dans les applications Node.js

Publié: 2022-03-11

Une fois, j'ai conduit une Audi avec un moteur V8 bi-turbo à l'intérieur, et ses performances étaient incroyables. Je conduisais à environ 140MPH sur l'autoroute IL-80 près de Chicago à 3h du matin quand il n'y avait personne sur la route. Depuis lors, le terme "V8" est devenu pour moi associé à des performances élevées.

Node.js est une plate-forme basée sur le moteur JavaScript V8 de Chrome pour la création facile d'applications réseau rapides et évolutives.

Bien que le V8 d'Audi soit très puissant, vous êtes toujours limité par la capacité de votre réservoir d'essence. Il en va de même pour le V8 de Google - le moteur JavaScript derrière Node.js. Ses performances sont incroyables et il existe de nombreuses raisons pour lesquelles Node.js fonctionne bien dans de nombreux cas d'utilisation, mais vous êtes toujours limité par la taille du tas. Lorsque vous devez traiter plus de requêtes dans votre application Node.js, vous avez deux choix : soit mettre à l'échelle verticalement, soit mettre à l'échelle horizontalement. La mise à l'échelle horizontale signifie que vous devez exécuter plusieurs instances d'application simultanées. Une fois bien fait, vous finissez par être en mesure de répondre à plus de demandes. La mise à l'échelle verticale signifie que vous devez améliorer l'utilisation de la mémoire et les performances de votre application ou augmenter les ressources disponibles pour votre instance d'application.

Débogage des fuites de mémoire dans les applications Node.js

Débogage des fuites de mémoire dans les applications Node.js
Tweeter

Récemment, on m'a demandé de travailler sur une application Node.js pour l'un de mes clients Toptal afin de résoudre un problème de fuite de mémoire. L'application, un serveur API, devait pouvoir traiter des centaines de milliers de requêtes chaque minute. L'application d'origine occupait près de 600 Mo de RAM et nous avons donc décidé de prendre les points de terminaison chauds de l'API et de les réimplémenter. Les frais généraux deviennent très coûteux lorsque vous devez répondre à de nombreuses demandes.

Pour la nouvelle API, nous avons choisi restify avec le pilote MongoDB natif et Kue pour les tâches en arrière-plan. Cela ressemble à une pile très légère, non? Pas assez. Pendant les pics de charge, une nouvelle instance d'application peut consommer jusqu'à 270 Mo de RAM. Par conséquent, mon rêve d'avoir deux instances d'application par 1X Heroku Dyno s'est évanoui.

Arsenal de débogage de fuite de mémoire Node.js

Montre-mémoire

Si vous recherchez "comment trouver une fuite dans un nœud", le premier outil que vous trouverez probablement est memwatch . L'emballage d'origine a été abandonné il y a longtemps et n'est plus maintenu. Cependant, vous pouvez facilement en trouver des versions plus récentes dans la liste de fourches de GitHub pour le référentiel. Ce module est utile car il peut émettre des événements de fuite s'il voit le tas grossir sur 5 ramasse-miettes consécutifs.

Vidage de tas

Excellent outil qui permet aux développeurs Node.js de prendre un instantané de tas et de les inspecter plus tard avec les outils de développement Chrome.

Inspecteur de nœud

Une alternative encore plus utile au vidage de tas, car il vous permet de vous connecter à une application en cours d'exécution, de faire un vidage de tas et même de le déboguer et de le recompiler à la volée.

Prendre "node-inspector" pour un spin

Malheureusement, vous ne pourrez pas vous connecter aux applications de production qui s'exécutent sur Heroku, car cela ne permet pas d'envoyer des signaux aux processus en cours d'exécution. Cependant, Heroku n'est pas la seule plate-forme d'hébergement.

Pour faire l'expérience de l'inspecteur de nœuds en action, nous allons écrire une application Node.js simple en utilisant restify et y placer une petite source de fuite de mémoire. Toutes les expériences ici sont faites avec Node.js v0.12.7, qui a été compilé avec V8 v3.28.71.19.

 var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });

L'application ici est très simple et présente une fuite très évidente. Les tâches de la baie augmenteraient au cours de la durée de vie de l'application, ce qui la ralentirait et finirait par planter. Le problème est que nous ne divulguons pas seulement la fermeture, mais également des objets de requête entiers.

GC dans V8 utilise une stratégie stop-the-world, donc cela signifie que plus vous avez d'objets en mémoire, plus il faudra de temps pour collecter les ordures. Sur le journal ci-dessous, vous pouvez clairement voir qu'au début de la vie de l'application, il faudrait en moyenne 20 ms pour collecter les ordures, mais quelques centaines de milliers de requêtes plus tard, cela prend environ 230 ms. Les personnes qui essaient d'accéder à notre application devraient attendre 230 ms de plus maintenant à cause de GC. Vous pouvez également voir que GC est invoqué toutes les quelques secondes, ce qui signifie que toutes les quelques secondes, les utilisateurs rencontreraient des problèmes pour accéder à notre application. Et le retard augmentera jusqu'à ce que l'application se bloque.

 [28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].

Ces lignes de journal sont imprimées lorsqu'une application Node.js est démarrée avec l'indicateur –trace_gc :

 node --trace_gc app.js

Supposons que nous ayons déjà démarré notre application Node.js avec ce drapeau. Avant de connecter l'application avec node-inspector, nous devons lui envoyer le signal SIGUSR1 au processus en cours d'exécution. Si vous exécutez Node.js en cluster, assurez-vous de vous connecter à l'un des processus esclaves.

 kill -SIGUSR1 $pid # Replace $pid with the actual process ID

En faisant cela, nous faisons passer l'application Node.js (V8 pour être précis) en mode débogage. Dans ce mode, l'application ouvre automatiquement le port 5858 avec le protocole de débogage V8.

Notre prochaine étape consiste à exécuter node-inspector qui se connectera à l'interface de débogage de l'application en cours d'exécution et ouvrira une autre interface Web sur le port 8080.

 $ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.

Si l'application s'exécute en production et que vous avez un pare-feu en place, nous pouvons tunneler le port distant 8080 vers localhost :

 ssh -L 8080:localhost:8080 [email protected]

Vous pouvez maintenant ouvrir votre navigateur Web Chrome et obtenir un accès complet aux outils de développement Chrome attachés à votre application de production à distance. Malheureusement, les outils de développement Chrome ne fonctionneront pas dans d'autres navigateurs.

Trouvons une fuite !

Les fuites de mémoire dans V8 ne sont pas de véritables fuites de mémoire telles que nous les connaissons dans les applications C/C++. En JavaScript, les variables ne disparaissent pas dans le vide, elles sont juste "oubliées". Notre objectif est de retrouver ces variables oubliées et de leur rappeler que Dobby est gratuit.

Dans Chrome Developer Tools, nous avons accès à plusieurs profileurs. Nous sommes particulièrement intéressés par Record Heap Allocations qui s'exécute et prend plusieurs instantanés de tas au fil du temps. Cela nous donne un aperçu clair dans lequel les objets fuient.

Commencez à enregistrer les allocations de tas et simulons 50 utilisateurs simultanés sur notre page d'accueil à l'aide d'Apache Benchmark.

Capture d'écran

 ab -c 50 -n 1000000 -k http://example.com/

Avant de prendre de nouveaux instantanés, V8 effectuerait une récupération de place par balayage de marques, nous savons donc qu'il n'y a pas d'anciens déchets dans l'instantané.

Réparer la fuite à la volée

Après avoir collecté des instantanés d'allocation de tas sur une période de 3 minutes , nous obtenons quelque chose comme ceci :

Capture d'écran

Nous pouvons clairement voir qu'il existe des tableaux gigantesques, ainsi que de nombreux objets IncomingMessage, ReadableState, ServerResponse et Domain dans le tas. Essayons d'analyser la source de la fuite.

Lors de la sélection de la différence de tas sur le graphique de 20s à 40s, nous ne verrons que les objets qui ont été ajoutés après 20s à partir du moment où vous avez démarré le profileur. De cette façon, vous pouvez exclure toutes les données normales.

En tenant compte du nombre d'objets de chaque type dans le système, nous étendons le filtre de 20 secondes à 1 minute. On constate que les baies, déjà assez gigantesques, ne cessent de s'agrandir. Sous "(tableau)", nous pouvons voir qu'il y a beaucoup d'objets "(propriétés de l'objet)" avec une distance égale. Ces objets sont la source de notre fuite de mémoire.

Nous pouvons également voir que les objets « (fermeture) » grandissent également rapidement.

Il peut également être utile de regarder les cordes. Sous la liste des chaînes, il y a beaucoup de phrases "Hi Leaky Master". Cela pourrait aussi nous donner des indices.

Dans notre cas, nous savons que la chaîne "Hi Leaky Master" ne peut être assemblée que sous la route "GET /".

Si vous ouvrez le chemin des retenues, vous verrez que cette chaîne est en quelque sorte référencée via req , puis il y a un contexte créé et tout cela ajouté à un tableau géant de fermetures.

Capture d'écran

Donc, à ce stade, nous savons que nous avons une sorte de gigantesque éventail de fermetures. Allons en fait donner un nom à toutes nos fermetures en temps réel sous l'onglet sources.

Capture d'écran

Une fois que nous avons fini d'éditer le code, nous pouvons appuyer sur CTRL+S pour enregistrer et recompiler le code à la volée !

Maintenant, enregistrons un autre instantané des allocations de tas et voyons quelles fermetures occupent la mémoire.

Il est clair que SomeKindOfClojure() est notre méchant. Nous pouvons maintenant voir que les fermetures SomeKindOfClojure() sont ajoutées à certains tableaux nommés tâches dans l'espace global.

Il est facile de voir que ce tableau est tout simplement inutile. Nous pouvons le commenter. Mais comment libérer de la mémoire la mémoire déjà occupée ? Très simple, nous attribuons simplement un tableau vide aux tâches et à la prochaine requête, il sera remplacé et la mémoire sera libérée après le prochain événement GC.

Capture d'écran

Dobby est libre!

La vie des ordures en V8

Eh bien, V8 JS n'a pas de fuites de mémoire, seulement des variables oubliées.

Eh bien, V8 JS n'a pas de fuites de mémoire, seulement des variables oubliées.
Tweeter

Le tas V8 est divisé en plusieurs espaces différents :

  • Nouvel espace : Cet espace est relativement petit et a une taille comprise entre 1 Mo et 8 Mo. La plupart des objets sont attribués ici.
  • Old Pointer Space : Contient des objets qui peuvent avoir des pointeurs vers d'autres objets. Si l'objet survit suffisamment longtemps dans le nouvel espace, il est promu dans l'ancien espace de pointeur.
  • Old Data Space : Contient uniquement des données brutes telles que des chaînes, des nombres encadrés et des tableaux de doubles non encadrés. Les objets qui ont survécu suffisamment longtemps au GC dans le New Space sont également déplacés ici.
  • Grand espace d' objets : les objets qui sont trop grands pour tenir dans d'autres espaces sont créés dans cet espace. Chaque objet a sa propre région mmap en mémoire
  • Espace code : Contient le code assembleur généré par le compilateur JIT.
  • Espace cellule, espace cellule de propriété, espace carte : cet espace contient Cell s, PropertyCell s et Map s. Ceci est utilisé pour simplifier la récupération de place.

Chaque espace est composé de pages. Une page est une région de mémoire allouée par le système d'exploitation avec mmap. Chaque page a toujours une taille de 1 Mo, à l'exception des pages dans l'espace objet volumineux.

V8 a deux mécanismes intégrés de récupération de place : Scavenge, Mark-Sweep et Mark-Compact.

Scavenge est une technique de récupération de place très rapide et fonctionne avec des objets dans le New Space . Scavenge est l'implémentation de l'algorithme de Cheney. L'idée est très simple, New Space est divisé en deux demi-espaces égaux : To-Space et From-Space. Scavenge GC se produit lorsque To-Space est plein. Il échange simplement les espaces To et From et copie tous les objets vivants dans To-Space ou les promeut dans l'un des anciens espaces s'ils ont survécu à deux récupérations, puis est entièrement effacé de l'espace. Les nettoyages sont très rapides, mais ils ont la surcharge de conserver un tas de taille double et de copier constamment des objets en mémoire. La raison d'utiliser des récupérations est que la plupart des objets meurent jeunes.

Mark-Sweep & Mark-Compact est un autre type de ramasse-miettes utilisé dans V8. L'autre nom est un ramasse-miettes complet. Il marque tous les nœuds actifs, puis balaie tous les nœuds morts et défragmente la mémoire.

Conseils sur les performances et le débogage du GC

Bien que pour les applications Web, les hautes performances ne soient pas un si gros problème, vous voudrez toujours éviter les fuites à tout prix. Pendant la phase de marquage dans le GC complet, l'application est en fait suspendue jusqu'à ce que la récupération de place soit terminée. Cela signifie que plus vous avez d'objets dans le tas, plus il faudra de temps pour effectuer le GC et plus les utilisateurs devront attendre longtemps.

Donnez toujours des noms aux fermetures et aux fonctions

Il est beaucoup plus facile d'inspecter les traces de pile et les tas lorsque toutes vos fermetures et fonctions ont des noms.

 db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })

Évitez les gros objets dans les fonctions chaudes

Idéalement, vous voulez éviter les objets volumineux à l'intérieur des fonctions chaudes afin que toutes les données tiennent dans New Space . Toutes les opérations liées au processeur et à la mémoire doivent être exécutées en arrière-plan. Évitez également les déclencheurs de désoptimisation pour les fonctions chaudes, la fonction chaude optimisée utilise moins de mémoire que les fonctions non optimisées.

Les fonctions chaudes doivent être optimisées

Les fonctions chaudes qui s'exécutent plus rapidement mais consomment également moins de mémoire entraînent une exécution moins fréquente du GC. V8 fournit des outils de débogage utiles pour repérer les fonctions non optimisées ou les fonctions désoptimisées.

Éviter le polymorphisme pour les CI dans les fonctions chaudes

Les caches en ligne (IC) sont utilisés pour accélérer l'exécution de certains morceaux de code, soit en mettant en cache l'accès à la propriété de l'objet obj.key ou une fonction simple.

 function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3

Lorsque x(a,b) est exécuté pour la première fois, V8 crée un CI monomorphe. Lorsque vous appelez x une deuxième fois, V8 efface l'ancien IC et crée un nouveau IC polymorphe qui prend en charge les deux types d'opérandes entier et chaîne. Lorsque vous appelez IC pour la troisième fois, V8 répète la même procédure et crée un autre IC polymorphe de niveau 3.

Cependant, il y a une limite. Une fois que le niveau IC atteint 5 (peut être modifié avec l'indicateur –max_inlining_levels ), la fonction devient mégamorphique et n'est plus considérée comme optimisable.

Il est intuitivement compréhensible que les fonctions monomorphes s'exécutent le plus rapidement et aient également une empreinte mémoire plus petite.

N'ajoutez pas de fichiers volumineux à la mémoire

Celui-ci est évident et bien connu. Si vous avez de gros fichiers à traiter, par exemple un gros fichier CSV, lisez-le ligne par ligne et traitez-le par petits morceaux au lieu de charger le fichier entier en mémoire. Il existe des cas assez rares où une seule ligne de csv serait supérieure à 1 Mo, vous permettant ainsi de l'adapter à New Space .

Ne pas bloquer le thread du serveur principal

Si vous avez une API chaude qui prend un certain temps à traiter, comme une API pour redimensionner les images, déplacez-la vers un thread séparé ou transformez-la en tâche d'arrière-plan. Les opérations gourmandes en CPU bloqueraient le thread principal, forçant tous les autres clients à attendre et à continuer à envoyer des demandes. Les données de requête non traitées s'empileraient en mémoire, obligeant ainsi le GC complet à prendre plus de temps pour se terminer.

Ne créez pas de données inutiles

J'ai eu une fois une expérience étrange avec restify. Si vous envoyez quelques centaines de milliers de requêtes à une URL invalide, la mémoire de l'application augmenterait rapidement jusqu'à une centaine de mégaoctets jusqu'à ce qu'un GC complet démarre quelques secondes plus tard, c'est-à-dire lorsque tout reviendrait à la normale. Il s'avère que pour chaque URL invalide, restify génère un nouvel objet d'erreur qui inclut de longues traces de pile. Cela obligeait les objets nouvellement créés à être alloués dans Large Object Space plutôt que dans New Space .

Avoir accès à de telles données pourrait être très utile pendant le développement, mais évidemment pas obligatoire en production. Par conséquent, la règle est simple - ne générez pas de données à moins que vous n'en ayez certainement besoin.

Connaissez vos outils

Le dernier, mais certainement pas le moindre, est de connaître vos outils. Il existe divers débogueurs, cathers de fuite et générateurs de graphiques d'utilisation. Tous ces outils peuvent vous aider à rendre votre logiciel plus rapide et plus efficace.

Conclusion

Comprendre le fonctionnement de la récupération de place et de l'optimiseur de code de V8 est essentiel aux performances des applications. V8 compile JavaScript en assemblage natif et, dans certains cas, un code bien écrit peut atteindre des performances comparables à celles des applications compilées par GCC.

Et au cas où vous vous poseriez la question, la nouvelle application API pour mon client Toptal, bien qu'il y ait place à l'amélioration, fonctionne très bien !

Joyent a récemment publié une nouvelle version de Node.js qui utilise l'une des dernières versions de V8. Certaines applications écrites pour Node.js v0.12.x peuvent ne pas être compatibles avec la nouvelle version v4.x. Cependant, les applications connaîtront une amélioration considérable des performances et de l'utilisation de la mémoire dans la nouvelle version de Node.js.