Good practices for high-performance and scalable Node.js applications [Part 1/3]

Good practices for high-performance and scalable Node.js applications [Part 1/3]

Pubblicato da Marco Virgadamo il 12/01/2018 in Thinking

Questo articolo è disponibile in lingua inglese sul nostro canale Medium.

In questa serie di tre articoli saranno trattate alcune best practice riguardo lo sviluppo di applicativi back-end in Node.js.

Questa serie non sarà un tutorial base su Node, tutto ciò che leggerete è indirizzato a sviluppatori che possiedono già una buona familiarità con i concetti base di Node.js e sono alla ricerca di spunti su come poter migliorare lo sviluppo e l’architettura.

Gli argomenti chiave saranno l’efficienza e le performance, nell’ottica di poter ottenere il miglior risultato con la minor quantità di risorse.

Un metodo per migliorare la capacità di un’applicazione web è quello di istanziarla un certo numero di volte (scalare) distribuendo le richieste in ingresso tra le varie istanze.

In questo percorso, affronteremo insieme tre tematiche legate al linguaggio Node.js e nel particolare:

  1. Horizontally scaling a Node.js application: come scalare orizzontalmente un’applicazione Node.js, su diverse CPU o su diverse macchine;
  2. Common state and stateless authentication: quando si scala risulta necessario prestare attenzione a diversi aspetti della propria applicazione, come la memorizzazione dello stato o la gestione dell’autenticazione. Il secondo articolo tratterà di cosa è necessario considerare allo scalare dell’applicazione;
  3. Web / Worker pattern, queues and crons: oltre alle pratiche obbligatorie, esistono una serie di best practice che si possono seguire che saranno trattate nel terzo articolo, come ad esempio la divisione di processi api e worker, l’adozione di code di priorità, la gestione di task periodici che non devono essere eseguiti in parallelo su tutti i processi.

Scalare orizzontalmente un applicativo Node.js

Scalare orizzontalmente un’applicazione web consiste nell’adozione di più istanze al fine di poter gestire un numero maggiore di richieste in ingresso. Questa pratica può essere messa in atto sia su una singola macchina multi-core, sia su differenti macchine.

Scalare verticalmente significa invece potenziare le performance della singola macchina su cui si opera, e non dovrebbe richiedere particolari modifiche lato codice.

Più processi sulla stessa macchina

Una pratica comune per migliorare la capacità della propria applicazione su una macchina multi-core è la creazione di un processo per ogni cpu disponibile sulla macchina. In questo modo la già efficiente “gestione della concorrenza” delle richieste in Node-js (vedi “event driven, non-blocking I/O”) può essere moltiplicata e parallelizzata.

Probabilmente non è conveniente istanziare un numero di processi superiore al numero di core disponibili, in quanto a livello più basso il sistema operativo distribuirà comunque il tempo di cpu tra i processi in eccesso.

Dal punto di vista operativo ci sono differenti modalità per scalare su una singola macchina, ma il concetto comune è quello di avere diversi processi che condividono la porta in ascolto, con le richieste in ingresso distribuite nei momenti di carico tra i vari processi e quindi tra i vari core.

IQUII - NodeJS

Le modalità descritte di seguito sono la cluster mode nativa di Node.js e la modalità cluster di PM2, funzionalità automatica e più ad alto livello.

Cluster mode nativa

Il modulo cluster nativo di Node.js è la modalità più basilare per scalare su una singola macchina. Un’istanza del processo (chiamata “master”) è responsabile dello spawning degli altri processi figli (chiamati “workers”), uno per ogni core, i quali eseguono effettivamente l’applicativo. Le connessioni in ingresso vengono distribuite ciclicamente secondo il principio round-robin verso tutti i worker, che espongono il servizio condividendo la stessa porta.

Il principale svantaggio di questo approccio risiede nella necessità di gestire manualmente all’interno del codice la differenza tra processo master e worker, tipicamente con un classico blocco if-else, senza la possibilità di modificare dinamicamente il numero di processi eseguiti.

Questo seguente esempio è tratto dalla documentazione ufficiale:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`Master ${process.pid} is running`);

 // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

 cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });
} else {
  // Workers can share any TCP connection
  // In this case it is an HTTP server
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);

 console.log(`Worker ${process.pid} started`);
}

Modalità cluster di PM2

Se si utilizza PM2 come process manager (vivamente consigliato, specialmente in produzione), è disponibile una modalità cluster che permette di scalare la propria applicazione su tutti i core senza la necessità di preoccuparsi del modulo cluster nativo. Il demone PM2 coprirà il ruolo di processo “master”, lanciando a sua volta le copie richieste del processo worker della propria applicazione, con distribuzione del carico in modalità round-robin.

