6 min read

Applicazioni web scalabili e alte prestazioni, le good practices - Parte 1

Giuseppe Mariniello

Al giorno d’oggi come utenti ci aspettiamo di poter accedere ad un siti web in maniera fulminea, e soprattutto ci aspettiamo che questo sia sempre disponibile in qualsiasi momento lo desideriamo. Se ribaltiamo questa esigenza dal punto di vista dell’azienda, del nostro cliente, non si parla più di un nice to have ma di un vero e proprio requisito di business: non è insolito infatti che vengano concordati degli SLA di servizio con annesse penali, per cui garantire performance e disponibilità è un obiettivo non certo secondario.

Per fortuna non siamo soli in questa sfida ingegneristica: qualunque sia il nostro fornitore di servizi cloud (AWS, GCloud, Azure) troveremo la giusta soluzione a livello infrastrutturale su cui ospitare il nostro software. A tal proposito il team di Devops saprà sicuramente consigliarvi nel migliore dei modi, tuttavia il nostro software dovrà contenere tutta una serie di accorgimenti senza i quali non potremo certo poter asserire di aver raggiunto l’obiettivo.

Quando si decide di progettare un’applicativo web scalabile, sono diversi gli aspetti da tenere in considerazione: dalla gestione dello stato (database, storage di file, ecc…) all’autenticazione, gestione delle sessioni, processi pianificati e via discorrendo.

Nella serie di articoli di cui questo articolo è l’apripista, andremo quindi ad analizzare gli aspetti da tenere in considerazione con applicativi Node.JS, seppure sono concetti validi ed estendibili anche su altre tecnologie.

Scalabilità verticale ed orizzontale

Prima o poi potremmo incappare nel raggiungimento del numero massimo di richieste che il nostro applicativo potrà gestire. Cosa fare però se il traffico da gestire è superiore? Semplice: possiamo aumentare le specifiche computazionali del nostro server, oppure potremmo provare ad aggiungere nuove istanze della nostra applicazione.

Scalare verticalmente è sicuramente la soluzione più semplice, ma potrebbe mostrare ugualmente i propri limiti nel tempo. Nel caso di Node.JS poi, ci sono ulteriori accorgimenti da seguire.

Data la natura sua natura single-thread, ed il limite di default di 1.76GB di memoria allocabile da singolo processo, seppur disponessimo di una macchina con 8 core e 32 GB di RAM non la staremmo utilizzando al massimo delle sue potenzialità. Ma creando copie (istanze) multiple del nostro processo riusciamo a sfruttare tutta la potenza elaborativa e aumentare di conseguenza il throughput della nostra applicazione.

Native Cluster vs PM2 Clusters

Per sfruttare tutti i vantaggi di un un sistema multi-core, possiamo creare un “cluster” di processi sfruttando l’omonimo modulo cluster di Node.JS. L’esempio più semplice possibile ce lo offre proprio la documentazione ufficiale.

