NGINX: l’evoluzione dei web server

NGINX è un software open source ideato da Igor Sysoev, che nasce come web server con lo scopo di garantire le massime performance e la massima stabilità tramite una sofisticata architettura event-driven. Oggi viene usato anche per operazioni di reverse proxy, caching, load balancing – rendendo obsoleti le controparti hardware – e molto altro.

Un passo indietro, il problema C10k

Il termine C10k è stato coniato nel 1999 da Dan Kegel per riferirsi alla incapacità di un server di scalare oltre le 10.000 (10k) connessioni concorrenti (C) a causa delle limitazioni fisiche dell’hardware, come ad esempio la quantità limitata di RAM da poter allocare o la velocità delle operazioni I/O.

Ad oggi il problema C10k in sé è stato superato, per essere sostituito dal C10m dove il limite è posto a 1.000.000 connessioni simultanee, dimostrando come in un ambiente web la concorrenza continui ad essere di primaria importanza.

NGINX nasce in questo contesto, con lo scopo di risolvere le problematiche relative alle connessioni concorrenti.

La risposta di NGINX

I web server “tradizionali” presentano una criticità, ovvero utilizzano un modello per il quale ogni richiesta viene trattata con un nuovo processo – o thread – e bloccando le operazioni di I/O fino al loro compimento. Tale modello può, in base al tipo di applicazione, risultare inefficiente, sia dal punto di vista del consumo della memoria sia della CPU.

Per illustrare bene il problema possiamo immaginare un web server tradizionale che serve una risposta generica di 100 KB ad un client relativamente lento, con una banda di 80 kbps (~10 KB/s). In questo caso il server sarebbe relativamente rapido nel fornire la risposta, ma poi dovrebbe attendere ~10 secondi per completare l’invio al client e liberare la connessione. Moltiplicate per 1.000 connessioni simultanee, e avrete un quadro dell’inefficienza di questo approccio.

Un web server tradizionale, come Apache ad esempio, deve anche allocare della memoria per ogni client. Arrotondando per difetto ad 1 MB, si parlerebbe dunque di 1000 MB (~1 GB) per servire a 1000 client una risposta da 100 KB come quella dell’esempio sopra.

L’evoluzione della comunicazione mobile e delle connessioni persistenti ha esacerbato ancora di più il problema, poiché per evitare la latenza dovuta allo stabilire nuove connessioni HTTP i client restano connessi al server, senza che sia possibile deallocare memoria.

La soluzione proposta da NGINX è un’architettura event-driven, asincrona, single-threaded e non-blocking, che permette di mantenere al minimo l’utilizzo delle risorse senza dover creare un nuovo thread per ogni richiesta garantendo una scalabilità non lineare sia sul numero di connessioni simultanee che per richieste al secondo.

L’Architettura

NGINX è stato pensato per essere uno strumento specializzato per raggiungere il massimo delle performance e massimizzare l’economia delle risorse permettendo comunque una crescita e scalabilità costante a chi lo utilizza.

L’architettura affonda le proprie radici in un connubio tra multiplex, notifiche di eventi e task specifici per separare i vari processi, permettendo di gestire migliaia di richieste tramite un numero limitato di processi single-thread.

NGINX si divide in quattro processi principali: Master, Workers, Cache Loader e Cache Manager. Tutti i processi sono single-thread e comunicano tra di loro tramite una memoria condivisa.

Master

È il processo principale che si occupa di creare e orchestrare tutti gli altri, ed è responsabile della lettura e validazione della configurazione, creazione, binding e chiusura dei socket, gestione dei processi Worker, riconfigurazione senza interruzioni del servizio, e altro.

Cache Loader

È il processo che gestisce il caricamento in memoria della cache disk-based, popolando il database in memoria con metadati e la preparazione dell’istanza di NGINX per lavorare con file storicizzati sul disco in una determinata struttura di directory.

Quando ha completato il proprio compito, termina la propria esecuzione mantenendo al minimo le quantità di risorse necessarie.

Cache Manager

Processo che viene eseguito periodicamente, e si occupa di pulire la cache affinché resti all’interno dei limiti di grandezza specificata in configurazione. In caso di fallimento il Master si occupa di riavviarlo.

Workers

Sono i processi che contengono il core e moduli principali per il funzionamento di NGINX, sono single-thread e indipendenti tra di loro. Vengono creati all’avvio dal Master e si mettono in ascolto per eventuali eventi, scatenati da connessioni in ingresso, su una determinata lista di socket.

Responsabili del mantenimento del run-loop e dell’esecuzione dei moduli appropriati all’interno del ciclo vitale di ogni richiesta.

Il run-loop a cui abbiamo più volte accennato è la parte più complessa dei processi Worker, il cui principio base è di essere meno bloccante possibile. Per raggiungere questo obiettivo si affida principalmente all’idea di task asincroni, implementati tramite la modularità, notifiche di eventi, callback functions e timer; tuttavia è possibile che alcune operazioni siano comunque bloccanti, ad esempio nel caso in cui non ci sia più spazio a disposizione sul disco.

Questo approccio permette un utilizzo di memoria conservativo ed efficiente. Non solo, anche sul fronte della CPU si risparmiano cicli, data l’assenza di una continua creazione e distruzione di processi o thread.

Per ottimizzare e massimizzare l’utilizzo di architettura multicore, generalmente viene creato un processo Worker per ogni core della CPU, soprattutto nel caso in cui ci sia un utilizzo intenso del processore (come, ad esempio, gestire molte connessioni TCP). Non è però vero in altre circostanze, dove si potrebbe preferire un numero 1,5 / 2 volte maggiore di Worker per core, qualora le operazioni più intensive fossero di I/O, dove potrebbe essere necessario servire differenti storage.

