
di St-Pete
Ciao a tutti! Sono Mons Anderson, architetto della piattaforma , racconterò come abbiamo costruito il nostro storage S3, come funziona, quali soluzioni si sono rivelate efficaci e quali avremmo dovuto cambiare se avessimo iniziato lo stesso progetto da zero ora.
Questo articolo è stato preparato sulla base di una presentazione a di Mail.ru Cloud Solutions & Tarantool. In questo articolo parleremo di:
- come era strutturato lo storage di Mail.ru, su cui abbiamo costruito lo storage S3;
- cosa abbiamo aggiunto per creare Mail.ru Cloud Storage;
- come funziona il modello di storage a oggetti e quali passi sono stati compiuti per il passaggio in produzione;
- sulle modifiche al sistema operativo: failover e scalabilità;
- come abbiamo implementato lo sharding e la resharing;
- e anche sul lavoro con i certificati SSL.
Se non vuoi leggere, puoi .
Come era strutturato lo storage di Mail.ru, su cui abbiamo costruito lo storage S3
Lo sviluppo del nostro S3 è iniziato sopra lo storage di Mail.ru Cloud, quindi è importante raccontare come è strutturato e cosa può fare.
Lo storage cloud di Mail.ru è composto da server con dischi. In media, un server di storage moderno ha 36 dischi da 12–14 terabyte. Prima i dischi erano più piccoli, ma in tre anni la capacità dei dischi è aumentata e oggi siamo quasi a mezzo petabyte di dati grezzi.
I dischi di diversi server di storage vengono uniti in quelle che chiamiamo "coppie" (pair). Una coppia è una unità di archiviazione dei file. In sostanza, è un disco montato in una certa partizione a un certo percorso, dove possono risiedere file identificati tramite hash.
Coppia è un nome storico, rimasto fino ad oggi, anche se ora in una coppia non ci devono essere necessariamente solo due dischi. Possono essercene tre, e possono esserci anche varie forme di storage ibride, come 3/2.

Coppie (pair) — unità di archiviazione degli oggetti
Tutte le coppie sono memorizzate in PairDB — un'applicazione basata su Tarantool. Tutte le banche dati nel nostro storage, a partire dalle prime, sono Tarantool, non usiamo altre banche dati.
PairDB memorizza tutte le coppie, i loro stati, lo spazio libero, le possibilità di failover, gli ultimi errori. Può anche controllare autonomamente le coppie, aggiornare il loro stato, verificare se funzionano o meno. Quindi PairDB è una sorta di snapshot generale dello stato di tutti i dischi del nostro sistema.

Pair DB: database con lo stato delle coppie
Nei corsi sono archiviati dei file e, per sapere quale file si trova in quale corso, è necessaria un'altra base — FileDB. Essa memorizza il mapping, la definizione di corrispondenza: questo file si trova in questo corso, oltre a una piccola quantità di attributi necessari.
File DB: luogo in cui è archiviato il file
Un altro anello importante è il servizio Nylon, un router per lavorare con i database. È un unico punto di accesso, permette di lavorare attraverso un'interfaccia unica sia con PairDB che con FileDB. Questo è un servizio stateless, esegue il bilanciamento delle richieste, comprende a quale shard di FileDB è necessario andare, sa quali corsi sono attivi e quali no.

Nylon: router per lavorare con i database
Inoltre, è necessario in qualche modo caricare i contenuti nell'archivio. A questo scopo esiste il servizio — Streamer. Esso fornisce due metodi HTTP: il metodo PUT per caricare contenuti nell'archivio e il metodo GET per prelevarli da lì. HTTP è un protocollo piuttosto popolare e comodo per la trasmissione dei dati.
Quando ci rivolgiamo a Streamer, esso, attraverso Nylon, si rivolge a PairDB, scopre a quale corso è possibile caricare il file e poi trasmette i dati tramite WebDAV a quel corso.
In sostanza, qualsiasi server di storage è un nginx più i dischi montati secondo i percorsi specificati. Possiamo caricare un file nell'archivio da Streamer, eliminarlo, rinominarlo o verificarne l'integrità. Quindi, è un'interfaccia comoda per l'interazione a basso livello con l'archivio.

