
Hallo, ich bin Sergey Ylantsev und ich entwickle in Yandex.Cloud. Zuvor leitete ich die Entwicklung des L7-Lastausgleichs für das Yandex-Portal – meine Kollegen scherzen, dass egal was ich mache, es ein Lastausgleich wird. Ich werde den Lesern von Habr erzählen, wie man die Last in einer Cloud-Plattform verwalten sollte, welches ideale Werkzeug wir für dieses Ziel sehen und wie wir auf dem Weg sind, dieses Werkzeug zu entwickeln.
Zunächst lassen Sie uns einige Begriffe einführen:
- VIP (Virtuelle IP) – Die IP-Adresse des Lastenausgleichs
- Server, Backend, Instanz – eine virtuelle Maschine mit einer laufenden Anwendung
- RIP (Echte IP) – Die IP-Adresse des Servers
- Healthcheck – Prüfung der Serverbereitschaft
- Verfügbarkeitszone, Availability Zone, AZ – isolierte Infrastruktur im Rechenzentrum
- Region – eine Zusammenstellung verschiedener AZs
Lastverteilungs Systeme erfüllen drei Hauptaufgaben: Sie verteilen die Last, verbessern die Ausfallsicherheit des Dienstes und erleichtern dessen Skalierbarkeit. Die Ausfallsicherheit wird durch das automatische Traffic-Management gewährleistet: Der Lastverteiler überwacht den Zustand der Anwendung und schließt instanzen aus der Lastverteilung aus, die den Live-Check nicht bestehen. Die Skalierbarkeit erfolgt durch eine gleichmäßige Verteilung der Last auf die Instanzen sowie durch die Aktualisierung der Instanzenliste in Echtzeit. Wenn die Lastverteilung nicht ausreichend gleichmäßig erfolgt, können einige Instanzen mit einer Last konfrontiert werden, die ihre Betriebsgrenzen überschreitet, was die Zuverlässigkeit des Dienstes beeinträchtigt.
Lastverteiler werden oft nach dem Protokollniveau des OSI-Modells klassifiziert, auf dem sie operieren. Der Cloud-Lasterteiler arbeitet auf der TCP-Ebene, die der vierten Ebene, L4, entspricht.
Kommen wir zum Überblick über die Architektur des Cloud-Load-Balancers. Wir werden schrittweise das Detailniveau erhöhen. Die Komponenten des Load-Balancers teilen sich in drei Klassen auf. Die Klasse config plane ist für die Interaktion mit dem Benutzer verantwortlich und speichert den gewünschten Zustand des Systems. Die Control plane behält den aktuellen Zustand des Systems im Auge und steuert die Systeme der Klasse data plane, die direkt für die Verteilung des Datenverkehrs von den Kunden zu Ihren Instanzen zuständig sind.
Data plane
Der Datenverkehr gelangt zu teuren Geräten, die als Border Routers bezeichnet werden. Um die Ausfallsicherheit zu erhöhen, arbeiten in einem Rechenzentrum gleichzeitig mehrere solcher Geräte. Anschließend wird der Datenverkehr an die Load-Balancer weitergeleitet, die den Kunden über BGP eine Anycast-IP-Adresse für alle AZs bereitstellen.

Der Datenverkehr wird über ECMP geleitet – eine Routing-Strategie, bei der mehrere gleichwertige Routen zum Ziel (in unserem Fall die Ziel-IP-Adresse) existieren, und die Datenpakete über eine dieser Routen gesendet werden können. Zudem unterstützen wir den Betrieb in mehreren Verfügbarkeitszonen nach folgendem Schema: Wir kündigen die Adresse in jeder Zone an, der Datenverkehr gelangt zur nächstgelegenen und verlässt diese nicht mehr. Im weiteren Verlauf des Beitrags werfen wir einen genaueren Blick darauf, was mit dem Datenverkehr passiert.
Config plane
Ein zentrales Element des Config-Planes ist die API, über die die grundlegenden Operationen mit Lastenausgleichsfunktionen durchgeführt werden: Erstellung, Löschung, Änderung der Instanzen, Abfrage der Ergebnisse von Healthchecks usw. Einerseits handelt es sich um eine REST-API, andererseits verwenden wir in der Cloud sehr häufig das Framework gRPC. Daher "übersetzen" wir REST in gRPC und verwenden weiter ausschließlich gRPC. Jede Anfrage löst eine Reihe von asynchronen, idempotenten Aufgaben aus, die in einem gemeinsamen Pool von Arbeitern im Yandex.Cloud ausgeführt werden. Die Aufgaben sind so gestaltet, dass sie jederzeit angehalten und später erneut gestartet werden können. Dies gewährleistet Skalierbarkeit, Wiederholbarkeit und Protokollierbarkeit der Operationen.

