
Ciao, sono Sergey Elancev, sviluppo in Yandex.Cloud. In passato ho guidato lo sviluppo del bilanciatore L7 del portale Yandex — i colleghi scherzano che qualunque cosa io faccia, si trasforma in un bilanciatore. Racconterò ai lettori di Habr come gestire il carico sulla piattaforma cloud, quale strumento ideale vediamo per raggiungere questo obiettivo e come stiamo procedendo nella costruzione di questo strumento.
Per iniziare, introduciamo alcuni termini:
- VIP (Virtual IP) — indirizzo IP del bilanciatore
- Server, backend, istanza — macchina virtuale con un'applicazione in esecuzione
- RIP (Real IP) — indirizzo IP del server
- Healthcheck — verifica della disponibilità del server
- Zona di disponibilità, Availability Zone, AZ — infrastruttura isolata in un centro dati
- Regione — unione di diverse AZ
I bilanciatori di carico risolvono tre compiti principali: eseguono il bilanciamento stesso, migliorano la tolleranza ai guasti del servizio e semplificano la sua scalabilità. La tolleranza ai guasti è garantita dalla gestione automatica del traffico: il bilanciatore controlla lo stato dell'applicazione e esclude dalla bilanciatura le istanze che non superano il controllo di vivacità. La scalabilità è garantita da una distribuzione uniforme del carico tra le istanze e dall'aggiornamento dinamico dell'elenco delle istanze. Se il bilanciamento non è sufficientemente uniforme, alcune delle istanze riceveranno un carico che supera i loro limiti di operatività, e il servizio diventerà meno affidabile.
I bilanciatori di carico vengono spesso classificati in base al livello del protocollo del modello OSI su cui operano. Il bilanciatore di Cloud opera a livello TCP, che corrisponde al quarto livello, L4.
Passiamo alla panoramica dell'architettura del bilanciatore di Cloud. Aumenteremo progressivamente il livello di dettaglio. Dividiamo i componenti del bilanciatore in tre classi. La classe config plane si occupa dell'interazione con l'utente e conserva lo stato desiderato del sistema. Il control plane conserva lo stato attuale del sistema e gestisce i sistemi della classe data plane, che sono responsabili della consegna del traffico dai clienti alle vostre istanze.
Data plane
Il traffico arriva a costosi dispositivi chiamati border routers. Per aumentare la resilienza, diversi di questi dispositivi operano contemporaneamente in un data center. Successivamente, il traffico viene indirizzato ai bilanciatori, che annunciano agli utenti l'indirizzo IP anycast su tutti gli AZ tramite BGP.

Il traffico viene trasferito tramite ECMP, che è una strategia di routing in base alla quale possono esistere più percorsi equivalenti per raggiungere una destinazione (nel nostro caso, l'indirizzo IP di destinazione) e i pacchetti possono essere inviati lungo uno qualsiasi di essi. Supportiamo anche il funzionamento in più zone di disponibilità secondo il seguente schema: annunciamo l'indirizzo in ciascuna delle zone, il traffico entra nella più vicina e non esce oltre di essa. Più avanti in questo post esamineremo più dettagliatamente cosa succede al traffico.
Config plane
Il componente chiave del config plane è l'API, tramite cui vengono eseguite le operazioni principali con i bilanciatori: creazione, eliminazione, modifica del set di istanze, ottenimento dei risultati dei controlli di integrità, ecc. Da un lato, si tratta di un'API REST, dall'altro, nel Cloud usiamo molto spesso il framework gRPC, quindi "traduciamo" REST in gRPC e successivamente utilizziamo solo gRPC. Ogni richiesta porta alla creazione di una serie di attività idempotenti asincrone, che vengono eseguite su un pool condiviso di worker di Yandex.Cloud. Le attività sono scritte in modo tale da poter essere sospese in qualsiasi momento e riavviate in seguito. Ciò garantisce scalabilità, ripetibilità e registrazione delle operazioni.

Di conseguenza, il compito dall'API effettuerà una richiesta al service controller dei bilanciatori, che è scritto in Go. Esso può aggiungere ed eliminare bilanciatori, modificare il set di backend e le configurazioni.

Il servizio memorizza il proprio stato in Yandex Database, un database distribuito gestito che presto potrete utilizzare anche voi. In Yandex.Cloud, come abbiamo già , è in atto il concetto di dog food: se noi stessi utilizziamo i nostri servizi, anche i nostri clienti li utilizzeranno con piacere. Yandex Database è un esempio dell'attuazione di tale concetto. Memorizziamo in YDB tutti i nostri dati e non dobbiamo preoccuparci della manutenzione e della scalabilità del database: questi problemi sono stati risolti per noi, utilizziamo il database come servizio.
Torniamo al controller del bilanciatore. Il suo compito è quello di mantenere le informazioni sul bilanciatore, inviare un'operazione di verifica della prontezza della macchina virtuale al healthcheck controller.
Healthcheck controller
Riceve richieste di modifica delle regole di controllo, le memorizza in YDB, distribuisce compiti tra i nodi di healthcheck e aggrega i risultati, che vengono poi salvati nel database e inviati al loadbalancer controller. Questo, a sua volta, invia una richiesta per modificare la composizione del cluster nel data plane al loadbalancer-node, di cui parlerò in seguito.

Parliamo più nel dettaglio degli healthcheck. Possono essere suddivisi in diverse classi. I controlli hanno diversi criteri di successo. I controlli TCP devono stabilire una connessione con successo entro un tempo prestabilito. I controlli HTTP richiedono sia una connessione riuscita che una risposta con codice di stato 200.
Inoltre, i controlli si differenziano per classe di azione: possono essere attivi o passivi. I controlli passivi osservano semplicemente ciò che accade con il traffico, senza intraprendere azioni specifiche. Questo non funziona molto bene a livello L4, poiché dipende dalla logica dei protocolli di livello superiore: a L4 non ci sono informazioni su quanto tempo ha richiesto l'operazione e se la chiusura della connessione è stata buona o cattiva. I controlli attivi richiedono che il bilanciatore invii richieste a ciascun'istanza del server.
La maggior parte dei bilanciatori di carico esegue autonomamente controlli di "vitalità". Noi, in Cloud, abbiamo deciso di separare queste parti del sistema per migliorare la scalabilità. Questo approccio ci permetterà di aumentare il numero di bilanciatori mantenendo costante il numero di richieste di healthcheck al servizio. I controlli vengono eseguiti da nodi di healthcheck separati, su cui vengono shardati e replicati gli obiettivi di controllo. Non è possibile eseguire controlli da un singolo host, poiché potrebbe guastarsi. Allora non avremmo lo stato delle istanze verificate. Eseguiamo controlli su ogni istanza da un minimo di tre nodi di healthcheck. Shardiamo gli obiettivi di controllo tra i nodi utilizzando algoritmi di hashing consistente.

La separazione del bilanciamento e del healthcheck può causare problemi. Se il nodo healthcheck effettua richieste all'istanza, saltando il bilanciatore (che attualmente non gestisce traffico), si verifica una situazione strana: la risorsa sembra essere attiva, ma il traffico non arriverà. Risolviamo questo problema garantendo che il traffico di healthcheck passi attraverso i bilanciatori. In altre parole, lo schema di instradamento dei pacchetti con il traffico dei client e degli healthcheck differisce minimamente: in entrambi i casi, i pacchetti raggiungeranno i bilanciatori, che li consegneranno alle risorse target.
La differenza è che i client effettuano richieste su VIP, mentre gli healthcheck si rivolgono a ciascun RIP separatamente. Qui nasce un problema interessante: ai nostri utenti diamo la possibilità di creare risorse in reti IP grigie. Immaginiamo che ci siano due diversi proprietari di cloud che hanno nascosto i loro servizi dietro ai bilanciatori. Ognuno di loro ha risorse nella sottorete 10.0.0.1/24, con indirizzi identici. Dobbiamo sapere come distinguerli e qui è necessario approfondire la struttura della rete virtuale di Yandex.Cloud. È meglio scoprire i dettagli in , è importante che la rete sia multilivello e contenga tunnel che possono essere distinti per id della sottorete.
I nodi healthcheck si rivolgono ai bilanciatori utilizzando quelli che vengono chiamati indirizzi quasi-IPv6. Un indirizzo quasi è un indirizzo IPv6 che incorpora un indirizzo IPv4 e l'id della sottorete dell'utente. Il traffico arriva al bilanciatore, il quale estrae l'indirizzo IPv4 della risorsa, sostituisce l'IPv6 con l'IPv4 e invia il pacchetto nella rete dell'utente.
Il traffico di ritorno funziona allo stesso modo: il bilanciatore vede che la destinazione è una rete grigia di healthchecker e converte l'IPv4 in IPv6.
VPP è il cuore del data plane.
Il bilanciatore è implementato con la tecnologia Vector Packet Processing (VPP) — un framework di Cisco per l'elaborazione dei pacchetti di traffico di rete. Nel nostro caso, il framework funziona sopra la libreria di gestione dei dispositivi di rete in user-space — Data Plane Development Kit (DPDK). Questo assicura elevate prestazioni nell'elaborazione dei pacchetti: ci sono molte meno interruzioni nel nucleo e non ci sono commutazioni di contesto tra kernel space e user space.
VPP va oltre e sfrutta ancora di più le prestazioni del sistema unendo i pacchetti in batch. L'aumento delle prestazioni avviene grazie all'uso aggressivo delle cache dei moderni processori. Vengono utilizzate sia le cache dei dati (i pacchetti vengono elaborati tramite «vettori», i dati sono molto vicini tra loro), sia le cache delle istruzioni: in VPP l'elaborazione dei pacchetti avviene lungo un grafo, in cui i nodi contengono funzioni che eseguono un compito specifico.
Ad esempio, l'elaborazione dei pacchetti IP in VPP avviene nel seguente ordine: prima, nel nodo di parsing, avviene il parsing degli header dei pacchetti, e poi vengono inviati a un nodo che inoltra i pacchetti secondo le tabelle di routing.
Un po' di hard-core. Gli autori di VPP non accettano compromessi nell'uso delle cache del processore, per cui il codice tipico di elaborazione dei vettori di pacchetti contiene la vettorizzazione manuale: c'è un ciclo di elaborazione in cui si gestisce la situazione «abbiamo quattro pacchetti in coda», poi lo stesso per due, e poi per uno. Vengono spesso utilizzate istruzioni di prefetch che caricano 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)
{
// elaborazione di più pacchetti contemporaneamente
u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
// ...
/* Prefetch iterazione successiva. */
{
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);
}
// elaborare effettivamente i dati
/* verificare gli enqueue speculativi, magari cambiare il frame successivo corrente */
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)
{
// elaborazione dei pacchetti uno alla volta
}
// batch elaborato
vlib_put_next_frame (vm, node, next_index, n_left_to_next);
}Quindi, i Healthchecks si rivolgono a VPP tramite IPv6, che li trasforma in IPv4. Questo è gestito da un nodo del grafo, che chiamiamo NAT algoritmico. Per il traffico di ritorno (e la conversione da IPv6 a IPv4) esiste lo stesso nodo di NAT algoritmico.

Il traffico diretto dai clienti del bilanciatore passa attraverso i nodi del grafo che eseguono il bilanciamento vero e proprio.

Il primo nodo — sticky sessions. Contiene un hash di per session stabilite. Il 5-tuple include l'indirizzo e la porta del client da cui vengono trasmessi i dati, l'indirizzo e le porte delle risorse disponibili per ricevere il traffico, così come il protocollo di rete.
L'hash del 5-tuple ci aiuta a ridurre i calcoli nel successivo nodo di hashing consistente, oltre a gestire meglio le modifiche nella lista delle risorse dietro il bilanciatore. Quando un pacchetto senza sessione raggiunge il bilanciatore, viene inviato al nodo di hashing consistente. Qui avviene il bilanciamento tramite hashing consistente: scegliamo una risorsa dall'elenco delle risorse "vive" disponibili. Successivamente, i pacchetti vengono inviati al nodo NAT, che esegue l'effettivo cambio dell'indirizzo di destinazione e il ricalcolo dei checksum. Come potete vedere, seguiamo le regole di VPP: simile al simile, raggruppiamo calcoli simili per aumentare l'efficienza delle cache della CPU.
Hashing Consistente
Perché lo abbiamo scelto e che cos'è? Per iniziare, consideriamo il problema precedente: la scelta di una risorsa dall'elenco.

Nel caso di hashing non consistente, si calcola l'hash del pacchetto in arrivo e si sceglie la risorsa dalla lista in base al resto della divisione di questo hash per il numero di risorse. Finché l'elenco rimane invariato, questo schema funziona bene: inviamo sempre pacchetti con lo stesso 5-tuple allo stesso istanza. Se, ad esempio, una certa risorsa smette di rispondere ai health checks, la scelta per una parte significativa degli hash cambierà. Le connessioni TCP del client si interromperanno: un pacchetto che in precedenza andava all'istanza A potrebbe ora andare all'istanza B, con la quale non ha familiarità.
L'hashing consistente risolve il problema descritto. È più facile spiegare questo concetto in questo modo: immaginate di avere un anello sul quale distribuite risorse in base all'hash (ad esempio, in base a IP:porta). La scelta della risorsa è come girare la ruota su un angolo determinato dall'hash del pacchetto.