const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster) { for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`worker ${worker.process.pid} died`); }); } else { http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n'); }).listen(8000); }

Come è possibile notare dal codice, un’istanza del nostro processo - chiamata “master” - ha la responsabilità di lanciare del processi figli - chiamati “workers” - e ricrearli nel caso in cui terminino in maniera inattesa a causa di un’eccezione non gestita.

Per quanto efficace, non si tratta forse del sistema più semplice da gestire e manutenere. Se lavorate con Node.JS già da un po’ di tempo, sicuramente avrete già sentito parlare di PM2. Si tratta di un process manager in grado di occuparsi di gestire e riavviare i processi in caso di crash, ma soprattutto ci permette di avviare un processo in modalità cluster.

PM2 assumerà il ruolo di master e distribuirà il traffico verso i worker con un bilanciamento di tipo round-robin. Inoltre in caso di deploy di nuove versioni, sarà possibile riavviare in maniera progressiva tutti i worker, assicurando un rilascio a zero-downtime. I nostri utenti apprezzeranno!

Configurazione con macchine multiple

Se le richieste di traffico superano quanto la nostra singola macchina può garantire, o se vogliamo (o dobbiamo!) garantire ridondanza del sistema, oltre a quanto visto nel paragrafo precedente, dovremmo valutare la possibilità di scalare su macchine multiple, ognuna con una o più istanze dei nostri processi, con un balancer che redirige il traffico di rete verso la singola macchina. La volta giunta sulla singola macchina, sarà il balancer interno a ridirigere verso il processo designato.

Per il balancer ci sono molteplici possibilità, come ad esempio l’utilizzo di una macchina con sopra NGINX o HAProxy, l’uso di soluzioni gestite come ELB se state utilizzando AWS.

Proveremo ad approfondire il tema in articoli successivi, tuttavia colgo l’occasione per introdurre un concetto fondamentale, tanto che si decida di scalare verticalmente oppure orizzontalmente.

La gestione dello stato

Per funzionare correttamente, la nostra applicazione ha sicuramente bisogno di dati e di poterli elaborare e mantenerli nel tempo. Immaginiamo ad esempio le informazioni di sessione relative ad un utente loggato sul nostro sistema.

La soluzione più ovvia sarebbe salvare tali informazioni in una variabile globale o nella porzione di memoria assegnata al nostro processo: bello, funziona, è semplice ma…. cosa succede se il nostro processo termina in maniera inattesa? Cosa succede se abbiamo più processi attivi in parallelo? Il rischio di comportamenti inattesi è elevatissimo, e non possiamo permetterci malfunzionamenti di questo tipo.

Come possiamo fare quindi? Il primo passo è rendere la nostra applicazione stateless, in modo da separare in maniera netta la parte applicativa e quella relativa dei dati.

Prendiamo l’esempio delle sessioni di autenticazione e le possibilità che abbiamo per la loro gestione in una architettura distribuita:

  • sessioni in file su storage condiviso
  • sessioni su database (MySQL, PostgreSQL, Mongodb)
  • sessioni su memoria condivisa (Redis, Memcached)
  • sessioni stateless con JWT

Se la prima opzione è sicuramente quella meno preferibile, utilizzare un database o meglio ancora una memoria condivisa chiave-valore come Redis garantisce prestazioni migliori, seppure permane la necessità di interrogare un servizio esterno: se la nostra applicazione deve verificare i dati di sessione ad ogni richiesta, dal punto di vista delle pure performance è sicuramente penalizzante.

Autenticazione stateless con JWT

Un approccio sicuramente semplice ed efficace all’autenticazione stateless è offerto da JSON Web Token. L’idea dietro JWT è tanto semplice quanto funzionale. Quando un utente si logga, il sistema genera un token che dovrà poi essere utilizzato dal client per autenticare le successive richieste che farà verso il nostro server.

In token JWT, inserito in un apposito header della nostra richiesta, è costituito da un oggetto JSON codificato in Base64 contenente un payload arbitrario (come l’user id o il ruolo adesso assegnato) più la firma di tale payload ottenuta calcolando l’hash con un segreto a disposizione del solo server.

Quando arriva una nuova richiesta, il nostro server estrapola il payload, ne ricalcola l’hash e verifica che le firme coincidano, garantendo che il token non è stato alterato. Una volta decodificato, le informazioni possono essere utilizzate senza la necessità di I/O, a tutto beneficio delle performance e della scalabilità.

Attenzione però! Il payload del nostro JWT non ha subito nessuna forma di encryption, pertanto è fondamentale che lo scambio dei dati avvenga su un canale cifrato come HTTPS. Inoltre è buona norma non inserire nessuna informazione sensibile al suo interno.

Ed ora?

Con questo articolo abbiamo coperto i primissi aspetti da tenere a mente quando si intende progettare applicativi che siano scalabili, ridondanti e performanti. Non sono certo finiti, anzi.. nei prossimi articoli cercheremo di capire insieme come gestire le configurazioni, l’accesso ai nostri database e come tenere alte le prestazioni, oltre a tutta una serie di accorgimenti che vi renderanno la vita facile. Una volta sedimentati tutti questi concetti parleremo anche di micro-servizi, quindi rimanete sintonizzati!