
Ciao, Habr! Sono Artem Karamychev, il responsabile del team di amministrazione dei sistemi. . Negli ultimi dodici mesi abbiamo lanciato molti nuovi prodotti. Abbiamo cercato di garantire che i servizi API fossero facilmente scalabili, resilienti e pronti a gestire un rapido aumento del carico degli utenti. La nostra piattaforma è costruita su OpenStack e voglio raccontarvi quali problemi di resilienza dei componenti abbiamo dovuto affrontare per ottenere un sistema robusto. Penso che sarà interessante per chi sviluppa anche prodotti su OpenStack.
La resilienza complessiva della piattaforma è composta dalla robustezza dei suoi componenti. Quindi, passeremo gradualmente attraverso tutti i livelli in cui abbiamo individuato rischi e li abbiamo mitigati.
Puoi vedere la versione video di questa storia, originata da un intervento alla conferenza Uptime Day 4, organizzata da , su .
Resilienza dell'architettura fisica
La parte pubblica del cloud MCS si basa attualmente su due data center di livello Tier III, con una connessione in fibra ottica dedicata e riservata a livello fisico tramite percorsi diversi, con una capacità di 200 Gbit/s. Il livello Tier III assicura il necessario livello di resilienza dell'infrastruttura fisica.
La fibra ottica è riservata sia a livello fisico che logico. Il processo di riservazione dei canali è stato iterativo, con problemi occasionali, e stiamo costantemente migliorando la connessione tra i data center.
Ad esempio, di recente, durante dei lavori in un pozzetto vicino a uno dei data center, un escavatore ha colpito un tubo, all'interno del quale si trovavano sia il cavo in fibra ottica principale che quello di riserva. Il nostro canale resiliente con il data center è risultato vulnerabile in un singolo punto, nel pozzetto. Pertanto, abbiamo perso parte dell'infrastruttura. Abbiamo tratto delle conclusioni e intrapreso una serie di azioni, tra cui la posa di ulteriore fibra ottica in un pozzetto adiacente.
Nei data center ci sono punti di presenza dei fornitori di servizi di telecomunicazione, a cui trasmettiamo i nostri prefissi tramite BGP. Per ogni direzione di rete viene selezionata la metrica migliore, il che consente di garantire ai vari clienti la migliore qualità di connessione. Se la connessione tramite un fornitore si interrompe, riorganizziamo il nostro instradamento tramite fornitori disponibili.
In caso di guasto del fornitore, 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 assorbe tutto il carico.

Resilienza dell'infrastruttura fisica
Cosa usiamo per la resilienza a livello applicativo
Il nostro servizio è costruito su un insieme di componenti open source.
ExaBGP — un servizio che implementa alcune funzionalità utilizzando il protocollo di instradamento dinamico basato su BGP. Lo utilizziamo attivamente per annunciare i nostri indirizzi IP pubblici, attraverso cui gli utenti accedono all'API.
HAProxy — un bilanciatore di carico ad alta intensità, che consente di impostare regole molto flessibili per il bilanciamento del traffico a 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 (di seguito semplicemente worker) — nei servizi OpenStack, è un demone infrastrutturale che consente di trasmettere comandi API all'infrastruttura. Ad esempio, la creazione di un disco avviene proprio nel worker, mentre la richiesta di creazione è nell'API application.
Architettura standard di OpenStack Application
La maggior parte dei servizi sviluppati per OpenStack cerca di seguire un'unica paròdi. Il servizio in genere consiste di 2 parti: API e worker (esecutori backend). Di norma, l'API è un'applicazione WSGI in python, che viene eseguita come processo autonomo (demone) o tramite un server web come Nginx o Apache. L'API gestisce la richiesta dell'utente e trasmette ulteriori istruzioni all'applicazione worker. La trasmissione avviene tramite un broker di messaggi, di solito RabbitMQ, mentre gli altri sono poco supportati. Quando i messaggi raggiungono il broker, vengono elaborati dai worker e, se necessario, restituiscono una risposta.
Questa particolare architettura implica punti di guasto isolati: RabbitMQ e il database. Tuttavia, RabbitMQ è isolato all'interno di un singolo servizio e, idealmente, può essere dedicato a ciascun servizio. Pertanto, noi di MCS separiamo al massimo questi servizi, creando un database e un RabbitMQ separati per ogni progetto. Questo approccio è vantaggioso perché, in caso di guasti in alcuni punti vulnerabili, non si interrompe l'intero servizio, ma solo una sua parte.
Il numero di applicazioni worker non è limitato, quindi l'API può facilmente scalare orizzontalmente attraverso i bilanciatori per aumentare le prestazioni e la tolleranza agli errori.
In alcuni servizi è necessaria una coordinazione interna — quando si svolgono operazioni sequenziali complesse tra API e worker. In questo caso, viene utilizzato un centro di coordinazione unico, un sistema cluster come Redis, Memcache, etcd, che consente a un worker di informare un altro che un compito è assegnato a lui («per favore, non prenderlo»). Noi utilizziamo etcd. Di norma, i worker comunicano attivamente con il database, leggendo e scrivendo informazioni. Come database, utilizziamo MariaDB, che si trova in un cluster multimaster.
Questo classico servizio singolo è organizzato in modo convenzionale per OpenStack. Può essere visto come un sistema chiuso, per il quale sono chiari i metodi di scalabilità e tolleranza agli errori. Ad esempio, per garantire la tolleranza agli errori, è sufficiente mettere un bilanciatore davanti all'API. La scalabilità dei worker è ottenuta aumentando il loro numero.
Il punto debole di tutto lo schema è rappresentato da RabbitMQ e MariaDB. La loro architettura merita un articolo a parte. In questo articolo voglio concentrarmi sulla tolleranza agli errori dell'API.

