Debuggen von Speicherlecks in Node.js-Anwendungen

Veröffentlicht: 2022-03-11

Ich bin einmal einen Audi mit einem V8-Twin-Turbo-Motor gefahren, und seine Leistung war unglaublich. Ich fuhr um 3 Uhr morgens mit etwa 140 Meilen pro Stunde auf dem IL-80 Highway in der Nähe von Chicago, als niemand auf der Straße war. Seitdem ist der Begriff „V8“ für mich mit Höchstleistung verbunden.

Node.js ist eine Plattform, die auf der V8-JavaScript-Engine von Chrome zum einfachen Erstellen schneller und skalierbarer Netzwerkanwendungen basiert.

Obwohl der V8 von Audi sehr leistungsstark ist, sind Sie immer noch mit der Kapazität Ihres Gastanks begrenzt. Gleiches gilt für Googles V8 – die JavaScript-Engine hinter Node.js. Seine Leistung ist unglaublich und es gibt viele Gründe, warum Node.js für viele Anwendungsfälle gut funktioniert, aber Sie sind immer durch die Heap-Größe begrenzt. Wenn Sie mehr Anfragen in Ihrer Node.js-Anwendung verarbeiten müssen, haben Sie zwei Möglichkeiten: entweder vertikal skalieren oder horizontal skalieren. Horizontale Skalierung bedeutet, dass Sie mehr gleichzeitige Anwendungsinstanzen ausführen müssen. Wenn Sie es richtig machen, können Sie am Ende mehr Anfragen bedienen. Vertikale Skalierung bedeutet, dass Sie die Speichernutzung und Leistung Ihrer Anwendung verbessern oder die für Ihre Anwendungsinstanz verfügbaren Ressourcen erhöhen müssen.

Debuggen von Speicherlecks in Node.js-Anwendungen

Debuggen von Speicherlecks in Node.js-Anwendungen
Twittern

Kürzlich wurde ich gebeten, an einer Node.js-Anwendung für einen meiner Toptal-Kunden zu arbeiten, um ein Speicherleckproblem zu beheben. Die Anwendung, ein API-Server, sollte hunderttausende Anfragen pro Minute verarbeiten können. Die ursprüngliche Anwendung belegte fast 600 MB RAM und deshalb haben wir uns entschieden, die heißen API-Endpunkte zu nehmen und sie neu zu implementieren. Overhead wird sehr teuer, wenn Sie viele Anfragen bedienen müssen.

Für die neue API haben wir uns für Restify mit nativem MongoDB-Treiber und Kue für Hintergrundjobs entschieden. Klingt nach einem sehr leichten Stack, oder? Nicht ganz. Während der Spitzenlast kann eine neue Anwendungsinstanz bis zu 270 MB RAM verbrauchen. Daher ist mein Traum von zwei Anwendungsinstanzen pro 1X Heroku Dyno verschwunden.

Node.js Memory Leak Debugging Arsenal

Memwatch

Wenn Sie nach „Wie finde ich ein Leck im Knoten“ suchen, ist das erste Tool, das Sie wahrscheinlich finden würden, Memwatch . Das ursprüngliche Paket wurde vor langer Zeit aufgegeben und wird nicht mehr gepflegt. Sie können jedoch leicht neuere Versionen davon in der Fork-Liste von GitHub für das Repository finden. Dieses Modul ist nützlich, da es Leckereignisse ausgeben kann, wenn es sieht, dass der Heap über 5 aufeinanderfolgende Garbage Collections wächst.

Heapdump

Tolles Tool, mit dem Node.js-Entwickler Heap-Snapshots erstellen und sie später mit den Chrome Developer Tools untersuchen können.

Knoteninspektor

Eine noch nützlichere Alternative zu Heapdump, da Sie damit eine Verbindung zu einer laufenden Anwendung herstellen, einen Heap-Dump erstellen und ihn sogar spontan debuggen und neu kompilieren können.

„Node-Inspector“ auf eine Spritztour nehmen

Leider können Sie keine Verbindung zu Produktionsanwendungen herstellen, die auf Heroku ausgeführt werden, da es keine Signale an laufende Prozesse senden kann. Heroku ist jedoch nicht die einzige Hosting-Plattform.