Streamer: punto di accesso all'archivio
Cosa abbiamo aggiunto per realizzare l'archivio S3
Quindi, abbiamo esaminato la struttura di base dell'archivio al momento in cui ci siamo preparati a lanciare l'archivio S3. Con il metodo PUT potevamo caricare contenuti arbitrari e ottenere come identificatore di quei dati un hash. Con questo identificatore si poteva successivamente andare a prelevare il file originale. Ma questo non è sufficiente per implementare S3. Nel protocollo S3, oltre al semplice stoccaggio degli oggetti, ci sono:
- stoccaggio dei metadati — proprietà aggiuntive degli oggetti;
- organizzazione dell'accesso agli oggetti tramite HTTP;
- raggruppamento degli oggetti in collezioni — bucket;
- HTTP-S3 Endpoint. S3 organizza i dati in strutture specifiche — bucket, ognuno dei quali fornisce un punto di accesso per l'archiviazione dei file.
Per implementare questa logica era necessario un servizio separato. Volevamo anche prevedere subito un'architettura per una futura crescita del servizio con scalabilità lineare.
I primi componenti
Un demone che implementa l'API S3. Questo è il standard S3 API di Amazon, che supporta l'elaborazione XML per i metadati e consente di trasmettere contenuti direttamente. Non abbiamo dovuto inventare nulla, tutto è descritto e documentato.
Davanti al servizio abbiamo anche installato Nginx. L'abbiamo utilizzato per la terminazione SSL, il bilanciamento del carico, e per alcune logiche in Lua (metriche, logging e tracciamento).
Per memorizzare i metadati S3 abbiamo scelto Tarantool. Nella prima versione, il demone S3 accedeva a questo database per i metadati, conservando il contenuto in un grande deposito tramite Streamer.

Nginx + API S3 + metadati
Modello oggetto per la memorizzazione
Vediamo come funziona S3. L'utente può creare un bucket — una raccolta di oggetti. Il bucket è indirizzato con il nome dell'host e costituisce un sottodominio del servizio. All'interno del bucket, l'utente può creare oggetti. L'identificatore dell'oggetto sarà l'URL. Il contenuto dell'oggetto è un blob, un array di dati binari che memorizzeremo nel deposito. Inoltre, l'oggetto ha attributi: il nome — appunto l'URL, ACL (lista di controllo degli accessi), altri attributi aggiuntivi o arbitrari — tutto questo è salvato nei metadati.
Uno schema normalizzato di questi dati può apparire così: ci sono progetti cui appartengono i bucket, a cui appartengono gli oggetti, e gli oggetti possono essere composti. Poiché uno dei modi per caricare un oggetto è a pezzi, ci sono due tabelle ausiliarie per il caricamento: uploads e chunks. Inoltre, i progetti hanno credenziali di accesso e billing.

Schema dei dati
Poiché stavamo creando un servizio b2b con accesso a pagamento, era necessario il billing in questo schema.
Il servizio di billing è stato implementato anche su Tarantool.

Modifiche allo storage S3: passi verso la produzione
Abbiamo già realizzato un modello funzionante che può essere utilizzato: gli oggetti e i metadati erano memorizzati, ma per il rilascio in produzione mancavano alcuni aspetti.
In primo luogo, il sistema di rate limiting. Se avviassimo il servizio senza di esso, durante i picchi di carico potremmo sovraccaricare in modo imprevedibile una parte del sistema. Il rate limit dovrebbe funzionare in questo modo: ogni richiesta S3 arriva a un host specifico, questo host è l'identificatore del bucket e il bucket appartiene a un determinato cliente. Dobbiamo definire una funzione per il bucket che consenta di calcolare il rate limit.
Inoltre, il sistema di rate limiting deve essere abbastanza performante da gestire il carico che arriva su S3.
Qui abbiamo nuovamente utilizzato Tarantool. I rate limit sono un cluster di 21 istanze, suddivise in gruppi, distribuite su tre nodi fisici e unite in un grande cluster topologico. Le modifiche di configurazione vengono propagate automaticamente: vengono impostati i rate limit, i valori predefiniti e la configurazione. Ogni bucket è servito da una sola istanza. Quando arriva una richiesta per un determinato bucket, viene calcolata l'istanza responsabile di quel bucket. All'interno di questo nodo, viene effettuato il conteggio del rate attuale delle richieste attraverso un algoritmo simile al Token Bucket. Successivamente, il sistema di rate limiting, in base ai dati attuali di carico e alle caratteristiche impostate per quel bucket specifico, determina se la richiesta può essere eseguita o meno. Il controllo dei limiti viene eseguito alla fase più iniziale dell'elaborazione della richiesta S3, proteggendo tutti gli altri elementi del sistema da sovraload.

