
Hallo, Habr! Ich bin Artem Karamishev, Leiter des Systemadministrationsteams . Im vergangenen Jahr haben wir viele neue Produkte gestartet. Wir wollten, dass API-Dienste skalierbar, ausfallsicher und für schnelles Wachstum der Benutzerlast bereit sind. Unsere Plattform basiert auf OpenStack und ich möchte erzählen, welche Herausforderungen zur Ausfallsicherheit von Komponenten wir bewältigen mussten, um ein ausfallsicheres System zu erhalten. Ich denke, das wird für diejenigen interessant sein, die ebenfalls Produkte auf OpenStack entwickeln.
Die allgemeine Ausfallsicherheit der Plattform setzt sich aus der Robustheit ihrer Komponenten zusammen. Wir werden also schrittweise alle Ebenen durchgehen, auf denen wir Risiken identifiziert und behoben haben.
Die Video-Version dieser Geschichte, die ursprünglich aus einem Vortrag auf der Uptime day 4 Konferenz stammt, organisiert von , kann angesehen werden .
Die Ausfallsicherheit der physischen Architektur
Der öffentliche Teil der MCS-Cloud basiert derzeit auf zwei Tier III-Rechenzentren, zwischen denen ein eigenes dunkles Glasfaser vorhanden ist, das auf physischer Ebene durch verschiedene Routen reserviert ist und eine Bandbreite von 200 Gbit/s bietet. Tier III bietet das erforderliche Maß an Ausfallsicherheit für die physische Infrastruktur.
Das dunkle Glasfaser ist sowohl auf physischer als auch auf logischer Ebene reserviert. Der Prozess der Kanalsicherung war iterativ, es traten Probleme auf und wir verbessern ständig die Verbindung zwischen den Rechenzentren.
Zum Beispiel wurde vor kurzem bei Arbeiten in einem Schacht neben einem der Rechenzentren mit einem Bagger ein Rohr durchbohrt, in diesem Rohr befanden sich sowohl das Haupt- als auch das резервный optische Kabel. Unser ausfallsicherer Kommunikationskanal mit dem Rechenzentrum war an einem Punkt im Schacht anfällig. Entsprechend haben wir einen Teil der Infrastruktur verloren. Wir haben Lehren daraus gezogen, eine Reihe von Maßnahmen ergriffen und unter anderem optische Kabel im benachbarten Schacht verlegt.
In den Rechenzentren gibt es Standorte von Netzwerkdienstanbietern, an die wir unsere Präfixe über BGP übertragen. Für jede Netzwerkrichtung wird die beste Metrik ausgewählt, was es uns ermöglicht, unseren Kunden die beste Verbindungsqualität zu bieten. Wenn die Verbindung über einen Anbieter unterbrochen wird, stellen wir unsere Routing-Strategie auf die verfügbaren Anbieter um.
Im Falle eines Ausfalls des Anbieters wechseln wir automatisch zum nächsten. Bei einem Ausfall eines der Rechenzentren haben wir eine Spiegelkopie unserer Dienste im zweiten Rechenzentrum, die die gesamte Last übernehmen.

