Quando si parla di incrementare le performance di un sito web o di un qualunque servizio, ci sono solo due opzioni: aumentare l’efficenza del codice o scalare l’intera infrastruttura server.
Quando si ha a che fare con migliaia e migliaia di contatti migliorare l’efficenza dell’applicativo non basta. A un certo punto scalare diventa necessario.

Quando si scala, lo si può fare in due maniere. Verticalmente oppure orizzontalmente. Lo scaling verticale si traduce in un incremento delle risorse del singolo server: CPU più veloce, più ram, più capienza. Lo scaling orizzontale invece si traduce nella diffusione del carico di lavoro fra più istanze del server su macchine separate. Lo scaling verticale può andar bene per alcuni scenari, ma quando il traffico aumenta e non si può aggiungere altra capacità al server lo scaling orizzontale rimane l’unica soluzione.

Un esempio di server in Node.js può essere questo:

var args = process.argv.splice(2);
var http = require('http');
 
function compute() {
 
    // funzione che effettua qualunque sorta di operazione sincrona
    // ...
 
}
 
// Creiamo un server base che risponde ad ogni richiesta con una data operazione
var server = http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type' : 'text/plain'});
    res.end('Result: ' + compute());
});
 
// Ascoltiamo sulla porta passata come argomento o di default la 8000
server.listen(args[0] || 8000);

Questo server, così com’è può processare una richiesta per volta, perchè la funzione compute() assumiamo esegua delle operazioni sincrone. Come possiamo dunque migliorare le performance?
La risposta è: avendo più server in esecuzione e distribuendo le richieste fra di essi.

Load Balancer

Per distribuire il carico dobbiamo creare un Load Balancer che si ponga davanti alle istanze dei nostri server e passi le richieste seguendo delle regole ben precise (ad esempio, con l’algoritmo round robin a turno ogni server riceverà una richiesta, giunti all’ultimo server si riprende l’assegnazione partendo dal primo).

Per questo esempio eseguiamo 3 server in locale, rispettivamente sulla porta 8001, 8002, 8003. Mentre il load balancer resterà in ascolto sulla porta 8000. Per gestire l’inoltro delle richieste e dunque realizzare un load balancer a tutti gli effetti, ci avvaliamo di questo pratico modulo, chiamato node-http-proxy. Sotto il codice per il load balancer:

var args = process.argv.splice(2);
var httpProxy = require('http-proxy');
 
// Indirizzi da usare nel Proxy Round-Robin
var addresses = [
    {
        host: 'localhost',
        port: 8001
    },
    {
        host: 'localhost',
        port: 8002
    },
    {
        host: 'localhost',
        port: 8003
    }
];
 
var i = 0;
httpProxy.createServer(function (req, res, proxy) {
    proxy.proxyRequest(req, res, addresses[i]);
 
    i = (i + 1) % addresses.length;
}).listen(args[0] || 8000);

Ora avviamo i 3 server e il load balancer:

$ node my-server.js 8001 &
$ node my-server.js 8002 &
$ node my-server.js 8003 &
$ node load-balancer.js 8000

Notare la “&” alla fine dei comandi, che fa si che il processo venga eseguito in background. Dunque per essere terminato andrà killato manualmente in seguito.

Arrivati a questo punto è possibile effettuare delle richieste individualmente ai server sulle porte 8001, 8002, 8003 oppure direttamente sulla porta 8000 e fare in modo che ogni richiesta venga bilanciata scegliendo a turno uno dei server presenti nel load balancer.

Misurare le performance

Per misurare le performance possiamo usare JMeter. Oppure loadtest che è sviluppato e disponibile come pacchetto Node, da installare globalmente:

$ npm install -g loadtest

e che può essere usalto facilmente da linea di comando in questo modo:

loadtest -c 10 --rps 200 http://mysite.com/

Il comando sopra effettua 200 richieste per secondo, di cui 10 concorrentemente e restituisce i tempi di risposta medi.
Distribuendo il carico fra più server, si è in grado di supportare un numero di richieste maggiori e drasticamente ridurre i tempi di risposta medi.

Ulteriori miglioramenti

