O systemach wysokiej dostępności na przykładzie RabbitMQ

ha_systems_rabbitmq

W ostatnich miesiącach miałem przyjemność wygłosić prelekcję pt. High Availability with RabbitMQ, czyli o systemach wysokiej dostępności na przykładzie RabbitMQ w Rzeszowie i Krakowie. Ten post jest pisemną formą wiedzy, którą starałem się przekazać w trakcie tych spotkań. Jeśli masz więcej czasu, zapraszam do obejrzenia filmu.

Zacznijmy od “The What and Why”

Dlaczego w ogóle warto przyjrzeć się Wysokiej Dostępności, czyli High Availability (dalej będę się do tego pojęcia odnosił przez HA) i przeczytać ten artykuł? Po pierwsze, Wysoka Dostępność jest zwyczajnie dość częstym oczekiwaniem w stosunku do wielu wykorzystywanych dzisiaj systemów – chcemy mieć ciągłość dostępu, sensowne alternatywne odpowiedzi pokazywane klientom w razie awarii jakiegoś komponentu zamiast “brzydkiego 503” (tzw. fallbacki). Po drugie, HA jest nierozerwalnie związane z systemami rozproszonymi, bo tylko w takim środowisku możemy mówić o redundancji, która jest podstawowym składnikiem wysokiej dostępności. A nie od dziś wiadomo, że systemy rozproszone to twory skomplikowane, a mamy z nimi do czynienia wszędzie – wystarczy zdać sobie sprawę, że każda aplikacja realizująca wzorzec klient-serwer jest już systemem rozproszonym. Warto zatem zająć się tematem ze względu na potrzebę wiedzy w istotnym obszarze rozwoju oprogramowania.

Spójrzmy teraz co to jest Wysoka Dostępność w kontekście systemów informatycznych.

Mamy w pojęciu HA Dostępność (Availability), którą możemy zdefiniować jako właściwość systemu, który może obsługiwać żądania klientów w określonym przedziale czasowym. Dla przykładu, możemy powiedzieć, że system bankowy jest dostępny, jeśli klienci mogą się zalogować i wykonać jakieś podstawowe operacje jak np. sprawdzenie stanu konta (oczywiście, mamy tutaj ograniczoną funkcjonalność, więc można się kłócić, że tak naprawdę system nie jest dostępny – jest to kwestia wyznaczenia pewnej granicy opartej o dostępność pewnych krytycznych funkcjonalności).

W świetle tak zdefiniowanej Dostępności, Wysoka Dostępność jest właściwością systemu, który jest dostępny przez większość czasu, w okresie na który zostało zaplanowane jego działanie. W przypadku systemu bankowego, dla klientów bankowości elektronicznej oczekujemy stałej dostępności tego sytemu. Znowu, czy system jest HA czy nie, będzie zależeć od wyznaczenia pewnej granicy. W przypadku banku, zdroworozsądkowe wydaje się stwierdzenie, że system jest HA jeśli ma co najwyżej kilka dni niedostępności w roku – z perspektywy klienta indywidualnego, bo może ona być inna dla klienta firmowego.

Na koniec rozważań o tym czym jest HA, warto jeszcze spojrzeć w jaki sposób mierzy się dostępność systemów. Podaje się ją w procentach, które wskazują stosunek czasu dostępności do czasu niedostępności. Przyjęło się również nazewnictwo określające klasy dostępności systemów, np. five nines, nine nines itd., które oznaczają dostępność odpowiednio na poziomie 99.999% i 99.9999999%. Systemy klasyfikowane jako five nines będą co najwyżej niedostępne przez 5.26 minuty w ciągu roku, a te drugie jedynie 31.56 milisekundy w ciągu roku (!!! 😲).