Um den Node-Inspector in Aktion zu erleben, schreiben wir eine einfache Node.js-Anwendung mit restify und fügen darin eine kleine Speicherleckquelle ein. Alle Experimente hier wurden mit Node.js v0.12.7 durchgeführt, das gegen V8 v3.28.71.19 kompiliert wurde.

 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); });

Die Anwendung hier ist sehr einfach und hat ein sehr offensichtliches Leck. Die Array- Tasks würden über die Lebensdauer der Anwendung anwachsen, was dazu führen würde, dass sie langsamer wird und schließlich abstürzt. Das Problem ist, dass wir nicht nur den Abschluss, sondern auch ganze Anforderungsobjekte durchsickern lassen.

GC in V8 verwendet die Stop-the-World-Strategie, daher bedeutet dies, dass Sie mehr Objekte im Speicher haben, je länger es dauert, Garbage Collection zu sammeln. Im Protokoll unten können Sie deutlich sehen, dass es zu Beginn der Anwendungslebensdauer durchschnittlich 20 ms dauern würde, um den Müll zu sammeln, aber einige hunderttausend Anfragen später dauert es etwa 230 ms. Leute, die versuchen, auf unsere Anwendung zuzugreifen, müssten jetzt wegen GC 230 ms länger warten. Sie können auch sehen, dass GC alle paar Sekunden aufgerufen wird, was bedeutet, dass Benutzer alle paar Sekunden Probleme beim Zugriff auf unsere Anwendung haben. Und die Verzögerung wächst, bis die Anwendung abstürzt.

 [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].

Diese Protokollzeilen werden gedruckt, wenn eine Node.js-Anwendung mit dem Flag –trace_gc gestartet wird:

 node --trace_gc app.js

Nehmen wir an, dass wir unsere Node.js-Anwendung bereits mit diesem Flag gestartet haben. Bevor wir die Anwendung mit dem Node-Inspector verbinden, müssen wir ihr das SIGUSR1-Signal an den laufenden Prozess senden. Wenn Sie Node.js im Cluster ausführen, stellen Sie sicher, dass Sie eine Verbindung zu einem der Slave-Prozesse herstellen.

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

Auf diese Weise bringen wir die Node.js-Anwendung (V8, um genau zu sein) in den Debugging-Modus. In diesem Modus öffnet die Anwendung automatisch den Port 5858 mit V8 Debugging Protocol.

Unser nächster Schritt besteht darin, den Knoteninspektor auszuführen, der eine Verbindung zur Debugging-Schnittstelle der laufenden Anwendung herstellt und eine weitere Webschnittstelle auf Port 8080 öffnet.

 $ 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.

Falls die Anwendung in der Produktion ausgeführt wird und Sie eine Firewall installiert haben, können wir den Remote-Port 8080 zu localhost tunneln:

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

Jetzt können Sie Ihren Chrome-Webbrowser öffnen und vollen Zugriff auf die Chrome-Entwicklungstools erhalten, die an Ihre Remote-Produktionsanwendung angehängt sind. Leider funktionieren die Chrome Developer Tools nicht in anderen Browsern.

Lassen Sie uns ein Leck finden!

Speicherlecks in V8 sind keine echten Speicherlecks, wie wir sie von C/C++-Anwendungen kennen. In JavaScript verschwinden Variablen nicht im Nichts, sie werden nur „vergessen“. Unser Ziel ist es, diese vergessenen Variablen zu finden und sie daran zu erinnern, dass Dobby frei ist.

Innerhalb der Chrome Developer Tools haben wir Zugriff auf mehrere Profiler. Wir sind besonders an Record Heap Allocations interessiert, die ausgeführt werden und im Laufe der Zeit mehrere Heap-Snapshots erstellen. Dies gibt uns einen klaren Blick darauf, welche Objekte undicht sind.

Beginnen Sie mit der Aufzeichnung von Heap-Zuweisungen und simulieren Sie 50 gleichzeitige Benutzer auf unserer Homepage mit Apache Benchmark.

Bildschirmfoto

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

Bevor neue Snapshots erstellt werden, führt V8 eine Mark-Sweep-Garbage-Collection durch, sodass wir definitiv wissen, dass der Snapshot keinen alten Garbage enthält.

Das Leck im laufenden Betrieb beheben

Nachdem wir über einen Zeitraum von 3 Minuten Heap-Zuweisungs-Snapshots gesammelt haben, erhalten wir so etwas wie das Folgende:

Bildschirmfoto

Wir können deutlich sehen, dass es einige gigantische Arrays gibt, viele IncomingMessage-, ReadableState-, ServerResponse- und Domain-Objekte sowie im Heap. Versuchen wir, die Quelle des Lecks zu analysieren.

Bei der Auswahl von Heap Diff on Chart von 20s bis 40s sehen wir nur Objekte, die 20s nach dem Start des Profilers hinzugefügt wurden. Auf diese Weise könnten Sie alle normalen Daten ausschließen.

Unter Berücksichtigung der Anzahl der Objekte jedes Typs im System erweitern wir den Filter von 20 Sekunden auf 1 Minute. Wir können sehen, dass die Arrays, die bereits ziemlich gigantisch sind, weiter wachsen. Unter „(array)“ sehen wir, dass es viele Objekte „(object properties)“ mit gleichem Abstand gibt. Diese Objekte sind die Quelle unseres Speicherlecks.

Wir können auch sehen, dass „(Closure)“-Objekte ebenfalls schnell wachsen.

Es könnte auch praktisch sein, sich die Saiten anzusehen. Unter der Streicherliste gibt es viele „Hi Leaky Master“-Phrasen. Diese könnten uns auch einen Hinweis geben.

In unserem Fall wissen wir, dass der String „Hi Leaky Master“ nur unter der Route „GET /“ assembliert werden konnte.

Wenn Sie den Retainers-Pfad öffnen, werden Sie sehen, dass diese Zeichenfolge irgendwie über req referenziert wird, dann wird Kontext erstellt und all dies zu einer riesigen Reihe von Closures hinzugefügt.

Bildschirmfoto

An diesem Punkt wissen wir also, dass wir eine Art gigantische Reihe von Schließungen haben. Lassen Sie uns tatsächlich gehen und allen unseren Schließungen in Echtzeit unter der Registerkarte "Quellen" einen Namen geben.

Bildschirmfoto

Nachdem wir mit der Bearbeitung des Codes fertig sind, können wir STRG+S drücken, um den Code spontan zu speichern und neu zu kompilieren!

Lassen Sie uns nun einen weiteren Heap Allocations Snapshot aufzeichnen und sehen, welche Closures den Speicher belegen.

Es ist klar, dass SomeKindOfClojure() unser Bösewicht ist. Jetzt können wir sehen, dass SomeKindOfClojure() Closures zu einigen Arrays mit dem Namen Tasks im globalen Raum hinzugefügt werden.

Es ist leicht zu erkennen, dass dieses Array einfach nutzlos ist. Wir können es auskommentieren. Aber wie geben wir den bereits belegten Speicher wieder frei? Sehr einfach, wir weisen Aufgaben einfach ein leeres Array zu und bei der nächsten Anfrage wird es überschrieben und der Speicher wird nach dem nächsten GC-Ereignis freigegeben.

Bildschirmfoto

Dobby ist frei!

Das Leben des Mülls in V8

Nun, V8 JS hat keine Speicherlecks, nur vergessene Variablen.

Nun, V8 JS hat keine Speicherlecks, nur vergessene Variablen.
Twittern

Der V8-Heap ist in mehrere verschiedene Bereiche unterteilt:

  • Neuer Speicherplatz: Dieser Speicherplatz ist relativ klein und hat eine Größe zwischen 1 MB und 8 MB. Hier werden die meisten Objekte zugeordnet.
  • Alter Zeigerraum : Hat Objekte, die Zeiger auf andere Objekte haben können. Wenn das Objekt lange genug im New Space überlebt, wird es in den Old Pointer Space befördert.
  • Alter Datenraum: Enthält nur Rohdaten wie Zeichenfolgen, Zahlen in Boxen und Arrays von Doubles ohne Box. Objekte, die GC im New Space lange genug überlebt haben, werden ebenfalls hierher verschoben.
  • Großer Objektbereich : In diesem Bereich werden Objekte erstellt, die zu groß sind, um in andere Bereiche zu passen. Jedes Objekt hat seinen eigenen mmap -Bereich im Speicher
  • Codebereich : Enthält vom JIT-Compiler generierten Assemblercode.
  • Zellenbereich, Eigenschaftszellenbereich, Kartenbereich : Dieser Bereich enthält Cell s, PropertyCell s und Map s. Dies wird verwendet, um die Garbage Collection zu vereinfachen.

Jeder Bereich besteht aus Seiten. Eine Seite ist ein Speicherbereich, der vom Betriebssystem mit mmap zugewiesen wird. Jede Seite ist immer 1 MB groß, mit Ausnahme von Seiten im großen Objektbereich.

V8 hat zwei eingebaute Garbage-Collection-Mechanismen: Scavenge, Mark-Sweep und Mark-Compact.

Scavenge ist eine sehr schnelle Garbage-Collection-Technik und arbeitet mit Objekten im New Space . Scavenge ist die Implementierung von Cheneys Algorithmus. Die Idee ist sehr einfach, New Space ist in zwei gleiche Halbräume unterteilt: To-Space und From-Space. Scavenge GC tritt auf, wenn To-Space voll ist. Es tauscht einfach die To- und From-Räume aus und kopiert alle lebenden Objekte in den To-Space oder befördert sie in einen der alten Spaces, wenn sie zwei Plünderungen überlebt haben, und wird dann vollständig aus dem Space gelöscht. Scavenges sind sehr schnell, aber sie haben den Overhead, doppelt so große Heaps zu halten und ständig Objekte in den Speicher zu kopieren. Der Grund für die Verwendung von Scavenges liegt darin, dass die meisten Objekte jung sterben.

Mark-Sweep & Mark-Compact ist eine andere Art von Garbage Collector, die in V8 verwendet wird. Der andere Name ist Full Garbage Collector. Es markiert alle aktiven Knoten, fegt dann alle toten Knoten und defragmentiert den Speicher.

GC-Leistungs- und Debugging-Tipps

Während für Webanwendungen eine hohe Leistung möglicherweise kein so großes Problem darstellt, möchten Sie dennoch Lecks um jeden Preis vermeiden. Während der Markierungsphase im vollständigen GC wird die Anwendung tatsächlich angehalten, bis die Garbage Collection abgeschlossen ist. Das bedeutet, je mehr Objekte Sie im Heap haben, desto länger dauert die Durchführung von GC und desto länger müssen die Benutzer warten.

Geben Sie Closures und Funktionen immer Namen

Es ist viel einfacher, Stack-Traces und Heaps zu inspizieren, wenn alle Closures und Funktionen Namen haben.

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

Vermeiden Sie große Objekte in heißen Funktionen

Idealerweise möchten Sie große Objekte innerhalb von heißen Funktionen vermeiden, damit alle Daten in New Space passen. Alle CPU- und speichergebundenen Operationen sollten im Hintergrund ausgeführt werden. Vermeiden Sie auch Deoptimierungsauslöser für Hot-Funktionen, optimierte Hot-Funktionen verwenden weniger Speicher als nicht optimierte.

Hot-Funktionen sollten optimiert werden

Hot-Funktionen, die schneller ausgeführt werden, aber auch weniger Speicher verbrauchen, führen dazu, dass GC seltener ausgeführt wird. V8 bietet einige hilfreiche Debugging-Tools, um nicht optimierte Funktionen oder deoptimierte Funktionen zu erkennen.

Vermeiden Sie Polymorphismus für ICs in heißen Funktionen

Inline-Caches (IC) werden verwendet, um die Ausführung einiger Codeblöcke zu beschleunigen, entweder durch Zwischenspeichern des Objekteigenschaftenzugriffs obj.key oder einer einfachen Funktion.

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