Infolgedessen führt eine API-Anfrage einen Aufruf an den Service-Controller der Lastenausgleichsfunktionen aus, der in Go geschrieben ist. Er kann Lastenausgleichsfunktionen hinzufügen und entfernen, die Zusammensetzung der Backends ändern und Einstellungen anpassen.

Der Service speichert seinen Zustand in der Yandex Database – einer verwalteten, verteilten Datenbank, die Sie bald ebenfalls nutzen können. In Yandex.Cloud, wie bereits erwähnt, , es gilt das Konzept des Dog Fooding: Wenn wir unsere eigenen Dienste nutzen, werden auch unsere Kunden sie gerne verwenden. Yandex Database ist ein Beispiel für die Umsetzung dieses Konzepts. Wir speichern alle unsere Daten in YDB und müssen uns nicht um Wartung und Skalierung der Datenbank kümmern: Diese Probleme wurden für uns gelöst, wir nutzen die Datenbank als Service.
Kehren wir zum Load-Balancer-Controller zurück. Seine Aufgabe ist es, Informationen über den Load-Balancer zu speichern und dem Healthcheck-Controller die Aufgabe zu übertragen, die Virtual Machine zu überprüfen.
Healthcheck-Controller
Er empfängt Anfragen zur Änderung der Überprüfungsregeln, speichert diese in YDB, verteilt Aufgaben an die Healthcheck-Nodes und aggregiert die Ergebnisse, die anschließend in die Datenbank gespeichert und an den Load-Balancer-Controller gesendet werden. Dieser wiederum sendet eine Anfrage zur Änderung der Cluster-Zusammensetzung an den Data Plane auf dem Load-Balancer-Knoten, über den ich gleich sprechen werde.

Lassen Sie uns näher über Healthchecks sprechen. Diese lassen sich in mehrere Klassen unterteilen. Die Prüfungen haben verschiedene Erfolgsparameter. Bei TCP-Checks muss eine Verbindung innerhalb einer festgelegten Zeit erfolgreich hergestellt werden. HTTP-Checks hingegen erfordern sowohl eine erfolgreiche Verbindung als auch eine Antwort mit dem Statuscode 200.
Die Checks unterscheiden sich auch nach ihrer Aktionsklasse – sie können aktiv oder passiv sein. Passive Prüfungen überwachen lediglich den Verkehr, ohne spezielle Maßnahmen zu ergreifen. Dies funktioniert auf L4 nicht optimal, da es von der Logik höherer Protokolle abhängt: Auf L4 gibt es keine Informationen darüber, wie lange eine Operation gedauert hat und ob die Verbindung gut oder schlecht abgeschlossen wurde. Aktive Prüfungen verlangen, dass der Load Balancer Anfragen an jede Serverinstanz sendet.
Die meisten Lastenausgleicher führen die "Lebenszeichen"-Überprüfungen eigenständig durch. Bei uns in der Cloud haben wir uns entschieden, diese Teile des Systems zur Verbesserung der Skalierbarkeit zu trennen. Dieser Ansatz ermöglicht es uns, die Anzahl der Lastenausgleicher zu erhöhen und gleichzeitig die Anzahl der Health-Check-Anfragen an den Dienst beizubehalten. Die Überprüfungen werden von separaten Health-Check-Knoten durchgeführt, und die Überprüfzielen sind shardiert und repliziert. Es ist nicht möglich, Prüfungen von einem einzigen Host aus durchzuführen, da dieser ausfallen kann. In diesem Fall erhalten wir keine Informationen über den Status der von ihm überprüften Instanzen. Wir führen die Prüfungen von mindestens drei Health-Check-Knoten aus für jede der Instanzen durch. Die Ziele der Prüfungen verteilen wir zwischen den Knoten mithilfe konsistenter Hashing-Algorithmen.