Ausfallsicherheit der physischen Infrastruktur
Was wir für die Ausfallsicherheit auf Anwendungsebene verwenden
Unser Service basiert auf einer Reihe von Open-Source-Komponenten.
ExaBGP — ein Service, der eine Reihe von Funktionen mithilfe des dynamischen Routing-Protokolls auf Basis von BGP implementiert. Wir nutzen ihn aktiv, um unsere öffentlichen IP-Adressen anzukündigen, über die Benutzer Zugriff auf die API erhalten.
HAProxy — ein hochbelasteter Load Balancer, der es ermöglicht, sehr flexible Regeln zur Lastverteilung auf verschiedenen Ebenen des OSI-Modells zu konfigurieren. Wir verwenden ihn zur Lastverteilung vor allen Diensten: Datenbanken, Nachrichtenbroker, API-Dienste, Web-Dienste, unsere internen Projekte – alles läuft hinter HAProxy.
API-Anwendung — eine Webanwendung, die in Python geschrieben wurde und es dem Benutzer ermöglicht, seine Infrastruktur und seinen Service zu verwalten.
Worker-Anwendung (im Folgenden einfach Worker) – in den OpenStack-Diensten ist dies ein Infrastrukturdämon, der es ermöglicht, API-Befehle an die Infrastruktur zu übertragen. Beispielsweise wird die Erstellung einer Festplatte genau im Worker durchgeführt, während die Anfrage zur Erstellung in der API-Anwendung erfolgt.
Standardarchitektur der OpenStack-Anwendung
Die meisten Dienste, die für OpenStack entwickelt werden, versuchen, einer einheitlichen Paradigma zu folgen. Ein Dienst besteht typischerweise 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 konfigurierten Webserver wie Nginx oder Apache läuft. Die API verarbeitet die Benutzeranfragen und gibt die weiteren Anweisungen zur Durchführung an die Worker-Anwendung weiter. Die Übertragung erfolgt über einen Nachrichtenbroker, der in der Regel RabbitMQ ist, während andere schlecht unterstützt werden. Wenn die Nachrichten den Broker erreichen, werden sie von den Workern verarbeitet, die bei Bedarf eine Antwort zurückgeben.
Dieses Paradigma impliziert isolierte gemeinsame Ausfallpunkte: RabbitMQ und die Datenbank. RabbitMQ ist jedoch innerhalb eines Dienstes isoliert und kann theoretisch für jeden Dienst individuell sein. Daher trennen wir in MCS diese Dienste so weit wie möglich, erstellen für jedes einzelne Projekt eine separate Datenbank und ein separates RabbitMQ. Dieser Ansatz hat den Vorteil, dass im Falle eines Ausfalls in bestimmten verwundbaren Punkten nicht der gesamte Dienst ausfällt, sondern nur ein Teil davon.
Die Anzahl der Worker-Anwendungen ist nicht begrenzt, sodass die API leicht horizontal skaliert werden kann, um die Leistung und Ausfallsicherheit zu erhöhen.
In einigen Diensten ist eine Koordination innerhalb des Dienstes erforderlich – insbesondere bei komplexen, aufeinanderfolgenden Operationen zwischen API und Workern. In diesem Fall wird ein zentrales Koordinationssystem verwendet, ein Cluster-System wie Redis, Memcache, etcd, das es einem Worker ermöglicht, einem anderen zu sagen, dass diese Aufgabe ihm zugewiesen ist ("bitte nimm sie nicht"). Wir verwenden etcd. In der Regel kommunizieren die Worker aktiv mit der Datenbank und lesen und schreiben Informationen daraus. Als Datenbank verwenden wir MariaDB, die sich in einem Multi-Master-Cluster befindet.
Ein solcher klassischer Einzelservice ist auf die allgemein anerkannte Weise für OpenStack organisiert. Er kann als geschlossenes System betrachtet werden, für das die Skalierung und Ausfallsicherheit recht offensichtlich sind. Zum Beispiel ist es für die Ausfallsicherheit ausreichend, einen Lastenausgleichsserver vor der API zu setzen. Die Skalierung der Worker erfolgt durch Erhöhung ihrer Anzahl.
Ein Schwachpunkt in der gesamten Architektur 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-Load-Balancer ausfallsicher mit ExaBGP
Um sicherzustellen, dass unsere APIs skalierbar, schnell und ausfallsicher sind, haben wir einen Load-Balancer vor sie gesetzt. Wir haben uns für HAProxy entschieden. Meiner Meinung nach bietet er alle notwendigen Eigenschaften für unsere Aufgabe: Lastverteilung auf mehreren OSI-Ebenen, Verwaltungsoberfläche, Flexibilität und Skalierbarkeit, eine große Anzahl von Lastverteilungsmethoden, Unterstützung für Sitzungstabellen.
Das erste Problem, das gelöst werden musste, war die Ausfallsicherheit des Load-Balancers selbst. Die bloße Installation eines Load-Balancers schafft ebenfalls einen Ausfallpunkt: Bricht der Load-Balancer zusammen, fällt der Dienst aus. Um das zu verhindern, haben wir HAProxy zusammen mit ExaBGP verwendet.
ExaBGP ermöglicht die Implementierung eines Mechanismus zur Überprüfung des Dienststatus. Wir haben diesen Mechanismus genutzt, um die Funktionsfähigkeit von HAProxy zu überprüfen und im Falle von Problemen den HAProxy-Dienst aus BGP herauszunehmen.
Schema ExaBGP+HAProxy
- Wir installieren die notwendige Software ExaBGP und HAProxy auf drei Servern.
- Auf jedem Server erstellen wir ein Loopback-Interface.
- Auf allen drei Servern weisen wir diesem Interface die gleiche öffentliche IP-Adresse zu.
- Die öffentliche IP-Adresse wird über ExaBGP im Internet angekündigt.
Die Ausfallsicherheit wird erreicht, indem dieselbe IP-Adresse von allen drei Servern aus angekündigt wird. Aus Sicht des Netzwerks ist die gleiche Adresse von drei verschiedenen Next Hops erreichbar. Der Router sieht drei identische Routen und wählt die bevorzugteste aus, basierend auf seiner Metrik (in der Regel ist dies dieselbe Option), und der Verkehr geht nur an einen der Server.
Im Falle von Problemen mit HAProxy oder einem Ausfall des Servers hört ExaBGP auf, die Route anzukündigen, und der Verkehr wechselt sanft zu einem anderen Server.
So haben wir die Ausfallsicherheit des Load-Balancers erreicht.

Ausfallsicherheit der HAProxy-Load-Balancer
Das Schema ist nicht ideal ausgefallen: Wir haben gelernt, HAProxy ausfallsicher zu machen, aber nicht gelernt, die Last innerhalb der Dienste zu verteilen. Daher haben wir dieses Schema etwas erweitert: Wir haben auf die Lastverteilung zwischen mehreren öffentlichen IP-Adressen umgestellt.
DNS- und BGP-basierte Lastenverteilung
Das Problem der Lastenverteilung vor unseren HAProxy ist noch ungelöst. Dennoch kann es recht einfach gelöst werden, wie wir es bei uns getan haben.
Für die Lastenverteilung von drei Servern werden 3 öffentliche IP-Adressen und der gute alte DNS benötigt. Jede dieser Adressen wird auf das Loopback-Interface jedes HAProxy zugeordnet und im Internet angekündigt.
In OpenStack wird ein Dienstekatalog zur Verwaltung von Ressourcen verwendet, in dem der API-Endpunkt eines jeden Dienstes festgelegt wird. In diesem Katalog geben wir den Domainnamen – public.infra.mail.ru – an, der über DNS auf drei verschiedene IP-Adressen aufgelöst wird. Dadurch erhalten wir eine Lastenverteilung über die drei Adressen mittels DNS.
Da wir bei der Ankündigung der öffentlichen IP-Adressen die Prioritäten der Serverauswahl nicht steuern, handelt es sich bislang nicht um eine Lastenverteilung. In der Regel wird nur ein Server basierend auf der IP-Adressen-Hierarchie ausgewählt, während die beiden anderen untätig bleiben, da keine Metriken im BGP angegeben sind.
Wir haben begonnen, Routen über ExaBGP mit verschiedenen Metriken anzubieten. Jeder Lastenverteilung hat alle drei öffentlichen IP-Adressen angekündigt, aber eine davon, die Hauptadresse für diesen Lastenverteilung, wird mit der minimalen Metrik angekündigt. Solange alle drei Lastenverteilung aktiv sind, gelangen Anfragen an die erste IP-Adresse zum ersten Lastenverteilung, Anfragen an die zweite zur zweiten, und an die dritte zur dritten.
Was passiert, wenn einer der Lastenverteilungen ausfällt? Im Falle eines Ausfalls eines Lastenverteilung wird dessen Hauptadresse weiterhin von zwei anderen angekündigt, und der Traffic zwischen ihnen wird umverteilt. So bieten wir dem Benutzer über DNS mehrere IP-Adressen an. Durch die Lastenverteilung über DNS und unterschiedliche Metriken erreichen wir eine gleichmäßige Lastenverteilung auf alle drei Lastenverteilungen, ohne die Fehlertoleranz zu verlieren.

Lastenverteilung von HAProxy basierend auf DNS + BGP
Interaktion zwischen ExaBGP und HAProxy
Wir haben also die Fehlertoleranz für den Fall eines Serverausfalls implementiert, basierend auf dem Einsetzen der Routenankündigungen. Aber HAProxy kann aus verschiedenen Gründen ausfallen, neben dem Ausfall eines Servers: Administrationsfehler, interne Dienststörungen. In diesen Fällen möchten wir den defekten Lastenverteilung aus der Lastenverteilung entfernen, und dafür ist ein anderer Mechanismus erforderlich.
Daher haben wir das vorherige Schema erweitert und ein Heartbeat zwischen ExaBGP und HAProxy implementiert. Dies ist eine softwarebasierte Implementierung der Interaktion zwischen ExaBGP und HAProxy, wobei ExaBGP benutzerdefinierte Skripte zur Überprüfung des Status der Anwendungen verwendet.
Dazu muss im ExaBGP-Config ein Health Checker konfiguriert werden, der den Status von HAProxy überprüfen kann. In unserem Fall haben wir das Health Backend in HAProxy eingerichtet, und von Seiten ExaBGP prüfen wir den Status mit einer einfachen GET-Anfrage. Wenn die Ankündigung aufhört, dann funktioniert HAProxy höchstwahrscheinlich nicht, und wir brauchen ihn nicht anzukündigen.

HAProxy Health Check
HAProxy Peers: Sitzungsynchronisierung
Das Nächste, was zu tun war, war die Synchronisierung der Sitzungen. Bei der Arbeit mit verteilten Lastverteilern ist es schwierig, Informationen über die Sitzungen der Kunden zu speichern. Doch HAProxy ist einer der wenigen Lastverteiler, die dies ermöglichen, dank der Funktionalität Peers — der Möglichkeit, zwischen verschiedenen HAProxy-Prozessen Sitzungstabellen zu übertragen.
Es gibt verschiedene Methoden der Lastverteilung: einfache wie , und erweiterte, bei denen die Sitzung des Kunden gespeichert wird und er jedes Mal denselben Server erreicht wie zuvor. Wir wollten die zweite Variante umsetzen.
In HAProxy wird für die Speicherung der Sitzungen des Kunden dieser Mechanismus stick-tables verwendet. Diese speichern die ursprüngliche IP-Adresse des Kunden, die gewählte Zieladresse (Backend) und einige Dienstinformationen. Normalerweise werden Stick-Tabellen verwendet, um das Paar source-IP + destination-IP zu speichern, was besonders nützlich ist für Anwendungen, die den Sitzungskontext des Benutzers nicht über andere Lastverteiler hinweg übertragen können, zum Beispiel im RoundRobin-Balancing-Modus.
Wenn wir die Stick-Tabelle lehren, zwischen verschiedenen HAProxy-Prozessen (zwischen denen die Lastverteilung erfolgt) zu migrieren, können unsere Lastverteiler mit einem Pool von Stick-Tabellen arbeiten. Dies ermöglicht nahtloses Client-Netzwerk-Um-Schalten, wenn einer der Lastverteiler ausfällt, und die Arbeit mit den Kunden-Sitzungen wird auf denselben Backends fortgesetzt, die zuvor ausgewählt wurden.
Für das ordnungsgemäße Funktionieren muss das Problem der Quell-IP-Adresse des Lastverteilers gelöst werden, von dem die Sitzung hergestellt wurde. In unserem Fall handelt es sich um eine dynamische Adresse am Loopback-Interface.
Die korrekte Funktion von Peers tritt nur unter bestimmten Bedingungen auf. Das bedeutet, dass die TCP-Timeouts groß genug sein müssen oder der Wechsel schnell genug erfolgen muss, damit die TCP-Sitzung nicht abbricht. Dennoch ermöglicht dies nahtloses Umschalten.
Wir haben in IaaS einen Dienst, der auf derselben Technologie basiert. Das ist , der Octavia heißt. Er basiert auf zwei HAProxy-Prozessen und unterstützt von Anfang an Peers. In diesem Dienst haben sie sich hervorragend bewährt.
Das Bild zeigt schematisch die Verschiebung von Peers-Tabellen zwischen drei HAProxy-Instanzen, ein vorgeschlagener Konfigurationsansatz, wie dies eingerichtet werden kann:

HAProxy Peers (Sitzungssynchronisation)
Wenn Sie ein ähnliches Schema implementieren, sollten Sie dessen Funktionalität sorgfältig testen. Es ist nicht garantiert, dass es in dieser Form in 100 % der Fälle funktioniert. Aber zumindest verlieren Sie keine Stick-Tabellen, wenn es notwendig ist, die Quell-IP des Clients im Gedächtnis zu behalten.
Begrenzung der Anzahl gleichzeitiger Anfragen von demselben Client
Alle Dienste, die öffentlich zugänglich sind, einschließlich unserer APIs, können von Anfragefluten betroffen sein. Ihre Ursachen können sehr unterschiedlich sein, von Benutzerfehlern bis hin zu gezielten Angriffen. Gelegentlich werden wir per DDoS-Angriff über IP-Adressen angegriffen. Kunden machen oft Fehler in ihren Skripten und verursachen damit Mini-DDoS-Attacken.
Wie auch immer, es ist notwendig, zusätzlichen Schutz vorzusehen. Eine offensichtliche Lösung ist es, die Anzahl der Anfragen an die API zu begrenzen und keine Prozessorzeit für die Verarbeitung schädlicher Anfragen aufzuwenden.
Um solche Begrenzungen umzusetzen, verwenden wir Rate Limits, die auf Grundlage von HAProxy organisiert sind, unter Verwendung derselben Stick-Tabellen. Die Limits lassen sich relativ einfach einstellen und ermöglichen es, die Anzahl der Anfragen eines Benutzers an die API zu beschränken. Der Algorithmus merkt sich die Quell-IP, von der die Anfragen kommen, und begrenzt die Anzahl gleichzeitiger Anfragen von einem Benutzer. Natürlich haben wir das durchschnittliche Lastprofil der API für jeden Dienst ermittelt und ein Limit festgelegt, das etwa 10-mal höher ist als dieser Wert. Wir beobachten die Situation weiterhin genau und behalten die Kontrolle.
Wie sieht das in der Praxis aus? Wir haben Kunden, die ständig unsere APIs für die automatisierte Skalierung nutzen. Sie erstellen etwa zweihundert bis dreihundert virtuelle Maschinen am Morgen und löschen diese gegen Abend. Um in OpenStack eine virtuelle Maschine zu erstellen, zusammen mit PaaS-Diensten, benötigt man mindestens 1000 API-Anfragen, 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 ist unser Rate-Limit geworden. Wir haben die Situation stets im Blick. Oft sehen wir Bots und Scanner, die versuchen herauszufinden, ob wir irgendwelche CGA-Skripte haben, die sie starten können, und wir schneiden diese aktiv ab.
Wie man den Code-Bestand unbemerkt für die Benutzer aktualisiert
Wir implementieren auch Ausfallsicherheit auf der Ebene des Code-Deployments. Bei den Rollouts kann es zu Ausfällen kommen, jedoch lässt sich deren Einfluss auf die Verfügbarkeit der Dienste minimieren.
Wir aktualisieren ständig unsere Dienste und müssen den Prozess der Aktualisierung des Code-Bestands für die Benutzer ohne Auswirkungen gewährleisten. Diese Aufgabe konnten wir lösen, indem wir die Möglichkeiten der Verwaltung von HAProxy und die Implementierung von Graceful Shutdown in unseren Diensten genutzt haben.
Um diese Aufgabe zu lösen, musste die Verwaltung des Load Balancers und das „korrekte“ Ausschalten der Dienste sichergestellt werden:
- Im Falle von HAProxy erfolgt die Verwaltung über eine Stats-Datei, die im Wesentlichen ein Socket darstellt und in der HAProxy-Konfiguration definiert ist. Kommandos können über stdio an ihn übergeben werden. Unser primäres Werkzeug zur Kontrolle der Konfigurationen ist jedoch Ansible, weshalb es ein integriertes Modul zur Verwaltung von HAProxy gibt, das wir aktiv nutzen.
- Der größte Teil unserer API- und Engine-Dienste unterstützt die Technologie des Graceful Shutdown: Beim Ausschalten warten sie, bis die aktuelle Aufgabe vollständig abgeschlossen ist, sei es eine http-Anfrage oder eine andere Dienstaufgabe. Dasselbe 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 wie folgt aus.
- Der Entwickler stellt ein neues Code-Paket (bei uns ist das RPM) zusammen, testet es in der Entwicklungsumgebung, testet es im Staging und lässt es im Staging-Repository.
- Der Entwickler gibt die Aufgabe für das Deployment mit einer möglichst detaillierten Beschreibung der „Artefakte“ an: Version des neuen Pakets, Beschreibung der neuen Funktionalität und andere Einzelheiten zum Deployment, falls erforderlich.
- Der Systemadministrator beginnt mit dem Update. Er startet das Ansible-Playbook, welches folgendes tut:
- Es nimmt das Paket aus dem Stage-Repository und aktualisiert die Version des Pakets im Produkt-Repository.
- Es erstellt eine Liste der Backends des aktualisierten Dienstes.
- Es schaltet den ersten zu aktualisierenden Dienst in HAProxy aus und wartet, bis die Prozesse abgeschlossen sind. Dank des sanften Herunterfahrens sind wir sicher, dass alle aktuellen Kundenanfragen erfolgreich abgeschlossen werden.
- Nach dem vollständigen Stoppen der API, der Worker und dem Ausschalten von HAProxy erfolgt das Code-Update.
- Ansible startet die Dienste.
- Für jeden Dienst zieht es bestimmte „Regler“, die Unit-Tests anhand einer Reihe von zuvor definierten Schlüsseltests durchführen. Es erfolgt eine grundlegende Überprüfung des neuen Codes.
- Wenn bei dem vorherigen Schritt keine Fehler entdeckt wurden, wird das Backend aktiviert.
- Wir gehen zum nächsten Backend über.
- Nach der Aktualisierung aller Backends werden funktionale Tests durchgeführt. Wenn diese nicht ausreichen, schaut der Entwickler sich jede neue Funktionalität an, die er erstellt hat.
Damit ist das Deployment abgeschlossen.

Der Update-Zyklus des Dienstes
Dieses Schema wäre nicht funktionsfähig, wenn wir nicht eine Regel hätten. Wir unterstützen gleichzeitig die alte und die neue Version im Live-Betrieb. Bereits in der Entwicklungsphase der Software wird festgelegt, dass selbst wenn Änderungen an der Datenbank des Dienstes vorgenommen werden, sie den alten Code nicht brechen werden. Dadurch erfolgt ein schrittweises Update der Codebasis.
Fazit
Wenn ich meine Gedanken zur ausfallsicheren WEB-Architektur teile, möchte ich nochmals ihre Schlüsselpunkte hervorheben:
- physische Ausfallsicherheit;
- netzwerkbasierte Ausfallsicherheit (Lastverteiler, BGP);
- Ausfallsicherheit der verwendeten und entwickelten Software.
Allen eine stabile Betriebszeit!
Quelle: habr.com
