Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud
Hallo, ich bin Sergey Elantsev, ich entwickle Netzwerk-Load-Balancer in Yandex.Cloud. Zuvor leitete ich die Entwicklung des L7-Balancers für das Yandex-Portal – Kollegen scherzen, dass es sich, egal was ich mache, als Balancer herausstellt. Ich werde den Habr-Lesern erklären, wie man die Last in einer Cloud-Plattform verwaltet, welches unserer Meinung nach das ideale Tool zum Erreichen dieses Ziels ist und wie wir uns auf den Aufbau dieses Tools konzentrieren.

Lassen Sie uns zunächst einige Begriffe vorstellen:

  • VIP (Virtual IP) – Balancer-IP-Adresse
  • Server, Backend, Instanz – eine virtuelle Maschine, auf der eine Anwendung ausgeführt wird
  • RIP (Real IP) – Server-IP-Adresse
  • Healthcheck – Überprüfung der Serverbereitschaft
  • Availability Zone, AZ – isolierte Infrastruktur in einem Rechenzentrum
  • Region – ein Zusammenschluss verschiedener AZs

Load Balancer lösen drei Hauptaufgaben: Sie übernehmen den Balancing selbst, verbessern die Fehlertoleranz des Dienstes und vereinfachen dessen Skalierung. Fehlertoleranz wird durch automatisches Traffic-Management gewährleistet: Der Balancer überwacht den Status der Anwendung und schließt Instanzen vom Balancing aus, die die Liveness-Prüfung nicht bestehen. Die Skalierung wird sichergestellt, indem die Last gleichmäßig auf die Instanzen verteilt wird und die Liste der Instanzen im laufenden Betrieb aktualisiert wird. Wenn der Ausgleich nicht einheitlich genug ist, werden einige Instanzen eine Last erhalten, die ihre Kapazitätsgrenze überschreitet, und der Dienst wird weniger zuverlässig.

Ein Load Balancer wird häufig nach der Protokollschicht des OSI-Modells klassifiziert, auf dem er ausgeführt wird. Der Cloud Balancer arbeitet auf der TCP-Ebene, die der vierten Schicht, L4, entspricht.

Kommen wir zu einem Überblick über die Cloud-Balancer-Architektur. Wir werden den Detaillierungsgrad schrittweise erhöhen. Wir unterteilen die Balancer-Komponenten in drei Klassen. Die Config-Plane-Klasse ist für die Benutzerinteraktion verantwortlich und speichert den Zielzustand des Systems. Die Kontrollebene speichert den aktuellen Status des Systems und verwaltet Systeme aus der Datenebenenklasse, die direkt für die Bereitstellung des Datenverkehrs von Clients an Ihre Instanzen verantwortlich sind.

Datenebene

Der Datenverkehr landet auf teuren Geräten, sogenannten Grenzroutern. Um die Fehlertoleranz zu erhöhen, arbeiten mehrere solcher Geräte gleichzeitig in einem Rechenzentrum. Als nächstes geht der Datenverkehr an Balancer, die Anycast-IP-Adressen über BGP für Clients an alle AZs bekannt geben. 

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Der Datenverkehr wird über ECMP übertragen – dabei handelt es sich um eine Routing-Strategie, bei der es mehrere gleich gute Routen zum Ziel geben kann (in unserem Fall ist das Ziel die Ziel-IP-Adresse) und Pakete über jede dieser Routen gesendet werden können. Wir unterstützen auch die Arbeit in mehreren Verfügbarkeitszonen nach folgendem Schema: Wir geben in jeder Zone eine Adresse bekannt, der Verkehr geht zur nächstgelegenen und überschreitet deren Grenzen nicht. Später im Beitrag werden wir genauer darauf eingehen, was mit dem Verkehr passiert.