Inoltre, sotto carico è piuttosto difficile fare a meno della cache. In S3 si prevede l'accesso ripetuto agli stessi oggetti, quindi si tratta di uno storage a caldo. Di norma, l'accesso a un file singolo è gestito dall'intera catena: Streamer, FileDB, PairDB, Storage. Tuttavia, nel caso di accessi ripetuti a un file, ottimizziamo l'accesso a questo contenuto utilizzando una cache locale.
La cache è multilivello e realizzata con nginx, dischi locali, SSD e RAM. Non abbiamo utilizzato Tarantool qui, poiché è più comodo servire gli oggetti dal filesystem, consentendo di fare tiering della cache. Inoltre, abbiamo oggetti di grandi dimensioni con una dimensione massima di 32 gigabyte, mentre in Tarantool è possibile memorizzare solo oggetti di piccole dimensioni.

Questo primo sistema con cui abbiamo avviato aveva una certa capacità calcolata, sufficiente per l'indagine, la comprensione del prodotto e per verificare che funzionasse.
Miglioramenti del sistema di combattimento: failover e scalabilità
Il sistema era già in esercizio, ma all'avvio abbiamo trascurato qualcosa: dovevamo aggiungere il failover e la scalabilità.
Il nostro demone S3 raccoglieva metadati tramite il protocollo Tarantool. Al posto del database originale, abbiamo implementato Tarantool, che fungeva da router proxy per le richieste di metadati. Dal punto di vista dell'applicazione che implementa l'API, nulla è cambiato: continuava a comunicare con il database tramite il protocollo Tarantool, ma il router poteva garantire un failover attivo. Ciò significava che potevamo verificare la disponibilità dei nodi, mantenere una pausa durante il passaggio e le interruzioni, e così via. Allo stesso tempo, non abbiamo modificato l'applicazione stessa.

Maggiore dettagli su come abbiamo implementato il partizionamento
La prossima questione che ci siamo posti era il partizionamento. Il sistema cresceva, aumentava il numero di oggetti e dovevamo garantire possibilità per una ulteriore crescita.
Torniamo allo schema dei dati: ci sono progetti, ci sono bucket, credenziali e fatturazione. Questi sono oggetti che con grande probabilità nel prossimo futuro non cresceranno oltre un'istanza, né per volume né per richieste. Pertanto, non ha senso partizionarli, e li abbiamo spostati in un'istanza separata che rimarrà non partizionata. Questo consente una gestione più coerente dei progetti e dei bucket, poiché esiste un'unica istanza non partizionata.

Ci sono anche oggetti nello schema che crescono linearmente: inizialmente erano centinaia di migliaia, ora il loro numero si misura in miliardi. Tali oggetti, insieme alle loro parti, dovevano essere spostati in un cluster partizionato.

Abbiamo separato lo schema, ma gli oggetti devono lavorare con i bucket: ogni oggetto appartiene sempre a un bucket specifico, e su ogni bucket opera un ACL. Pertanto, per ciascun shard con oggetti, manteniamo una copia secondaria di ogni bucket. Inoltre, durante la modifica degli oggetti e l'esecuzione delle richieste, è necessario calcolare il volume per la fatturazione, quindi in ogni shard ci sono contatori per la fatturazione.
Abbiamo anche aggiunto ulteriori tabelle e componenti:
- il cestino, per distruggere vecchi progetti che vengono eliminati o congelati;
- coda per attività di background, cioè lo storage principale può eseguire attività di background che devono essere effettuate nel cluster;
- supporto per il lifecycle - un meccanismo che consente di lavorare con gli oggetti, gestendo il loro ciclo di vita.

Poiché parte dei dati è stata trasferita su shard, è necessaria una proxy di sharding. Si potrebbe riutilizzare il router per questo ruolo, ma una proxy di sharding separata, responsabile solo per lo sharding dei dati, consente di accedere ai dati nel router senza preoccuparsi dello sharding.

Racconterò a parte perché non abbiamo scelto una soluzione pronta, ma volevamo realizzare una funzione di sharding personalizzata.
Vediamo come è strutturata. Abbiamo 256 sharding disponibili. Per ciascun bucket, allocchiamo un intervallo usando una funzione consistente. È semplice: proprio come usate una funzione consistente per determinare a quale shard appartiene un bucket, definite lo shard iniziale e allocate un intervallo:
f(bucket, shards) = subset
Quindi, se prendiamo un bucket, possiamo dire che esso e i suoi dati risiederanno sempre su un sottoinsieme specifico di tutti gli shard. Questo riduce l'impatto di alcuni bucket su altri e semplifica il lavoro delle query map-reduce, quando ad esempio è necessario elencare gli oggetti del bucket. Per fare questo, è necessario interrogare tutti gli shard in cui questi oggetti sono memorizzati. Se gli oggetti fossero memorizzati su tutti gli shard, qualsiasi elenco influenzerebbe l'intero sistema, mentre qui influenzerebbe solo un sottoinsieme specifico.
Inoltre, ogni oggetto appartiene a un bucket specifico, quindi quando richiediamo un oggetto, ci rivolgiamo a un oggetto per nome nel bucket specifico. Possiamo quindi definire una funzione per l'oggetto non su tutto l'intervallo disponibile di shard, ma solo su un sottoinsieme del suo bucket:
f(object, subset) = shard
Prendiamo un oggetto specifico e come argomenti della funzione passiamo non tutti gli shard, ma solo il sottoinsieme del suo bucket, e otteniamo uno shard specifico.

