
Hallo, Habr! Ich bin Artem Karamyshev, der Leiter des Systemadministrationsteams. . Im letzten Jahr haben wir viele neue Produkte eingeführt. Unser Ziel war es, sicherzustellen, dass die API-Dienste leicht skalierbar, ausfallsicher und in der Lage sind, plötzliche Benutzerlasten schnell zu bewältigen. Unsere Plattform basiert auf OpenStack, und ich möchte Ihnen erzählen, welche Herausforderungen in Bezug auf die Ausfallsicherheit der Komponenten wir bewältigen mussten, um ein ausfallsicheres System zu erhalten. Ich denke, das wird auch für diejenigen interessant sein, die Produkte auf OpenStack entwickeln.
Die allgemeine Ausfallsicherheit der Plattform ergibt sich aus der Robustheit ihrer Komponenten. Daher werden wir schrittweise alle Ebenen durchlaufen, auf denen wir Risiken erkannt und diese behoben haben.
Die Video-Version dieser Geschichte, die aus einem Vortrag auf der Konferenz Uptime Day 4 stammt, die von organisiert wurde, kann angesehen werden .
Die Ausfallsicherheit der physischen Architektur.
Der öffentliche Teil der MCS-Cloud ist derzeit in zwei Tier-III-Rechenzentren untergebracht, die durch ein eigenes dunkles Glasfaser-Netzwerk verbunden sind, das auf physischer Ebene durch verschiedene Routen reserviert ist, mit einer Bandbreite von 200 Gbit/s. Der Tier-III-Niveau gewährleistet die erforderliche Ausfallsicherheit der physischen Infrastruktur.
Das dunkle Glasfaser-Netzwerk ist sowohl auf physischer als auch auf logischer Ebene reserviert. Der Prozess der Kanalreservierung war iterativ, es gab Schwierigkeiten, und wir verbessern kontinuierlich die Verbindung zwischen den Rechenzentren.
Zum Beispiel wurde nicht so lange her bei Arbeiten in einem Schacht neben einem der Rechenzentren mit einem Bagger ein Rohr durchbrochen, in dem sich sowohl das Haupt- als auch das Reserve-Optik-Kabel befanden. Unser Ausfall-sicherer Kommunikationskanal zum Rechenzentrum war an einem Punkt, dem Schacht, verletzlich. Infolgedessen verloren wir einen Teil der Infrastruktur. Wir zogen Lehren daraus und ergriffen eine Reihe von Maßnahmen, einschließlich der Verlegung von zusätzlicher Optik durch den benachbarten Schacht.
In den Rechenzentren befinden sich Präsenzpunkte von Netzbetreibern, an die wir unsere Präfixe über BGP übertragen. Für jede Netzwerkrichtung wird die beste Metrik ausgewählt, um unseren Kunden die bestmögliche Verbindungsqualität zu gewährleisten. Wenn die Verbindung über einen Anbieter unterbrochen wird, passen wir unsere Routing-Strategie auf verfügbare Anbieter an.
Im Falle eines Ausfalls eines Anbieters schalten wir automatisch auf den nächsten um. Bei einem Ausfall eines der Rechenzentren haben wir eine Spiegelkopie unserer Dienste im zweiten Rechenzentrum, die die gesamte Last übernehmen.