Konfigurationsebene

 
Die Schlüsselkomponente der Konfigurationsebene ist die API, über die grundlegende Vorgänge mit Balancern ausgeführt werden: Erstellen, Löschen, Ändern der Zusammensetzung von Instanzen, Erhalten von Healthcheck-Ergebnissen usw. Einerseits ist dies eine REST-API und andererseits Andererseits verwenden wir in der Cloud sehr oft das Framework gRPC, also „übersetzen“ wir REST in gRPC und verwenden dann nur gRPC. Jede Anfrage führt zur Erstellung einer Reihe asynchroner idempotenter Aufgaben, die in einem gemeinsamen Pool von Yandex.Cloud-Workern ausgeführt werden. Aufgaben sind so geschrieben, dass sie jederzeit angehalten und dann neu gestartet werden können. Dies gewährleistet Skalierbarkeit, Wiederholbarkeit und Protokollierung von Vorgängen.

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Infolgedessen stellt die Aufgabe von der API eine Anfrage an den Balancer-Service-Controller, der in Go geschrieben ist. Es kann Balancer hinzufügen und entfernen sowie die Zusammensetzung von Backends und Einstellungen ändern. 

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Der Dienst speichert seinen Status in der Yandex-Datenbank, einer verteilten verwalteten Datenbank, die Sie bald nutzen können. In Yandex.Cloud, wie wir bereits erzähltFür uns gilt das Hundefutter-Gedanke: Wenn wir selbst unsere Leistungen in Anspruch nehmen, dann nehmen auch unsere Kunden diese gerne in Anspruch. Yandex Database ist ein Beispiel für die Umsetzung eines solchen Konzepts. Wir speichern alle unsere Daten in YDB und müssen uns keine Gedanken über die Wartung und Skalierung der Datenbank machen: Diese Probleme sind für uns gelöst, wir nutzen die Datenbank als Service.

Kehren wir zum Balancer-Controller zurück. Seine Aufgabe besteht darin, Informationen über den Balancer zu speichern und eine Aufgabe zur Überprüfung der Bereitschaft der virtuellen Maschine an den Healthcheck-Controller zu senden.

Healthcheck-Controller

Es empfängt Anfragen zur Änderung von Prüfregeln, speichert sie in YDB, verteilt Aufgaben auf Healtcheck-Knoten und aggregiert die Ergebnisse, die dann in der Datenbank gespeichert und an den Loadbalancer-Controller gesendet werden. Es sendet wiederum eine Anfrage, die Zusammensetzung des Clusters auf der Datenebene zu ändern, an den Loadbalancer-Knoten, worauf ich weiter unten eingehen werde.

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Lassen Sie uns mehr über Gesundheitschecks sprechen. Sie können in mehrere Klassen eingeteilt werden. Audits haben unterschiedliche Erfolgskriterien. TCP-Prüfungen müssen innerhalb einer festgelegten Zeitspanne erfolgreich eine Verbindung herstellen. HTTP-Prüfungen erfordern sowohl eine erfolgreiche Verbindung als auch eine Antwort mit dem Statuscode 200.

Außerdem unterscheiden sich Schecks in der Aktionsklasse – sie sind aktiv und passiv. Passive Prüfungen überwachen lediglich, was mit dem Datenverkehr passiert, ohne dass besondere Maßnahmen ergriffen werden müssen. Dies funktioniert auf L4 nicht besonders gut, da es von der Logik der übergeordneten Protokolle abhängt: Auf L4 gibt es keine Informationen darüber, wie lange der Vorgang gedauert hat oder ob der Verbindungsaufbau gut oder schlecht war. Bei aktiven Prüfungen muss der Balancer Anfragen an jede Serverinstanz senden.

Die meisten Load Balancer führen selbst Lebendigkeitsprüfungen durch. Bei Cloud haben wir uns entschieden, diese Teile des Systems zu trennen, um die Skalierbarkeit zu erhöhen. Dieser Ansatz ermöglicht es uns, die Anzahl der Balancer zu erhöhen und gleichzeitig die Anzahl der Healthcheck-Anfragen an den Dienst beizubehalten. Prüfungen werden von separaten Healthcheck-Knoten durchgeführt, über die Prüfziele aufgeteilt und repliziert werden. Sie können keine Prüfungen von einem Host aus durchführen, da diese möglicherweise fehlschlagen. Dann erhalten wir den Status der von ihm überprüften Instanzen nicht. Wir führen Prüfungen für alle Instanzen von mindestens drei Healthcheck-Knoten aus durch. Wir teilen die Zwecke der Überprüfungen zwischen Knoten mithilfe konsistenter Hashing-Algorithmen auf.

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Die Trennung von Balancing und Healthcheck kann zu Problemen führen. Wenn der Healthcheck-Knoten Anfragen an die Instanz stellt und dabei den Balancer umgeht (der derzeit keinen Datenverkehr bedient), entsteht eine seltsame Situation: Die Ressource scheint aktiv zu sein, aber der Datenverkehr erreicht sie nicht. Wir lösen dieses Problem auf diese Weise: Wir initiieren garantiert den Healthcheck-Verkehr über Balancer. Mit anderen Worten: Das Schema zum Verschieben von Paketen mit Datenverkehr von Clients und von Healthchecks unterscheidet sich minimal: In beiden Fällen erreichen die Pakete die Balancer, die sie an die Zielressourcen weiterleiten.

