Skip to content

Profilatura delle Applicazioni Node.js

Esistono molti strumenti di terze parti disponibili per la profilatura delle applicazioni Node.js, ma in molti casi l'opzione più semplice è utilizzare il profiler integrato di Node.js. Il profiler integrato utilizza il profiler interno di V8 che campionaa lo stack a intervalli regolari durante l'esecuzione del programma. Registra i risultati di questi campioni, insieme a importanti eventi di ottimizzazione come le compilazioni jit, come una serie di tick:

bash
code-creation,LazyCompile,0,0x2d5000a337a0,396,"bp native array.js:1153:16",0x289f644df68,~
code-creation,LazyCompile,0,0x2d5000a33940,716,"hasOwnProperty native v8natives.js:198:30",0x289f64438d0,~
code-creation,LazyCompile,0,0x2d5000a33c20,284,"ToName native runtime.js:549:16",0x289f643bb28,~
code-creation,Stub,2,0x2d5000a33d40,182,"DoubleToIStub"
code-creation,Stub,2,0x2d5000a33e00,507,"NumberToStringStub"

In passato, era necessario il codice sorgente di V8 per poter interpretare i tick. Fortunatamente, da Node.js 4.4.0 sono stati introdotti strumenti che facilitano il consumo di queste informazioni senza dover compilare separatamente V8 dal sorgente. Vediamo come il profiler integrato può aiutare a fornire informazioni sulle prestazioni dell'applicazione.

Per illustrare l'utilizzo del profiler tick, lavoreremo con una semplice applicazione Express. La nostra applicazione avrà due handler, uno per aggiungere nuovi utenti al nostro sistema:

javascript
app.get('/newUser', (req, res) => {
  let username = req.query.username || ''
  const password = req.query.password || ''
  username = username.replace(/[!@#$%^&*]/g, '')
  if (!username || !password || users[username]) {
    return res.sendStatus(400)
  }
  const salt = crypto.randomBytes(128).toString('base64')
  const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512')
  users[username] = { salt, hash }
  res.sendStatus(200)
})

e un altro per la convalida dei tentativi di autenticazione utente:

javascript
app.get('/auth', (req, res) => {
  let username = req.query.username || ''
  const password = req.query.password || ''
  username = username.replace(/[!@#$%^&*]/g, '')
  if (!username || !password || !users[username]) {
    return res.sendStatus(400)
  }
  const { salt, hash } = users[username]
  const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512')
  if (crypto.timingSafeEqual(hash, encryptHash)) {
    res.sendStatus(200)
  } else {
    res.sendStatus(401)
  }
})

Si prega di notare che questi NON sono handler consigliati per l'autenticazione degli utenti nelle vostre applicazioni Node.js e sono utilizzati puramente a scopo illustrativo. In generale, non si dovrebbe cercare di progettare i propri meccanismi di autenticazione crittografica. È molto meglio utilizzare soluzioni di autenticazione esistenti e collaudate.

Ora supponiamo di aver distribuito la nostra applicazione e che gli utenti si lamentino dell'elevata latenza delle richieste. Possiamo facilmente eseguire l'applicazione con il profiler integrato:

bash
NODE_ENV=production node --prof app.js

e mettere un po' di carico sul server usando ab (ApacheBench):

bash
curl -X GET "http://localhost:8080/newUser?username=matt&password=password"
ab -k -c 20 -n 250 "http://localhost:8080/auth?username=matt&password=password"

e ottenere un output ab di:

bash
Concurrency Level:      20
Time taken for tests:   46.932 seconds
Complete requests:      250
Failed requests:        0
Keep-Alive requests:    250
Total transferred:      50250 bytes
HTML transferred:       500 bytes
Requests per second:    5.33 [#/sec] (mean)
Time per request:       3754.556 [ms] (mean)
Time per request:       187.728 [ms] (mean, across all concurrent requests)
Transfer rate:          1.05 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
  50%   3755
  66%   3804
  75%   3818
  80%   3825
  90%   3845
  95%   3858
  98%   3874
  99%   3875
 100%   4225 (longest request)

Da questo output, vediamo che riusciamo a servire solo circa 5 richieste al secondo e che la richiesta media richiede poco meno di 4 secondi di andata e ritorno. In un esempio reale, potremmo fare molto lavoro in molte funzioni per conto di una richiesta utente, ma anche nel nostro semplice esempio, il tempo potrebbe essere perso compilando espressioni regolari, generando sali casuali, generando hash univoci dalle password utente, o all'interno del framework Express stesso.

Dal momento che abbiamo eseguito la nostra applicazione usando l'opzione --prof, è stato generato un file tick nella stessa directory della tua esecuzione locale dell'applicazione. Dovrebbe avere la forma isolate-0xnnnnnnnnnnnn-v8.log (dove n è una cifra).

Per dare un senso a questo file, dobbiamo utilizzare l'elaboratore di tick incluso nel binario Node.js. Per eseguire l'elaboratore, utilizzare il flag --prof-process:

bash
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt

Aprire processed.txt nel tuo editor di testo preferito ti fornirà diversi tipi di informazioni. Il file è suddiviso in sezioni che a loro volta sono suddivise per linguaggio. Innanzitutto, guardiamo la sezione riepilogativa e vediamo:

bash
[Summary]:
   ticks  total  nonlib   name
     79    0.2%    0.2%  JavaScript
  36703   97.2%   99.2%  C++
      7    0.0%    0.0%  GC
    767    2.0%          Shared libraries
    215    0.6%          Unaccounted

Questo ci dice che il 97% di tutti i campioni raccolti si è verificato nel codice C++ e che, osservando altre sezioni dell'output elaborato, dovremmo prestare maggiore attenzione al lavoro svolto in C++ (al contrario di JavaScript). Con questo in mente, troviamo la sezione [C++] che contiene informazioni su quali funzioni C++ stanno richiedendo più tempo CPU e vediamo:

bash
 [C++]:
   ticks  total  nonlib   name
  19557   51.8%   52.9%  node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
   4510   11.9%   12.2%  _sha1_block_data_order
   3165    8.4%    8.6%  _malloc_zone_malloc

Vediamo che le prime 3 voci rappresentano il 72,1% del tempo CPU impiegato dal programma. Da questo output, vediamo immediatamente che almeno il 51,8% del tempo CPU è occupato da una funzione chiamata PBKDF2 che corrisponde alla nostra generazione di hash da una password utente. Tuttavia, potrebbe non essere immediatamente ovvio come le due voci inferiori incidano sulla nostra applicazione (o se lo è, faremo finta di no per il bene dell'esempio). Per comprendere meglio la relazione tra queste funzioni, esamineremo la sezione [Bottom up (heavy) profile] che fornisce informazioni sui chiamanti principali di ciascuna funzione. Esaminando questa sezione, troviamo:

bash
  ticks parent  name
  19557   51.8%  node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
  19557  100.0%    v8::internal::Builtins::~Builtins()
  19557  100.0%      LazyCompile: ~pbkdf2 crypto.js:557:16
   4510   11.9%  _sha1_block_data_order
   4510  100.0%    LazyCompile: *pbkdf2 crypto.js:557:16
   4510  100.0%      LazyCompile: *exports.pbkdf2Sync crypto.js:552:30
   3165    8.4%  _malloc_zone_malloc
   3161   99.9%    LazyCompile: *pbkdf2 crypto.js:557:16
   3161  100.0%      LazyCompile: *exports.pbkdf2Sync crypto.js:552:30

L'analisi di questa sezione richiede un po' più di lavoro rispetto ai conteggi grezzi dei tick sopra. All'interno di ciascuno degli "stack di chiamate" sopra, la percentuale nella colonna parent indica la percentuale di campioni per cui la funzione nella riga sopra è stata chiamata dalla funzione nella riga corrente. Ad esempio, nello "stack di chiamate" intermedio sopra per _sha1_block_data_order, vediamo che _sha1_block_data_order si è verificato nell'11,9% dei campioni, cosa che sapevamo dai conteggi grezzi sopra. Tuttavia, qui possiamo anche dire che è sempre stato chiamato dalla funzione pbkdf2 all'interno del modulo crypto di Node.js. Vediamo che, allo stesso modo, _malloc_zone_malloc è stato chiamato quasi esclusivamente dalla stessa funzione pbkdf2. Quindi, usando le informazioni in questa vista, possiamo dire che il nostro calcolo hash dalla password dell'utente rappresenta non solo il 51,8% di sopra, ma anche tutto il tempo CPU nelle prime 3 funzioni più campionate, poiché le chiamate a _sha1_block_data_order e _malloc_zone_malloc sono state effettuate per conto della funzione pbkdf2.

A questo punto, è molto chiaro che la generazione di hash basata sulla password dovrebbe essere l'obiettivo della nostra ottimizzazione. Per fortuna, hai pienamente interiorizzato i benefici della programmazione asincrona e ti rendi conto che il lavoro per generare un hash dalla password dell'utente viene svolto in modo sincrono e quindi lega il loop degli eventi. Questo ci impedisce di lavorare su altre richieste in arrivo durante il calcolo di un hash.

Per rimediare a questo problema, apportiamo una piccola modifica agli handler di cui sopra per utilizzare la versione asincrona della funzione pbkdf2:

javascript
app.get('/auth', (req, res) => {
  let username = req.query.username || ''
  const password = req.query.password || ''
  username = username.replace(/[!@#$%^&*]/g, '')
  if (!username || !password || !users[username]) {
    return res.sendStatus(400)
  }
  crypto.pbkdf2(password, users[username].salt, 10000, 512, 'sha512', (err, hash) => {
    if (users[username].hash.toString() === hash.toString()) {
      res.sendStatus(200)
    } else {
      res.sendStatus(401)
    }
  })
})

Una nuova esecuzione del benchmark ab di cui sopra con la versione asincrona della tua app produce:

bash
Concurrency Level:      20
Time taken for tests:   12.846 seconds
Complete requests:      250
Failed requests:        0
Keep-Alive requests:    250
Total transferred:      50250 bytes
HTML transferred:       500 bytes
Requests per second:    19.46 [#/sec] (mean)
Time per request:       1027.689 [ms] (mean)
Time per request:       51.384 [ms] (mean, across all concurrent requests)
Transfer rate:          3.82 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
  50%   1018
  66%   1035
  75%   1041
  80%   1043
  90%   1049
  95%   1063
  98%   1070
  99%   1071
 100%   1079 (longest request)

Evviva! La tua app ora serve circa 20 richieste al secondo, circa 4 volte di più rispetto alla generazione di hash sincrona. Inoltre, la latenza media è scesa dai 4 secondi precedenti a poco più di 1 secondo.

Si spera che, attraverso l'indagine sulle prestazioni di questo esempio (ammesso che sia artificioso), tu abbia visto come l'elaboratore di tick di V8 possa aiutarti a ottenere una migliore comprensione delle prestazioni delle tue applicazioni Node.js.

Potresti anche trovare utile come creare un flame graph.