
Ciao, Habr! Sono Artem Karamyshev, il responsabile del team di amministrazione di sistema. . Negli ultimi anno abbiamo lanciato molti nuovi prodotti. Volevamo assicurarci che i servizi API potessero scalare facilmente, fossero fault-tolerant e pronti a una rapida crescita del carico degli utenti. La nostra piattaforma è basata su OpenStack e voglio raccontare quali problemi di tolleranza ai guasti dei componenti abbiamo dovuto affrontare per ottenere un sistema resistente. Penso che questo possa interessare chi sta sviluppando prodotti su OpenStack.
La tolleranza ai guasti della piattaforma deriva dalla robustezza dei suoi componenti. Quindi passeremo gradualmente attraverso tutti i livelli in cui abbiamo identificato rischi e li abbiamo affrontati.
Puoi guardare la versione video di questa storia, che ha avuto origine da una presentazione alla conferenza Uptime Day 4, organizzata da , su .
Tolleranza ai guasti dell'architettura fisica
La parte pubblica della nuvola MCS è attualmente basata in due data center di livello Tier III, tra i quali è presente una fibra ottica scura dedicata, riservata a livello fisico attraverso diverse rotte, con una capacità di 200 Gbit/s. Il livello Tier III garantisce il necessario livello di tolleranza ai guasti per l'infrastruttura fisica.
La fibra ottica scura è riservata sia a livello fisico che logico. Il processo di riservazione dei canali è stato iterativo, ci sono stati problemi e stiamo continuamente migliorando la connessione tra i data center.
Ad esempio, recentemente durante i lavori in una fossa vicino a uno dei data center, un escavatore ha colpito un tubo che conteneva sia il cavo ottico principale sia quello di riserva. Il nostro canale di comunicazione a prova di guasto con il data center si è rivelato vulnerabile in un solo punto, nella fossa. Di conseguenza, abbiamo perso parte dell'infrastruttura. Abbiamo tratto delle conclusioni e abbiamo intrapreso diverse azioni, tra cui l'installazione di ulteriore fibra ottica attraverso una fossa vicina.
Nei data center ci sono punti di presenza dei provider di telecomunicazioni, a cui trasmettiamo i nostri prefissi tramite BGP. Per ogni direzione di rete viene scelta la migliore metrica, che consente di garantire ai vari clienti la migliore qualità di connessione. Se la connessione tramite un provider si interrompe, ristrutturiamo il nostro instradamento attraverso i provider disponibili.
In caso di guasto di un provider, passiamo automaticamente al successivo. In caso di guasto di uno dei data center, abbiamo una copia speculare dei nostri servizi nel secondo data center, che prende in carico tutto il carico.