Ausfallsicherheit der physischen Infrastruktur
Womit wir Ausfallsicherheit auf Anwendungsebene gewährleisten
Unser Dienst basiert auf einer Reihe von Open-Source-Komponenten.
ExaBGP — ein Dienst, der eine Reihe von Funktionen mit Hilfe des dynamischen Routing-Protokolls auf Basis von BGP implementiert. Wir nutzen ihn aktiv, um unsere weißen IP-Adressen anzukündigen, über die Benutzer auf die API zugreifen.
HAProxy — hochbelastbarer Load-Balancer, der sehr flexible Load-Balancing-Regeln auf verschiedenen OSI-Modell-Ebenen ermöglicht. Wir verwenden ihn zum Balancieren vor all unseren Diensten: Datenbanken, Nachrichtenbroker, API-Dienste, Web-Dienste, unsere internen Projekte — alles steht hinter HAProxy.
API-Anwendung — Web-Anwendung, die in Python geschrieben ist und es dem Benutzer ermöglicht, seine Infrastruktur und seinen Dienst zu verwalten.
Worker-Anwendung (im Folgenden einfach Worker) — in den OpenStack-Diensten ist dies ein infrastruktureller Daemon, der es ermöglicht, API-Befehle in die Infrastruktur zu übertragen. Zum Beispiel erfolgt die Erstellung einer Festplatte genau im Worker, während die Anfrage zur Erstellung in der API-Anwendung erfolgt.
Standardarchitektur der OpenStack-Anwendung
Die meisten Dienste, die für OpenStack entwickelt werden, folgen einem einheitlichen Paradigma. Ein Dienst besteht normalerweise aus zwei Teilen: API und Worker (Backend-Executoren). In der Regel ist die API eine WSGI-Anwendung in Python, die entweder als eigenständiger Prozess (Daemon) oder mit einem bereits vorhandenen Webserver wie Nginx oder Apache ausgeführt wird. Die API verarbeitet die Benutzeranfrage und übergibt die weiteren Anweisungen an die Worker-Anwendung. Die Übertragung erfolgt über einen Message Broker, typischerweise RabbitMQ, während andere Broker nur schlecht unterstützt werden. Wenn Nachrichten im Broker ankommen, verarbeiten die Worker diese und geben bei Bedarf eine Antwort zurück.
Dieses Paradigma sieht isolierte gemeinsame Fehlerpunkte vor: RabbitMQ und die Datenbank. Allerdings ist RabbitMQ innerhalb eines Dienstes isoliert und kann theoretisch für jeden Dienst individuell sein. Daher trennen wir bei MCS diese Dienste möglichst, indem wir für jedes einzelne Projekt eine separate Datenbank und ein separates RabbitMQ erstellen. Dieser Ansatz hat den Vorteil, dass im Falle eines Ausfalls an bestimmten anfälligen Punkten nicht der gesamte Dienst ausfällt, sondern nur ein Teil davon.
Die Anzahl der Worker-Anwendungen ist unbegrenzt, sodass die API problemlos horizontal skaliert werden kann, um die Leistung und Ausfallsicherheit zu erhöhen, indem sie hinter Lastenausgleichssystemen platziert wird.
In einigen Diensten ist eine Koordination innerhalb des Dienstes erforderlich, wenn komplexe aufeinanderfolgende Operationen zwischen der API und den Workern erfolgen. In diesem Fall wird ein zentraler Koordinationspunkt verwendet, ein Cluster-System wie Redis, Memcache, etcd, das es einem Worker ermöglicht, einem anderen mitzuteilen, dass diese Aufgabe ihm zugewiesen wurde („bitte, nimm sie nicht an“). Wir verwenden etcd. In der Regel kommunizieren die Worker aktiv mit der Datenbank und schreiben und lesen Informationen daraus. Als Datenbank verwenden wir MariaDB, die sich in einem multimaster Cluster befindet.
Ein klassischer Einzelservice wird nach dem allgemein anerkannten Verfahren für OpenStack organisiert. Er kann als ein geschlossenes System betrachtet werden, bei dem die Methoden zur Skalierung und Ausfallsicherheit offensichtlich sind. Beispielsweise genügt es für die Ausfallsicherheit, einen Lastenausgleich vor der API einzurichten. Die Skalierung der Worker wird durch Erhöhung ihrer Anzahl erreicht.
Ein Schwachpunkt im gesamten System sind RabbitMQ und MariaDB. Ihre Architektur verdient einen eigenen Artikel. In diesem Artikel möchte ich mich auf die Ausfallsicherheit der API konzentrieren.