Der Unterschied besteht darin, dass Clients Anfragen an VIP stellen, während Healthchecks Anfragen an jeden einzelnen RIP stellen. Hier entsteht ein interessantes Problem: Wir geben unseren Nutzern die Möglichkeit, Ressourcen in grauen IP-Netzwerken zu erstellen. Stellen wir uns vor, dass es zwei verschiedene Cloud-Besitzer gibt, die ihre Dienste hinter Balancern versteckt haben. Jeder von ihnen verfügt über Ressourcen im Subnetz 10.0.0.1/24 mit denselben Adressen. Sie müssen in der Lage sein, sie irgendwie zu unterscheiden, und hier müssen Sie in die Struktur des virtuellen Netzwerks Yandex.Cloud eintauchen. Es ist besser, mehr Details in herauszufinden Video vom about:cloud-EventFür uns ist es jetzt wichtig, dass das Netzwerk mehrschichtig ist und über Tunnel verfügt, die anhand der Subnetz-ID unterschieden werden können.

Healthcheck-Knoten kontaktieren Balancer über sogenannte Quasi-IPv6-Adressen. Eine Quasi-Adresse ist eine IPv6-Adresse mit einer darin eingebetteten IPv4-Adresse und Benutzer-Subnetz-ID. Der Datenverkehr erreicht den Balancer, der daraus die IPv4-Ressourcenadresse extrahiert, IPv6 durch IPv4 ersetzt und das Paket an das Netzwerk des Benutzers sendet.

Der umgekehrte Datenverkehr verläuft auf die gleiche Weise: Der Balancer erkennt anhand von Healthcheckern, dass das Ziel ein graues Netzwerk ist, und konvertiert IPv4 in IPv6.

VPP – das Herzstück der Datenebene

Der Balancer wird mithilfe der Vector Packet Processing (VPP)-Technologie implementiert, einem Framework von Cisco zur Stapelverarbeitung von Netzwerkverkehr. In unserem Fall funktioniert das Framework auf der Basis der User-Space-Netzwerkgeräteverwaltungsbibliothek – dem Data Plane Development Kit (DPDK). Dies gewährleistet eine hohe Paketverarbeitungsleistung: Es treten deutlich weniger Interrupts im Kernel auf und es gibt keine Kontextwechsel zwischen Kernel-Space und User-Space. 

VPP geht noch einen Schritt weiter und holt noch mehr Leistung aus dem System heraus, indem es Pakete zu Stapeln zusammenfasst. Die Leistungssteigerungen ergeben sich aus der aggressiven Nutzung von Caches auf modernen Prozessoren. Es werden sowohl Datencaches verwendet (Pakete werden in „Vektoren“ verarbeitet, die Daten liegen nahe beieinander) als auch Befehlscaches: Bei VPP folgt die Paketverarbeitung einem Diagramm, dessen Knoten Funktionen enthalten, die dieselbe Aufgabe ausführen.

Beispielsweise erfolgt die Verarbeitung von IP-Paketen in VPP in der folgenden Reihenfolge: Zuerst werden die Paketheader im Parsing-Knoten geparst und dann an den Knoten gesendet, der die Pakete gemäß Routing-Tabellen weiterleitet.

Ein bisschen Hardcore. Die Autoren von VPP tolerieren keine Kompromisse bei der Verwendung von Prozessor-Caches, daher enthält typischer Code zur Verarbeitung eines Paketvektors eine manuelle Vektorisierung: Es gibt eine Verarbeitungsschleife, in der eine Situation wie „Wir haben vier Pakete in der Warteschlange“ verarbeitet wird. dann dasselbe für zwei, dann - für einen. Prefetch-Anweisungen werden häufig zum Laden von Daten in Caches verwendet, um den Zugriff darauf in nachfolgenden 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)
    {
        // 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);
}