Affidabilità dell'infrastruttura fisica
Cosa utilizziamo per l'affidabilità a livello di applicazioni
Il nostro servizio è costruito su una serie di componenti open source.
ExaBGP — un servizio che implementa diverse funzionalità utilizzando il protocollo di instradamento dinamico basato su BGP. Lo utilizziamo attivamente per annunciare i nostri indirizzi IP pubblici, attraverso i quali gli utenti accedono all'API.
HAProxy — un bilanciatore di carico ad alta intensità, che consente di configurare regole di bilanciamento del traffico molto flessibili su diversi livelli del modello OSI. Lo utilizziamo per il bilanciamento davanti a tutti i servizi: database, broker di messaggi, servizi API, servizi web, i nostri progetti interni — tutto è gestito da HAProxy.
API application — un'applicazione web scritta in Python, tramite la quale l'utente gestisce la propria infrastruttura, il proprio servizio.
Worker application (d'ora in poi semplicemente worker) — nei servizi OpenStack è un demone di infrastruttura che consente di trasmettere comandi API all'infrastruttura. Ad esempio, la creazione di un disco avviene proprio nel worker, mentre la richiesta di creazione avviene nell'API application.
Architettura standard dell'OpenStack Application
La maggior parte dei servizi sviluppati per OpenStack cerca di seguire un'unica concezione. Un servizio di solito è composto da 2 parti: API e worker (esecutori di backend). In genere, l'API è un'applicazione WSGI in Python, che può essere eseguita come processo autonomo (daemon), oppure utilizzando un server web già configurato come Nginx o Apache. L'API gestisce la richiesta dell'utente e trasmette successivamente le istruzioni all'applicazione worker. La trasmissione avviene tramite un broker di messaggi, generalmente RabbitMQ, mentre le altre opzioni sono poco supportate. Quando i messaggi raggiungono il broker, vengono elaborati dai worker e, in caso di necessità, restituiscono una risposta.
Questa concezione implica isolati punti di guasto comuni: RabbitMQ e il database. Tuttavia, RabbitMQ è isolato all'interno di un singolo servizio e, in teoria, può essere dedicato a ciascun servizio. Per questo motivo, in MCS cerchiamo di separare al massimo questi servizi, creando per ogni progetto un database e un RabbitMQ distinti. Questo approccio è vantaggioso poiché in caso di guasti in alcuni punti vulnerabili, non si interrompe l'intero servizio, ma solo una sua parte.
Il numero di worker application non è limitato, quindi l'API può scalare facilmente orizzontalmente dietro ai bilanciatori, al fine di aumentare le prestazioni e la resilienza.
In alcuni servizi è necessaria una coordinazione interna, quando si svolgono operazioni sequenziali complesse tra API e worker. In questo caso si utilizza un centro di coordinazione unico, un sistema cluster come Redis, Memcache, etcd, che permette a un worker di comunicare a un altro che un compito è assegnato a lui («tu, per favore, non prenderlo»). Noi usiamo etcd. In genere, i worker comunicano attivamente con il database, leggendo e scrivendo informazioni. Come database utilizziamo MariaDB, che si trova in un cluster multimaster.
Un servizio classico singolo è organizzato in modo consolidato per OpenStack. Può essere visto come un sistema chiuso, per il quale sono abbastanza evidenti i metodi di scaling e resilienza. Ad esempio, per la resilienza dell'API è sufficiente posizionare un bilanciatore davanti ad essa. Lo scaling dei worker si ottiene aumentando il loro numero.
Il punto debole dell'intero sistema sono RabbitMQ e MariaDB. La loro architettura merita un articolo dedicato. In questo articolo voglio concentrarmi sull'affidabilità dell'API.

Architettura Openstack Application. Bilanciamento e affidabilità della piattaforma cloud.
Rendiamo il bilanciatore HAProxy affidabile con ExaBGP.
Per garantire che le nostre API siano scalabili, veloci e affidabili, abbiamo inserito un bilanciatore. Abbiamo scelto HAProxy. A mio avviso, possiede tutte le caratteristiche necessarie per il nostro compito: bilanciamento su più livelli OSI, interfaccia di gestione, flessibilità e scalabilità, un grande numero di metodi di bilanciamento, supporto per le tabelle di sessione.
Il primo problema da risolvere è stata l'affidabilità del bilanciatore stesso. L'installazione di un bilanciatore crea comunque un punto di guasto: se il bilanciatore si blocca, il servizio va giù. Per evitare questo problema, abbiamo utilizzato HAProxy insieme a ExaBGP.
ExaBGP consente di implementare un meccanismo di verifica dello stato del servizio. Abbiamo utilizzato questo meccanismo per controllare il funzionamento di HAProxy e, in caso di problemi, disattivare il servizio HAProxy dal BGP.
Schema ExaBGP+HAProxy.
- Installiamo il software necessario, ExaBGP e HAProxy, su tre server.
- Su ciascun server creiamo un'interfaccia loopback.
- Su tutti e tre i server impostiamo lo stesso indirizzo IP pubblico su questa interfaccia.
- L'indirizzo IP pubblico viene annunciato su Internet tramite ExaBGP.
L'affidabilità si ottiene annunciando lo stesso indirizzo IP da tutti e tre i server. Dal punto di vista della rete, lo stesso indirizzo è accessibile da tre diversi next hop. Il router vede tre percorsi identici e seleziona quello più priorizzato secondo la sua metrica (che di solito è lo stesso percorso), e il traffico viene instradato solo su uno dei server.
In caso di problemi con il funzionamento di HAProxy o di guasto del server, ExaBGP smette di annunciare il percorso, e il traffico viene immediatamente reindirizzato a un altro server.
In questo modo abbiamo raggiunto l'affidabilità del bilanciatore.

Affidabilità dei bilanciatori HAProxy.
Lo schema non è risultato ideale: abbiamo imparato a riservare HAProxy, ma non a distribuire il carico all'interno dei servizi. Pertanto, abbiamo espanso un po' questa architettura: siamo passati al bilanciamento tra più indirizzi IP pubblici.
Bilanciamento basato su DNS e BGP
Rimane irrisolto il problema del bilanciamento del carico davanti ai nostri HAProxy. Tuttavia, è possibile risolverlo in modo piuttosto semplice, come abbiamo fatto noi.
Per bilanciare tre server sono necessari 3 indirizzi IP pubblici e il buon vecchio DNS. Ognuno di questi indirizzi è definito sull'interfaccia loopback di ciascun HAProxy e viene annunciato su Internet.
In OpenStack, per la gestione delle risorse, viene utilizzato un catalogo dei servizi, in cui è definito l'endpoint API di ciascun servizio. In questo catalogo, indichiamo il nome di dominio — public.infra.mail.ru, che viene risolto tramite DNS tramite tre indirizzi IP diversi. Di conseguenza, otteniamo una distribuzione del carico tra i tre indirizzi tramite DNS.
Ma poiché, nell'annuncio degli indirizzi IP pubblici, non gestiamo le priorità di selezione del server, al momento non si tratta di bilanciamento. Di solito, viene selezionato solo un server in base all'anzianità dell'indirizzo IP, mentre gli altri due rimarranno inattivi, poiché non sono state specificate metriche in BGP.
Abbiamo iniziato a trasmettere percorsi tramite ExaBGP con metriche diverse. Ogni bilanciatore annuncia tutti e tre gli indirizzi IP pubblici, ma uno di essi, principale per quel bilanciatore, viene annunciato con la metrica più bassa. Quindi, finché tutti e tre i bilanciatori sono attivi, le richieste al primo indirizzo IP vanno al primo bilanciatore, quelle al secondo al secondo, e quelle al terzo al terzo.
Cosa succede nel momento in cui uno dei bilanciatori fallisce? In caso di guasto di un bilanciatore, il suo indirizzo principale viene ancora annunciato dagli altri due, e il traffico tra di loro viene ridistribuito. In questo modo, forniamo all'utente tramite DNS immediatamente più indirizzi IP. Attraverso il bilanciamento via DNS e la metrica diversa, otteniamo una distribuzione uniforme del carico su tutti e tre i bilanciatori, senza perdere la resilienza.

Bilanciamento HAProxy basato su DNS + BGP
Interazione tra ExaBGP e HAProxy
Quindi, abbiamo implementato la resilienza nel caso di un guasto del server, sulla base dell'interruzione dell'annuncio dei percorsi. Ma HAProxy può disattivarsi anche per altri motivi, oltre al guasto del server: errori di amministrazione, guasti all'interno del servizio. Vogliamo rimuovere il bilanciatore rotto dal carico anche in questi casi, e serve un altro meccanismo.
Pertanto, espandendo la precedente configurazione, abbiamo realizzato un heartbeat tra ExaBGP e HAProxy. Questa è un'implementazione software dell'interazione tra ExaBGP e HAProxy, dove ExaBGP utilizza script personalizzati per controllare lo stato delle applicazioni.
A tal fine, nel file di configurazione di ExaBGP è necessario impostare un health checker in grado di verificare lo stato di HAProxy. Nel nostro caso, abbiamo configurato un health backend in HAProxy e, da parte di ExaBGP, verifichiamo con una semplice richiesta GET. Se l'annuncio smette di avvenire, HAProxy probabilmente non funziona e non deve essere annunciato.

Controllo dello stato di HAProxy
Peer di HAProxy: sincronizzazione delle sessioni
La successiva operazione da effettuare è stata sincronizzare le sessioni. Lavorando attraverso bilanciatori distribuiti, è difficile organizzare la conservazione delle informazioni sulle sessioni dei clienti. Ma HAProxy è uno dei pochi bilanciatori che può farlo grazie alla funzionalità Peers, che consente il passaggio delle tabelle di sessioni tra i vari processi di HAProxy.
Esistono diversi metodi di bilanciamento: semplici, come , e avanzati, in cui la sessione del cliente viene memorizzata, e ogni volta il cliente viene indirizzato allo stesso server di prima. Volevamo implementare la seconda opzione.
In HAProxy, per mantenere le sessioni del cliente, viene utilizzato il meccanismo delle stick-tables. Queste memorizzano l'indirizzo IP originale del cliente, l'indirizzo target selezionato (backend) e alcune informazioni di servizio. Di solito, le stick-tables vengono utilizzate per memorizzare la coppia source-IP + destination-IP, il che è particolarmente utile per le applicazioni che non possono trasmettere il contesto della sessione utente quando si passa a un altro bilanciatore, ad esempio, in modalità bilanciamento RoundRobin.
Se la stick-table viene addestrata a spostarsi tra i diversi processi di HAProxy (tra cui avviene il bilanciamento), i nostri bilanciatori potranno lavorare con un unico pool di stick-tables. Questo consentirà un passaggio senza soluzione di continuità della rete del cliente in caso di caduta di uno dei bilanciatori, e il lavoro con le sessioni dei clienti continuerà sugli stessi backend selezionati in precedenza.
Per un funzionamento corretto, deve essere risolta la questione dell'indirizzo IP sorgente del bilanciatore da cui è stata stabilita la sessione. Nel nostro caso, si tratta di un indirizzo dinamico sull'interfaccia loopback.
Il corretto funzionamento dei peers si realizza solo in determinate condizioni. In altre parole, i timeout TCP devono essere sufficientemente lunghi oppure il passaggio deve avvenire abbastanza rapidamente affinché la sessione TCP non si interrompa. Tuttavia, ciò consente uno switch senza interruzioni.
Nel nostro IaaS abbiamo un servizio costruito con la stessa tecnologia. Questo è , chiamato Octavia. È basato su due processi HAProxy, supporta nativamente i peers. In questo servizio, si sono rivelati molto efficaci.
Nell'immagine è rappresentato schematicamente il trasferimento delle tabelle dei peers tra tre istanze di HAProxy, è fornita una configurazione su come impostarla:

HAProxy Peers (sincronizzazione delle sessioni)
Se intendi implementare uno schema simile, è necessario testarne il funzionamento con attenzione. Non è garantito che funzioni allo stesso modo nel 100% dei casi. Tuttavia, perlomeno, non perderai le tabelle stick quando è necessario ricordare l'IP sorgente del cliente.
Limitazione del numero di richieste simultanee da parte dello stesso cliente
Qualsiasi servizio accessibile pubblicamente, compresi i nostri API, può essere soggetto a ondate di richieste. Le cause possono essere completamente diverse, da errori degli utenti a attacchi mirati. Siamo periodicamente soggetti a attacchi DDoS per indirizzi IP. I clienti spesso commettono errori nei loro script, creando mini-DDoS.
In ogni caso, è necessario prevedere una protezione aggiuntiva. Una soluzione ovvia è limitare il numero di richieste agli API per non sprecare tempo di elaborazione su richieste dannose.
Per implementare tali limitazioni utilizziamo i rate limits, organizzati sulla base di HAProxy, tramite le stesse tabelle stick. I limiti sono facili da configurare e consentono di limitare l'utente per numero di richieste agli API. L'algoritmo memorizza l'IP sorgente da cui vengono effettuate le richieste e limita il numero di richieste simultanee da un singolo utente. Naturalmente, abbiamo calcolato il profilo medio del carico sugli API per ciascun servizio e abbiamo impostato un limite di circa 10 volte superiore a questo valore. Continuiamo a monitorare attentamente la situazione, mantenendo un occhio sempre vigile.
Come appare nella pratica? Abbiamo clienti che utilizzano costantemente le nostre API per l'auto-scalabilità. Creano circa duecento-trecento macchine virtuali al mattino e le cancellano verso sera. Per OpenStack, creare una macchina virtuale, insieme ai servizi PaaS, richiede almeno 1000 richieste API, poiché l'interazione tra i servizi avviene anch'essa tramite API.
Questi spostamenti di carico causano un notevole sovraccarico. Abbiamo valutato questo sovraccarico, raccolto i picchi giornalieri, aumentati di dieci volte, ed è diventato il nostro rate limit. Teniamo la mano sul polso. Spesso vediamo bot e scanner che cercano di capire se abbiamo script CGA che possono essere eseguiti e li blocchiamo attivamente.
Come aggiornare la base di codice senza che gli utenti se ne accorgano
Implementiamo la tolleranza ai guasti anche a livello di processi di deployment del codice. Durante i rilasci possono verificarsi errori, ma il loro impatto sulla disponibilità dei servizi può essere minimizzato.
Aggiorniamo continuamente i nostri servizi e dobbiamo garantire il processo di aggiornamento della base di codice senza effetti sugli utenti. Siamo riusciti a risolvere questo problema utilizzando le funzionalità di gestione di HAProxy e implementando il Graceful Shutdown nei nostri servizi.
Per affrontare questa sfida, era necessario garantire il controllo del bilanciatore e lo 'shutdown corretto' dei servizi:
- Nel caso di HAProxy, la gestione avviene tramite un file stat, che di fatto è un socket e viene definito nella configurazione di HAProxy. Le istruzioni possono essere inviate tramite stdio. Ma il nostro principale strumento per il controllo delle configurazioni è Ansible, quindi dispone di un modulo integrato per la gestione di HAProxy, che utilizziamo attivamente.
- La maggior parte dei nostri servizi API e Engine supporta le tecnologie di graceful shutdown: durante lo spegnimento, attendono il completamento dell'attuale attività, sia essa una richiesta http o un compito di servizio. Lo stesso accade con i worker. Essi conoscono tutti i compiti che stanno eseguendo e terminano quando hanno completato con successo tutto.
Grazie a questi due aspetti, l'algoritmo sicuro del nostro deployment appare nel seguente modo.
- Lo sviluppatore compila un nuovo pacchetto di codice (per noi è un RPM), lo testa nell'ambiente di sviluppo, lo testa in fase e lo lascia nel repository di fase.
- Lo sviluppatore imposta il compito per il deployment con una descrizione dettagliata degli "artefatti": versione del nuovo pacchetto, descrizione delle nuove funzionalità e ulteriori dettagli sul deployment, se necessario.
- L'amministratore di sistema inizia l'aggiornamento. Avvia il playbook Ansible, che a sua volta esegue le seguenti operazioni:
- Preleva il pacchetto dal repository di staging e aggiorna la versione del pacchetto nel repository di produzione.
- Compila un elenco dei backend del servizio da aggiornare.
- Disattiva il primo servizio da aggiornare in HAProxy e attende che i suoi processi siano completati. Grazie allo shutdown controllato, siamo certi che tutte le richieste attuali dei clienti vengano completate con successo.
- Dopo il completo arresto dell'API, dei worker e la disattivazione di HAProxy, avviene l'aggiornamento del codice.
- Ansible avvia i servizi.
- Per ogni servizio attiva determinati "trigger" che eseguono il test unitario in base a una serie di test chiave predefiniti. Viene effettuato un controllo di base del nuovo codice.
- Se nel passaggio precedente non sono stati rilevati errori, il backend viene attivato.
- Si passa al backend successivo.
- Dopo l'aggiornamento di tutti i backend, vengono eseguiti test funzionali. Se non sono sufficienti, lo sviluppatore esamina qualsiasi nuova funzionalità che ha sviluppato.
A questo punto, il deployment è completato.

Ciclo di aggiornamento del servizio
Questo schema non sarebbe operativo se non avessimo una regola. Manteniamo attivamente sia la versione vecchia che quella nuova. Sin dalla fase di sviluppo del software, si prevede che anche se ci saranno modifiche nel database del servizio, non romperanno il codice precedente. Di conseguenza, avviene un aggiornamento graduale della base di codice.
Conclusione
Condividendo i miei pensieri riguardo all'architettura WEB a prova di guasto, voglio ribadire alcuni punti chiave:
- resilienza fisica;
- resilienza di rete (bilanciatori, BGP);
- resilienza del software utilizzato e sviluppato.
A tutti un uptime stabile!
Fonte: habr.com