Die Trennung von Lastverteilung und Health-Check kann Probleme verursachen. Wenn der Health-Check-Node Anfragen an eine Instanz sendet, während der Lastverteiler (der momentan keinen Verkehr verarbeitet) umgangen wird, kann eine seltsame Situation entstehen: Die Ressource scheint zwar aktiv zu sein, aber der Verkehr erreicht sie nicht. Wir lösen dieses Problem, indem wir sicherstellen, dass der Health-Check-Verkehr über die Lastverteiler geleitet wird. Anders ausgedrückt: Das Schema der Paketlagerung für den Benutzer- und Health-Check-Verkehr unterscheidet sich minimal; in beiden Fällen gelangen die Pakete zu den Lastverteilern, die sie zu den Zielressourcen weiterleiten.
Der Unterschied besteht darin, dass die Kunden Anfragen an die VIP senden, während die Health-Checks an jede einzelne RIP-Adresse gerichtet sind. Hierbei entsteht ein interessantes Problem: Wir bieten unseren Nutzern die Möglichkeit, Ressourcen in privaten IP-Netzen zu erstellen. Stellen Sie sich vor, es gibt zwei verschiedene Cloud-Besitzer, die ihre Dienste hinter Lastverteilern verstecken. Jeder von ihnen hat Ressourcen im Subnetz 10.0.0.1/24 mit identischen Adressen. Es ist entscheidend, wie man sie unterscheiden kann, und hier muss man in die Struktur des virtuellen Netzwerks von Yandex.Cloud eintauchen. Weitere Einzelheiten erfahren Sie in , es ist uns jetzt wichtig, dass das Netzwerk mehrschichtig ist und über Tunnel verfügt, die nach der ID des Subnetzes unterschieden werden können.
Healthcheck-Knoten kommunizieren mit den Load Balancern über sogenannte Quasi-IPv6-Adressen. Eine Quasi-Adresse ist eine IPv6-Adresse, die eine IPv4-Adresse und die ID des Benutzersubnetzes integriert. Der Datenverkehr gelangt zum Load Balancer, der die IPv4-Adresse der Ressource extrahiert, IPv6 durch IPv4 ersetzt und das Paket ins Benutzer-Netzwerk sendet.
Der Rückverkehr verläuft ebenso: Der Load Balancer erkennt, dass das Ziel ein graues Netzwerk aus Healthcheckern ist, und wandelt IPv4 in IPv6 um.
VPP ist das Herz des Datenpfades
Der Load Balancer basiert auf der Technologie Vector Packet Processing (VPP) – einem Framework von Cisco zur Paketverarbeitung im Netzwerkverkehr. In unserem Fall arbeitet das Framework über einer Benutzerraumverwaltung für Netzwerkinfrastrukturen – dem Data Plane Development Kit (DPDK). Dies gewährleistet eine hohe Leistung bei der Paketverarbeitung: Im Kern gibt es deutlich weniger Unterbrechungen, und es finden keine Kontextwechsel zwischen Kernel-Space und User-Space statt.
VPP geht noch weiter und maximiert die Systemleistung durch die Bündelung von Paketen in Batch-Verarbeitung. Die Leistungssteigerung erfolgt durch die aggressive Nutzung der Caches moderner Prozessoren. Sowohl Datencaches (Pakete werden in 'Vektoren' verarbeitet, wobei die Daten dicht beieinander liegen) als auch Instruktions-Caches kommen zum Einsatz: In VPP folgt die Paketverarbeitung einem Graphen, in dessen Knoten Funktionen stehen, die eine bestimmte Aufgabe ausführen.
Zum Beispiel erfolgt die Verarbeitung von IP-Paketen in VPP in folgender Reihenfolge: Zuerst wird im Parse-Knoten die Header der Pakete analysiert, danach werden sie an einen Knoten gesendet, der die Pakete gemäß den Routing-Tabellen weiterleitet.
Ein wenig Hardcore. Die Autoren von VPP machen keine Kompromisse bei der Nutzung der Prozessor-Caches, weshalb der typische Vektorverarbeitungscode manuelle Vektorisierung enthält: Es gibt eine Verarbeitungs-Schleife, in der die Situation 'wir haben vier Pakete in der Warteschlange' behandelt wird, danach dasselbe für zwei, dann für eines. Oft kommen Prefetch-Instruktionen zum Einsatz, die Daten in die Caches laden, um den Zugriff bei den nächsten Iterationen zu beschleunigen.
n_left_from = frame- 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)
{
// Verarbeitung mehrerer Pakete auf einmal
u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
// ...
/* Vorab holen der nächsten 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);
}
// Tatsächlich Daten verarbeiten
/* Überprüfen von spekulativen Enqueues, möglicherweise aktuelle nächste Frame wechseln */
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)
{
// Verarbeitung von Paketen eins nach dem anderen
}
// Verarbeitete Charge
vlib_put_next_frame(vm, node, next_index, n_left_to_next);
}Healthchecks kontaktieren den VPP über IPv6, der sie in IPv4 umwandelt. Dies geschieht durch einen Graphknoten, den wir als algorithmisches NAT bezeichnen. Für den Rückverkehr (und die Umwandlung von IPv6 in IPv4) gibt es denselben algorithmischen NAT-Knoten.