Miara dostępności systemów informatycznych (źródło: https://en.wikipedia.org/wiki/High_availability)
Miara dostępności systemów informatycznych (źródło: https://en.wikipedia.org/wiki/High_availability)

Właściwości systemów HA

Żeby o systemie można było powiedzieć, że charakteryzuje się Wysoką Dostępnością, musi on mieć następujące właściwości:

  1. Brak centralnego punktu awarii (No Single Point of Failure) – w systemie nie może istnieć komponent, którego awaria spowodowałaby przerwę w dostarczaniu usługi świadczonej przez system.

  2. Niezawodność (Reliability) – system działa w razie nieoczekiwanych warunków, np. awaria komponentu, niespójność danych. W systemie rozproszonym awarie się zdarzają, ale jeśli jest on HA, żadna z nich nie “kładzie” całego systemu.

  3. Tolerancja błędów (Fault Tolerance) – jeśli w systemie nastąpi awaria, to system zachowuje się w sposób przewidywalny.

  4. Zdolność do samo-naprawy (Resilience) – system samodzielnie i szybko “wychodzi” z awarii.

Żeby zmaterializować te pojęcia, weźmy na tapetę przykład usługi webowej, do której klienci łączą się po HTTP. Sama usługa ma dwie niezależne instancje i korzysta z bazy danych w konfiguracji master-slave – dla zapewnienia redundancji danych.

Przykład usługi webowej cechującej się wysoką dostępnością
Przykład usługi webowej cechującej się wysoką dostępnością

Teraz wyobraźmy sobie, że master bazy danych ulega awarii. Jak powinien w takiej sytuacji zachować się system, żeby można było mówić o jego Wysokiej Dostępności?

Przykład usługi webowej, w której master bazy danych ulega awarii
Przykład usługi webowej, w której master bazy danych ulega awarii
  1. Awaria instancji bazy danych nie może spowodować awarii całej usługi (brak centralnego punktu awarii) – tak więc musi nastąpić automatyczna rekonfiguracja, dzięki której slave przejmie rolę mastera. W przypadku braku HA, połączenia klienckie otrzymałyby błąd HTTP 500 (internal server error), a nowi klienci nie mogliby się połączyć.

  2. Pomimo awarii bazy danych, musi zostać zachowana ciągłość usługi (niezawodność) – nawet jeśli będzie ona w jakimś zakresie ograniczona – np. o operacje związane z zapisem danych. W przypadku braku HA, klienci mogą zobaczyć po prostu stronę informującą o błędzie, która “odcięłaby ich” od wszelkiej funkcjonalności systemu – podobnie jak w poprzednim punkcie.

  3. Awaria bazy musi mieć przewidywalne i jasno zdefiniowane konsekwencje (odporność na błędy) – np. jeśli na czas rekonfiguracji bazy danych system jest chwilowo niedostępny, zalogowani klienci dostają odpowiedni komunikat o chwilowej przerwie, a ci próbujący się zalogować są proszeni o ponowienie próby za chwilę. W przypadku braku HA, klienci naszej usługi są zdezorientowani np. długim ładowaniem się strony w przeglądarce.

  4. Wreszcie awaria bazy danych nie może wymagać manualnej interwencji administratora – żeby mówić o HA musi nastąpić automatyczna naprawa.

W jaki sposób RabbitMQ realizuje HA?

Przykładowa konfiguracja oferująca HA

UWAGA: Opisuję tutaj HA oparte o Mirrored Queues. Od serii 3.8.x, RabbitMQ wprowadza Quorum Queues, które w nieco inny sposób realizują HA. Więcej w tym wpisie.

Droga wiadomości w 3-węzłowym klastrze RabbitMQ z kolejką HA (włączone Publisher Confirms i Consumer Acknowledgments)
Droga wiadomości w 3-węzłowym klastrze RabbitMQ z kolejką HA (włączone Publisher Confirms i Consumer Acknowledgments)

Powyższa grafika ilustruje 3-węzłową konfigurację klastra RabbitMQ z kolejką HA mającą 2 repliki (Mirrored Queues) oraz strategią obsługi partycji sieciowych ustawioną na pause_minority.

Poniższy listing zawiera konfigurację polityki HA w przykładowym klastrze (to dla tych, którzy już nieco znają temat):

ha-sync-mode: automatic
ha-mode: exactly
ha-params: 2

Każda kolejka w RabbitMQ ma swój master node, który realizuje wszystkie operacje na kolejce (np. publikowanie/konsumowanie wiadomości). Jeśli jest to kolejka HA, ma ona również swoje repliki na tzw. slave node’ach. Same węzły są równoważne – tzn. nie ma żadnego lidera – to kolejki mają węzły główne i te na których rezydują repliki.

W omawianym przykładzie producent (Producer na grafice) publikuje wiadomości w trybie Publisher Confirms – serwer komunikuje przejęcie odpowiedzialności za wiadomość przesyłając ACK do producenta. Z kolei konsument (Consumer na grafice) potwierdza przetworzenie wiadomości wysyłając ACK do serwera. Serwer przechowuje niepotwierdzoną przez konsumenta wiadomość w pamięci RAM.

Droga wiadomości w tej konfiguracji, kiedy wszystko działa dobrze, wygląda następująco:

  1. Producent wysyła wiadomość.
  2. Wiadomość zostaje umieszczona w kolejce na jej master node (node A).
  3. Wiadomość zostaje zreplikowana na slave node kolejki (node C).
  4. Serwer wysyła ACK do klienta informując o przejęciu odpowiedzialności za wiadomość.
  5. Wiadomość zostaje wysłana do konsumenta (który w rzeczywistości komunikuje się z nodem A, bo na nim rezyduje master node kolejki – mimo że jest fizycznie połączony z nodem C).
  6. Konsument przetwarza wiadomość i wysyła ACK do serwera potwierdzając jej przetworzenie (znowu, ACK trafia do node’a A, który jest masterem kolejki).
  7. Serwer usuwa wiadomość z kolejki.

W powyższym opisie jest pewne uproszczenie – w rzeczywistości, jeśli w momencie przejęcia wiadomości przez serwer są dostępni konsumenci, tzn. kolejka jest pusta i są konsumenci czekający na wiadomość, to kroki 3. i 5. wykonują się równolegle, co może prowadzić do ciekawych “anomalii” (więcej przeczytasz w moim wpisie w sekcji Delayed Publisher Confirms

Przykładowa awaria klastra HA

Przyjmijmy teraz, że jest to konfiguracja gwarantująca Wysoką Dostępność naszej kolejki. W końcu używamy Mirrored Queues – więc jakże mogłoby być inaczej?! Sprawdźmy to zatem w boju i “zepsujmy sieć” pomiędzy węzłami klastra “odcinając” node’a C. Żeby sytuacja była ciekawsza, przyjmijmy że fizycznie sieć ulega uszkodzenia w momencie, kiedy ACK od konsumenta jest w drodze od node’a C do node’a A. Poniższa grafika przedstawia opisaną sytuację.

Awaria węzła w 3-węzłowym klastrze RabbitMQ z kolejką HA
Awaria węzła w 3-węzłowym klastrze RabbitMQ z kolejką HA

Przyjrzymy się dokładniej co się dzieje:

  1. Następuje awaria sieci
    • pakiety wysyłane na połączeniach A-C oraz B-C nie są dostarczane – zatem “ginie” nasz ACK od konsumenta,
    • klienci połączeni z węzłem C kontynuują działanie.
  2. Następuje wykrycie awarii i rekonfiguracja klastra
    • węzły w klastrze wysyłają pomiędzy sobą tzw. net ticks, dopiero kiedy jakiś węzeł nie otrzyma 4 “ticków” od sąsiada w klastrze, uznaje że ten nie działa – tak więc w domyślnej konfiguracji wykrycie awarii sieciowej zajmuje poszczególnym węzłom 45-75s,
    • kiedy “odcięty węzeł” odkrywa, że nie może komunikować się z pozostałymi węzłami w klastrze, wyłącza się (technicznie erlangowa aplikacja rabbit na węźle zostaje zatrzymana – sam węzeł dalej żyje), żeby uchronić się przed potencjalnymi niespójnościami, które mogłyby być konsekwencją działania dwóch wysp klastra (determinuje to strategia pause_minority),
    • na skutek zatrzymania się node’a C, konsument zostaje rozłączony; załóżmy że ponownie próbuje nawiązać połączanie i na skutek load balancingu łączy się z nodem B,
    • kiedy kolejka odkrywa, że straciła replikę na węźle C, następuje jej odtworzenie na węźle B – w domyślnej konfiguracji następuje też synchronizacja tej kolejki, tzn. nie tylko kolejka zostaje odtworzona, ale również wszystkie wiadomości,
    • wiadomość, która została już wysłana do konsumenta, a której ACK “zaginął” podczas awarii sieci, jest uznana za niedostarczoną i zostaje ponownie wysłana do konsumenta kolejki z flagą redelivered=true – tak się składa, że w naszym przykładzie to ten sam konsument, który został odłączony od node’a C – zatem jeśli przetwarzane wiadomości nie są idempotentne, wspomniana flaga musi zostać wzięta pod uwagę.
  3. Następuje przywrócenie sieci i node C odzyskuje połączenie z resztą klastra
    • węzeł C periodycznie monitoruje sieć i kiedy tylko odzyska połączenie z resztą klastra ponownie do niego “dołącza”,
    • ważne jest to, że dołącza jako “świeży węzeł”, tzn. traci wszystkie dane sprzed awarii i synchronizuje się z klastrem, do którego dołącza,
    • węzeł C może przyjmować połączenia.

Spróbujmy skonfrontować ten przykład ze zdefiniowanymi wcześniej właściwościami systemów wysokiej dostępności:

  1. Czy mamy w tej konfiguracji centralny punkt awarii? NIE – mamy redundancję kolejek i w przypadku awarii node’a z naszą kolejką dysponujemy dodatkową repliką. To samo tyczy się węzłów: po awarii jednego, pozostałe node’y wciąż mogą przyjmować połączenia i obsługiwać klientów.

  2. Czy system jest niezawodny w sensie kontynuacji świadczenia usługi? TAK – pomimo tego, że jeden z węzłów uległ awarii, nasi klienci dalej są obsługiwani i nie utraciliśmy wiadomości, której przyszło się mierzyć z awarią sieci (w ogólnym przypadku przy założeniu, że 2 węzły są w stanie “pomieścić klientów” węzła C; patrz capacity management).

  3. Czy system jest odporny na błędy w sensie przewidywalności działania? TAK – pomimo awarii sieci, zgodnie ze strategią jej obsługi (pause_minority), node w mniejszej wyspie zatrzymał się, rozłączeni klienci podłączyli się do zdrowego węzła, a wiadomość dostarczona ponownie – nie było takiej potrzeby, ale to zachowanie jest wyspecyfikowane i niezbędne do zapewnienia niezawodności.

  4. Czy system “sam się uleczył”? TAK – zaraz po wykryciu awarii nastąpiła automatyczna rekonfiguracja, kolejka została odtworzona i zsynchronizowana, a po ustąpieniu awarii sieci node automatycznie dołączył do klastra, który wrócił do pierwotnej konfiguracji (efektem ubocznym jest inny slave node repliki).

Wydaje się zatem, że mamy do czynienia z systemem wysokiej dostępności. Jednak pójdźmy o krok dalej i spójrzmy na “kawałek teorii” i potem w jej świetle jeszcze raz wrócimy do omawianego przykładu.

Twierdzenie CAP

Diagram obrazujący twierdzenie CAP
Diagram obrazujący twierdzenie CAP

Brzmi ono następująco: rozproszona baza danych, może jednocześnie zapewnić 2 z 3 poniższych gwarancji:

  1. Spójność (Consistency) – każdy odczyt zawiera zawsze najnowsze dane lub zwracany jest błąd – tzn. wszyscy klienci zawsze widzą te same dane. Warto zaznaczyć, że CAP odnosi się do tzw. Silnej Spójności (Strong Consistency).

  2. Dostępność (Availability) – każde żądanie jest obsłużone (nie zostaje zwrócony błąd), pomimo awarii węzłów – nie zawiera się tu gwarancja, że każdy odczyt zawsze dostanie najnowsze dane.

  3. Odporność na Partycje (Partition Tolerance) – system działa niezależnie od sieci pomiędzy węzłami, która na skutek awarii może doprowadzić do utraty pakietów przesyłanych między węzłami.

Innymi słowy, w myśl powyższego twierdzenia, każdy system rozproszony może zapewnić co najwyżej dwie z wymienionych gwarancji. Co więcej, każdy sensowny system rozproszony na miarę dzisiejszych czasów, musi być odporny na awarię sieci – bo one po prostu się zdarzają. Tak więc w rzeczywistości wybieramy pomiędzy systemami typu:

a) CP – czyli zapewniającymi Spójność danych (C) oraz odporność na partycje sieci (P), oraz
b) AP – czyli zapewniającymi Dostępność (A) oraz odporność na partycje sieci (P).

Jak przełożyć twierdzenie CAP na RabbitMQ?

  1. Consistency będzie się wyrażało tym, że każdy węzeł będzie “widział” te same kolejki wraz z ich zawartością, czyli każdy klient łączący się do dowolnego node’a klastra będzie miał do dyspozycji ten sam zestaw kolejek z tymi samymi wiadomościami.

  2. Availability będzie oznaczać, że każda kolejka będzie zawsze dostępna, tzn. będzie można do niej publikować i z niej konsumować – niezależnie od awarii węzłów w klastrze. Dostępność w tym ujęciu nie będzie oznaczać, że każdy klient zawsze będzie widział te same wiadomości (po prostu ta gwarancja jest rozłączna ze Spójnością).

  3. Partition tolerance będzie spełnione, jeśli system będzie działał wedle oczekiwań nawet jeśli gdzieś po drodze “zaginie” np. jakiś ACK na skutek awarii sieci.

Wracają do naszego przykładu…

Awaria węzła w 3-węzłowym klastrze RabbitMQ z kolejką HA
Awaria węzła w 3-węzłowym klastrze RabbitMQ z kolejką HA

W świetle powyższej teorii postawmy pytanie – czy nasz system jest bardziej AP czy CP? Mogłoby się wydawać, że AP, skoro cały czas mówimy o Availability – jednak odpowiedź nie jest oczywista, oto dlaczego:

  1. Kiedy następuje awaria sieci węzły potrzebują od 45-75s na jej wykrycie. Można ten czas zmniejszyć (podana wartość jest wartością domyślną), ale zwiększa to ryzyko fałszywych alarmów (tzw. false positives). Jednak w czym problem? W trakcie wykrywania “netsplita”, czyli w sytuacji kiedy dane między węzłami nie są przesyłane, ale nie ma jeszcze “oficjalnie” wykrytej awarii sieci, publikowanie i konsumowanie wiadomości jest zablokowane. Dzieje się tak ponieważ operacje na kolejce HA wymagają komunikacji pomiędzy węzłami A i C, która jest zakłócona. To podejście promuje bardziej Consistency niż Availability.

  2. Po wykryciu awarii, node C wstrzymuje swoje działanie celem uniknięcia pojawienia się niespójności danych. W wyniku zatrzymania node’a C, jego klienci zostają rozłączeni i łączą się do innych węzłów klastra kosztem ich zasobów. Znowu, to podejście zdecydowanie promuje Consistency, nie Availability.

  3. Instalacja nowej repliki kolejki na węźle B wiąże się z synchronizacją wiadomości w kolejce. Na czas synchronizacji kolejka jest zablokowana – tzn. nie można wykonać na niej żadnej operacji, w tym publikować ani konsumować. Przy małej kolejce nie będzie to problemem, ale zdarza się, że synchronizowana jest kolejka z setkami tysięcy wiadomości (albo i więcej) – taka synchronizacja może trwać bardzo długo (czasy rzędu minut albo i więcej). To podejście wspiera bardziej Consistency niż Availability.

  4. W użyciu są potwierdzenia, zarówno po stronie producentów jak i konsumentów, które również promują bardziej Consistency niż Availability bo np. niepotwierdzone przez konsumentów wiadomości przechowywane są w pamięci RAM – bez możliwości “zrzucenia” na dysk w ramach mechanizmów obronnych serwera. Dlatego awaria konsumenta może znacząco wpłynąć na zużycie pamięci węzła, co z kolei może potencjalnie skutkować obniżeniem jego dostępności ze względu na zużycie zasobów.

Reasumując powyższą analizę reakcji naszego klastra na awarię, okazało się, że w świetle twierdzenia CAP jest to konfiguracja, która bardziej promuje Spójność niż Dostępność.

Czy jest to zatem system Wysokiej Dostępności?

Tym zamieszaniem chcę pokazać, że omawiane tematy nie dają się się jednoznacznie określić jako czarne albo białe. Jak przeanalizowaliśmy na początku, nasz system spełnia wymagania Wysokiej Dostępności – zgodnie z nimi reaguje na awarię. Jednak po przyjrzeniu się mu przez pryzmat twierdzenia CAP okazało się, że mimo bycia HA, “zmierza” on bardziej w kierunku zapewnienia Spójności danych kosztem Dostępności.

Odpowiadając na pytanie: Tak, nasz system jest HA o ile opisane wyżej kompromisy w zakresie Dostępności są dla nas akceptowalne.

A jak uzyskać maksymalną dostępność – czyli zminimalizować czas zablokowania kolejki i czas w którym będą dostępne tylko 2 węzły? Trzeba pogodzić się z utratą wiadomości, która może nastąpić w przypadku braku synchronizacji replik oraz w przypadku użycia innej strategii radzenia sobie z awariami sieci – nie będę już tutaj wchodził w szczegóły, bo to temat na osobny artykuł 🙂

Na zakończenie

Tak więc możemy mieć “różne HA” – tzn. nasz system oferujący mechanizmy Wysokiej Dostępności będzie szedł na różne kompromisy w sytuacjach awaryjnych. Uważam, że to co jest kluczowe to właśnie znajomość tych kompromisów, ich konsekwencji i świadoma ich akceptacja.

Na koniec jeszcze kilka myśli:

  1. Systemy rozproszone są trudne
    • wiele rzeczy może pójść nie tak i trzeba przewidzieć wiele sytuacji awaryjnych,
    • żeby zbudować system Wysokiej Dostępności musimy zapewnić brak jednego punktu awarii, niezawodność (działanie pomimo awarii), odporność na błędy (przewidywalność działania w sytuacji kryzysowej) oraz zdolność do szybkiej samo-naprawy.
  2. Ponieważ systemy rozproszone ulegają awariom, warto wspomagać się pewnymi technikami:
  3. “Nie można mieć wszystkiego”:
    • w świetle CAP możemy zapewnić tylko 3 gwarancje spośród Spójności, Dostępności oraz Odporności na Partycje Sieciowe,
    • nie jest kwestią oczywistą, czy w świetle CAP dany system jest AP czy CP – wymaga to dobrej znajomości jego zachowań w sytuacjach awaryjnych.
15 Błędów Przy Pracy z RabbitMQ

One Reply to “O systemach wysokiej dostępności na przykładzie RabbitMQ”

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *