
Ciao, sono Sergey Elanciev, sviluppo in Yandex.Cloud. In passato ho guidato lo sviluppo del bilanciatore L7 del portale di Yandex — i miei colleghi scherzano che qualunque cosa io faccia, riesco a crearne uno. Racconterò ai lettori di Habr come gestire il carico su una piattaforma cloud, quale strumento ideale immaginiamo per raggiungere questo obiettivo e come ci stiamo muovendo per costruire questo strumento.
Iniziamo con alcune definizioni:
- 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 prontezza del server
- Zona di disponibilità, Availability Zone, AZ — infrastruttura isolata in un data center
- Regione — unione di diverse AZ
I bilanciatori di carico risolvono tre compiti principali: effettuano la bilanciatura stessa, migliorano l'affidabilità del servizio e semplificano la sua scalabilità. L'affidabilità è garantita dalla gestione automatica del traffico: il bilanciatore monitora lo stato dell'applicazione ed esclude dalla bilanciatura le istanze che non superano il check di vitalità. La scalabilità è garantita da una distribuzione uniforme del carico tra le istanze e dall'aggiornamento dinamico dell'elenco delle istanze. Se la bilanciatura non è sufficientemente uniforme, alcune istanze riceveranno un carico superiore ai loro limiti operativi, rendendo il servizio meno affidabile.
Il bilanciatore di carico è spesso classificato in base al livello del protocollo del modello OSI su cui opera. Il bilanciatore di Cloud opera a livello TCP, che corrisponde al quarto livello, L4.
Passiamo alla panoramica dell'architettura del bilanciatore di Cloud. Aumenteremo gradualmente il livello di dettaglio. Suddividiamo i componenti del bilanciatore in tre classi. La classe config plane si occupa dell'interazione con l'utente e contiene 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 router border. Per aumentare l'affidabilità, più di questi dispositivi operano contemporaneamente in uno stesso data center. Successivamente, il traffico raggiunge i bilanciatori, che annunciano agli utenti l'indirizzo IP anycast in tutte le AZ tramite BGP.

Il traffico viene trasferito attraverso ECMP — una strategia di instradamento che consente l'esistenza di più percorsi equivalenti verso la destinazione (in questo caso sarà l'indirizzo IP di destinazione) e i pacchetti possono essere inviati lungo uno qualsiasi di essi. Supportiamo anche l'operatività in più zone di disponibilità secondo il seguente schema: annunciamo l'indirizzo in ciascuna zona, il traffico arriva alla più vicina e non oltrepassa il suo confine. Più avanti nel post discuteremo in modo più dettagliato cosa accade con il traffico.
Config plane
Il componente chiave del config plane è l'API, attraverso la quale vengono eseguite le operazioni principali con i bilanciatori: creazione, eliminazione, modifica della composizione delle istanze, raccolta dei risultati degli healthcheck, ecc. Da un lato, si tratta di un'API REST, dall'altro, in Cloud utilizziamo molto spesso il framework gRPC, quindi "traduciamo" REST in gRPC e utilizziamo solo gRPC. Qualunque richiesta crea una serie di attività idempotenti asincrone che vengono eseguite su un pool comune di lavoratori di Yandex.Cloud. Le attività sono scritte in modo tale da poter essere sospese in qualsiasi momento e poi riavviate. Questo assicura scalabilità, ripetibilità e tracciabilità delle operazioni.

Di conseguenza, una richiesta API invierà una richiesta al controller di servizio dei bilanciatori, scritto in Go. Può aggiungere ed eliminare bilanciatori, cambiare la composizione dei backend e le impostazioni.

Il servizio conserva il proprio stato in Yandex Database — un database distribuito gestito, che ben presto potrete utilizzare anche voi. In Yandex.Cloud, come abbiamo già accennato, opera il concetto di dog food: se noi stessi utilizziamo i nostri servizi, anche i nostri clienti li utilizzeranno volentieri. Yandex Database è un esempio di tale concetto. Conserviamo in YDB tutti i nostri dati e non dobbiamo preoccuparci della manutenzione e della scalabilità del database: questi problemi sono risolti per noi, utilizziamo il database come servizio. Torniamo al controller del bilanciatore. Il suo compito è mantenere le informazioni sul bilanciatore e inviare la richiesta di verifica della prontezza della macchina virtuale al controller healthcheck.
Healthcheck controller
Controllore di salute
Riceve richieste di modifica delle regole di verifica, le salva in YDB, distribuisce le attività sui nodi di healthcheck e aggrega i risultati, che vengono quindi salvati nel database e inviati al controller del load balancer. Quest'ultimo invia a sua volta una richiesta di modifica della composizione del cluster al data plane sul load balancer-node, di cui parlerò più avanti.