Healthchecks kommunizieren also über IPv6 mit dem VPP, wodurch sie in IPv4 umgewandelt werden. Dies geschieht durch einen Knoten im Diagramm, den wir algorithmisches NAT nennen. Für den umgekehrten Datenverkehr (und die Konvertierung von IPv6 zu IPv4) gibt es denselben algorithmischen NAT-Knoten.

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Der direkte Datenverkehr von den Balancer-Clients läuft über die Graph-Knoten, die den Ausgleich selbst durchführen. 

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Der erste Knoten sind Sticky Sessions. Es speichert den Hash von 5-Tupel für etablierte Sitzungen. 5-Tupel umfasst die Adresse und den Port des Clients, von dem Informationen übertragen werden, die Adresse und Ports der für den Empfang von Datenverkehr verfügbaren Ressourcen sowie das Netzwerkprotokoll. 

Der 5-Tupel-Hash hilft uns, im nachfolgenden konsistenten Hashing-Knoten weniger Berechnungen durchzuführen und Änderungen an der Ressourcenliste hinter dem Balancer besser zu verarbeiten. Wenn ein Paket, für das es keine Sitzung gibt, beim Balancer ankommt, wird es an den konsistenten Hashing-Knoten gesendet. Hier erfolgt der Ausgleich durch konsistentes Hashing: Wir wählen eine Ressource aus der Liste der verfügbaren „Live“-Ressourcen aus. Anschließend werden die Pakete an den NAT-Knoten gesendet, der tatsächlich die Zieladresse ersetzt und die Prüfsummen neu berechnet. Wie Sie sehen, befolgen wir die Regeln von VPP – Like to Like und gruppieren ähnliche Berechnungen, um die Effizienz der Prozessor-Caches zu erhöhen.

Konsistentes Hashing

Warum haben wir es gewählt und was ist es überhaupt? Betrachten wir zunächst die vorherige Aufgabe – die Auswahl einer Ressource aus der Liste. 

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Beim inkonsistenten Hashing wird der Hash des eingehenden Pakets berechnet und eine Ressource aus der Liste ausgewählt, indem dieser Hash durch die Anzahl der Ressourcen dividiert wird. Solange die Liste unverändert bleibt, funktioniert dieses Schema gut: Wir senden Pakete mit demselben 5-Tupel immer an dieselbe Instanz. Wenn beispielsweise eine Ressource nicht mehr auf Integritätsprüfungen reagiert, ändert sich die Auswahl für einen erheblichen Teil der Hashes. Die TCP-Verbindungen des Clients werden unterbrochen: Ein Paket, das zuvor Instanz A erreicht hat, beginnt möglicherweise, Instanz B zu erreichen, die mit der Sitzung für dieses Paket nicht vertraut ist.

Konsistentes Hashing löst das beschriebene Problem. Der einfachste Weg, dieses Konzept zu erklären, ist dieser: Stellen Sie sich vor, Sie haben einen Ring, an den Sie Ressourcen per Hash verteilen (z. B. per IP:Port). Bei der Auswahl einer Ressource wird das Rad um einen Winkel gedreht, der durch den Hash des Pakets bestimmt wird.

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Dies minimiert die Umverteilung des Datenverkehrs, wenn sich die Zusammensetzung der Ressourcen ändert. Das Löschen einer Ressource wirkt sich nur auf den Teil des konsistenten Hashing-Rings aus, in dem sich die Ressource befand. Durch das Hinzufügen einer Ressource ändert sich auch die Verteilung, aber wir haben einen Sticky-Sessions-Knoten, der es uns ermöglicht, bereits eingerichtete Sitzungen nicht auf neue Ressourcen umzustellen.

Wir haben uns angesehen, was mit dem direkten Datenverkehr zwischen dem Balancer und den Ressourcen passiert. Schauen wir uns nun den Rückverkehr an. Es folgt dem gleichen Muster wie der Prüfverkehr – über algorithmisches NAT, d. h. über Reverse NAT 44 für Client-Verkehr und über NAT 46 für Healthcheck-Verkehr. Wir folgen unserem eigenen Schema: Wir vereinheitlichen den Gesundheitscheck-Verkehr und den echten Benutzerverkehr.

Loadbalancer-Knoten und zusammengebaute Komponenten

