Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Architettura di un bilanciatore del carico di rete in Yandex.Cloud
Ciao, sono Sergey Elantsev, sviluppo bilanciatore del carico di rete in Yandex.Cloud. In precedenza, ho guidato lo sviluppo del bilanciatore L7 per il portale Yandex: i colleghi scherzano dicendo che qualunque cosa io faccia, risulta essere un bilanciatore. Dirò ai lettori di Habr come gestire il carico in una piattaforma cloud, cosa consideriamo lo strumento ideale per raggiungere questo obiettivo e come ci stiamo muovendo verso la costruzione di questo strumento.

Innanzitutto introduciamo alcuni termini:

  • VIP (IP virtuale) - indirizzo IP del bilanciatore
  • Server, backend, istanza: una macchina virtuale che esegue un'applicazione
  • RIP (IP reale): indirizzo IP del server
  • Healthcheck: verifica della disponibilità del server
  • Zona di disponibilità, Arizona: infrastruttura isolata in un data center
  • Regione: unione di diverse AZ

I bilanciatori di carico risolvono tre compiti principali: eseguono il bilanciamento stesso, migliorano la tolleranza agli errori del servizio e ne semplificano il ridimensionamento. La tolleranza ai guasti è assicurata attraverso la gestione automatica del traffico: il bilanciatore monitora lo stato dell'applicazione ed esclude dal bilanciamento le istanze che non superano il liveness check. La scalabilità è garantita dalla distribuzione uniforme del carico tra le istanze e dall'aggiornamento immediato dell'elenco delle istanze. Se il bilanciamento non è sufficientemente uniforme, alcune istanze riceveranno un carico che supera il limite di capacità e il servizio diventerà meno affidabile.

Un sistema di bilanciamento del carico viene spesso classificato in base al livello di protocollo del modello OSI su cui viene eseguito. Il Cloud Balancer opera a livello TCP, che corrisponde al quarto livello, L4.

Passiamo ad una panoramica dell'architettura del bilanciatore Cloud. Aumenteremo gradualmente il livello di dettaglio. Dividiamo i componenti del bilanciatore in tre classi. La classe del piano di configurazione è responsabile dell'interazione dell'utente e memorizza lo stato di destinazione del sistema. Il piano di controllo memorizza lo stato corrente del sistema e gestisce i sistemi dalla classe del piano dati, che sono direttamente responsabili della distribuzione del traffico dai client alle tue istanze.

Piano dati