Der direkte Verkehr von den Clients des Load Balancers läuft über die Graphknoten, die das Load Balancing durchführen.

Der erste Knoten — sticky sessions. Er speichert den Hash von für geöffnete Sitzungen. Der 5-Tuple umfasst die Adresse und den Port des Clients, von dem die Informationen übertragen werden, die Adresse und die Ports der Ressourcen, die für den Empfang des Traffics verfügbar sind, sowie das Netzwerkprotokoll.
Der Hash des 5-Tuple hilft uns, weniger Berechnungen im nächsten Knoten des konsistenten Hashings durchzuführen und besser auf Änderungen in der Liste der Ressourcen hinter dem Lastenausgleich zu reagieren. Wenn ein Paket an den Lastenausgleich kommt, für das keine Sitzung existiert, wird es an den Knoten für konsistentes Hashing gesendet. Hier erfolgt die Lastenverteilung durch konsistentes Hashing: Wir wählen eine Ressource aus der Liste der verfügbaren 'lebenden' Ressourcen aus. Anschließend werden die Pakete an den NAT-Knoten weitergeleitet, der die tatsächliche Adressänderung und die Neuberechnung der Prüfziffern durchführt. Wie Sie sehen, halten wir uns an die Prinzipien von VPP — Ähnliches zu Ähnlichem, wir gruppieren ähnliche Berechnungen, um die Effizienz der CPU-Caches zu erhöhen.
Konsistentes Hashing
Warum haben wir uns genau dafür entschieden und was ist das überhaupt? Lassen Sie uns zunächst die vorherige Aufgabe betrachten — die Auswahl einer Ressource aus der Liste.

Bei inkonsistentem Hashing wird der Hash des eingehenden Pakets berechnet und die Ressource basierend auf dem Rest der Division dieses Hashes durch die Anzahl der Ressourcen ausgewählt. Solange die Liste unverändert bleibt, funktioniert dieses Verfahren gut: Wir senden Pakete mit dem gleichen 5-Tuple immer an dasselbe Instanz. Wenn jedoch beispielsweise eine Ressource auf Healthchecks nicht mehr reagiert, ändert sich die Auswahl für einen bedeutenden Teil der Hashes. Die TCP-Verbindungen des Clients werden unterbrochen: Ein Paket, das zuvor an Instanz A gesendet wurde, kann nun an Instanz B gelangen, die mit dieser Sitzung für das Paket nicht vertraut ist.
Konsistentes Hashing löst das beschriebene Problem. Am einfachsten lässt sich dieses Konzept so erklären: Stellen Sie sich vor, Sie haben einen Ring, auf dem Sie Ressourcen basierend auf dem Hash verteilen (zum Beispiel nach IP:Port). Die Auswahl der Ressource erfolgt durch eine Drehung des Rades um einen Winkel, der durch den Hash des Pakets bestimmt wird.

So wird die Umverteilung des Datenverkehrs bei Änderungen der Ressourcen minimiert. Das Entfernen einer Ressource wirkt sich nur auf den Teil des konsistenten Hashrings aus, auf dem sich diese Ressource befand. Das Hinzufügen einer Ressource verändert ebenfalls die Verteilung, aber wir verfügen über einen Knoten für Sticky Sessions, der es ermöglicht, bereits bestehende Sessions nicht auf neue Ressourcen umzuleiten.
Wir haben betrachtet, was mit dem direkten Datenverkehr zwischen dem Lastenausgleich und den Ressourcen passiert. Jetzt werfen wir einen Blick auf den Rückverkehr. Dieser folgt demselben Muster wie der Prüfverkehr — über algorithmisches NAT, also über das umgekehrte NAT 44 für den Client-Datenverkehr und über NAT 46 für den Datenverkehr der Gesundheitsprüfungen. Wir halten uns an unser eigenes Schema: Wir vereinheitlichen den Prüfverkehr und den tatsächlichen Nutzerdatenverkehr.
Loadbalancer-Knoten und Komponenten im Bundle
Der loadbalancer-node informiert über die Zusammensetzung der Lastverteiler und Ressourcen in VPP. Er abonniert den Ereignisstream von loadbalancer-controller, kann die Differenz zwischen dem aktuellen Zustand von VPP und dem Zielzustand, der vom Controller erhalten wird, ermitteln. So entsteht ein geschlossenes System: Ereignisse aus der API gelangen zum Lastverteiler-Controller, der dem Healthcheck-Controller Aufgaben zur Überprüfung der «Lebensfähigkeit» der Ressourcen zuweist. Dieser wiederum beauftragt den healthcheck-node und aggregiert die Ergebnisse, welche dann an den Lastverteiler-Controller zurückgegeben werden. Der Loadbalancer-node abonniert die Ereignisse vom Controller und ändert den Zustand von VPP. In einem solchen System kennt jeder Dienst nur das Notwendige über die benachbarten Dienste. Die Anzahl der Verbindungen ist begrenzt, wodurch wir die Möglichkeit haben, verschiedene Segmente unabhängig zu betreiben und zu skalieren.

