
Cześć, Habr! Jestem Artem Karamyszev, kierownik zespołu administracji systemowej. . W ciągu ostatniego roku mieliśmy wiele premier nowych produktów. Chcieliśmy, aby usługi API były łatwo skalowalne, odporne na awarie i gotowe na szybki wzrost obciążenia użytkowników. Nasza platforma jest zrealizowana na OpenStack, a ja chciałbym opowiedzieć, jakie problemy z odpornością komponentów musieliśmy rozwiązać, aby uzyskać system odporny na awarie. Myślę, że będzie to interesujące dla tych, którzy również rozwijają produkty na OpenStack.
Ogólna odporność platformy składa się z odporności jej komponentów. Przeto stopniowo przejdziemy przez wszystkie poziomy, na których zidentyfikowaliśmy ryzyka i je wyeliminowaliśmy.
Wersję wideo tej historii, której źródłem była prezentacja na konferencji Uptime Day 4, zorganizowanej przez , można obejrzeć .
Odporność architektury fizycznej
Publiczna część chmury MCS obecnie bazuje w dwóch centrach danych poziomu Tier III, między nimi znajduje się własne włókno ciemne, zarezerwowane na poziomie fizycznym różnymi trasami, o przepustowości 200 Gbit/s. Poziom Tier III zapewnia wymagany poziom odporności fizycznej infrastruktury.
Włókno ciemne zostało zarezerwowane zarówno na poziomie fizycznym, jak i logicznym. Proces rezerwacji kanałów był iteracyjny, pojawiły się problemy i ciągle doskonalimy łączność między centrami danych.
Na przykład, niedawno podczas prac w studzience obok jednego z centrów danych, koparka przebiła rurę, a wewnątrz tej rury znajdowały się zarówno główny, jak i zapasowy kabel optyczny. Nasz odporny na awarie kanał komunikacji z centrum danych okazał się wrażliwy w jednym punkcie, w studzience. W związku z tym straciliśmy część infrastruktury. Wyciągnęliśmy wnioski, podjęliśmy szereg działań, w tym położyliśmy dodatkową optykę w sąsiedniej studzience.
W centrach danych znajdują się punkty obecności dostawców łączności, do których przesyłamy nasze prefiksy za pośrednictwem BGP. Dla każdego kierunku sieci wybierana jest najlepsza metryka, co pozwala zapewnić różnym klientom najwyższą jakość połączenia. Jeśli połączenie przez jednego dostawcę zostaje przerwane, dostosowujemy naszą trasę przez dostępnych dostawców.
W przypadku awarii dostawcy automatycznie przełączamy się na następnego. W przypadku awarii jednego z centrów danych posiadamy lustrzaną kopię naszych usług w drugim centrum, które przejmuje cały ruch.

Odporność infrastruktury fizycznej
Czego używamy do zapewnienia odporności na poziomie aplikacji
Nasz serwis oparty jest na szeregu komponentów open source.
ExaBGP — serwis, który realizuje szereg funkcji z użyciem protokołu dynamicznego routingu opartego na BGP. Aktywnie go wykorzystujemy do ogłaszania naszych publicznych adresów IP, przez które użytkownicy uzyskują dostęp do API.
HAProxy — wysokoobciążony balancer, który umożliwia ustawianie bardzo elastycznych reguł balansowania ruchu na różnych poziomach modelu OSI. Używamy go do balansowania przed wszystkimi naszymi usługami: bazami danych, brokerami wiadomości, usługami API, usługami webowymi, naszymi projektami wewnętrznymi — wszystko to jest obsługiwane przez HAProxy.
API application — aplikacja webowa napisana w Pythonie, za pomocą której użytkownik zarządza swoją infrastrukturą i swoimi usługami.
Worker application (dalej po prostu worker) — w usługach OpenStack to demon infrastrukturalny, który pozwala na przesyłanie poleceń API do infrastruktury. Na przykład, tworzenie dysku odbywa się właśnie w workerze, a zapytanie o utworzenie — w aplikacji API.
Standardowa architektura OpenStack Application
Większość usług opracowywanych dla OpenStack stara się podążać jednolitą paradygmatą. Usługa zwykle składa się z dwóch części: API i workerów (wykonawców backendu). Zazwyczaj API to aplikacja WSGI napisana w Pythonie, która działa jako niezależny proces (daemon) lub za pomocą gotowego serwera webowego Nginx, Apache. API przetwarza zapytanie użytkownika i przekazuje dalsze instrukcje do realizacji aplikacji worker. Przekazanie odbywa się za pośrednictwem brokera wiadomości, którym zazwyczaj jest RabbitMQ, inne są słabo wspierane. Gdy wiadomości trafiają do brokera, są przetwarzane przez workerów i w razie potrzeby zwracają odpowiedź.
Ten paradygmat zakłada izolowane wspólne punkty awarii: RabbitMQ i bazę danych. Jednak RabbitMQ jest izolowany w ramach jednej usługi i w teorii może być indywidualny dla każdej usługi. Dlatego w MCS maksymalnie dzielimy te usługi, dla każdego oddzielnego projektu tworzymy osobną bazę, oddzielny RabbitMQ. Takie podejście jest dobre, ponieważ w przypadku awarii w niektórych wrażliwych punktach nie psuje się cała usługa, a tylko jej część.
Liczba aplikacji worker nie jest niczym ograniczona, dlatego API może łatwo skalować się horyzontalnie za balancerami w celu zwiększenia wydajności i odporności na awarie.
W niektórych usługach konieczna jest koordynacja wewnątrz usługi — gdy mają miejsce skomplikowane sekwencyjne operacje między API a workerami. W tym przypadku używa się jednego centrum koordynacji, systemu klastrowego typu Redis, Memcache, etcd, które pozwala jednemu workerowi powiedzieć innemu, że to zadanie jest przypisane do niego („proszę, nie bierz tego”). Używamy etcd. Zazwyczaj workerzy aktywnie komunikują się z bazą danych, pisząc i odczytując z niej informacje. Jako bazę danych używamy mariadb, która znajduje się w klastrze multimaster.
Taka klasyczna pojedyncza usługa jest zorganizowana w powszechnie akceptowany sposób dla OpenStack. Można ją traktować jako zamknięty system, dla którego sposoby skalowania i odporności na awarie są dość oczywiste. Na przykład, aby zapewnić odporność na awarie, przed API wystarczy umieścić balancer. Skalowanie workerów osiąga się poprzez zwiększenie ich liczby.
Słabym punktem całej konstrukcji są RabbitMQ i MariaDB. Ich architektura zasługuje na osobny artykuł. W tym artykule chciałbym skupić się na odporności na awarie API.

Architektura Openstack Application. Balansowanie i odporność na awarie platformy chmurowej
Tworzymy odporność na awarie dla balancerów HAProxy z pomocą ExaBGP
Aby nasze API były skalowalne, szybkie i odporne na awarie, postawiliśmy przed nimi balancer. Wybraliśmy HAProxy. Moim zdaniem, posiada on wszystkie wymagane cechy dla naszego zadania: balansowanie na wielu poziomach OSI, interfejs zarządzania, elastyczność i skalowalność, dużą liczbę metod balansowania, wsparcie dla tabel sesji.
Pierwszy problem, który należało rozwiązać, to odporność samego balancera. Proste zainstalowanie balancera również tworzy punkt awarii: balancer się psuje — serwis pada. Aby do tego nie dopuścić, użyliśmy HAProxy wraz z ExaBGP.
ExaBGP pozwala na realizację mechanizmu sprawdzania stanu serwisu. Użyliśmy tego mechanizmu do monitorowania sprawności HAProxy i w przypadku problemów wyłączaliśmy serwis HAProxy z BGP.
Schemat ExaBGP+HAProxy
- Instalujemy potrzebne oprogramowanie, ExaBGP i HAProxy, na trzech serwerach.
- Na każdym z serwerów tworzymy interfejs loopback.
- Na wszystkich trzech serwerach przypisujemy do tego interfejsu ten sam publiczny adres IP.
- Publiczny adres IP jest ogłaszany w internecie przez ExaBGP.
Odporność na awarie osiągamy poprzez ogłaszanie tego samego adresu IP ze wszystkich trzech serwerów. Z punktu widzenia sieci jeden i ten sam adres jest dostępny z trzech różnych next hopów. Router widzi trzy identyczne trasy, wybiera najbardziej priorytetową według własnej metryki (zwykle to ta sama opcja) i ruch kierowany jest tylko do jednego z serwerów.
W przypadku problemów z działaniem HAProxy lub awarii serwera, ExaBGP przestaje ogłaszać trasę, a ruch płynnie przełącza się na inny serwer.
W ten sposób osiągnęliśmy odporność na awarie dla balancerów.

Odporność na awarie balancerów HAProxy
Schemat okazał się niedoskonały: nauczyliśmy się rezerwować HAProxy, ale nie nauczyliśmy się rozkładać obciążenia wewnątrz serwisów. Dlatego nieco rozszerzyliśmy tę konstrukcję: przeszliśmy do balansowania pomiędzy kilkoma publicznymi adresami IP.
Balansowanie na bazie DNS plus BGP
Problem z балансировкой obciążenia przed naszymi HAProxy nadal pozostaje nierozwiązany. Niemniej jednak, można go dość prosto rozwiązać, tak jak zrobiliśmy to u siebie.
Do balansowania trzech serwerów potrzebne będą 3 białe adresy IP i dobry stary DNS. Każdy z tych adresów jest przypisywany do interfejsu loopback każdego HAProxy i ogłaszany w internecie.
W OpenStack do zarządzania zasobami używany jest katalog usług, w którym definiowany jest punkt końcowy API danej usługi. W tym katalogu zapisujemy nazwę domeny — public.infra.mail.ru, która resolwuje się przez DNS za pomocą trzech różnych adresów IP. W rezultacie uzyskujemy rozkład obciążenia pomiędzy trzema adresami za pomocą DNS.
Jednakże, ponieważ ogłaszając białe adresy IP, nie zarządzamy priorytetami wyboru serwera, wciąż nie jest to balansowanie. Zazwyczaj wybierany będzie tylko jeden serwer na podstawie wyższości adresu IP, podczas gdy dwa inne będą bezczynne, ponieważ nie podano żadnych metryk w BGP.
Zaczęliśmy ogłaszać trasy przez ExaBGP z różnymi metrykami. Każdy balansujący ogłasza wszystkie trzy białe adresy IP, ale jeden z nich, główny dla danego balansującego, jest ogłoszony z minimalną metryką. Tak więc, póki wszystkie trzy balansujące są w akcji, zapytania do pierwszego adresu IP trafiają do pierwszego balansującego, zapytania do drugiego do drugiego, a do trzeciego do trzeciego.
Co się dzieje w momencie, gdy jeden z balansujących pada? W przypadku awarii jakiegokolwiek balansującego, jego podstawowy adres wciąż jest ogłaszany przez dwóch pozostałych, a ruch między nimi jest redystrybucjonowany. W ten sposób przekazujemy użytkownikowi przez DNS od razu kilka adresów IP. Dzięki balansowaniu za pomocą DNS i różnym metrykom uzyskujemy równomierny rozkład obciążenia na wszystkie trzy balansujące. I przy tym nie tracimy odporności na awarie.

Balansowanie HAProxy na bazie DNS + BGP
Interakcja między ExaBGP a HAProxy
Zrealizowaliśmy więc odporność na awarie w przypadku odejścia serwera, opartą na zaprzestaniu ogłaszania tras. Jednak HAProxy może się wyłączyć z innych powodów niż awaria serwera: błędy administracyjne, awarie w obrębie usługi. Chcemy usunąć uszkodzony balansujący z obciążenia i w tych przypadkach potrzebny jest inny mechanizm.
Dlatego, rozwijając wcześniej przedstawiony schemat, wdrożyliśmy heartbeat między ExaBGP a HAProxy. To programowa implementacja interakcji między ExaBGP a HAProxy, gdzie ExaBGP wykorzystuje niestandardowe skrypty do sprawdzania statusu aplikacji.
W tym celu w konfiguracji ExaBGP należy skonfigurować health checker, który będzie w stanie sprawdzać status HAProxy. W naszym przypadku skonfigurowaliśmy health backend w HAProxy, a z strony ExaBGP sprawdzamy to za pomocą prostego zapytania GET. Jeśli ogłoszenie przestaje mieć miejsce, oznacza to, że HAProxy prawdopodobnie nie działa i nie należy go ogłaszać.