Il traffico finisce su dispositivi costosi chiamati router di confine. Per aumentare la tolleranza agli errori, diversi dispositivi di questo tipo funzionano contemporaneamente in un data center. Successivamente, il traffico va ai bilanciatori, che annunciano gli indirizzi IP anycast a tutte le zone di disponibilità tramite BGP per i client. 

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Il traffico viene trasmesso tramite ECMP: questa è una strategia di routing in base alla quale possono esserci diversi percorsi ugualmente buoni verso la destinazione (nel nostro caso, la destinazione sarà l'indirizzo IP di destinazione) e i pacchetti possono essere inviati lungo ognuno di essi. Supportiamo anche il lavoro in più zone di disponibilità secondo il seguente schema: pubblicizziamo un indirizzo in ciascuna zona, il traffico va a quello più vicino e non va oltre i suoi limiti. Più avanti nel post esamineremo più in dettaglio cosa succede al traffico.

Piano di configurazione

 
Il componente chiave del piano di configurazione è l'API, attraverso la quale vengono eseguite le operazioni di base con i bilanciatori: creazione, eliminazione, modifica della composizione delle istanze, acquisizione dei risultati dei controlli di integrità, ecc. Da un lato, questa è un'API REST, dall'altro altro, noi nel Cloud utilizziamo molto spesso il framework gRPC, quindi “traduciamo” REST in gRPC e poi utilizziamo solo gRPC. Qualsiasi richiesta porta alla creazione di una serie di attività idempotenti asincrone che vengono eseguite su un pool comune di lavoratori Yandex.Cloud. Le attività sono scritte in modo tale da poter essere sospese in qualsiasi momento e quindi riavviate. Ciò garantisce scalabilità, ripetibilità e registrazione delle operazioni.

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Di conseguenza, l'attività dell'API invierà una richiesta al controller del servizio di bilanciamento, scritta in Go. Può aggiungere e rimuovere bilanciatori, modificare la composizione dei backend e delle impostazioni. 

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Il servizio memorizza il suo stato nel database Yandex, un database gestito distribuito che presto potrai utilizzare. In Yandex.Cloud, come già detto, vale il concetto di cibo per cani: se usiamo noi stessi i nostri servizi, anche i nostri clienti ne saranno felici. Il database Yandex è un esempio dell'implementazione di tale concetto. Archiviamo tutti i nostri dati in YDB e non dobbiamo pensare a mantenere e ridimensionare il database: questi problemi sono risolti per noi, utilizziamo il database come servizio.

Torniamo al controller del bilanciatore. Il suo compito è salvare le informazioni sul bilanciatore e inviare un'attività per verificare la disponibilità della macchina virtuale al controller di controllo dello stato.

Controllore del controllo dello stato

Riceve richieste di modifica delle regole di controllo, le salva in YDB, distribuisce le attività tra i nodi di controllo sanitario e aggrega i risultati, che vengono poi salvati nel database e inviati al controller del bilanciatore di carico. A sua volta, invia una richiesta per modificare la composizione del cluster nel piano dati al nodo del bilanciamento del carico, di cui parlerò di seguito.

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Parliamo ancora di controlli sanitari. Possono essere suddivisi in diverse classi. Gli audit hanno criteri di successo diversi. I controlli TCP devono stabilire con successo una connessione entro un periodo di tempo fisso. I controlli HTTP richiedono sia una connessione riuscita che una risposta con un codice di stato 200.

Inoltre, i controlli differiscono nella classe di azione: sono attivi e passivi. I controlli passivi monitorano semplicemente ciò che sta accadendo al traffico senza intraprendere alcuna azione speciale. Questo non funziona molto bene su L4 perché dipende dalla logica dei protocolli di livello superiore: su L4 non c'è informazione su quanto tempo ha impiegato l'operazione o se il completamento della connessione è stato positivo o negativo. I controlli attivi richiedono che il sistema di bilanciamento invii richieste a ciascuna istanza del server.

La maggior parte dei sistemi di bilanciamento del carico esegue autonomamente i controlli di attività. Noi di Cloud abbiamo deciso di separare queste parti del sistema per aumentare la scalabilità. Questo approccio ci consentirà di aumentare il numero di bilanciatori mantenendo il numero di richieste di controllo dello stato al servizio. I controlli vengono eseguiti da nodi di controllo dello stato separati, attraverso i quali gli obiettivi dei controlli vengono suddivisi e replicati. Non è possibile eseguire controlli da un host poiché potrebbe non riuscire. Quindi non otterremo lo stato delle istanze che ha controllato. Eseguiamo controlli su qualsiasi istanza da almeno tre nodi di controllo dello stato. Suddividiamo gli scopi dei controlli tra i nodi utilizzando algoritmi di hashing coerenti.

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Separare il bilanciamento e il controllo dello stato può portare a problemi. Se il nodo di controllo dello stato effettua richieste all'istanza, bypassando il bilanciatore (che attualmente non serve il traffico), si verifica una situazione strana: la risorsa sembra essere viva, ma il traffico non la raggiungerà. Risolviamo questo problema in questo modo: abbiamo la garanzia di avviare il traffico di controllo dello stato attraverso i bilanciatori. In altre parole, lo schema per spostare i pacchetti con traffico dai client e dai controlli di integrità differisce minimamente: in entrambi i casi, i pacchetti raggiungeranno i bilanciatori, che li consegneranno alle risorse di destinazione.

La differenza è che i client effettuano richieste al VIP, mentre i controlli di integrità effettuano richieste a ogni singolo RIP. Qui sorge un problema interessante: diamo ai nostri utenti l'opportunità di creare risorse nelle reti IP grigie. Immaginiamo che ci siano due diversi proprietari di cloud che hanno nascosto i loro servizi dietro i bilanciatori. Ognuno di essi ha risorse nella sottorete 10.0.0.1/24, con gli stessi indirizzi. Devi essere in grado di distinguerli in qualche modo, e qui devi immergerti nella struttura della rete virtuale Yandex.Cloud. È meglio scoprire maggiori dettagli in video dall'evento about:cloud, per noi ora è importante che la rete sia multistrato e disponga di tunnel che possano essere distinti dall'ID della sottorete.

I nodi di controllo dello stato contattano i bilanciatori utilizzando i cosiddetti indirizzi quasi-IPv6. Un quasi-indirizzo è un indirizzo IPv6 con un indirizzo IPv4 e un ID sottorete utente incorporati al suo interno. Il traffico raggiunge il bilanciatore, che estrae da esso l’indirizzo della risorsa IPv4, sostituisce IPv6 con IPv4 e invia il pacchetto alla rete dell’utente.

Il traffico inverso avviene allo stesso modo: il bilanciatore vede che la destinazione è una rete grigia dagli esperti di controllo sanitario e converte IPv4 in IPv6.

VPP: il cuore del piano dati

Il bilanciatore è implementato utilizzando la tecnologia Vector Packet Processing (VPP), un framework Cisco per l'elaborazione batch del traffico di rete. Nel nostro caso, il framework funziona sulla libreria di gestione dei dispositivi di rete nello spazio utente: Data Plane Development Kit (DPDK). Ciò garantisce elevate prestazioni di elaborazione dei pacchetti: si verificano molti meno interruzioni nel kernel e non vi sono cambi di contesto tra lo spazio kernel e lo spazio utente. 

VPP va ancora oltre e spreme ancora più prestazioni dal sistema combinando i pacchetti in batch. I miglioramenti in termini di prestazioni derivano dall'uso aggressivo delle cache sui processori moderni. Vengono utilizzate sia cache di dati (i pacchetti vengono elaborati in “vettori”, i dati sono vicini tra loro) sia cache di istruzioni: in VPP, l'elaborazione dei pacchetti segue un grafico, i cui nodi contengono funzioni che svolgono lo stesso compito.

Ad esempio, l'elaborazione dei pacchetti IP in VPP avviene nel seguente ordine: prima le intestazioni dei pacchetti vengono analizzate nel nodo di analisi, quindi vengono inviate al nodo, che inoltra ulteriormente i pacchetti secondo le tabelle di instradamento.

Un po' hardcore. Gli autori di VPP non tollerano compromessi nell'uso delle cache del processore, quindi il codice tipico per l'elaborazione di un vettore di pacchetti contiene una vettorizzazione manuale: esiste un ciclo di elaborazione in cui viene elaborata una situazione del tipo "abbiamo quattro pacchetti in coda", poi lo stesso per due, quindi - per uno. Le istruzioni di prelettura vengono spesso utilizzate per caricare i dati nelle cache per accelerarne l'accesso nelle iterazioni successive.

n_left_from = frame->n_vectors;
while (n_left_from > 0)
{
    vlib_get_next_frame (vm, node, next_index, to_next, n_left_to_next);
    // ...
    while (n_left_from >= 4 && n_left_to_next >= 2)
    {
        // processing multiple packets at once
        u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        // ...
        /* Prefetch next iteration. */
        {
            vlib_buffer_t *p2, *p3;

            p2 = vlib_get_buffer (vm, from[2]);
            p3 = vlib_get_buffer (vm, from[3]);

            vlib_prefetch_buffer_header (p2, LOAD);
            vlib_prefetch_buffer_header (p3, LOAD);

            CLIB_PREFETCH (p2->data, CLIB_CACHE_LINE_BYTES, STORE);
            CLIB_PREFETCH (p3->data, CLIB_CACHE_LINE_BYTES, STORE);
        }
        // actually process data
        /* verify speculative enqueues, maybe switch current next frame */
        vlib_validate_buffer_enqueue_x2 (vm, node, next_index,
                to_next, n_left_to_next,
                bi0, bi1, next0, next1);
    }

    while (n_left_from > 0 && n_left_to_next > 0)
    {
        // processing packets by one
    }

    // processed batch
    vlib_put_next_frame (vm, node, next_index, n_left_to_next);
}

Pertanto, gli Healthcheck comunicano tramite IPv6 con il VPP, che li trasforma in IPv4. Questo viene fatto da un nodo nel grafico, che chiamiamo NAT algoritmico. Per il traffico inverso (e la conversione da IPv6 a IPv4) esiste lo stesso nodo NAT algoritmico.

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Il traffico diretto dai client del bilanciatore passa attraverso i nodi del grafico, che eseguono il bilanciamento stesso. 

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Il primo nodo sono le sessioni permanenti. Memorizza l'hash di 5 tupla per le sessioni stabilite. 5-tuple include l'indirizzo e la porta del client da cui vengono trasmesse le informazioni, l'indirizzo e le porte delle risorse disponibili per ricevere il traffico, nonché il protocollo di rete. 

L'hash a 5 tuple ci aiuta a eseguire meno calcoli nel successivo nodo di hashing coerente, nonché a gestire meglio le modifiche dell'elenco di risorse dietro il sistema di bilanciamento. Quando un pacchetto per il quale non è presente alcuna sessione arriva al bilanciatore, viene inviato al nodo hashing coerente. È qui che avviene il bilanciamento utilizzando l'hashing coerente: selezioniamo una risorsa dall'elenco delle risorse "live" disponibili. Successivamente, i pacchetti vengono inviati al nodo NAT, che di fatto sostituisce l'indirizzo di destinazione e ricalcola i checksum. Come puoi vedere, seguiamo le regole del VPP: piace a piacere, raggruppando calcoli simili per aumentare l'efficienza delle cache del processore.

Hashing coerente

Perché l'abbiamo scelto e cos'è? Innanzitutto, consideriamo l'attività precedente: selezionare una risorsa dall'elenco. 

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Con l'hashing incoerente, viene calcolato l'hash del pacchetto in entrata e una risorsa viene selezionata dall'elenco dividendo il resto di questo hash per il numero di risorse. Finché l'elenco rimane invariato, questo schema funziona bene: inviamo sempre pacchetti con la stessa tupla di 5 elementi alla stessa istanza. Se, ad esempio, alcune risorse smettono di rispondere ai controlli di integrità, per una parte significativa degli hash la scelta cambierà. Le connessioni TCP del client verranno interrotte: un pacchetto che in precedenza ha raggiunto l'istanza A potrebbe iniziare a raggiungere l'istanza B, che non ha familiarità con la sessione di questo pacchetto.

L'hashing coerente risolve il problema descritto. Il modo più semplice per spiegare questo concetto è questo: immagina di avere un anello a cui distribuire le risorse tramite hash (ad esempio, tramite IP:porta). Selezionare una risorsa significa far girare la ruota di un angolo, determinato dall'hash del pacchetto.

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Ciò riduce al minimo la ridistribuzione del traffico quando cambia la composizione delle risorse. L'eliminazione di una risorsa influenzerà solo la parte dell'anello di hashing coerente in cui si trovava la risorsa. Anche l'aggiunta di una risorsa modifica la distribuzione, ma abbiamo un nodo sessioni sticky, che ci consente di non cambiare sessioni già stabilite con nuove risorse.

Abbiamo esaminato cosa succede al traffico diretto tra il bilanciatore e le risorse. Consideriamo ora il traffico di ritorno. Segue lo stesso schema del traffico di controllo: tramite NAT algoritmico, ovvero tramite NAT inverso 44 per il traffico client e tramite NAT 46 per il traffico di controlli di integrità. Aderiamo al nostro schema: unifichiamo il traffico dei controlli sanitari e il traffico degli utenti reali.

Nodo di bilanciamento del carico e componenti assemblati

La composizione dei bilanciatori e delle risorse in VPP è segnalata dal servizio locale - nodo di bilanciamento del carico. Si iscrive al flusso di eventi dal controller del bilanciatore di carico ed è in grado di tracciare la differenza tra lo stato VPP corrente e lo stato target ricevuto dal controller. Otteniamo un sistema chiuso: gli eventi dall'API arrivano al controller del bilanciatore, che assegna compiti al controller del controllo sanitario per verificare la "vivacità" delle risorse. Questo, a sua volta, assegna compiti al nodo di controllo dello stato e aggrega i risultati, dopodiché li rimanda al controller del bilanciatore. Il nodo di bilanciamento del carico si iscrive agli eventi del controller e modifica lo stato del VPP. In un tale sistema, ogni servizio conosce solo ciò che è necessario dei servizi vicini. Il numero di connessioni è limitato e abbiamo la capacità di operare e scalare diversi segmenti in modo indipendente.

Architettura di un bilanciatore del carico di rete in Yandex.Cloud

Quali problemi sono stati evitati?

Tutti i nostri servizi nel piano di controllo sono scritti in Go e hanno buone caratteristiche di scalabilità e affidabilità. Go ha molte librerie open source per la creazione di sistemi distribuiti. Utilizziamo attivamente GRPC, tutti i componenti contengono un'implementazione open source di rilevamento dei servizi: i nostri servizi monitorano le prestazioni reciproche, possono modificare la loro composizione in modo dinamico e abbiamo collegato questo al bilanciamento GRPC. Per le metriche utilizziamo anche una soluzione open source. Nel piano dati abbiamo ottenuto prestazioni decenti e una grande riserva di risorse: si è rivelato molto difficile assemblare uno stand su cui poter contare sulle prestazioni di un VPP, piuttosto che su una scheda di rete di ferro.

Problemi e soluzioni

Cosa non ha funzionato così bene? Go dispone della gestione automatica della memoria, ma si verificano comunque perdite di memoria. Il modo più semplice per gestirli è eseguire le goroutine e ricordarsi di terminarle. Conclusione: osserva il consumo di memoria dei tuoi programmi Go. Spesso un buon indicatore è il numero di goroutine. C'è un vantaggio in questa storia: in Go è facile ottenere dati di runtime: consumo di memoria, numero di goroutine in esecuzione e molti altri parametri.

Inoltre, Go potrebbe non essere la scelta migliore per i test funzionali. Sono piuttosto prolissi e l'approccio standard di "eseguire tutto in CI in batch" non è molto adatto a loro. Il fatto è che i test funzionali richiedono più risorse e causano timeout reali. Per questo motivo, i test potrebbero fallire perché la CPU è impegnata con i test unitari. Conclusione: se possibile, eseguire test "pesanti" separatamente dai test unitari. 

L'architettura degli eventi dei microservizi è più complessa di un monolite: raccogliere log su decine di macchine diverse non è molto conveniente. Conclusione: se realizzi microservizi, pensa subito al tracciamento.

I nostri piani

Lanceremo un bilanciatore interno, un bilanciatore IPv6, aggiungeremo il supporto per gli script Kubernetes, continueremo a partizionare i nostri servizi (attualmente solo Healthcheck-Node e HealthCheck-ctrl sono partizionati), aggiungeremo nuovi controlli sanitari e implementeremo anche l'aggregazione intelligente dei controlli. Stiamo valutando la possibilità di rendere i nostri servizi ancora più indipendenti, in modo che comunichino non direttamente tra loro, ma utilizzando una coda di messaggi. Recentemente è apparso nel Cloud un servizio compatibile con SQS Coda di messaggi Yandex.

Recentemente ha avuto luogo il rilascio pubblico di Yandex Load Balancer. Esplorare documentazione al servizio, gestisci i bilanciatori nel modo per te conveniente e aumenta la tolleranza agli errori dei tuoi progetti!

Fonte: habr.com

Aggiungi un commento