Debug di perdite di memoria nelle applicazioni Node.js

Pubblicato: 2022-03-11

Una volta ho guidato un'Audi con un motore V8 biturbo all'interno e le sue prestazioni sono state incredibili. Stavo guidando a circa 140 MPH sull'autostrada IL-80 vicino a Chicago alle 3 del mattino quando non c'era nessuno sulla strada. Da allora, il termine "V8" è stato associato a prestazioni elevate per me.

Node.js è una piattaforma basata sul motore JavaScript V8 di Chrome per creare facilmente applicazioni di rete veloci e scalabili.

Sebbene il V8 di Audi sia molto potente, sei ancora limitato dalla capacità del tuo serbatoio del gas. Lo stesso vale per V8 di Google, il motore JavaScript dietro Node.js. Le sue prestazioni sono incredibili e ci sono molte ragioni per cui Node.js funziona bene per molti casi d'uso, ma sei sempre limitato dalle dimensioni dell'heap. Quando devi elaborare più richieste nella tua applicazione Node.js, hai due scelte: ridimensionare verticalmente o ridimensionare orizzontalmente. Il ridimensionamento orizzontale significa che devi eseguire più istanze dell'applicazione simultanee. Se fatto bene, finisci per essere in grado di soddisfare più richieste. Il ridimensionamento verticale significa che devi migliorare l'utilizzo della memoria e le prestazioni dell'applicazione o aumentare le risorse disponibili per l'istanza dell'applicazione.

Debug di perdite di memoria nelle applicazioni Node.js

Debug di perdite di memoria nelle applicazioni Node.js
Twitta

Di recente mi è stato chiesto di lavorare su un'applicazione Node.js per uno dei miei client Toptal per risolvere un problema di perdita di memoria. L'applicazione, un server API, doveva essere in grado di elaborare centinaia di migliaia di richieste ogni minuto. L'applicazione originale occupava quasi 600 MB di RAM e quindi abbiamo deciso di prendere gli endpoint API caldi e reimplementarli. Le spese generali diventano molto costose quando è necessario soddisfare molte richieste.

Per la nuova API abbiamo scelto restify con driver MongoDB nativo e Kue per i lavori in background. Suona come uno stack molto leggero, giusto? Non proprio. Durante il carico di picco una nuova istanza dell'applicazione potrebbe consumare fino a 270 MB di RAM. Pertanto il mio sogno di avere due istanze dell'applicazione per 1X Heroku Dyno è svanito.

Perdita di memoria di Node.js durante il debug di Arsenal

Memwatch

Se cerchi "come trovare la perdita nel nodo" il primo strumento che probabilmente troverai è memwatch . La confezione originale è stata abbandonata molto tempo fa e non viene più mantenuta. Tuttavia puoi facilmente trovarne le versioni più recenti nell'elenco dei fork di GitHub per il repository. Questo modulo è utile perché può generare eventi di perdita se vede l'heap crescere su 5 Garbage Collection consecutive.

discarica

Ottimo strumento che consente agli sviluppatori Node.js di scattare istantanee dell'heap e ispezionarle in un secondo momento con gli strumenti per sviluppatori di Chrome.

Nodo-ispettore

Un'alternativa ancora più utile a heapdump, perché ti consente di connetterti a un'applicazione in esecuzione, eseguire dump di heap e persino eseguirne il debug e ricompilarlo al volo.

Prendendo "node-inspector" per un giro

Sfortunatamente, non sarai in grado di connetterti alle applicazioni di produzione in esecuzione su Heroku, perché non consente l'invio di segnali ai processi in esecuzione. Tuttavia, Heroku non è l'unica piattaforma di hosting.

Per sperimentare node-inspector in azione, scriveremo una semplice applicazione Node.js usando restify e inseriremo una piccola fonte di perdita di memoria al suo interno. Tutti gli esperimenti qui sono realizzati con Node.js v0.12.7, che è stato compilato rispetto a 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'applicazione qui è molto semplice e presenta una perdita molto evidente. Le attività dell'array aumenterebbero nel corso della vita dell'applicazione causandone il rallentamento e alla fine l'arresto anomalo. Il problema è che non stiamo solo perdendo la chiusura ma anche interi oggetti di richiesta.

GC in V8 utilizza la strategia stop-the-world, quindi significa che più oggetti hai in memoria più tempo ci vorrà per raccogliere i rifiuti. Nel registro sottostante puoi vedere chiaramente che all'inizio della vita dell'applicazione ci vorrebbero in media 20 ms per raccogliere la spazzatura, ma poche centinaia di migliaia di richieste dopo ci vogliono circa 230 ms. Le persone che stanno tentando di accedere alla nostra applicazione dovrebbero attendere 230 ms in più ora a causa di GC. Inoltre puoi vedere che GC viene invocato ogni pochi secondi, il che significa che ogni pochi secondi gli utenti potrebbero riscontrare problemi nell'accesso alla nostra applicazione. E il ritardo aumenterà fino a quando l'applicazione non si arresta in modo anomalo.

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

Queste righe di registro vengono stampate quando un'applicazione Node.js viene avviata con il flag –trace_gc :

 node --trace_gc app.js

Supponiamo di aver già avviato la nostra applicazione Node.js con questo flag. Prima di collegare l'applicazione con node-inspector, è necessario inviargli il segnale SIGUSR1 al processo in esecuzione. Se esegui Node.js nel cluster, assicurati di connetterti a uno dei processi slave.

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

In questo modo, stiamo facendo entrare l'applicazione Node.js (V8 per la precisione) in modalità di debug. In questa modalità, l'applicazione apre automaticamente la porta 5858 con protocollo di debug V8.

Il nostro prossimo passo è eseguire node-inspector che si collegherà all'interfaccia di debug dell'applicazione in esecuzione e aprirà un'altra interfaccia web sulla porta 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.

Nel caso in cui l'applicazione sia in esecuzione in produzione e disponi di un firewall, possiamo eseguire il tunneling della porta remota 8080 su localhost:

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

Ora puoi aprire il browser Web Chrome e ottenere l'accesso completo agli strumenti di sviluppo Chrome collegati alla tua applicazione di produzione remota. Sfortunatamente, Chrome Developer Tools non funzionerà in altri browser.

Troviamo una perdita!

Le perdite di memoria in V8 non sono vere perdite di memoria poiché le conosciamo dalle applicazioni C/C++. In JavaScript le variabili non scompaiono nel vuoto, vengono semplicemente "dimenticate". Il nostro obiettivo è trovare queste variabili dimenticate e ricordare loro che Dobby è gratuito.

All'interno degli Strumenti per sviluppatori di Chrome abbiamo accesso a più profiler. Siamo particolarmente interessati alle allocazioni dell'heap record che vengono eseguite e acquisiscono più snapshot dell'heap nel tempo. Questo ci dà una chiara sbirciatina in cui gli oggetti perdono.

Inizia a registrare le allocazioni di heap e simuliamo 50 utenti simultanei sulla nostra home page utilizzando Apache Benchmark.

Immagine dello schermo

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

Prima di acquisire nuove istantanee, V8 eseguiva la raccolta di dati obsoleti mark-sweep, quindi sappiamo sicuramente che non ci sono vecchi rifiuti nello snapshot.

Riparare la perdita al volo

Dopo aver raccolto istantanee di allocazione dell'heap in un periodo di 3 minuti , ci ritroviamo con qualcosa di simile al seguente:

Immagine dello schermo

Possiamo vedere chiaramente che ci sono alcuni giganteschi array, molti oggetti IncomingMessage, ReadableState, ServerResponse e Domain anche nell'heap. Proviamo ad analizzare la fonte della fuga.

Dopo aver selezionato heap diff sul grafico da 20 a 40 secondi, vedremo solo gli oggetti che sono stati aggiunti dopo 20 secondi da quando hai avviato il profiler. In questo modo potresti escludere tutti i dati normali.

Prendendo nota di quanti oggetti di ogni tipo sono presenti nel sistema, espandiamo il filtro da 20 secondi a 1 minuto. Possiamo vedere che gli array, già abbastanza giganteschi, continuano a crescere. Sotto "(array)" possiamo vedere che ci sono molti oggetti "(proprietà dell'oggetto)" con uguale distanza. Quegli oggetti sono la fonte della nostra perdita di memoria.

Inoltre possiamo vedere che anche gli oggetti di "(chiusura)" crescono rapidamente.

Potrebbe essere utile guardare anche le stringhe. Sotto l'elenco delle stringhe ci sono molte frasi "Hi Leaky Master". Anche quelli potrebbero darci qualche indizio.

Nel nostro caso sappiamo che la stringa "Hi Leaky Master" poteva essere assemblata solo sotto il percorso "GET /".

Se apri il percorso dei fermi, vedrai che questa stringa è in qualche modo referenziata tramite req , quindi viene creato il contesto e tutto questo viene aggiunto a una gigantesca matrice di chiusure.

Immagine dello schermo

Quindi a questo punto sappiamo che abbiamo una specie di gigantesca serie di chiusure. Andiamo effettivamente a dare un nome a tutte le nostre chiusure in tempo reale nella scheda delle fonti.

Immagine dello schermo

Dopo aver finito di modificare il codice, possiamo premere CTRL+S per salvare e ricompilare il codice al volo!

Ora registriamo un'altra istantanea di allocazioni heap e vediamo quali chiusure stanno occupando la memoria.

È chiaro che SomeKindOfClojure() è il nostro cattivo. Ora possiamo vedere che le chiusure SomeKindOfClojure() vengono aggiunte ad alcuni task denominati array nello spazio globale.

È facile vedere che questo array è semplicemente inutile. Possiamo commentarlo. Ma come liberare memoria la memoria già occupata? Molto semplice, assegniamo semplicemente un array vuoto alle attività e con la richiesta successiva verrà sovrascritto e la memoria verrà liberata dopo il prossimo evento GC.

Immagine dello schermo

Dobby è gratis!

Vita di spazzatura in V8

Bene, V8 JS non ha perdite di memoria, solo variabili dimenticate.

Bene, V8 JS non ha perdite di memoria, solo variabili dimenticate.
Twitta

L'heap V8 è suddiviso in diversi spazi:

  • Nuovo spazio : questo spazio è relativamente piccolo e ha una dimensione compresa tra 1 MB e 8 MB. La maggior parte degli oggetti sono allocati qui.
  • Old Pointer Space : contiene oggetti che possono avere puntatori ad altri oggetti. Se l'oggetto sopravvive abbastanza a lungo nel Nuovo Spazio, viene promosso a Vecchio Spazio Puntatore.
  • Vecchio spazio dati : contiene solo dati grezzi come stringhe, numeri in scatola e matrici di doppi non in scatola. Anche gli oggetti che sono sopravvissuti abbastanza a lungo a GC nel Nuovo Spazio vengono spostati qui.
  • Spazio per oggetti grandi : gli oggetti che sono troppo grandi per stare in altri spazi vengono creati in questo spazio. Ogni oggetto ha la propria regione in memoria di mmap
  • Spazio codice : contiene il codice assembly generato dal compilatore JIT.
  • Spazio cella, spazio cella proprietà, spazio mappa : questo spazio contiene Cell s, PropertyCell s e Map s. Questo è usato per semplificare la raccolta dei rifiuti.

Ogni spazio è composto da pagine. Una pagina è una regione di memoria allocata dal sistema operativo con mmap. Ogni pagina ha sempre una dimensione di 1 MB, ad eccezione delle pagine in uno spazio oggetti di grandi dimensioni.

V8 ha due meccanismi di raccolta dei rifiuti integrati: Scavenge, Mark-Sweep e Mark-Compact.

Scavenge è una tecnica di raccolta dei rifiuti molto veloce e opera con oggetti in New Space . Scavenge è l'implementazione dell'algoritmo di Cheney. L'idea è molto semplice, New Space è diviso in due semispazi uguali: To-Space e From-Space. Scavenge GC si verifica quando To-Space è pieno. Scambia semplicemente gli spazi To e From e copia tutti gli oggetti live in To-Space o li promuove in uno dei vecchi spazi se sono sopravvissuti a due scavenge, quindi viene completamente cancellato dallo spazio. Gli Scavenges sono molto veloci, tuttavia hanno il sovraccarico di mantenere un heap di dimensioni doppie e copiare costantemente gli oggetti in memoria. Il motivo per usare scavenge è perché la maggior parte degli oggetti muore giovane.

Mark-Sweep & Mark-Compact è un altro tipo di garbage collector utilizzato in V8. L'altro nome è Garbage Collector completo. Contrassegna tutti i nodi attivi, quindi spazza tutti i nodi morti e deframmenta la memoria.

Suggerimenti per le prestazioni e il debug di GC

Mentre per le applicazioni Web le prestazioni elevate potrebbero non essere un grosso problema, vorrai comunque evitare perdite a tutti i costi. Durante la fase di mark in full GC l'applicazione viene effettivamente sospesa fino al completamento della garbage collection. Ciò significa che più oggetti hai nell'heap, più tempo ci vorrà per eseguire GC e più a lungo gli utenti dovranno aspettare.

Dai sempre nomi a chiusure e funzioni

È molto più semplice ispezionare le tracce e gli heap dello stack quando tutte le chiusure e le funzioni hanno nomi.

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

Evitare oggetti di grandi dimensioni nelle funzioni calde

Idealmente si desidera evitare oggetti di grandi dimensioni all'interno delle funzioni calde in modo che tutti i dati rientrino in New Space . Tutte le operazioni relative alla CPU e alla memoria devono essere eseguite in background. Evita anche i trigger di deottimizzazione per le funzioni calde, le funzioni calde ottimizzate utilizzano meno memoria rispetto a quelle non ottimizzate.

Le funzioni calde dovrebbero essere ottimizzate

Le funzioni calde che vengono eseguite più velocemente ma consumano anche meno memoria fanno sì che GC venga eseguito meno spesso. V8 fornisce alcuni utili strumenti di debug per individuare funzioni non ottimizzate o funzioni deottimizzate.

Evita il polimorfismo per i circuiti integrati nelle funzioni calde

Le cache in linea (IC) vengono utilizzate per accelerare l'esecuzione di alcuni blocchi di codice, memorizzando nella cache l'accesso alla proprietà dell'oggetto obj.key o alcune semplici funzioni.

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

Quando x(a,b) viene eseguito per la prima volta, V8 crea un circuito integrato monomorfo. Quando si chiama x una seconda volta, V8 cancella il vecchio IC e crea un nuovo IC polimorfico che supporta entrambi i tipi di operandi intero e stringa. Quando chiami IC per la terza volta, V8 ripete la stessa procedura e crea un altro IC polimorfico di livello 3.

Tuttavia, c'è una limitazione. Dopo che il livello IC raggiunge 5 (può essere modificato con il flag –max_inlining_levels ) la funzione diventa megamorfica e non è più considerata ottimizzabile.

È intuitivamente comprensibile che le funzioni monomorfiche funzionino più velocemente e abbiano anche un footprint di memoria inferiore.

Non aggiungere file di grandi dimensioni alla memoria

Questo è ovvio e ben noto. Se hai file di grandi dimensioni da elaborare, ad esempio un file CSV di grandi dimensioni, leggilo riga per riga ed elaboralo in piccoli blocchi invece di caricare l'intero file in memoria. Ci sono casi piuttosto rari in cui una singola riga di csv sarebbe più grande di 1mb, permettendoti così di adattarla a New Space .

Non bloccare il thread del server principale

Se disponi di un'API calda che richiede del tempo per l'elaborazione, ad esempio un'API per ridimensionare le immagini, spostala in un thread separato o trasformala in un processo in background. Le operazioni ad alta intensità di CPU bloccherebbero il thread principale costringendo tutti gli altri clienti ad attendere e continuare a inviare richieste. I dati delle richieste non elaborati si accumulano in memoria, costringendo così il GC completo a richiedere più tempo per il completamento.

Non creare dati non necessari

Una volta ho avuto una strana esperienza con restify. Se invii alcune centinaia di migliaia di richieste a un URL non valido, la memoria dell'applicazione aumenterà rapidamente fino a centinaia di megabyte fino a quando un GC completo verrà avviato pochi secondi dopo, ovvero quando tutto tornerà alla normalità. Si scopre che per ogni URL non valido, restify genera un nuovo oggetto di errore che include lunghe tracce di stack. Ciò ha costretto gli oggetti appena creati ad essere allocati in Large Object Space anziché in New Space .

Avere accesso a tali dati potrebbe essere molto utile durante lo sviluppo, ma ovviamente non è necessario durante la produzione. Pertanto la regola è semplice: non generare dati a meno che non ne abbiate sicuramente bisogno.

Conosci i tuoi strumenti

Ultimo, ma non meno importante, è conoscere i propri strumenti. Esistono vari debugger, leak cather e generatori di grafici di utilizzo. Tutti questi strumenti possono aiutarti a rendere il tuo software più veloce ed efficiente.

Conclusione

Comprendere come funzionano la raccolta dei rifiuti e l'ottimizzatore del codice di V8 è una chiave per le prestazioni dell'applicazione. V8 compila JavaScript in assembly nativo e in alcuni casi un codice ben scritto potrebbe ottenere prestazioni paragonabili alle applicazioni compilate con GCC.

E nel caso ve lo stiate chiedendo, la nuova applicazione API per il mio client Toptal, anche se ci sono margini di miglioramento, sta funzionando molto bene!

Joyent ha recentemente rilasciato una nuova versione di Node.js che utilizza una delle ultime versioni di V8. Alcune applicazioni scritte per Node.js v0.12.x potrebbero non essere compatibili con la nuova versione v4.x. Tuttavia, le applicazioni sperimenteranno un enorme miglioramento delle prestazioni e dell'utilizzo della memoria all'interno della nuova versione di Node.js.