Parliamo più nel dettaglio degli healthchecks. Possono essere suddivisi in diverse categorie. I controlli hanno criteri di successo differenti. I controlli TCP devono stabilire una connessione con successo entro un tempo fisso. I controlli HTTP richiedono sia una connessione riuscita che una risposta con codice di stato 200.
I controlli si distinguono anche per classe di azione: possono essere attivi o passivi. I controlli passivi monitorano semplicemente ciò che accade con il traffico, senza intraprendere azioni speciali. Questo funziona malamente su L4, poiché dipende dalla logica dei protocolli di livello superiore: su L4 non ci sono informazioni su quanto tempo ha impiegato l'operazione e se la connessione è stata chiusa correttamente o meno. I controlli attivi richiedono che il bilanciatore invii richieste a ciascun'istanza del server.
La maggior parte dei bilanciatori di carico esegue autonomamente i controlli di 'vitalità'. In облаке abbiamo deciso di separare queste parti del sistema per aumentare la scalabilità. Questo approccio ci permetterà di aumentare il numero di bilanciatori, mantenendo inalterato il numero di richieste di healthcheck al servizio. I controlli vengono eseguiti su nodi di healthcheck separati, su cui sono sharded e replicati gli obiettivi di controllo. Non è possibile eseguire i controlli da un solo host, poiché potrebbe guastarsi. In tal caso non riceveremmo lo stato delle istanze verificate. Eseguiamo controlli su ciascuna delle istanze da un minimo di tre nodi di healthcheck. Gli obiettivi dei controlli sono sharded tra i nodi utilizzando algoritmi di hashing consistente.