Architettura Openstack Application. Bilanciamento e tolleranza agli errori della piattaforma cloud
Rendiamo il bilanciatore HAProxy tollerante agli errori con ExaBGP
Per garantire che le nostre API siano scalabili, veloci e tolleranti agli errori, abbiamo posto un bilanciatore davanti a loro. Abbiamo scelto HAProxy. A mio avviso, possiede tutte le caratteristiche necessarie per il nostro compito: bilanciamento a più livelli OSI, interfaccia di gestione, flessibilità e scalabilità, ampia gamma di metodi di bilanciamento, supporto per le tabelle di sessione.
Il primo problema da risolvere è la tolleranza agli errori del bilanciatore stesso. L'installazione di un bilanciatore crea anch'essa un punto di guasto: se il bilanciatore si guasta, il servizio crolla. Per evitare ciò, abbiamo utilizzato HAProxy insieme a ExaBGP.
ExaBGP consente l'implementazione di un meccanismo di monitoraggio 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 ciascuno dei server creiamo un'interfaccia loopback.
- Su tutti e tre i server configuriamo lo stesso indirizzo IP pubblico per questa interfaccia.
- L'indirizzo IP pubblico viene annunciato su Internet tramite ExaBGP.
La tolleranza agli errori è raggiunta 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, in base alla propria metrica, il più prioritario (di solito è sempre lo stesso) e il traffico va solo su uno dei server.
In caso di problemi con HAProxy o di guasto di un server, ExaBGP smette di annunciare il percorso e il traffico si sposta gradualmente su un altro server.
Così abbiamo ottenuto la tolleranza agli errori del bilanciatore.

Tolleranza agli errori dei bilanciatori HAProxy
Lo schema risultante non è ideale: abbiamo imparato a riservare HAProxy, ma non a distribuire il carico all'interno dei servizi. Pertanto, abbiamo leggermente ampliato questo schema: siamo passati a un bilanciamento tra più indirizzi IP pubblici.
Bilanciamento basato su DNS più BGP
Rimane irrisolto il problema della distribuzione del carico davanti ai nostri HAProxy. Tuttavia, questo può essere risolto piuttosto semplicemente, come abbiamo fatto noi.
Per bilanciare tre server, saranno necessari 3 indirizzi IP pubblici e il buon vecchio DNS. Ognuno di questi indirizzi è assegnato all'interfaccia loopback di ciascun HAProxy e annunciato su Internet.
In OpenStack, per gestire le risorse, si utilizza un catalogo dei servizi, in cui si definisce l'endpoint API di ciascun servizio. In questo catalogo registriamo il nome di dominio — public.infra.mail.ru, che viene risolto tramite DNS in tre diversi indirizzi IP. Di conseguenza, otteniamo una distribuzione del carico tra i tre indirizzi tramite DNS.
Tuttavia, poiché non gestiamo le priorità di selezione del server durante l'annuncio degli indirizzi IP pubblici, finché non si tratta di bilanciamento. Di solito, verrà selezionato solo un server in base alla anzianità dell'indirizzo IP, mentre gli altri due rimarranno inattivi poiché non sono specificate metriche in BGP.
Abbiamo iniziato a pubblicare le rotte 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 minima. Così, finché tutti e tre i bilanciatori sono operativi, le richieste al primo indirizzo IP vanno al primo bilanciatore, le richieste al secondo al secondo, e al terzo al terzo.
Cosa succede nel momento in cui uno dei bilanciatori si guasta? In caso di guasto di uno qualsiasi dei bilanciatori, l'indirizzo principale viene comunque annunciato dagli altri due, e il traffico tra di loro viene ridistribuito. In questo modo, restituiamo all'utente attraverso DNS diversi indirizzi IP. Attraverso il bilanciamento DNS e metriche diverse otteniamo una distribuzione uniforme del carico su tutti e tre i bilanciatori, senza compromettere la resilienza.