Pur avendo migliorato drasticamente le performance generali dell’applicazione, la soluzione non è perfetta e lontana dalle best-practice comuni.
In particolare si noti come all’interno del load balancer gli indirizzi dei server siano staticamente imposti. Questo non è proprio l’ideale. Se volessimo aggiungerne uno, si dovrebbe scrivere all’interno dello script e riavviare il load balancer. E se un server andasse giù? anche in questo caso si dovrebbe manualmente rimuovere l’indirizzo e riavviare il load balancer.
Una soluzione migliore consiste nell’avere un load balancer che automaticamente sappia quali server sono attivi e in esecuzione e che possa gestire in autonomia l’aggiunta di nuovi server senza necessità di esser riavviato. Questo può essere ottenuto usando un registro di servizi che conosca gli host attivi, come ad esempio seaport, sfortunatamente e apparentemente non più mantenuto da substack.

Come funziona seaport?
In seaport ogni server viene definito come un “worker”. Quando un worker si avvia non fa altro che registrarsi presso il server su cui gira seaport (che può persino assegnargli una porta d’ascolto).
Per iniziare ad usare seaport è sufficente installarlo e avviarlo su una porta specifica:

$ sudo npm install -g seaport
$ seaport listen 9090 &

Ora possiamo modificare il codice del nostro server per far si che si registri al seaport durante l’avvio.

var http = require('http');
 
var seaport = require('seaport');
var ports = seaport.connect('localhost', 9090); // ci connettiamo al server seaport
 
 
function compute() {
 
    // funzione che effettua qualunque sorta di operazione
    // ...
 
}
 
// Server di base
var server = http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type' : 'text/plain'});
    res.end('Result: ' + compute());
});
 
// In ascolto su una porta specifica
server.listen(ports.register('my-server'));

Adesso anche il load balancer non ha più un numero prefissato di server su cui ciclare. Ciclerà bensì sul numero di worker visti da seaport.

var args = process.argv.splice(2);
var httpProxy = require('http-proxy');
 
var seaport = require('seaport');
var ports = seaport.connect('localhost', 9090);
 
// Indirizzi da usare nel Round Robin
var i = -1;
httpProxy.createServer(function (req, res, proxy) {
    var addresses = ports.query('my-server');
 
    // Se non ci sono workers, restituisci errore
    if (!addresses.length) {
        res.writeHead(503, {'Content-Type' : 'text/plain'});
        res.end('Service unavailable');
        return;
    }
 
    i = (i + 1) % addresses.length;
    proxy.proxyRequest(req, res, addresses[i]);
}).listen(args[0] || 8000);

Esecuzione

Assicuriamoci di aver chiuso ogni precedente istanza di server, altrimenti potremmo ricevere degli errori circa l’utilizzo ripetuto di porte.
Dopodichè possiamo proseguire lanciando direttamente il load balancer:

$ node load-balancer.js 8000

Navigando all’indirizzo del server riceveremo degli errori poichè nessun worker è disponibile.
Senza riavviare il load balancer avviamo un numero arbitrario di worker:

$ node my-server.js &
$ node my-server.js &
$ node my-server.js &

A questo punto, il balancer sulla porta 8000 inizierà a distribuire le richieste fra i workers disponibili.
Con un semplice comando possiamo lanciare nuovi worker e scalare piacevolmente la nostra web app.
L’esempio è stato realizzato in locale, l’idea è che uno o più worker possano comunque girare su macchine differenti.

Altri accorgimenti

Per utilizzare un unico “stack” produttivo abbiamo usato esclusivamente Node.js, sia per il load balancer che per i server.
Tuttavia esistono altri proxy server altrettanto robusti e molto usati, come nginx e HAProxy. L’unico problema che si può dover risolvere è quello di mantenerli aggiornati con i workers disponibili, come abbiam già visto.
Inoltre un ulteriore se non OBBLIGATORIO miglioramento è quello di supportare le architetture multi-core. Quel che pochi sanno è che Node.js è single-thread, quindi di default questo thread gira su 1 singolo core della nostra macchina. Usando il modulo cluster è possibile dunque sfruttare appieno la capacità di calcolo della CPU e tutti i suoi core.