In questo modo si minimizza la riallocazione del traffico quando cambiano le risorse. La rimozione di una risorsa influenzerà solo quella parte dell'anello di hashing consistente in cui si trovava la risorsa stessa. L'aggiunta di una risorsa cambia anch'essa la distribuzione, ma abbiamo un nodo di sessioni sticky, che consente di non cambiare le sessioni già stabilite a nuove risorse.
Abbiamo esaminato cosa succede al traffico diretto tra il bilanciatore e le risorse. Ora vediamo il traffico inverso. Segue lo stesso schema del traffico di controllo: attraverso NAT algoritmico, cioè tramite NAT inverso 44 per il traffico client e tramite NAT 46 per il traffico di healthcheck. Seguiamo il nostro schema: unifichiamo il traffico di healthcheck e il traffico reale degli utenti.
Loadbalancer-node e componenti nell'insieme
La composizione dei bilanciatori e delle risorse in VPP è comunicata dal servizio locale — loadbalancer-node. Si iscrive a un flusso di eventi dal loadbalancer-controller, è in grado di costruire la differenza tra lo stato attuale di VPP e lo stato target ricevuto dal controller. Otteniamo un sistema chiuso: gli eventi dall'API arrivano al controller del bilanciatore, il quale assegna al controller di healthcheck compiti per verificare la "vitalità" delle risorse. Questi, a loro volta, assegnano compiti al healthcheck-node e aggregano i risultati, i quali vengono restituiti al controller dei bilanciatori. Loadbalancer-node si iscrive agli eventi dal controller e modifica lo stato di VPP. In questo sistema, ogni servizio conosce solo ciò che è necessario sui servizi vicini. Il numero di collegamenti è limitato, e abbiamo la possibilità di sfruttare e scalare indipendentemente diversi segmenti.

Quali domande siamo riusciti a evitare
Tutti i nostri servizi nel control plane sono scritti in Go e si distinguono per buone caratteristiche in termini di scalabilità e affidabilità. In Go ci sono molte librerie open source per costruire sistemi distribuiti. Utilizziamo attivamente GRPC, tutti i componenti contengono un'implementazione open source di service discovery: i nostri servizi monitorano la salute reciproca, possono cambiare dinamicamente la loro composizione, e abbiamo integrato questo con il bilanciamento GRPC. Utilizziamo anche una soluzione open source per le metriche. Nel data plane abbiamo ottenuto buone prestazioni e un grande margine di risorse: è stato molto difficile costruire un banco di prova in grado di raggiungere le prestazioni di VPP, e non quelle della scheda di rete.
Problemi e soluzioni
Cosa non ha funzionato molto bene? In Go, la gestione della memoria è automatica, ma le perdite di memoria si verificano comunque. Il modo più semplice per affrontarle è eseguire le goroutine e non dimenticare di terminarle. Conclusione: controllate l'utilizzo della memoria delle applicazioni Go. Spesso un buon indicatore è il numero di goroutine. In questa storia c'è anche un aspetto positivo: in Go è facile ottenere dati sul runtime - sul consumo di memoria, sul numero di goroutine attive e su molti altri parametri.
Inoltre, Go potrebbe non essere la scelta migliore per i test funzionali. Sono piuttosto verbosi e l'approccio standard di "eseguire tutto in CI in batch" non si adatta molto bene. Il motivo è che i test funzionali richiedono più risorse e possono andare incontro a veri e propri timeout. Di conseguenza, i test possono concludersi senza successo poiché la CPU è occupata dai test unitari. Conclusione: se possibile, eseguite i test "pesanti" separatamente dai test unitari.
Un'architettura microservizi basata su eventi è più complessa di un monolite: fare grep sui registri su decine di macchine diverse non è molto pratico. Conclusione: se state realizzando microservizi, pensate subito al tracciamento.
I nostri piani
Lanceremo un bilanciatore interno, un bilanciatore IPv6, aggiungeremo supporto per gli script di Kubernetes, continueremo a shardare i nostri servizi (attualmente solo healthcheck-node e healthcheck-ctrl sono shardati), aggiungeremo nuovi healthchecks e implementeremo un'aggregazione intelligente delle verifiche. Stiamo considerando la possibilità di rendere i nostri servizi ancora più indipendenti - affinché comunichino non direttamente tra di loro, ma attraverso una coda di messaggi. È recentemente apparso nel Cloud un servizio compatibile con SQS. .
Recentemente è stato rilasciato in pubblico Yandex Load Balancer. Scopri di più sul servizio, gestisci i bilanciatori nel modo che preferisci e aumenta la resilienza dei tuoi progetti!
Fonte: habr.com
