
Hallo, ich bin Sergey Jelantsev, ich entwickle in Yandex.Cloud. Zuvor leitete ich die Entwicklung des L7-Loadbalancers des Yandex-Portals – meine Kollegen scherzen, dass, egal was ich mache, es am Ende ein Loadbalancer wird. Ich werde den Lesern von Habr erzählen, wie man die Last in einer Cloud-Plattform verwaltet, welches ideale Werkzeug wir dafür sehen und wie wir uns auf dem Weg zum Bau dieses Werkzeugs befinden.
Zunächst definieren wir einige Begriffe:
- VIP (Virtual IP) – die IP-Adresse des Loadbalancers
- Server, Backend, Instanz – eine virtuelle Maschine mit einer laufenden Anwendung
- RIP (Real IP) – die IP-Adresse des Servers
- Healthcheck – die Überprüfung der Serverbereitschaft
- Verfügbarkeitszone, Availability Zone, AZ – isolierte Infrastruktur im Rechenzentrum
- Region – eine Gruppe von verschiedenen AZs
Lastenausgleichssysteme lösen drei Hauptaufgaben: sie führen den Lastenausgleich selbst durch, verbessern die Ausfallsicherheit des Dienstes und vereinfachen dessen Skalierung. Die Ausfallsicherheit wird durch das automatische Traffic-Management gewährleistet: der Loadbalancer überwacht den Zustand der Anwendung und schließt Instanzen, die den Healthcheck nicht bestehen, vom Lastenausgleich aus. Die Skalierung wird durch eine gleichmäßige Verteilung der Last auf die Instanzen sowie durch das Aktualisieren der Instanzenliste in Echtzeit sichergestellt. Wenn der Lastenausgleich nicht ausreichend gleichmäßig ist, erhalten einige der Instanzen eine Last, die deren Betriebsgrenze überschreitet, und der Dienst wird weniger zuverlässig.
Loadbalancer werden oft nach der Protokollebene des OSI-Modells klassifiziert, auf der sie arbeiten. Der Cloud-Loadbalancer arbeitet auf der TCP-Ebene, das entspricht der vierten Ebene, L4.
Lassen Sie uns einen Überblick über die Architektur des Cloud-Loadbalancers gewinnen. Wir werden schrittweise die Detailtiefe erhöhen. Wir unterteilen die Komponenten des Loadbalancers in drei Klassen. Die Klasse config plane ist für die Interaktion mit dem Benutzer verantwortlich und speichert den Zielzustand des Systems. Die Control plane speichert den aktuellen Zustand des Systems und verwaltet die Systeme der Klasse data plane, die direkt für die Zustellung des Traffics von den Kunden zu Ihren Instanzen verantwortlich sind.
Data plane
Der Verkehr gelangt auf teure Geräte, die als Border-Router bezeichnet werden. Um die Ausfallsicherheit zu erhöhen, arbeiten in einem Rechenzentrum gleichzeitig mehrere solcher Geräte. Danach gelangt der Verkehr zu den Load-Balancern, die den Kunden eine Anycast-IP-Adresse für alle AZ über BGP ankündigen.

Der Verkehr wird über ECMP übertragen – eine Routingstrategie, nach der mehrere gleichwertige Routen zum Ziel existieren können (in unserem Fall ist das Ziel die Ziel-IP-Adresse) und Pakete über jede dieser Routen gesendet werden können. Außerdem unterstützen wir den Betrieb in mehreren Verfügbarkeitszonen nach folgendem Schema: Wir kündigen die Adresse in jeder der Zonen an, der Verkehr gelangt in die nächstgelegene Zone und verlässt diese nicht mehr. Weiter unten im Beitrag werden wir genauer betrachten, was mit dem Verkehr passiert.
Config plane
Ein zentraler Bestandteil des Config Plane ist die API, über die die Hauptoperationen mit den Load-Balancern durchgeführt werden: Erstellen, Löschen, Ändern der Instanzen, Abrufen von Healthcheck-Ergebnissen usw. Einerseits handelt es sich um eine REST-API, andererseits verwenden wir in der Cloud sehr häufig das gRPC-Framework, weshalb wir REST in gRPC „übersetzen“ und anschließend nur gRPC verwenden. Jede Anfrage führt zur Erstellung einer Reihe von asynchronen idempotenten Aufgaben, die auf einem allgemeinen Pool von Arbeitern im Yandex.Cloud ausgeführt werden. Die Aufgaben sind so gestaltet, dass sie jederzeit unterbrochen und anschließend neu gestartet werden können. Dies gewährleistet Skalierbarkeit, Wiederholbarkeit und Protokollierung der Operationen.

Letztendlich wird die Aufgabe aus der API eine Anfrage an den Service-Controller der Load-Balancer stellen, der in Go geschrieben ist. Er kann Load-Balancer hinzufügen und entfernen, die Zusammensetzung der Backends und deren Einstellungen ändern.

Der Service speichert seinen Zustand in der Yandex Database – einer verteilten verwalteten Datenbank, die bald auch Sie nutzen können. In Yandex.Cloud, wie wir bereits , gilt das Konzept der Dog Food: Wenn wir unsere eigenen Dienste nutzen, werden auch unsere Kunden sie gerne verwenden. Die Yandex Database ist ein Beispiel für die Umsetzung dieses Konzepts. Wir speichern alle unsere Daten in YDB, und wir müssen uns nicht um die Wartung und Skalierung der Datenbank kümmern: Diese Probleme werden für uns gelöst, wir nutzen die Datenbank als Service.
Wir kehren zum Lastenausgleichscontroller zurück. Seine Aufgabe ist es, Informationen über den Lastenausgleichsserver zu speichern, die Aufgabe zur Überprüfung der Betriebsbereitschaft der virtuellen Maschine an den Healthcheck-Controller zu senden.
Healthcheck-Controller
Er erhält Anfragen zur Änderung der Prüfregeln, speichert sie in YDB, verteilt Aufgaben an Healthcheck-Knoten und aggregiert die Ergebnisse, die dann in die Datenbank gespeichert und an den Lastenausgleichscontroller gesendet werden. Dieser wiederum sendet eine Anfrage zur Änderung der Clusterzusammensetzung im Datenbereich an den Lastenausgleichs-Knoten, über den ich später sprechen werde.

Lassen Sie uns näher über Healthchecks sprechen. Diese lassen sich in mehrere Klassen einteilen. Die Prüfungen haben unterschiedliche Kriterien für den Erfolg. Bei TCP-Checks muss innerhalb einer bestimmten Zeit erfolgreich eine Verbindung hergestellt werden. HTTP-Checks erfordern sowohl eine erfolgreiche Verbindung als auch den Erhalt einer Antwort mit dem Statuscode 200.
Außerdem unterscheiden sich die Prüfungen nach Handlungsart – sie können aktiv oder passiv sein. Passive Prüfungen überwachen einfach, was mit dem Datenverkehr geschieht, ohne spezielle Maßnahmen zu ergreifen. Dies funktioniert nicht besonders gut auf L4, da es von der Logik höherer Protokolle abhängt: Auf L4 gibt es keine Informationen darüber, wie lange die Operation dauerte und ob die Verbindung gut oder schlecht beendet wurde. Aktive Prüfungen erfordern, dass der Lastenausgleichsserver Anfragen an jede Serverinstanz sendet.
Die meisten Lastenausgleichsserver führen die «Lebensprüfungen» eigenständig durch. Bei uns in der Cloud haben wir beschlossen, diese Teile des Systems zur Verbesserung der Skalierbarkeit zu trennen. Dieser Ansatz ermöglicht es uns, die Anzahl der Lastenausgleichsserver zu erhöhen, während die Anzahl der Healthcheck-Anfragen an den Dienst konstant bleibt. Die Prüfungen werden von separaten Healthcheck-Knoten durchgeführt, bei denen die Prüfziele shardiert und repliziert sind. Prüfungen von einem einzigen Host können nicht durchgeführt werden, da dieser ausfallen kann. In diesem Fall erhalten wir den Zustand der geprüften Instanzen nicht. Wir führen Prüfungen von jeder Instanz mindestens mit drei Healthcheck-Knoten durch. Die Prüfziele werden mithilfe von konsistenten Hashing-Algorithmen zwischen den Knoten shardiert.