La separazione del bilanciamento e degli healthcheck può portare a problemi. Se un nodo di healthcheck effettua richieste a un'istanza, bypassando il bilanciatore (che in quel momento non gestisce traffico), si verifica una situazione strana: la risorsa sembra viva, ma il traffico non vi arriverà. Risolviamo questo problema garantendo che il traffico di healthcheck venga instradato attraverso i bilanciatori. In altre parole, lo schema di instradamento dei pacchetti dal traffico dei clienti e dagli healthchecks differisce minimamente: in entrambi i casi, i pacchetti arriveranno ai bilanciatori, che li consegneranno alle risorse target.
La differenza è che i clienti fanno richieste al VIP, mentre gli healthchecks si rivolgono a ciascun RIP separato. Qui sorge un problema interessante: ai nostri utenti diamo la possibilità di creare risorse in reti IP grigie. Immaginiamo di avere due diversi proprietari di cloud che hanno nascosto i propri servizi dietro bilanciatori. Ognuno di essi ha risorse nella sottorete 10.0.0.1/24, con indirizzi identici. È necessario trovare un modo per distinguerli, e qui bisogna immergersi nella struttura della rete virtuale di Yandex.Cloud. I dettagli sono meglio conosciuti in , è importante notare che la rete è multilivello e ha tunnel che possono essere distinti per id sottorete.
I nodi di healthcheck si rivolgono ai bilanciatori tramite quelli che vengono chiamati indirizzi quasi-IPv6. Un indirizzo quasi è un indirizzo IPv6, all'interno del quale è incorporato un indirizzo IPv4 e l'id della sottorete dell'utente. Il traffico arriva al bilanciatore, che estrae da esso l'indirizzo IPv4 della risorsa, sostituisce l'IPv6 con l'IPv4 e invia il pacchetto nella rete dell'utente.
Il traffico di ritorno avviene allo stesso modo: il bilanciatore vede che la destinazione è una rete grigia di healthchecker e trasforma l'IPv4 in IPv6.
VPP è il cuore del data plane
Il bilanciatore è implementato tramite la tecnologia Vector Packet Processing (VPP) — un framework di Cisco per l'elaborazione del traffico di rete. Nel nostro caso, il framework opera sopra la libreria di gestione degli dispositivi di rete in user-space — Data Plane Development Kit (DPDK). Questo garantisce un'alta performance nell'elaborazione dei pacchetti: nel kernel avvengono molte meno interruzioni, non ci sono switch di contesto tra kernel space e user space.
VPP va oltre e ottimizza ulteriormente le performance del sistema raggruppando i pacchetti in batch. Il miglioramento delle performance avviene grazie all'uso aggressivo della cache dei moderni processori. Si utilizzano sia le cache dei dati (i pacchetti vengono elaborati ‘a vettori’, i dati sono vicini tra loro), sia le cache delle istruzioni: in VPP l'elaborazione dei pacchetti segue un grafo, nei nodi del quale si trovano funzioni che eseguono un compito specifico.
Ad esempio, il processamento dei pacchetti IP in VPP avviene in questo modo: prima si esegue il parsing degli header nei nodi di parsing, quindi i pacchetti vengono inviati al nodo che li inoltra secondo le tabelle di routing.
Un po' di hardcore. Gli autori di VPP non accettano compromessi nell'uso delle cache della CPU, quindi il codice tipico per l'elaborazione dei pacchetti vettoriali contiene una vettorizzazione manuale: c'è un ciclo di elaborazione che gestisce situazioni come "abbiamo quattro pacchetti in coda", poi lo stesso per due, e infine per uno. Spesso si utilizzano istruzioni prefetch che caricano i dati nelle cache per accelerare 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)
{
// processamento di più pacchetti contemporaneamente
u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
// ...
/* Prefetch per la prossima iterazione. */
{
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);
}
// effettivamente processa i dati
/* verifica le enqueue speculative, forse cambia 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)
{
// processamento dei pacchetti uno alla volta
}
// batch elaborato
vlib_put_next_frame (vm, node, next_index, n_left_to_next);
}Quindi, i Healthchecks si connettono a VPP tramite IPv6, che li converte 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 un nodo simile di NAT algoritmico.

Il traffico diretto dai client del bilanciatore passa attraverso i nodi del grafo che si occupano del bilanciamento stesso.

Il primo nodo è per le sessioni sticky. Qui viene memorizzato l'hash da per le sessioni stabilite. Il 5-tuple include l'indirizzo e la porta del client da cui viene trasmessa l'informazione, l'indirizzo e la porta delle risorse disponibili per ricevere il traffico, e il protocollo di rete.
L'hash del 5-tuple ci aiuta a ridurre i calcoli nel successivo nodo di hashing consistente, e a gestire meglio la modifica della lista di risorse dietro al bilanciatore. Quando arriva un pacchetto per il quale non esiste una sessione, viene inviato al nodo di hashing consistente. Qui avviene il bilanciamento tramite hashing consistente: scegliamo una risorsa dalla lista delle risorse 'vive' disponibili. I pacchetti vengono poi inviati al nodo NAT, che effettua la sostituzione dell'indirizzo di destinazione e il ricalcolo dei checksum. Come potete vedere, seguiamo le regole del VPP — simile a simile, raggruppiamo calcoli simili per aumentare l'efficacia delle cache della CPU.
Hashing consistente
Perché l'abbiamo scelto e che cos'è esattamente? Cominciamo esaminando il compito precedente — la selezione di una risorsa dall'elenco.

Con l'hashing non consistente, si calcola l'hash del pacchetto in entrata, e si seleziona una risorsa dall'elenco 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 healthchecks, cambiamenti significativi si verificheranno nella selezione degli hash. Le connessioni TCP del cliente si interromperanno: un pacchetto che prima andava all'istanza A, potrebbe cominciare ad andare all'istanza B, che non conosce la sessione per quel pacchetto.
L'hashing consistente risolve il problema descritto. La concetto più semplice da spiegare è il seguente: immaginate di avere un anello, sul quale distribuite le risorse in base all'hash (ad esempio, in base a IP:port). La selezione della risorsa avviene ruotando la ruota di un angolo determinato dall'hash del pacchetto.

In questo modo si minimizza la riallocazione del traffico quando cambia l'insieme delle risorse. La rimozione di una risorsa influenzerà solo quella parte dell'anello dell'hashing consistente in cui si trovava tale risorsa. L'aggiunta di una risorsa modifica anche la distribuzione, ma abbiamo un nodo per sessioni sticky che consente di non cambiare le sessioni già stabilite su nuove risorse.
Abbiamo esaminato cosa succede con il traffico diretto tra il bilanciatore e le risorse. Ora approfondiamo il traffico di ritorno. Segue lo stesso schema del traffico dei healthchecks — attraverso il NAT algoritmico, ovvero attraverso il NAT 44 per il traffico client e attraverso il NAT 46 per il traffico dei healthchecks. Seguiamo il nostro schema: unifichiamo il traffico dei healthchecks e il traffico reale degli utenti.
Loadbalancer-node e componenti in assemblea
La composizione dei bilanciatori e delle risorse nel VPP è riportata dal servizio locale — loadbalancer-node. Questo si iscrive al flusso degli eventi dal loadbalancer-controller, ed è in grado di costruire la differenza tra lo stato attuale del VPP e lo stato obiettivo ricevuto dal controllore. Otteniamo un sistema chiuso: gli eventi dall'API arrivano al controllore del bilanciatore, il quale assegna compiti al controllore di healthcheck per verificare la "vitalità" delle risorse. Questo, a sua volta, assegna compiti al healthcheck-node e aggrega i risultati, restituendoli poi al controllore dei bilanciatori. Il loadbalancer-node si iscrive agli eventi dal controllore e modifica lo stato del VPP. In questo sistema, ogni servizio conosce solo ciò che è necessario sugli altri servizi. Il numero di relazioni è 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 offrono 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 del service discovery — i nostri servizi monitorano la loro operatività, possono cambiare la loro composizione in modo dinamico, e abbiamo integrato questo con il bilanciamento GRPC. Anche per le metriche utilizziamo una soluzione open source. Nel data plane abbiamo ottenuto prestazioni dignitose e una buona riserva di risorse: è stato molto difficile assemblare un ambiente in cui potessimo raggiungere le prestazioni del VPP, e non quelle della scheda di rete fisica.
Problemi e soluzioni
Cosa ha funzionato non molto bene? In Go la gestione della memoria è automatica, ma le perdite di memoria si verificano comunque. Il modo più semplice per affrontarle è eseguire goroutine e non dimenticare di terminarle. Conclusione: monitora il consumo di memoria delle applicazioni Go. Un buon indicatore è spesso il numero di goroutine. In questa situazione, c'è anche un lato positivo: in Go è facile ottenere dati dal 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. Essi sono piuttosto verbosi, e l'approccio standard "esegui tutto in CI in batch" non si adatta molto bene a essi. Il motivo è che i test funzionali sono più esigenti in termini di risorse, e si verificano veri e propri timeout. A causa di ciò, i test possono fallire, poiché la CPU è occupata dai test unitari. Conclusione: se possibile, esegui i test "pesanti" separatamente dai test unitari.
L'architettura degli eventi dei microservizi è più complessa di un monolite: cercare nei log su decine di macchine diverse non è molto pratico. Conclusione: se fai microservizi, pensa subito al tracing.
I nostri piani
Lanceremo un bilanciatore interno, un bilanciatore IPv6, aggiungeremo supporto per scenari Kubernetes, continueremo a shardare i nostri servizi (attualmente solo healthcheck-node e healthcheck-ctrl sono shardati), aggiungeremo nuovi healthcheck e implementeremo un'aggregazione intelligente dei controlli. Stiamo considerando di rendere i nostri servizi ancora più indipendenti — affinché comunichino non direttamente tra di loro, ma tramite una coda di messaggi. Di recente è stato introdotto un servizio compatibile con SQS nel Cloud. .
Di recente è avvenuto il rilascio pubblico di Yandex Load Balancer. Esplora il servizio, gestisci i bilanciatori nel modo a te più conveniente e aumenta la resilienza dei tuoi progetti!
Fonte: habr.com