Il diavolo sta nell’implementazione della State Machine

Ogni connessione viene associata ad una state machine, ovvero un set di istruzioni per processare una determinata richiesta. NGINX supporta HTTP, TCP e protocolli di mail come SMTP, IMAP e POP3.

Chiaramente anche gli altri web server fanno uso di una state machine analoga a quella di NGINX, ciò che fa la differenza è la sua implementazione.

Blocking State Machine

Si tratta dell’implementazione che utilizzano i web server tradizionali e di cui abbiamo già accennato le caratteristiche, e criticità, nel capitolo precedente. Possiamo però utilizzare questa infografica per andare un po’ più nel dettaglio.

  1. Il web server resta in attesa di nuove connessioni sui listen socket;
  2. Quando arriva una nuova richiesta il web server la elabora e resta in attesa di una risposta da parte del client;
  3. Il server mantiene la connessione aperta in attesa del client. Quando la connessione viene chiusa, o per azione del client o quando si verifica un timeout, il server torna in ascolto per nuove richieste;

In questo caso ogni connessione necessita di un nuovo thread dedicato per gestire la richiesta.

Un’implementazione come la Blocking State Machine può avere dei vantaggi, come la possibilità di estendere facilmente l’architettura con moduli di terze parti; tuttavia ciò è comunque pesantemente soppesato dallo spreco delle risorse di questo approccio.

L’implementazione di NGINX

Come abbiamo già accennato prima i processi Worker di NGINX possono gestire più richieste simultaneamente.

  1. Il Worker resta in attesa di un evento sui listen e connection socket;
  2. Viene scatenato un evento e il Worker lo gestisce in base al socket di provenienza:
    1. Un evento sul listen socket corrisponde ad una nuova richiesta da parte del client, per il quale il Worker dovrà creare una nuova connection socket, aggiungendola al run-loop;
    2. Un evento sul connection socket corrisponde ad una nuova risposta da parte del client, al quale il Worker può rispondere.
  3. Quando la richiesta viene soddisfatta la connessione viene deallocata e rimossa dal run-loop;

In questo caso il nostro single-thread Worker non resta mai fermo in attesa di una risposta, potendosi così occupare di più richieste.

Caching overview

Il sistema di caching è implementato nella forma di uno storage gerarchico, configurabile, sul filesystem. Le chiavi della cache sono configurabili e determinati parametri nelle richieste possono essere usati per controllare ciò che deve essere inserito nella cache. Sia le chiavi che i metadati sono storicizzati nella memoria condivisa accessibile da tutti i processi di NGINX.

Ogni elemento della cache è posto in un file differente sul filesystem, il cui nome e percorso vengono derivati dall’hash MD5 dell’url del proxy.

Non è presente nessuna metodologia di ottimizzazione dei file oltre a quella operata dal sistema operativo.

Un bignami di Configurazione

Abbiamo accennato più volte alla possibilità di configurare determinati parametri e a come NGINX lavori andando a cercare una configurazione in base ad una determinata struttura di directory sul filesystem.

Questo si traduce in un file principale chiamato nginx.conf il quale, tipicamente, risiede in /etc/nginx.

La configurazione è composta da diversi contesti: main, http, server, upstream, location e mail. Questi blocchi di direttive non si sovrappongono, e per evitare ambiguità non esiste niente come una configurazione globale.

Esempio

Qui sotto un piccolo esempio di configurazione.

Nel contesto main viene specificato l’attributo worker_process auto, il quale demanda ad NGINX la responsabilità di gestire il numero di processi Worker secondo le regole che abbiamo visto.

Nel contesto http si occupa di configurare le richieste HTTP/HTTPS; in questo caso non c’è nessuna configurazione specifica generale per le richieste HTTP.

Nel contesto server viene messo in ascolto tramite la direttiva listen sulla porta 80, e tramite la direttiva server_name viene specificato l’Host header per il quale gestire le richieste.

Nel contesto location, infine, si utilizza la direttiva proxy_pass per definire l’indirizzo su cui mappare le eventuali richieste.

In questo caso, quindi, tutte le richieste effettuate a giuneco.com o www.giuneco.com sulla porta 80 vengono automaticamente indirizzate verso l’ip 127.0.0.1.

Modularità, estendere senza modificare

Come abbiamo accennato, NGINX è costituito da dei moduli orchestrati dai processi Worker.

Ogni modulo costituisce una parte di funzionalità del layer applicativo e di presentazione, occupandosi quindi delle operazioni di I/O, network, trasformazione dei contenuti, outbound filtering e del passaggio delle richieste agli upstream server quando il reverse proxy è attivo.

Con un approccio modulare viene data la possibilità di poter estendere NGINX, senza modificare il core del codice, con funzionalità di terze parti o scritte in casa per soddisfare le proprie esigenze.

Uno sguardo all’utilizzo

Nel 2019 NGINX è stata acquisita da F5 Networks per 670 milioni di dollari, i quali riportano l’utilizzo da parte di 375 milioni di siti.

Tra i Web Server #NGINX risulta essere tra i piu' usati, il cui utilizzo e' in costante crescita. Qual e' la sua architettura?

Nonostante Apache continui ad essere, in termini assoluti, il web server più utilizzato, si può notare come NGINX si sia creato una notevole fetta di mercato tra i siti di fascia più alta, diventando a tutti gli effetti uno standard nel mercato.

Crediti elementi immagine Designed by vectorpouch / Freepik Designed by kjpargeter / Freepik