HAProxy Health Check
HAProxy Peers: synchronizacja sesji
Następnie, co było konieczne do zrobienia, to synchronizacja sesji. Przy pracy przez rozproszone load balancery trudno zorganizować przechowywanie informacji o sesjach klientów. Jednak HAProxy jest jednym z nielicznych load balancerów, które potrafią to zrobić dzięki funkcjonalności Peers — możliwości transferu tablic sesji między różnymi procesami HAProxy.
Istnieją różne metody balansowania: proste, takie jak , oraz zaawansowane, kiedy zapamiętywana jest sesja klienta, a on za każdym razem trafia na ten sam serwer, co wcześniej. Chcieliśmy wdrożyć drugą opcję.
W HAProxy do przechowywania sesji klienta wykorzystuje się mechanizm stick-tables. Zachowują one pierwotny adres IP klienta, wybrany adres docelowy (backend) oraz pewne informacje pomocnicze. Zwykle stick-tables są używane do przechowywania par source-IP + destination-IP, co jest szczególnie przydatne dla aplikacji, które nie mogą przekazywać kontekstu sesji użytkownika przy przełączaniu na inny load balancer, na przykład — w trybie balansowania RoundRobin.
Jeśli stick-table nauczy się przemieszczać między różnymi procesami HAProxy (między którymi odbywa się balansowanie), nasze load balancery będą mogły pracować z jednym zestawem stick-tables. To umożliwi bezproblemowe przełączanie sieci klienta przy awarii jednego z load balancerów, a praca z sesjami klientów będzie kontynuowana na tych samych backendach, które zostały wybrane wcześniej.
Aby działało to prawidłowo, należy rozwiązać problem adresu IP źródłowego load balancera, z którego nawiązana jest sesja. W naszym przypadku jest to dynamiczny adres na interfejsie loopback.
Prawidłowe działanie peers osiąga się jedynie w określonych warunkach. To znaczy, że czasy oczekiwania TCP muszą być wystarczająco długie, a przełączenie wystarczająco szybkie, aby sesja TCP nie zdążyła się zerwać. Niemniej jednak pozwala to na płynne przełączanie.
W naszym IaaS mamy usługę opartą na tej samej technologii. To , która nazywa się Octavia. Opiera się ona na dwóch procesach HAProxy, w których od początku zaimplementowano wsparcie dla peers. W tej usłudze sprawdziły się one doskonale.
Na rysunku schematycznie przedstawiono przenoszenie tabel peers między trzema instancjami HAProxy, zaproponowano konfigurację, jak można to ustawić:

HAProxy Peers (synchronizacja sesji)
Jeśli będziesz wdrażać taki sam schemat, trzeba dokładnie przetestować jego działanie. Nie ma pewności, że zadziała w ten sam sposób w 100% przypadków. Niemniej jednak przynajmniej nie stracisz tabel stick, kiedy trzeba zapamiętać adres IP źródła klienta.
Limit liczby równoczesnych zapytań od jednego i tego samego klienta
Wszystkie usługi dostępne publicznie, w tym nasze API, mogą być narażone na lawiny zapytań. Powody mogą być całkowicie różne, od błędów użytkowników, po celowe ataki. Regularnie jesteśmy DDoS-owani po adresach IP. Klienci często mylą się w swoich skryptach, powodując mini-DDoSy.
W każdym razie konieczne jest przewidzenie dodatkowej ochrony. Oczywistym rozwiązaniem jest ograniczenie liczby zapytań do API i nie marnowanie czasu procesora na przetwarzanie złośliwych zapytań.
Aby wdrożyć takie ograniczenia, stosujemy limity szybkości, zorganizowane na bazie HAProxy, przy pomocy tych samych tabel stick. Limity ustawia się dość łatwo i pozwala to ograniczyć użytkownika pod względem liczby zapytań do API. Algorytm zapamiętuje adres IP źródła, z którego wysyłane są zapytania, i ogranicza liczbę równoczesnych zapytań od jednego użytkownika. Oczywiście obliczyliśmy średni profil obciążenia API dla każdej usługi i ustawiliśmy limit ≈ 10 razy większy od tej wartości. Nadal uważnie monitorujemy sytuację, mając rękę na pulsie.
Jak to wygląda w praktyce? Mamy klientów, którzy regularnie korzystają z naszego API do automatycznego skalowania. Tworzą około dwustu-trzystu maszyn wirtualnych przed południem, a następnie usuwają je późnym popołudniem. Dla OpenStack stworzenie maszyny wirtualnej, a także z usługami PaaS, to co najmniej 1000 zapytań API, ponieważ interakcja między usługami również odbywa się za pośrednictwem API.
Takie przenoszenie zadań generuje dość dużą obciążenie. Oszacowaliśmy to obciążenie, zebraliśmy dzienne szczyty, zwiększyliśmy je dziesięciokrotnie, co stało się naszym limitem rate. Mamy rękę na pulsie. Często widzimy boty, skanery, które próbują sprawdzić, czy mamy jakieś skrypty CGA, które można uruchomić, intensywnie je eliminujemy.
Jak aktualizować bazę kodu bez zauważania przez użytkowników
Realizujemy odporność na awarie także na poziomie procesów wdrażania kodu. Przy wdrożeniach zdarzają się awarie, ale ich wpływ na dostępność usług można zminimalizować.
Nieustannie aktualizujemy nasze usługi i musimy zapewnić proces aktualizacji bazy kodu bez wpływu na użytkowników. Zrealizowaliśmy to, wykorzystując możliwości zarządzania HAProxy oraz implementację Graceful Shutdown w naszych usługach.
Aby rozwiązać ten problem, trzeba zapewnić zarządzanie load balancerem i "prawidłowe" wyłączanie usług:
- W przypadku HAProxy zarządzanie odbywa się przez plik stats, który w zasadzie jest socketem i jest definiowany w konfiguracji HAProxy. Można mu przekazywać polecenia przez stdio. Jednak naszym głównym narzędziem do kontroli konfiguracji jest ansible, dlatego zawiera on wbudowany moduł do zarządzania HAProxy, który intensywnie wykorzystujemy.
- Większość naszych usług API i Engine wspiera technologie graceful shutdown: podczas wyłączania czekają one na zakończenie bieżącego zadania, niezależnie od tego, czy jest to zapytanie http, czy jakieś zadanie robocze. To samo dotyczy workera. Wie o wszystkich zadaniach, które wykonuje, i kończy się, gdy wszystkie zostaną pomyślnie wykonane.
Dzięki tym dwóm aspektom, bezpieczny algorytm naszego wdrożenia wygląda następująco.
- Programista zbiera nowy pakiet kodu (u nas to RPM), testuje w środowisku dev, testuje na stage, a następnie zostawia w repozytorium stage.
- Programista stawia zadanie na wdrożenie z jak najdokładniejszym opisem «artefaktów»: wersja nowego pakietu, opis nowych funkcjonalności oraz inne szczegóły dotyczące wdrożenia, jeśli zajdzie taka potrzeba.
- Administrator systemu rozpoczyna aktualizację. Uruchamia playbook Ansible, który z kolei wykonuje następujące czynności:
- Pobiera pakiet z repozytorium stagingowego, aktualizując wersję pakietu w repozytorium produkcyjnym.
- Tworzy listę backendów aktualizowanej usługi.
- Wyłącza pierwszy aktualizowany serwis w HAProxy i czeka na zakończenie pracy jego procesów. Dzięki graceful shutdown mamy pewność, że wszystkie bieżące zapytania klientów zakończą się pomyślnie.
- Po całkowitym zatrzymaniu API, workerów oraz wyłączeniu HAProxy, następuje aktualizacja kodu.
- Ansible uruchamia usługi.
- Dla każdej usługi wykonuje określone «ruchy», które przeprowadzają testy jednostkowe według z góry ustalonych kluczowych testów. Następuje podstawowa weryfikacja nowego kodu.
- Jeśli na poprzednim etapie nie wykryto błędów, backend zostaje aktywowany.
- Przechodzimy do kolejnego backendu.
- Po zaktualizowaniu wszystkich backendów uruchamiane są testy funkcjonalne. Jeśli ich brakuje, programista sprawdza wszelką nową funkcjonalność, którą wprowadzał.
Na tym wdrożenie się kończy.

Cykl aktualizacji usługi
Ten schemat nie byłby użyteczny, gdybyśmy nie mieli jednej zasady. Utrzymujemy w ruchu jednocześnie starą i nową wersję. Z wyprzedzeniem, na etapie rozwoju oprogramowania, zakłada się, że nawet jeśli dojdzie do zmian w bazie danych usługi, nie będą one łamały poprzedniego kodu. W rezultacie następuje stopniowa aktualizacja bazy kodowej.
Podsumowanie
Dzieląc się własnymi przemyśleniami na temat odpornej architektury WEB, chcę jeszcze raz podkreślić jej kluczowe punkty:
- fizyczna odporność;
- odporność sieciowa (load balancery, BGP);
- odporność używanego i rozwijanego oprogramowania.
Życzę wszystkim stabilnego uptime!
Źródło: habr.com