Die Zusammensetzung der Balancer und Ressourcen im VPP wird vom lokalen Dienst – Loadbalancer-Node – gemeldet. Es abonniert den Ereignisstrom vom Loadbalancer-Controller und ist in der Lage, die Differenz zwischen dem aktuellen VPP-Status und dem vom Controller empfangenen Zielstatus darzustellen. Wir erhalten ein geschlossenes System: Ereignisse von der API gelangen zum Balancer-Controller, der dem Healthcheck-Controller Aufgaben zuweist, um die „Lebendigkeit“ der Ressourcen zu überprüfen. Dieser wiederum weist dem Healthcheck-Knoten Aufgaben zu, aggregiert die Ergebnisse und sendet sie anschließend zurück an den Balancer-Controller. Der Loadbalancer-Knoten abonniert Ereignisse vom Controller und ändert den Status 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 voneinander zu betreiben und zu skalieren.

Netzwerk-Load-Balancer-Architektur in Yandex.Cloud

Welche Probleme wurden vermieden?

Alle unsere Dienste in der Kontrollebene sind in Go geschrieben und weisen gute Skalierungs- und Zuverlässigkeitseigenschaften auf. Go verfügt über viele Open-Source-Bibliotheken zum Aufbau verteilter Systeme. Wir nutzen GRPC aktiv, alle Komponenten enthalten eine Open-Source-Implementierung der Service Discovery – unsere Services überwachen sich gegenseitig in ihrer Leistung, können ihre Zusammensetzung dynamisch ändern und wir haben dies mit dem GRPC-Balancing verknüpft. Für Metriken nutzen wir ebenfalls eine Open-Source-Lösung. Auf der Datenebene haben wir eine ordentliche Leistung und eine große Ressourcenreserve erhalten: Es stellte sich als sehr schwierig heraus, einen Stand aufzubauen, bei dem wir uns auf die Leistung eines VPP und nicht auf eine eiserne Netzwerkkarte verlassen konnten.

Probleme und Lösungen

Was hat nicht so gut funktioniert? Go verfügt über eine automatische Speicherverwaltung, es kommt jedoch immer noch zu Speicherlecks. Der einfachste Weg, damit umzugehen, besteht darin, Goroutinen auszuführen und daran zu denken, sie zu beenden. Takeaway: Beobachten Sie den Speicherverbrauch Ihrer Go-Programme. Ein guter Indikator ist oft die Anzahl der Goroutinen. Diese Geschichte hat ein Plus: In Go ist es einfach, Laufzeitdaten abzurufen – Speicherverbrauch, Anzahl der ausgeführten Goroutinen und viele andere Parameter.

Außerdem ist Go möglicherweise nicht die beste Wahl für Funktionstests. Sie sind ziemlich ausführlich und der Standardansatz, „alles in CI in einem Stapel auszuführen“, ist für sie nicht sehr geeignet. Tatsache ist, dass Funktionstests ressourcenintensiver sind und echte Timeouts verursachen. Aus diesem Grund können Tests fehlschlagen, weil die CPU mit Unit-Tests beschäftigt ist. Fazit: Wenn möglich, führen Sie „schwere“ Tests getrennt von Unit-Tests durch. 

Die Ereignisarchitektur von Microservices ist komplexer als ein Monolith: Das Sammeln von Protokollen auf Dutzenden verschiedener Maschinen ist nicht sehr praktisch. Fazit: Wenn Sie Microservices erstellen, denken Sie sofort an Tracing.

Unsere Pläne

Wir werden einen internen Balancer, einen IPv6-Balancer, einführen, Unterstützung für Kubernetes-Skripte hinzufügen, unsere Dienste weiterhin fragmentieren (derzeit sind nur „healthcheck-node“ und „healthcheck-ctrl“ fragmentiert), neue Healthchecks hinzufügen und auch eine intelligente Aggregation von Checks implementieren. Wir erwägen die Möglichkeit, unsere Dienste noch unabhängiger zu machen – so dass sie nicht direkt miteinander kommunizieren, sondern über eine Nachrichtenwarteschlange. Kürzlich ist in der Cloud ein SQS-kompatibler Dienst aufgetaucht Yandex-Nachrichtenwarteschlange.

Vor kurzem fand die öffentliche Veröffentlichung von Yandex Load Balancer statt. Erkunden Dokumentation Nutzen Sie den Service, verwalten Sie Balancer auf eine für Sie komfortable Weise und erhöhen Sie die Fehlertoleranz Ihrer Projekte!

Source: habr.com

Kommentar hinzufügen