Welche Fragen konnten vermieden werden?
Alle unsere Dienste im Control Plane sind in Go geschrieben und zeichnen sich durch hervorragende Skalierbarkeit und Zuverlässigkeit aus. In Go gibt es viele Open-Source-Bibliotheken zum Aufbau verteilter Systeme. Wir nutzen aktiv gRPC, alle Komponenten enthalten eine Open-Source-Implementierung zur Serviceentdeckung — unsere Dienste überwachen gegenseitig ihre Betriebsbereitschaft, können ihre Zusammensetzung dynamisch ändern und wir haben dies mit gRPC-Lastverteilung verknüpft. Auch für Metriken verwenden wir eine Open-Source-Lösung. Im Data Plane haben wir eine gute Leistung und viel Spielraum bei den Ressourcen erreicht: Es war sehr schwierig, eine Testumgebung aufzubauen, die es ermöglicht, die Leistungsfähigkeit von VPP und nicht die einer physischen Netzwerkkarte auszutesten.
Probleme und Lösungen
Was hat nicht so gut funktioniert? In Go ist das Speichermanagement automatisch, aber dennoch kann es zu Speicherlecks kommen. Der einfachste Weg, damit umzugehen, besteht darin, Goroutinen zu starten und sicherzustellen, dass sie beendet werden. Fazit: Achten Sie auf den Speicherverbrauch von Go-Programmen. Oft ist die Anzahl der Goroutinen ein guter Indikator. Es gibt auch einen Vorteil: In Go ist es einfach, Laufzeitdaten zu erhalten – zum Beispiel zum Speicherverbrauch, zur Anzahl der aktiven Goroutinen und zu vielen anderen Parametern.
Außerdem ist Go möglicherweise nicht die beste Wahl für funktionale Tests. Diese sind ziemlich umfangreich, und der Standardansatz, "alles im CI-Paket auszuführen", eignet sich dafür nicht besonders gut. Das liegt daran, dass funktionale Tests ressourcenintensiver sind und echte Zeitüberschreitungen auftreten können. Dadurch können Tests fehlschlagen, da die CPU mit Unit-Tests beschäftigt ist. Fazit: Führen Sie schwere Tests wenn möglich getrennt von Unit-Tests durch.
Eine mikroservicebasierte ereignisgesteuerte Architektur ist komplizierter als ein Monolith: Das Durchsuchen von Logs auf Dutzenden von verschiedenen Maschinen ist nicht besonders praktisch. Fazit: Wenn Sie Mikrodienste erstellen, sollten Sie von Anfang an an das Tracking denken.
Unsere Pläne
Wir werden einen internen Load Balancer und einen IPv6-Load Balancer starten, Kubernetes-Skripte unterstützen, weiterhin unsere Dienste sharden (derzeit sind nur healthcheck-node und healthcheck-ctrl gechartet), neue Healthchecks hinzufügen und eine intelligente Aggregation der Checks implementieren. Wir prüfen außerdem die Möglichkeit, unsere Dienste noch unabhängiger zu gestalten – damit sie nicht direkt miteinander kommunizieren, sondern über eine Nachrichtenwarteschlange. Vor Kurzem wurde im Cloud SQS-kompatible Dienste eingeführt. .
Vor kurzem wurde der Yandex Load Balancer öffentlich veröffentlicht. Lernen Sie ihn kennen. Verwalten Sie die Load Balancer auf eine für Sie komfortable Weise und erhöhen Sie die Ausfallsicherheit Ihrer Projekte!
Quelle: habr.com