Wenn x(a,b) zum ersten Mal ausgeführt wird, erstellt V8 einen monomorphen IC. Wenn Sie x ein zweites Mal aufrufen, löscht V8 den alten IC und erstellt einen neuen polymorphen IC, der beide Arten von Operanden, Integer und String, unterstützt. Wenn Sie IC das dritte Mal aufrufen, wiederholt V8 denselben Vorgang und erstellt einen weiteren polymorphen IC der Stufe 3.

Es gibt jedoch eine Einschränkung. Nachdem der IC-Level 5 erreicht hat (kann mit dem Flag –max_inlining_levels geändert werden), wird die Funktion megamorph und gilt nicht mehr als optimierbar.

Es ist intuitiv verständlich, dass monomorphe Funktionen am schnellsten ausgeführt werden und auch einen geringeren Speicherbedarf haben.

Fügen Sie dem Speicher keine großen Dateien hinzu

Dieser ist offensichtlich und bekannt. Wenn Sie große Dateien verarbeiten müssen, z. B. eine große CSV-Datei, lesen Sie sie Zeile für Zeile und verarbeiten Sie sie in kleinen Abschnitten, anstatt die gesamte Datei in den Speicher zu laden. Es gibt eher seltene Fälle, in denen eine einzelne CSV-Zeile größer als 1 MB ist, sodass Sie sie in New Space einfügen können.

Hauptserver-Thread nicht blockieren

Wenn Sie eine heiße API haben, deren Verarbeitung einige Zeit in Anspruch nimmt, z. B. eine API zum Ändern der Bildgröße, verschieben Sie sie in einen separaten Thread oder verwandeln Sie sie in einen Hintergrundjob. CPU-intensive Operationen würden den Haupt-Thread blockieren und alle anderen Kunden zwingen, zu warten und weiterhin Anfragen zu senden. Unverarbeitete Anforderungsdaten würden sich im Speicher stapeln, wodurch eine vollständige GC dazu gezwungen würde, längere Zeit zum Beenden zu benötigen.

Erstellen Sie keine unnötigen Daten

Ich hatte einmal eine seltsame Erfahrung mit restify. Wenn Sie ein paar hunderttausend Anfragen an eine ungültige URL senden, würde der Anwendungsspeicher schnell auf bis zu hundert Megabyte anwachsen, bis ein paar Sekunden später ein voller GC einsetzt und dann alles wieder normal läuft. Es stellt sich heraus, dass restify für jede ungültige URL ein neues Fehlerobjekt generiert, das lange Stacktraces enthält. Dadurch mussten neu erstellte Objekte im Large Object Space und nicht im New Space zugewiesen werden.

Der Zugriff auf solche Daten könnte während der Entwicklung sehr hilfreich sein, ist aber für die Produktion offensichtlich nicht erforderlich. Daher ist die Regel einfach: Generieren Sie keine Daten, es sei denn, Sie benötigen sie unbedingt.

Kenne deine Werkzeuge

Zu guter Letzt müssen Sie Ihre Werkzeuge kennen. Es gibt verschiedene Debugger, Leak Cather und Generatoren für Nutzungsdiagramme. All diese Tools können Ihnen dabei helfen, Ihre Software schneller und effizienter zu machen.

Fazit

Das Verständnis, wie die Garbage Collection und der Codeoptimierer von V8 funktionieren, ist ein Schlüssel zur Anwendungsleistung. V8 kompiliert JavaScript zu nativem Assembler, und in einigen Fällen kann gut geschriebener Code eine Leistung erzielen, die mit GCC-kompilierten Anwendungen vergleichbar ist.

Und falls Sie sich fragen, die neue API-Anwendung für meinen Toptal-Client funktioniert sehr gut, obwohl es Raum für Verbesserungen gibt!

Joyent hat kürzlich eine neue Version von Node.js veröffentlicht, die eine der neuesten Versionen von V8 verwendet. Einige Anwendungen, die für Node.js v0.12.x geschrieben wurden, sind möglicherweise nicht mit der neuen Version v4.x kompatibel. Anwendungen werden jedoch in der neuen Version von Node.js eine enorme Leistungs- und Speichernutzungsverbesserung erfahren.