In questo modo basta scrivere la propria applicazione come se dovesse girare su un solo core (ad eccezione delle accortezze che vedremo nel prossimo articolo), e PM2 si occuperà del resto.

IQUII - Node.js - PM2

Una volta che l’applicazione è avviata in modalità cluster, è possibile modificare il numero di istanza dinamicamente tramite il comando “pm2 scale” ed eseguire riavvii senza downtime, dove i processi vengono riavviati in serie al fine di avere sempre un’istanza attiva per rispondere alle richieste.

In quanto process manager, PM2 si occuperà anche di riavviare i processi in caso di crash e di molte altre cose particolarmente utili nelle esecuzioni in produzione.

Nel caso ci sia bisogno di scalare ancora oltre, probabilmente è necessario istanziare più macchine.

Distribuzione del traffico su più macchine

Il concetto alla base dello scalare su più macchine è lo stesso applicato alla singola macchina, ossia la presenza di più macchine, ognuna delle quali esegue una o più istanze del processo, e di un balancer che distribuisce il traffico tra le varie macchine.

Una volta instradato il traffico verso una macchina, il balancer interno descritto nel precedente paragrafo si occupa di instradarlo a sua volta verso un particolare processo.

IQUII - Node.js

Un load balancer di rete può essere istanziato in diversi modi. Se la propria infrastruttura è ospitata su AWS, una buona scelta consiste nell’utilizzo di un load balancer gestito come un ELB (elastic load balancer), in quanto si integra con numerose funzionalità di AWS come l’auto-scaling ed è abbastanza facile da configurare.

Nel caso però si voglia procedere alla vecchia maniera, si può realizzare un balancer tramite NGINX su una propria macchina dedicata. Per la realizzazione è sufficiente configurare un reverse proxy che punti ad un upstream contenente l’elenco degli indirizzi delle proprie macchine. Di seguito un esempio di configurazione:

http {
    upstream myapp1 {
        server srv1.example.com;
        server srv2.example.com;
        server srv3.example.com;
    }

   server {
        listen 80;

       location / {
            proxy_pass http://myapp1;
        }
    }
}

In questa modalità il load balancer è l’unico punto di ingresso della propria applicazione esposto al mondo esterno. Nel caso si tema che questo possa rappresentare un potenziale punto debole della piattaforma, è possibile istanziare diversi load balancer che puntano agli stessi server.

Per distribuire il traffico in ingresso tra i diversi balancer (ognuno dei quali provvisto di un indirizzo ip pubblico diverso), è possibile aggiungere più record DNS di tipo “A” per lo stesso nome dominio esposto, ed il resolver DNS fornirà alternativamente i vari indirizzi distribuendo il traffico tra questi.

In questo modo è possibile ottenere ridondanza anche sui balancer.

IQUII - Node.js

Prossimi passi

Fino ad ora è stato illustrato come un’applicazione Node.js può scalare orizzontalmente su vari livelli al fine di sfruttare il più possibile le performance offerte dalla propria infrastruttura, partendo dal singolo nodo fino ad avere ridondanza su nodi e bilanciatori. Bisogna però prestare attenzione: per utilizzare la propria applicazione in un ambiente multi-processo, questa deve essere predisposta a tale utilizzo o si rischia di incorrere in diversi problemi e comportamenti indesiderati.

Nel prossimo articolo saranno analizzati i requisiti per rendere la propria applicazione pronta per scalare.


Marco Virgadamo

Full-Stack Developer @IQUII


Non perderti i prossimi post! Ricevili via mail per leggerli quando vuoi.

Dai un'occhiata all'archivio


Nel caso di commenti i dati verranno trattati come da informativa

ARTICOLI CORRELATI

di Daniele Margutti

Atlas: un approccio unificato al ciclo di sviluppo mobile in IQUII

Oggi Daniele Margutti, iOS dev, ci porta in un viaggio all’interno dei processi di sviluppo mobile in IQUII presentandoci Atlas.

in: Thinking

di Federico Meloni

Tackling iPhone X: impatti su progettazione, sviluppo e design

Dalla dimensione dello schermo alla gestione della Safe Area passando per il Notch, il nostro iOS developer ci racconta delle principali novità introdotte da Apple e gli impatti su progettazione, sviluppo e design.

in: Thinking

CONTATTACI SUBITO

IQUII S.r.l.Part of Be Group


Sede Legale: Viale dell'Esperanto 71 – 00144 Roma
Sede Operativa: Via Vincenzo Lamaro 13/15 Ed.U, Roma

P.iva 11289201003 - Cap.Soc. 10.000 €
Reg. Imprese di Roma REA n.1293642

Email. [email protected]
Tel. +39 06 72.15.125

Mobile Analytics

NEWSLETTER

Ricevi una volta al mese il nostro #ForwardThinking / Dai un'occhiata all'archivio