Architektur der OpenStack-Anwendung. Lastverteilung und Ausfallsicherheit der Cloud-Plattform.
Wir machen den HAProxy-Lastverteiler mit ExaBGP ausfallsicher.
Um unsere APIs skalierbar, schnell und ausfallsicher zu gestalten, haben wir einen Lastverteiler davor gesetzt. Wir haben uns für HAProxy entschieden, da er in meinen Augen alle notwendigen Eigenschaften für unsere Anforderungen besitzt: Lastverteilung auf mehreren OSI-Ebenen, Verwaltungsoberfläche, Flexibilität und Skalierbarkeit, eine Vielzahl von Lastverteilungsverfahren und Unterstützung für Sitzungstabellen.
Das erste Problem, das gelöst werden musste, war die Ausfallsicherheit des Lastverteilers selbst. Allein die Installation eines Lastverteilers schafft ebenfalls einen Ausfallpunkt: fällt der Lastverteiler aus, bricht der Dienst zusammen. Um dies zu vermeiden, haben wir HAProxy zusammen mit ExaBGP verwendet.
ExaBGP ermöglicht die Implementierung eines Überwachungsmechanismus für den Dienststatus. Wir haben diesen Mechanismus verwendet, um die Funktionsfähigkeit von HAProxy zu überprüfen und im Falle von Problemen den HAProxy-Dienst aus BGP auszuschalten.
Schema ExaBGP+HAProxy
- Wir installieren die benötigte Software ExaBGP und HAProxy auf drei Servern.
- Auf jedem der Server erstellen wir ein Loopback-Interface.
- Auf allen drei Servern tragen wir die gleiche öffentliche IP-Adresse in dieses Interface ein.
- Die öffentliche IP-Adresse wird über ExaBGP im Internet angekündigt.
Die Ausfallsicherheit wird erreicht, indem dieselbe IP-Adresse von allen drei Servern angekündigt wird. Aus Sicht des Netzwerks ist die gleiche Adresse von drei verschiedenen Next Hops zugänglich. Der Router sieht drei identische Routen und wählt diejenige mit der höchsten Priorität basierend auf seiner eigenen Metrik (in der Regel ist es die gleiche Option), sodass der Verkehr nur zu einem der Server geleitet wird.
Im Falle von Problemen mit HAProxy oder dem Ausfall eines Servers hört ExaBGP auf, die Route anzukündigen, und der Verkehr wechselt reibungslos zu einem anderen Server.
So haben wir die Ausfallsicherheit des Load-Balancers erreicht.

Ausfallsicherheit der HAProxy-Load-Balancer
Das Schema ist nicht perfekt geworden: Wir haben gelernt, HAProxy zu reservieren, aber nicht, die Last innerhalb der Dienste zu verteilen. Daher haben wir das Schema etwas erweitert: Wir haben die Balance zwischen mehreren weißen IP-Adressen eingeführt.
DNS-basierte Lastverteilung plus BGP
Die Frage der Lastverteilung vor unseren HAProxy ist noch offen. Dennoch lässt sie sich recht einfach lösen, wie wir es auch bei uns gemacht haben.
Für die Lastverteilung von drei Servern werden 3 öffentliche IP-Adressen und der alte gute DNS benötigt. Jede dieser Adressen wird dem Loopback-Interface jedes HAProxy zugeordnet und im Internet angekündigt.
In OpenStack wird ein Serviceverzeichnis zur Verwaltung von Ressourcen verwendet, in dem der API-Endpunkt des jeweiligen Dienstes festgelegt wird. In diesem Verzeichnis tragen wir den Domainnamen — public.infra.mail.ru ein, der über DNS auf drei verschiedene IP-Adressen aufgelöst wird. So erhalten wir eine Lastverteilung zwischen den drei Adressen über DNS.
Da wir bei der Ankündigung von öffentlichen IP-Adressen die Prioritäten bei der Serverauswahl nicht steuern, bis es zur Lastverteilung kommt, wird in der Regel nur ein Server basierend auf der IP-Adresse ausgewählt, während die beiden anderen untätig bleiben, da keine Metriken im BGP angegeben sind.
Wir haben begonnen, Routen über ExaBGP mit unterschiedlichen Metriken auszugeben. Jeder Lastverteiler kündigt alle drei öffentlichen IP-Adressen an, aber eine davon, die Hauptadresse für diesen Lastverteiler, wird mit der niedrigsten Metrik angekündigt. Solange alle drei Lastverteiler einsatzbereit sind, gelangen die Anfragen zur ersten IP-Adresse an den ersten Lastverteiler, Anfragen zur zweiten an den zweiten und zur dritten an den dritten.
Was passiert, wenn einer der Lastverteiler ausfällt? Bei einem Ausfall eines Lastverteilers wird die Hauptadresse weiterhin von den beiden anderen angekündigt, und der Verkehr zwischen ihnen wird umverteilt. Dadurch liefern wir dem Nutzer über DNS mehrere IP-Adressen gleichzeitig. Durch die DNS-Balancierung und unterschiedliche Metriken erreichen wir eine gleichmäßige Lastverteilung auf alle drei Lastverteiler und verlieren dabei nicht die Ausfallsicherheit.

HAProxy-Lastverteilung basierend auf DNS + BGP
Interaktion zwischen ExaBGP und HAProxy
Wir haben also die Ausfallsicherheit für den Fall eines Serverausfalls implementiert, basierend auf dem Ausbleiben von Routenankündigungen. Aber HAProxy kann aus anderen Gründen ausfallen als ein Serverausfall: Verwaltungsfehler, interne Dienstfehler. Wir möchten den defekten Lastenausgleich aus der Last nehmen und dafür ist ein anderer Mechanismus erforderlich.
Daher haben wir, um das vorherige Schema zu erweitern, ein Heartbeat zwischen ExaBGP und HAProxy implementiert. Dies ist eine Softwareimplementierung für die Interaktion zwischen ExaBGP und HAProxy, bei der ExaBGP benutzerdefinierte Skripte verwendet, um den Status von Anwendungen zu überprüfen.
Dafür muss im ExaBGP-Konfigurationsfile ein Health Checker eingerichtet werden, der den Status von HAProxy prüfen kann. In unserem Fall haben wir einen Health Backend in HAProxy konfiguriert, während wir von ExaBGP aus eine einfache GET-Anfrage zur Überprüfung verwenden. Wenn die Ankündigung aufhört, bedeutet das wahrscheinlich, dass HAProxy nicht funktioniert, und es ist nicht notwendig, es anzukündigen.

HAProxy Health Check
HAProxy Peers: Sitzungssynchronisation
Der nächste Schritt bestand darin, die Sitzungen zu synchronisieren. Bei der Arbeit mit verteilten Lastenausgleichern ist es schwierig, Informationen über die Kundensitzungen zu speichern. Doch HAProxy gehört zu den wenigen Lastenausgleichern, die dies dank der Funktion Peers — der Möglichkeit des Transfers von Sitzungstabellen zwischen verschiedenen HAProxy-Prozessen — können.
Es gibt verschiedene Methoden des Lastenausgleichs: einfache, wie , und erweiterte, bei denen die Sitzung des Kunden gespeichert wird, sodass er bei jeder Anfrage denselben Server erreicht wie zuvor. Wir wollten die zweite Option umsetzen.
In HAProxy wird dieser Mechanismus zur Speicherung der Kundensitzungen mit stick-tables realisiert. Diese speichern die ursprüngliche IP-Adresse des Kunden, die ausgewählten Zieladressen (Backends) und einige Verwaltungsinformationen. Üblicherweise werden stick-tables genutzt, um das Pair von source-IP + destination-IP zu speichern, was besonders nützlich für Anwendungen ist, die den Sitzungskontext des Benutzers beim Wechsel zu einem anderen Lastenausgleicher nicht übertragen können, beispielsweise im RoundRobin-Modus.
Wenn die stick-Tabelle dazu gebracht wird, zwischen verschiedenen HAProxy-Prozessen zu wechseln (zwischen denen das Load Balancing erfolgt), können unsere Load Balancer mit einem einzigen Pool von stick-Tabellen arbeiten. Dies ermöglicht ein nahtloses Umschalten des Client-Netzwerks, wenn einer der Load Balancer ausfällt; die Arbeit mit den Clientsitzungen wird auf denselben Backends fortgesetzt, die zuvor ausgewählt wurden.
Für den korrekten Betrieb muss das Problem mit der Quell-IP-Adresse des Load Balancers, von dem die Sitzung eingerichtet wurde, gelöst werden. In unserem Fall ist dies die dynamische Adresse auf dem Loopback-Interface.
Die korrekte Funktion der Peers wird nur unter bestimmten Bedingungen erreicht. Das bedeutet, dass die TCP-Timeouts ausreichend groß sein müssen oder das Umschalten schnell genug erfolgen muss, damit die TCP-Sitzung nicht abbricht. Dennoch ermöglicht dies ein nahtloses Umschalten.
In unserem IaaS gibt es einen Dienst, der auf der gleichen Technologie basiert. Das ist , der Octavia genannt wird. Er basiert auf zwei HAProxy-Prozessen und unterstützt ursprünglich Peers. In diesem Dienst haben sie sich hervorragend bewährt.
Das Bild zeigt schematisch die Verschiebung von Peers-Tabellen zwischen drei HAProxy-Instanzen, es wird eine Konfiguration vorgeschlagen, wie man dies einrichten kann:

HAProxy Peers (Sitzungssynchronisation)
Wenn Sie ein ähnliches Schema implementieren, müssen Sie dessen Funktionalität sorgfältig testen. Es ist nicht garantiert, dass es in genau der gleichen Form in 100 % der Fälle funktioniert. Aber zumindest verlieren Sie keine Stick-Tabellen, wenn Sie sich an die Quell-IP des Clients erinnern müssen.
Begrenzung der Anzahl gleichzeitiger Anfragen von demselben Client
Alle öffentlich zugänglichen Dienste, einschließlich unserer APIs, können von Anfragefluten betroffen sein. Die Gründe können sehr unterschiedlich sein, von Benutzerfehlern bis hin zu gezielten Angriffen. Wir werden regelmäßig durch IP-Adressen DDoS angegriffen. Kunden machen oft Fehler in ihren Skripten und verursachen damit Mini-DDoS-Attacken.
In jedem Fall muss zusätzlicher Schutz eingeplant werden. Eine offensichtliche Lösung besteht darin, die Anzahl der Anfragen an die API zu begrenzen und keine CPU-Zeit für die Verarbeitung bösartiger Anfragen aufzuwenden.
Um solche Begrenzungen zu implementieren, verwenden wir Rate Limits, die auf HAProxy basieren und dieselben Stick-Tabellen nutzen. Die Limits lassen sich relativ einfach einstellen und ermöglichen es, die Anzahl der API-Anfragen eines Benutzers zu begrenzen. Der Algorithmus speichert die Quell-IP, von der Anfragen gesendet werden, und limitiert die Anzahl gleichzeitiger Anfragen von einem Nutzer. Selbstverständlich haben wir das durchschnittliche Lastprofil der API für jeden Dienst ermittelt und einen Limit von etwa dem Zehnfachen dieses Wertes festgelegt. Wir beobachten die Situation weiterhin aufmerksam und halten den Puls der Zeit.
Wie sieht das in der Praxis aus? Wir haben Kunden, die unsere APIs zur automatischen Skalierung permanent nutzen. Sie erstellen morgens etwa zweihundert bis dreihundert virtuelle Maschinen und löschen sie am Abend wieder. Um in OpenStack eine virtuelle Maschine zu erstellen, einschließlich PaaS-Diensten, sind mindestens 1000 API-Anfragen nötig, da die Interaktion zwischen den Diensten ebenfalls über APIs erfolgt.
Solche Aufgabenverschiebungen verursachen eine erhebliche Belastung. Wir haben diese Belastung bewertet, die täglichen Spitzen erfasst, sie verzehnfacht und das wurde unser Rate-Limit. Wir sind ständig am Puls der Zeit. Oft beobachten wir Bots und Scanner, die versuchen, uns zu überwachen, ob wir irgendwelche CGA-Skripte haben, die sie starten können, und wir schneiden diese aktiv ab.
Wie man den Code unauffällig für die Benutzer aktualisiert.
Wir implementieren die Ausfallsicherheit auch auf der Ebene der Code-Deploy-Prozesse. Bei Rollouts können Fehler auftreten, aber deren Einfluss auf die Verfügbarkeit der Dienste kann minimiert werden.
Wir aktualisieren ständig unsere Dienste und müssen den Prozess der Aktualisierung der Codebasis ohne Auswirkungen auf die Benutzer sicherstellen. Diese Aufgabe konnten wir mithilfe der HAProxy-Management-Funktionen und der Umsetzung eines Graceful Shutdown in unseren Diensten lösen.
Um diese Aufgabe zu lösen, war es notwendig, die Steuerung des Load Balancers und das 'richtige' Herunterfahren der Dienste zu gewährleisten:
- Im Fall von HAProxy erfolgt die Verwaltung über eine Stats-Datei, die im Wesentlichen ein Socket ist und in der HAProxy-Konfiguration definiert wird. Befehle können über stdio übergeben werden. Unser Hauptwerkzeug zur Kontrolle der Konfigurationen ist jedoch Ansible, weshalb es ein integriertes Modul zur Verwaltung von HAProxy gibt, das wir aktiv nutzen.
- Die meisten unserer API- und Engine-Dienste unterstützen Technologien für einen sanften Shutdown: Beim Herunterfahren warten sie darauf, dass die aktuelle Aufgabe vollständig abgeschlossen ist, egal ob es sich um eine HTTP-Anfrage oder eine andere Dienstaufgabe handelt. Das Gleiche gilt für den Worker. Er kennt alle Aufgaben, die er ausführt, und beendet sich, wenn alles erfolgreich abgeschlossen ist.
Dank dieser beiden Aspekte sieht unser sicherer Deployment-Algorithmus folgendermaßen aus:
- Der Entwickler erstellt ein neues Codepaket (bei uns ist das ein RPM), testet es in der Entwicklungsumgebung, testet es in der Staging-Umgebung und hinterlässt es im Staging-Repository.
- Der Entwickler stellt eine Anfrage für das Deployment mit einer möglichst detaillierten Beschreibung der "Artefakte": Version des neuen Pakets, Beschreibung des neuen Funktionsumfangs und weitere Einzelheiten zum Deployment, falls erforderlich.
- Der Systemadministrator beginnt mit dem Update. Er startet das Ansible-Playbook, das wiederum Folgendes durchführt:
- Es nimmt das Paket aus dem Stage-Repository und aktualisiert daraufhin die Versionsnummer des Pakets im Produktionsrepository.
- Es erstellt eine Liste der Backends des aktualisierten Services.
- Der erste zu aktualisierende Service wird in HAProxy deaktiviert, und es wird auf das Ende seiner Prozesse gewartet. Dank des sanften Herunterfahrens sind wir sicher, dass alle aktuellen Kundenanfragen erfolgreich abgeschlossen werden.
- Nach dem vollständigen Stoppen der API, der Worker und der Deaktivierung von HAProxy erfolgt die Aktualisierung des Codes.
- Ansible startet die Dienste.
- Für jeden Dienst werden bestimmte "Händchen" gezogen, die Unit-Tests anhand einer Reihe vordefinierter Schlüsseltests durchführen. Es findet eine grundlegende Prüfung des neuen Codes statt.
- Wenn im vorherigen Schritt keine Fehler festgestellt wurden, wird das Backend aktiviert.
- Wir gehen zum nächsten Backend über.
- Nach dem Update aller Backends werden funktionale Tests gestartet. Wenn diese nicht ausreichen, prüft der Entwickler jede neue Funktionalität, die er implementiert hat.
Damit ist das Deployment abgeschlossen.

Der Aktualisierungszyklus des Services.
Dieses Schema wäre nicht funktionsfähig, wenn wir nicht eine Regel hätten. Wir unterstützen gleichzeitig die alte und die neue Version in Betrieb. Vorausgesetzt, in der Softwareentwicklung wird festgelegt, dass selbst wenn es Änderungen in der Datenbank des Dienstes gibt, sie den vorherigen Code nicht brechen werden. Infolgedessen erfolgt eine schrittweise Aktualisierung der Codebasis.
Fazit
Wenn ich meine Gedanken zur ausfallsicheren WEB-Architektur teile, möchte ich die wesentlichen Punkte noch einmal betonen:
- physische Ausfallsicherheit;
- Netzwerkausfallsicherheit (Lastenausgleich, BGP);
- Ausfallsicherheit der verwendeten und entwickelten Software.
Allen stabile Betriebszeiten!
Quelle: habr.com