Bilanciamento HAProxy basato su DNS + BGP
Interazione tra ExaBGP e HAProxy
Quindi, abbiamo implementato la resilienza in caso di uscita di un server, basata sulla cessazione dell'annuncio delle rotte. Ma HAProxy può disattivarsi per altri motivi oltre al guasto del server: errori di amministrazione, malfunzionamenti interni del servizio. Vogliamo rimuovere il bilanciatore guasto dal carico anche in questi casi, e ci serve un altro meccanismo.
Pertanto, ampliando lo schema precedente, abbiamo implementato un heartbeat tra ExaBGP e HAProxy. Questa è un'implementazione software dell'interazione tra ExaBGP e HAProxy, in cui ExaBGP utilizza script personalizzati per controllare lo stato delle applicazioni.
Per questo, nel file di configurazione di ExaBGP, è necessario impostare un health checker che possa verificare lo stato di HAProxy. Nel nostro caso, abbiamo impostato il backend di health in HAProxy, mentre dal lato di ExaBGP controlliamo con una semplice richiesta GET. Se l'annuncio smette di avvenire, HAProxy probabilmente non funziona, e non è necessario annunciarlo.

Controllo dello stato HAProxy
HAProxy Peers: sincronizzazione delle sessioni
La prossima cosa da fare è sincronizzare le sessioni. Quando si lavora attraverso bilanciatori distribuiti, è difficile organizzare la conservazione delle informazioni sulle sessioni dei clienti. Ma HAProxy è uno dei pochi bilanciatori in grado di farlo grazie alla funzionalità Peers, che consente di trasmettere tra diversi processi HAProxy le tabelle delle sessioni.
Esistono diversi metodi di bilanciamento: semplici, come , e avanzati, in cui viene memorizzata la sessione del cliente, e ogni volta finisce sullo stesso server di prima. Volevamo implementare la seconda opzione.
In HAProxy, per mantenere le sessioni del cliente, si utilizzano stick-tables. Queste memorizzano l'indirizzo IP originale del cliente, l'indirizzo del target scelto (backend) e alcune informazioni di servizio. Di solito, le stick-table vengono utilizzate per salvare la coppia source-IP + destination-IP, il che è particolarmente utile per le applicazioni che non possono trasmettere il contesto della sessione dell'utente quando cambiano bilanciatore, come nel caso del bilanciamento RoundRobin.
Se si insegna alla stick-table a muoversi tra i diversi processi di HAProxy (tra i quali avviene il bilanciamento), i nostri bilanciatori potranno lavorare con un unico pool di stick-table. Questo abiliterà uno switching senza soluzione di continuità della rete del cliente in caso di guasto di uno dei bilanciatori, e la gestione delle sessioni dei clienti continuerà sugli stessi backend precedentemente selezionati.
Per un corretto funzionamento deve essere risolto il problema dell'indirizzo IP di origine 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 ottiene solo in determinate condizioni. Cioè, i timeout TCP devono essere sufficientemente grandi o il passaggio deve essere abbastanza veloce, affinché la sessione TCP non venga interrotta. Tuttavia, questo consente uno switching senza soluzione di continuità.
Nella nostra IaaS, abbiamo un servizio costruito con la stessa tecnologia. È , chiamato Octavia. Si basa su due processi HAProxy e supporta inizialmente i peers. In questo servizio, si sono dimostrati molto efficaci.
Nell'immagine è schematicamente rappresentato il movimento delle tabelle dei peers tra tre istanze HAProxy, con una configurazione proposta su come impostarlo:

HAProxy Peers (sincronizzazione delle sessioni)
Se implementerete uno schema simile, è necessario testare attentamente il suo funzionamento. Non è garantito che funzioni allo stesso modo nel 100% dei casi. Ma, almeno, non perderete le stick-table quando è necessario ricordare l'IP del cliente.
Limitazione del numero di richieste simultanee da un singolo cliente
Qualsiasi servizio reso pubblico, compresi i nostri API, può essere soggetto a picchi di richieste. Le cause possono variare, dagli errori degli utenti agli attacchi mirati. Periodicamente subiamo attacchi DDoS per indirizzo IP. I clienti spesso commettono errori nei loro script, causando mini-DDoS.
In ogni caso, è necessario prevedere una protezione aggiuntiva. Una soluzione ovvia è limitare il numero di richieste alle API e risparmiare tempo di elaborazione per le richieste dannose.
Per implementare tali limitazioni utilizziamo i rate limits, organizzati su HAProxy, tramite le stick tables. I limiti vengono configurati in modo semplice e consentono di limitare l'utente nel numero di richieste alle API. L'algoritmo memorizza l'indirizzo 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 sulle API per ogni servizio e stabilito un limite di circa 10 volte superiore a questo valore. Continuiamo a monitorare attentamente la situazione, tenendo il polso della situazione.
Come si presenta in pratica? Abbiamo clienti che utilizzano costantemente le nostre API per il ridimensionamento automatico. Creano circa duecento- trecento macchine virtuali al mattino e le eliminano in serata. Per OpenStack, creare una macchina virtuale, specialmente con i servizi PaaS, richiede almeno 1000 richieste API, poiché l'interazione tra i servizi avviene anch'essa tramite API.
Tali flussi di lavoro generano un carico significativo. Abbiamo valutato questo carico, raccolto i picchi giornalieri, incrementati dieci volte, e questo è diventato il nostro rate limit. Rimaniamo vigili. Spesso osserviamo bot e scanner che cercano di capire se abbiamo degli script CGA che possono essere eseguiti, e li blocchiamo attivamente.
Come aggiornare il codice senza che gli utenti se ne accorgano
Implementiamo la resilienza anche a livello di processi di distribuzione del codice. Durante i deploy possono verificarsi errori, ma possiamo minimizzare il loro impatto sulla disponibilità dei servizi.
Aggiorniamo costantemente i nostri servizi e dobbiamo garantire un processo di aggiornamento della base di codice senza effetti per gli utenti. Questa sfida è stata risolta utilizzando le funzionalità di gestione di HAProxy e implementando il Graceful Shutdown nei nostri servizi.
Per affrontare questa problematica, è necessario garantire la gestione del bilanciatore e una corretta disattivazione dei servizi:
- Nel caso di HAProxy, la gestione avviene attraverso un file di stats, che di fatto è un socket e deve essere definito nella configurazione di HAProxy. Le istruzioni possono essere inviate tramite stdio. Tuttavia, il nostro principale strumento di controllo delle configurazioni è ansible, che ha un modulo integrato per gestire HAProxy. Lo utilizziamo attivamente.
- La maggior parte dei nostri servizi API ed Engine supportano la tecnologia del graceful shutdown: quando vengono spenti, attendono il completamento di tutti i compiti in corso, che sia una richiesta http o un'attività di servizio. Lo stesso vale per il worker. Esso è a conoscenza di tutti i compiti che sta eseguendo e si arresta quando ha completato tutto con successo.
Grazie a questi due aspetti, il nostro algoritmo di deployment sicuro appare come segue.
- Lo sviluppatore compila un nuovo pacchetto di codice (per noi è un RPM), lo testa nell'ambiente di sviluppo, lo testa nell'ambiente di staging e lo lascia nel repository di staging.
- Lo sviluppatore registra una richiesta di deployment con la massima descrizione dettagliata degli 'artefatti': versione del nuovo pacchetto, descrizione della nuova funzionalità e altri dettagli del deployment se necessario.
- L'amministratore di sistema inizia l'aggiornamento. Avvia il playbook Ansible, che a sua volta procede come segue:
- Prende il pacchetto dal repository di staging e aggiorna la versione del pacchetto nel repository di produzione.
- Compila l'elenco dei backend del servizio che deve essere aggiornato.
- Disattiva il primo servizio da aggiornare in HAProxy e attende il completamento dei suoi processi. Grazie al graceful shutdown, siamo certi che tutte le richieste in corso dei clienti verranno completate con successo.
- Dopo la completa arresto delle API, dei worker e dello spegnimento di HAProxy, avviene l'aggiornamento del codice.
- Ansible avvia i servizi.
- Per ciascun servizio verifica alcuni 'interruttori' che eseguono test di unità su una serie di test chiave predefiniti. Viene eseguita una verifica di base del nuovo codice.
- Se non sono emersi errori nel passaggio precedente, il backend viene attivato.
- Passiamo 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 implementato.
Con questa deployment è concluso.

Ciclo di aggiornamento del servizio
Questo schema non sarebbe praticabile se non avessimo una regola fondamentale. Mantenere in produzione sia la versione vecchia che quella nuova. Sin dall'inizio, durante lo sviluppo del software, si pianifica che anche se ci saranno modifiche nel database del servizio, non romperanno il codice precedente. Di conseguenza, si realizza un aggiornamento graduale del codice.
Conclusione
Condividendo i miei pensieri sull'architettura web resiliente, voglio ribadire i suoi punti chiave:
- resilienza fisica;
- resilienza di rete (bilanciatori, BGP);
- resilienza del software in uso e in sviluppo.
A tutti un uptime stabile!
Fonte: habr.com