Dunque, lo sharding è implementato, c'è una proxy di sharding. Resta solo da far interagire il router e il database dei metadati con la proxy di sharding. Ad esempio, per creare oggetti di copie shadow - quando creiamo un bucket, lo storage principale deve creare un rappresentante di questo bucket su tutti gli shard in cui deve essere presente.

Come abbiamo implementato il resharding
Il problema principale del sharding è il reshading. Era importante per noi realizzarlo senza downtime, poiché il sistema era già in produzione. Vi mostrerò come abbiamo risolto il problema con un esempio simile di migrazione dal vivo dei dati da un progetto a un altro.
Di seguito è riportato lo schema del nostro cluster, che abbiamo ottenuto dopo l'implementazione del sharding. Abbiamo nginx, l'API S3, un router, un database principale con i progetti, un proxy di sharding e, infine, gli shard.

In precedenza ho trascurato di menzionare che in un certo momento del progetto c'era un compito di prodotto: "Lanciare un altro storage, Icebox, come Hotbox, ma solo per i dati freddi". Fondamentalmente, uno storage simile, ma con URL diversi e senza cache.

Icebox è stato utilizzato meno di Hotbox, quindi è rimasto per molto tempo senza alcun sharding. Alla fine abbiamo deciso di eliminarlo e di unire Hotbox e Icebox in un unico servizio, semplicemente separando le classi di archiviazione.
I bucket negli storage non si sovrapponevano, potevano essere facilmente uniti e spostati, ma i clienti usavano entrambi gli storage, quindi dovevamo risolvere il problema dell'assenza di downtime. Non potevamo semplicemente spegnere e copiare. Abbiamo effettuato la migrazione in più fasi.
Per cominciare, abbiamo sincronizzato gli storage principali. Avevamo Tarantool e potevamo, durante la creazione di un oggetto, fare così:
- il database riceve una richiesta per la creazione di un bucket, ad esempio in Hotbox;
- Tarantool verifica in un altro database (in questo caso, Icebox) se quel bucket non esiste;
- se il bucket esiste, il database comunica che non può essere creato, e si sincronizza come esistente.

Sincronizzazione dei bucket
Nello storage che doveva ricevere tutti i dati, abbiamo introdotto per progetti e bucket un attributo che indicava dove si trovava quell'oggetto. Poteva essere archiviato localmente, cioè in Hotbox, in Icebox — in quel caso, non c'erano dati al riguardo nel nuovo storage, oppure poteva essere in stato di migrazione.
Se un progetto o un bucket aveva l'attributo Migrating, durante la migrazione la richiesta veniva inizialmente eseguita nel nuovo storage, dove i dati dovevano trovarsi, e se non c'erano, le richieste venivano reindirizzate allo storage alternativo.
Successivamente abbiamo reindirizzato il traffico. Poiché l'API poteva gestire sia le richieste di Icebox che quelle di Hotbox, siamo riusciti a reindirizzare il traffico senza downtime, semplicemente spostando gli host e aggiungendo le registrazioni appropriate in Nginx.
Dopo che il traffico è stato reindirizzato, Nginx e l'API di Icebox potevano essere rimossi.
Poi abbiamo rimosso Icebox nginx e S3 API — e tutto ha iniziato a funzionare:

Successivamente abbiamo avviato un processo di migrazione in background che lavora all'interno del database — analizza progetto per progetto e i loro bucket, imposta il segno Migrating per loro, trasferisce i dati e, al termine del trasferimento, imposta il segno Local.

Dopo il trasferimento dei dati, non abbiamo più bisogno del vecchio storage, e rimuoviamo le parti rimanenti del vecchio sistema, oltre a rimuovere dal codice il supporto per lo stato di migrazione.