Die Trennung von Lastverteilung und Gesundheitsprüfungen kann zu Problemen führen. Wenn der Gesundheitsprüfer Anfragen an die Instanz sendet, ohne den Lastverteiler zu nutzen (der momentan keinen Verkehr verwaltet), entsteht eine merkwürdige Situation: die Ressource scheint aktiv zu sein, aber der Verkehr erreicht sie nicht. Dieses Problem lösen wir so: Wir leiten den Gesundheitsprüfungsverkehr garantiert über die Lastverteiler. Mit anderen Worten: Das Schema der Paketverschiebung für den Verkehr von Kunden und Gesundheitsprüfungen unterscheidet sich nur minimal: In beiden Fällen gelangen die Pakete zu den Lastverteilern, die sie an die Zielressourcen weiterleiten.
Der Unterschied besteht darin, dass Kunden Anfragen an die VIP stellen, während Gesundheitsprüfungen sich an jede einzelne RIP wenden. Hier entsteht ein interessantes Problem: Wir geben unseren Benutzern die Möglichkeit, Ressourcen in grauen IP-Netzen zu erstellen. Stellen wir uns 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, und zwar mit denselben Adressen. Wir müssen sie irgendwie unterscheiden können, und dafür müssen wir uns mit der Struktur des virtuellen Netzwerks von Yandex.Cloud auseinandersetzen. Details erfahren Sie besser in , für uns ist jetzt wichtig, dass das Netzwerk mehrschichtig ist und Tunnel enthält, die anhand der Subnetz-ID unterschieden werden können.
Die Gesundheitsprüfungsnoten wenden sich mit sogenannten quasi-IPv6-Adressen an die Lastverteiler. Eine Quasi-Adresse ist eine IPv6-Adresse, in der eine IPv4-Adresse und die ID des Benutzersubnetzes eingebettet sind. Der Verkehr gelangt zum Lastverteiler, der die IPv4-Adresse der Ressource extrahiert, die IPv6-Adresse in IPv4 umwandelt und das Paket ins Netzwerk des Benutzers sendet.
Der Rückverkehr erfolgt genauso: Der Lastverteiler sieht, dass das Ziel ein graues Netzwerk aus Gesundheitsprüfern ist, und wandelt die IPv4-Adresse in IPv6 um.
VPP ist das Herz des Datenverkehrs
Der Lastverteiler basiert auf der Technologie Vector Packet Processing (VPP) – einem Framework von Cisco zur paketbasierten Verarbeitung von Netzwerkverkehr. In unserem Fall arbeitet das Framework über der User-Space-Bibliothek zur Verwaltung von Netzwerkinfrastrukturen – dem Data Plane Development Kit (DPDK). Dadurch wird eine hohe Leistung bei der Paketverarbeitung sichergestellt: Im Kernel gibt es bedeutend weniger Interrupts und keine Kontextwechsel zwischen dem Kernel-Space und dem User-Space.
VPP geht noch einen Schritt weiter und erzielt mit der Bündelung von Paketen in Batches noch mehr Leistung aus dem System. Die Leistungssteigerung erfolgt durch die aggressive Nutzung der Caches moderner Prozessoren. Es werden sowohl Datencaches (Pakete werden als „Vektoren“ verarbeitet, die Daten liegen nahe beieinander) als auch Instruktionscaches verwendet: Im VPP folgt die Verarbeitung von Paketen einem Graphen, in dessen Knoten Funktionen stehen, die eine Aufgabe ausführen.
Beispielsweise erfolgt die Verarbeitung von IP-Paketen im VPP in folgender Reihenfolge: Zuerst findet im Parsing-Knoten die Analyse der Paketköpfe statt, danach werden diese an einen Knoten gesendet, der die Pakete gemäß den Routentabellen weiterleitet.
Ein wenig Hardcore. Die Autoren von VPP machen keine Kompromisse bei der Nutzung der Prozessor-Caches, daher enthält der typische Code zur Verarbeitung von Paketvektoren manuelle Vektorisierung: Es gibt eine Verarbeitungs-Schleife, in der die Situation „wir haben vier Pakete in der Warteschlange“ behandelt wird, dann dasselbe für zwei, anschließend für eins. Häufig werden Prefetch-Instruktionen verwendet, die Daten in die Caches laden, um den Zugriff bei den nächsten Iterationen zu beschleunigen.
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)
{
// mehrere Pakete gleichzeitig verarbeiten
u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
// ...
/* Prefetch für die nächste 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
/* spekulative Einreihungen überprüfen, eventuell aktuellen nächsten 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)
{
// Pakete einzeln verarbeiten
}
// Batch verarbeitet
vlib_put_next_frame (vm, node, next_index, n_left_to_next);
}Healthchecks rufen über IPv6 an VPP, das sie in IPv4 umwandelt. Dies geschieht im Graph-Knoten, den wir algorithmisches NAT nennen. Für den Rückverkeht (und die Umwandlung von IPv6 in IPv4) gibt es den gleichen algorithmischen NAT-Knoten.

Der direkte Verkehr von den Clients des Lastverteilers geht über die Graph-Knoten, die die tatsächliche Lastverteilung durchführen.

Der erste Knoten — Sticky Sessions. Darin wird ein Hash von für festgelegte Sitzungen. Das 5-Tuple umfasst die Adresse und den Port des Clients, von dem die Informationen gesendet werden, die Adresse und die Ports der Ressourcen, die für den Empfang von Daten verfügbar sind, sowie das Netzwerkprotokoll.
Der Hash des 5-Tuple hilft uns, weniger Berechnungen im nächsten konsistenten Hashing-Knoten durchzuführen und die Änderung der Ressourcenauswahl hinter dem Lastenausgleichsgerät besser zu verarbeiten. Wenn ein Paket beim Lastenausgleichsgerät eintrifft, für das es keine Sitzung gibt, wird es an den Knoten des konsistenten Hashings gesendet. Hier erfolgt die Lastverteilung 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 gesendet, der die tatsächliche Zieladresse ersetzt und die Prüfziffern neu berechnet. Wie Sie sehen, folgen wir den VPP-Regeln — Ähnliches zu Ähnlichem, wir gruppieren ähnliche Berechnungen, um die Effizienz der CPU-Caches zu erhöhen.
Konsistentes Hashing
Warum haben wir uns gerade dafür entschieden und was ist das überhaupt? Lassen Sie uns zunächst die frühere Aufgabe betrachten — die Auswahl einer Ressource aus der Liste.

Beim inkonsistenten Hashing wird der Hash des eingehenden Pakets berechnet, und die Ressource wird anhand des Rests der Division dieses Hashes durch die Anzahl der Ressourcen aus der Liste ausgewählt. Solange die Liste unverändert bleibt, funktioniert dieses Schema gut: Wir senden Pakete mit demselben 5-Tuple immer an dasselbe Exemplar. Wenn beispielsweise eine Ressource nicht mehr auf Healthchecks reagiert, ändert sich die Auswahl für einen erheblichen Teil der Hashes. Die TCP-Verbindungen des Clients werden unterbrochen: Ein Paket, das zuvor an Exemplar A gesendet wurde, könnte nun an Exemplar B gesendet werden, das mit dieser Sitzung unbekannt 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 nach dem Hash verteilen (zum Beispiel nach IP:Port). Die Auswahl einer Ressource entspricht einer Drehung des Rades um einen Winkel, der durch den Hash des Pakets bestimmt wird.

Dadurch wird die Umverteilung des Datenverkehrs bei Änderungen der Ressourcen minimiert. Das Entfernen einer Ressource wirkt sich nur auf den Teil des Rings des konsistenten Hashings aus, auf dem sich diese Ressource befand. Das Hinzufügen einer Ressource ändert ebenfalls die Verteilung, aber wir haben einen Knoten für sticky sessions, der es ermöglicht, bereits etablierte Sitzungen nicht auf neue Ressourcen umzustellen.
Wir haben betrachtet, was mit dem direkten Verkehr zwischen dem Loadbalancer und den Ressourcen passiert. Lassen Sie uns nun mit dem Rückverkehr beschäftigen. Dieser erfolgt nach demselben Schema wie der Prüfverkehr – über algorithmisches NAT, das heißt über das umgekehrte NAT 44 für den Clientverkehr und über NAT 46 für den Gesundheitsprüfverkehr. Wir halten uns an unser eigenes Schema: Wir vereinheitlichen den Gesundheitsprüfverkehr und den tatsächlichen Verkehr der Benutzer.
Loadbalancer-Knoten und Komponenten im Bundle
Über die Zusammensetzung der Loadbalancer und Ressourcen im VPP berichtet der lokale Dienst – loadbalancer-node. Er abonniert den Ereignisstrom vom loadbalancer-controller, kann die Differenz zwischen dem aktuellen Zustand des VPP und dem Zielzustand, der vom Controller erhalten wurde, erstellen. Wir erhalten ein geschlossenes System: Ereignisse aus der API gelangen zum Loadbalancer-Controller, der die Aufgaben für den Gesundheitsprüfcontroller zur Überprüfung der 'Lebensfähigkeit' der Ressourcen festlegt. Dieser gibt Aufgaben an den healthcheck-node weiter und aggregiert die Ergebnisse, bevor er diese an den Loadbalancer-Controller zurückgibt. Loadbalancer-node abonniert die Ereignisse vom Controller und ändert den Zustand des VPP. In einem solchen System weiß jeder Dienst nur das Notwendige über benachbarte Dienste. Die Anzahl der Verbindungen ist begrenzt, und wir haben die Möglichkeit, 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 gute Skalierungs- und Zuverlässigkeitsmerkmale aus. In Go gibt es viele Open-Source-Bibliotheken für den Aufbau verteilter Systeme. Wir verwenden aktiv GRPC, alle Komponenten beinhalten eine Open-Source-Implementierung zur Dienstentdeckung – unsere Dienste überwachen die Funktionsfähigkeit miteinander, können ihre Zusammensetzung dynamisch ändern, und wir haben dies mit der GRPC-Balancierung verbunden. Auch für Metriken nutzen wir eine Open-Source-Lösung. Im Data Plane haben wir eine angemessene Leistung und einen großen Ressourcenvorrat erhalten: Es war sehr schwierig, eine Testumgebung aufzubauen, in der wir an die Leistung des VPP und nicht an die physische Netzwerkkarte anstoßen konnten.
Probleme und Lösungen
Was hat nicht so gut funktioniert? In Go ist das Speichermanagement automatisiert, aber es gibt dennoch Speicherlecks. Der einfachste Weg, damit umzugehen, ist, Goroutinen zu starten und sie nicht zu vergessen zu beenden. Fazit: Achten Sie auf den Speicherverbrauch Ihrer Go-Programme. Ein guter Indikator ist oft die Anzahl der Goroutinen. Es gibt auch einen Vorteil in dieser Geschichte: In Go sind Daten zur Laufzeit leicht zu erhalten – bezüglich des Speicherverbrauchs, der Anzahl der gestarteten Goroutinen und vieler anderer Parameter.
Außerdem ist Go möglicherweise nicht die beste Wahl für Funktionstests. Diese sind recht langwierig, und der Standardansatz, "alles im CI gebündelt zu starten", eignet sich dafür nicht besonders gut. Der Grund ist, dass Funktionstests ressourcenn intensiver sind und echte Timeouts auftreten können. Dadurch können die Tests fehlschlagen, da die CPU mit Unit-Tests beschäftigt ist. Fazit: Führen Sie nach Möglichkeit "schwere" Tests getrennt von Unit-Tests aus.
Eine mikroservicebasierte Ereignisarchitektur ist komplexer als ein Monolith: Protokolle auf Dutzenden von verschiedenen Maschinen zu durchsuchen, ist nicht sehr bequem. Fazit: Wenn Sie Mikrodienste erstellen, denken Sie sofort an das Tracing.
Unsere Pläne
Wir werden einen internen Load Balancer starten, einen IPv6-Load Balancer hinzufügen, die Unterstützung für Kubernetes-Szenarien erweitern, weiterhin unsere Dienste sharden (derzeit sind nur healthcheck-node und healthcheck-ctrl geclustert), neue Healthchecks hinzufügen und eine intelligente Aggregation der Prüfungen implementieren. Wir ziehen in Betracht, unsere Dienste noch unabhängiger zu gestalten – sodass sie nicht direkt miteinander kommunizieren, sondern über eine Nachrichtenwarteschlange. Kürzlich ist ein SQS-kompatibler Dienst in der Cloud verfügbar geworden. .
Vor kurzem fand die öffentliche Veröffentlichung des Yandex Load Balancer statt. Informieren Sie sich. verwenden Sie den Dienst, verwalten Sie die Load Balancer auf die für Sie bequemste Weise und erhöhen Sie die Ausfallsicherheit Ihrer Projekte!
Quelle: habr.com