Con gli stessi principi è stata effettuata anche la rishardizzazione dallo storage vecchio a quello shardato:
- Abbiamo contrassegnato tutti i bucket come
Non-sharded. Tutte le richieste a loro andavano nello storage originale, non shardato. - I nuovi bucket venivano creati immediatamente con stato
Sharded. - Prendevamo i bucket uno per uno, impostavamo lo stato
Migratinge trasferivamo i dati.
Le richieste venivano servite secondo il principio:
- Leggiamo nel nuovo, poi nel vecchio.
- Creiamo solo nel nuovo.
- Aggiorniamo in due fasi: se non c'è nel nuovo, trasferiamo dal vecchio al nuovo, poi aggiorniamo.
Lavorare con i certificati SSL
Nel front-end utilizziamo Nginx. Nel nostro caso non si tratta di un normale Nginx, ma di OpenResty, Nginx con supporto per LuaJIT.
Un altro pezzo del sistema è il lavoro con i certificati SSL. Nel storage S3 puoi impostare un dominio personalizzato per l'accesso a un particolare bucket, semplicemente utilizzando CNAME. Ma senza HTTPS al giorno d'oggi non è possibile: un dominio personalizzato implica un certificato SSL personale.
Come ho già detto, Nginx è responsabile del bilanciamento e della terminazione SSL. Nel nostro caso non si tratta di un normale Nginx, ma di OpenResty, Nginx con supporto per LuaJIT.
Questo ci ha permesso di insegnare al nostro Nginx a fornire certificati arbitrari in modo abbastanza semplice. Inoltre, avevamo bisogno di fornire certificati dinamicamente (senza la necessità di registrarli nel file di configurazione). Abbiamo utilizzato l'estensione ssl_certificate_by_lua, che consente di leggere il certificato da una fonte arbitraria direttamente durante il handshake TLS. Come deposito di certificati abbiamo anche utilizzato Tarantool: questo consente di gestire i certificati esternamente e garantisce una resa estremamente rapida.
È stato anche implementato un demone separato, il cui compito è l'aggiornamento regolare dei certificati, che sono stati emessi utilizzando Let's Encrypt.

Cosa salverei e cosa farei diversamente se sviluppassi il sistema di storage da zero
Cosa sarebbe stato necessario utilizzare fin dall'inizio
Sharding immediato. Ha causato parecchi problemi il re-sharding. È facile da implementare, ma, se si avviano progetti che necessitano di scalabilità, è meglio iniziare subito con un cluster sharded, anche se con un numero minimo di nodi. L'implementazione dello sharding all'inizio è quasi gratuita rispetto all'integrazione dello sharding in un sistema esistente.
Lavorare con Tarantool tramite bilanciatori. Attualmente colleghiamo subito tutti i nuovi database attraverso i bilanciatori. Questo ci consente di espandere la funzionalità e ottenere una maggiore resilienza.
Auto-failover. Avrei installato tutti gli strumenti necessari per l'auto-failover, poiché i primi fallimenti dopo il lancio erano legati alla sua assenza. Dopo l'esperienza con S3, tutti i prodotti successivi sono stati avviati tenendo conto di questo.
Funzionalità S3 «Versioning». Inizialmente sembrava che non fosse una funzionalità molto richiesta. Integrare questa possibilità nell'architettura di un sistema esistente è estremamente complicato.
Fatturazione separata. Il modo in cui abbiamo integrato la fatturazione nel nostro sistema ha funzionato bene all'inizio, ma in seguito è diventato un ostacolo; sarebbe stato meglio impostarla come un servizio completamente separato.
Cosa è stata una decisione riuscita
Modello dei dati. La storia ha dimostrato che durante lo sviluppo del servizio ci allineiamo abbastanza precisamente con il modello di dati di Amazon, quindi possiamo implementare le funzionalità presenti lì.
Schema di sharding. Sosterrei gli stessi sharding a intervallo per i bucket, poiché questo consente di distribuire bene le richieste tra diversi bucket su un grande cluster.
Uso di Tarantool. Tarantool ha dato un grande supporto durante lo sviluppo e la modifica del servizio; abbiamo lavorato facilmente con i dati, trasformato e shardato lo storage senza dover salire al livello dell'applicazione.
Questa presentazione è stata pronunciata per la prima volta a da Mail.ru Cloud Solutions&Tarantool. Vedi altre presentazioni e iscriviti agli annunci di eventi su Telegram .
Puoi anche guardare la mia vecchia presentazione su S3 o leggere l'articolo del mio collega sullo storage a blocchi.
- .
- .
Fonte: habr.com

