O siedmiu podstawowych błędnych założeniach przy projektowaniu systemów rozproszonych L. Peter Deutsch pisał już w roku 1994. Można się zatem zastanawiać, jak to możliwe, że ponad 20 lat później tak często się o nich zapomina. W czasach popularności publicznych chmur i architektury mikrousługowej, taka ignorancja wydaje się niczym innym jak świadomym proszeniem się o problemy. Co zatem powinniśmy zrobić, aby tych problemów uniknąć? Jak zapewnić odporność systemu (ang. resilience)?

Komunikacja ponad podziałami

Podstawowym mechanizmem wykorzystywanym w systemach rozproszonych jest oczywiście komunikacja. Peter Deutsch wspomina o dwóch błędnych założeniach w tej kwestii:

  • sieć jest niezawodna,
  • topologia się nie zmienia.

Oczywiście wiemy, że sieć niezawodna nie jest. Każdy, kto choć raz wykorzystywał cluster bazodanowy czy rozproszony cache, doświadczył problemów ze stabilnością połączenia sieciowego. Nie oznacza to jednak, że błędy pojawiają się cały czas i w każdej sekundzie gubimy setki pakietów. Z pewnością znajdą się nawet osoby, które wkalkulują chwilową niestabilność warstwy sieciowej w poziom dostępności świadczonych przez siebie usług. Tu jednak z pomocą w podjęciu (a raczej niepodjęciu) takiej decyzji przychodzi drugie błędne założenie – niezmienność topologii. W przypadku klasycznych systemów rozproszonych, wydawanych w cyklach release’owych i pozbawionych dynamicznej skalowalności, topologia faktycznie nie zmienia się zbyt często. Jednak wejście w rozwiązania chmurowe czy z założenia autonomiczne w zakresie zmian mikroserwisy wywraca do góry nogami reguły tej gry. Tutaj zmiany topologii mogą być spowodowane wieloma czynnikami:

  • dynamicznym skalowaniem instancji (zwiększanie i zmniejszanie liczby instancji),
  • migrowaniem aplikacji pomiędzy maszynami wirtualnymi, co powoduje zmianę adresów IP,
  • chwilową niedostępność aplikacji spowodowaną np. wdrażaniem nowej wersji.

Jak widać, nie jest to już tak mało prawdopodobne, jak zwykła niestabilność warstwy transportowej.

Katalog usług

Pierwszą linią obrony, rozwiązującą problem zmiennej topologii, wydaje się być po prostu load-balancer. Usługi nie komunikują się bezpośrednio ze sobą, ale robią to za pośrednictwem właśnie load-balancera. Aplikacja ma wskazany jedynie adres komponentu, który rozdziela ruch na poszczególne maszyny. W przypadku zmiany liczby instancji wystarczy zaktualizować konfigurację balancera i całe środowisko funkcjonuje prawidłowo. Jednak nie jest to rozwiązanie idealne. Balancer stanowi teraz pojedynczy punkt awarii (ang. single point of failure). Jego niedostępność uniemożliwia integrację komponentów, nawet jeżeli zarówno klient, jak i producent, funkcjonują prawidłowo. Obniża się też wydajność, z uwagi na konieczność nawiązania dodatkowej komunikacji sieciowej (aplikacja->LB i LB->aplikacja zamiast bezpośredniego połączenia aplikacja->aplikacja).

Innym rozwiązaniem tego samego problemu może być wykorzystanie katalogu usług (ang. service discovery). Katalog jest po prostu komponentem, w którym rejestrują się wszystkie aplikacje działające w systemie. Przykładem takiego katalogu jest Consul. Aplikacje zaraz po uruchomieniu zgłaszają się do takiego rejestru, a przed zakończeniem swojego życia kontaktują się ponownie, żeby wypisać się z rejestru. W międzyczasie rejestr monitoruje dostępność aplikacji, dbając o aktualność przechowywanej przez siebie listy. Teraz system A, jeżeli potrzebuje komunikować się z systemem B, co jakiś czas prosi rejestr o listę aktualnych instancji B. Następnie samodzielnie kieruje ruch do wybranych jednostek.

Ale, czy to nie jest w dalszym ciągu pojedynczy punkt awarii? Ano nie! Z dwóch powodów. Po pierwsze, taki rejestr może być rozproszony na kilka instancji i awaria jednej z nich nie uniemożliwia wykorzystania pozostałych. Co więcej, nawet niedostępność wszystkich nie zamyka możliwości komunikowania się ze sobą poszczególnych aplikacji (bo przecież listę naszych kolaboratorów cały czas przechowujemy „u siebie”). Przynajmniej tak długo, jak długo nie zmieni się znacząco ich topologia.

Spróbuj jeszcze raz

Udało nam się zaadresować problem migrujących instancji. W dalszym ciągu jednak nie zrobiliśmy nic z ich chwilową niedostępnością czy też niestabilnością sieci. Na szczęście oba te problemy możemy rozwiązać jednocześnie. Najprostszym sposobem radzenia sobie z chwilowymi problemami jest po prostu ponowienie próby. Ile osób chociaż raz nie ponowiło nieudanego testu z nadzieją, że za drugim razem rozbłyśnie na zielono? 🙂 Dokładnie tak samo potraktujmy zatem komunikację. Jeżeli na pierwsze żądnie (ang. request) odpowiedzią był time-out czy też jakikolwiek inny wyjątek, wystarczy spróbować jeszcze raz. I bardzo często to wystarcza. Oczywiście, ponawiać można wiele razy, w różnych odstępach czasowych, i wysyłając komunikaty do różnych instancji.

Niestety, jak to często bywa, tutaj też czeka na nas pułapka. A konkretnie problem idempotentności komunikacji. Idempotentność to nic innego, jak możliwość wielokrotnego wykonywania operacji, bez zmiany ich wyniku. W świecie synchroniczego REST’a, metody GET, PUT i DELETE można bezproblemowo ponawiać. Jeżeli pytam o dane użytkownika (GET), to wykonanie tego wielokrotnie nie powoduje zmiany stanu systemu. Podobnie jest z usunięciem danych (DELETE) – jeżeli usunąłem użytkownika z systemu, to zrobienie tego pięć razy nie spowoduje, że będzie on „bardziej usunięty”. Metoda PUT służy z kolei do zmiany stanu obiektu – np. adresu e-mail użytkownika. I tutaj znowu – fakt kilkukrotnego ustawienia adresu x@y.pl, nie powoduje, że adres ten jest „lepiej zmieniony”. Problemem jest natomiast metoda POST. Z założenia tworzy ona nowy zasób. Jeżeli doładowanie telefonu komórkowego zrealizujemy właśnie za jej pomocą, to ponowienie komunikacji będzie teoretycznie skutkowało wielokrotnym doładowaniem konta.

Aby zapewnić idempotentność w powyższym wypadku, z reguły stosuje się unikalne identyfikatory żądań. Oczywiście, wiąże się to z koniecznością pamiętania listy obsłużonej komunikacji. To z kolei powoduje, że serwisy nie są już w pełni bezstanowe. Co więcej, samo sprawdzenie, czy dany komunikat został już obsłużony, w niektórych wypadkach mogłoby trwać dłużej niż jego realne procesowanie. Niemniej jednak, możliwość ponawiania komunikacji i rozwiązania tym sposobem dwóch poważnych problemów warta jest tej ceny. Zwłaszcza, że – jeśli podejdziemy do tematu pragmatycznie – wystarczy nam wąskie, dajmy na to pięciominutowe okno idempotentności. Szansa, że ktoś ponowi żądnie po kilku miesiącach, jest bowiem niewielka.

Zawsze miej plan C

Plany A i B często zawodzą. Jeżeli nie powiedzie się podstawowa komunikacja, a potem zawiedzie nas jej ponawianie, pora na plan C. Procedurę awaryjną. Idea „design for failure”, szeroko stosowana przy projektowaniu systemów rozproszonych, przychodzi z pomocą właśnie tutaj. Implementując jakąkolwiek komunikację w takim systemie, musimy od razu zastanowić się, co możemy zrobić, jeżeli się ona nie powiedzie. Integrując moduł sprzedażowy z systemem wykrywania fraudów, od razu planujemy, co zrobimy, jeżeli moduł antyfraud nie będzie dostępny. Czy powinniśmy odrzucić wszystkich klientów? A może wszystkich akceptować? A może akceptować powracających klientów, o ile wartość ich zamówienia nie przekracza 500 złotych? Oczywiście, każda decyzja wiąże się z podjęciem dodatkowego ryzyka biznesowego. A podejmowanie takiego ryzyka nie jest odpowiedzialnością IT. Innymi słowy: programiści sygnalizują konieczność opracowania takiego wyjścia awaryjnego (ang. fallback), jednak to biznes podejmuje decyzję, jak ma ono funkcjonować.

Proszę nie czekać. Oddzwonimy

Jak pewnie część czytelników zdążyła zauważyć, większość opisanych powyżej problemów dotyczy komunikacji synchronicznej. Czy nie lepiej jest zatem wykorzystać komunikację asynchroniczną? Oczywiście, że lepiej! Co więcej, jest to domyślna forma komunikacji, którą powinniśmy rozważać. Stosujemy ją zawsze, dopóki nie działamy w modelu „potrzebuję tej informacji teraz, a jak jej nie dostanę, to poradzę sobie inaczej (procedura awaryjna)”. Podstawową zaletą integracji z wykorzystaniem kolejek jest to, że obie jej strony nie muszą żyć w tym samym czasie. Jeżeli system A wyśle wiadomość w czasie niedostępności systemu B, to ten przetworzy ją niezwłocznie po ponownym uruchomieniu. Nawet jeżeli to system A będzie w chwili z tego procesowania wyłączony. Działać musi jedynie kolejka. Tę traktujemy – podobnie jak na przykład bazy danych – jako element infrastrukturalny. A co za tym idzie, posiadający wyższą dostępność i rzadziej aktualizowany niż aplikacje.

Rozproszony monolit

System rozproszony, nawet jeżeli nazwiemy go „mikroserwisami”, a pozbawiony opisanych powyżej mechanizmów, jest po prostu rozrzuconym na kilka maszyn monolitem. Architektura monolityczna zakłada, że komponenty, skoro działają w ramach jednej aplikacji, są dostępne jednocześnie. Stosowanie takich samych założeń w mikroserwisach, powoduje, że realna dostępność platformy będzie bardziej zbliżona do samochodowego kierunkowskazu (działa – nie działa – działa – nie działa) niż oprogramowania klasy korporacyjnej. To właśnie uwzględnienie wskazanych powyżej praktyk pozwala na migrację z rozproszonego monolitu do prawidłowo działającego systemu rozproszonego.

2
Dodaj komentarz

avatar
1 Comment threads
1 Thread replies
0 Followers
 
Most reacted comment
Hottest comment thread
2 Comment authors
Jakub KubryńskiSebastian Recent comment authors
  Subscribe  
najnowszy najstarszy oceniany
Powiadom o
Sebastian
Gość
Sebastian

Bardzo fajny artykuł. Czy w związku z tym, że kolejka jest traktowana jako element infrastruktury to problem jej niedostępności wliczamy w ryzyko zawodowe 😂 czy jednak warto rozważyć jakiś mechanizm który sobie z tym poradzi? Wiem, że można zrobić cluster, jednak czasem i ten cluster zawodzi. Mam na myśli np. osiąganie watermarków na pamięci, co skutkuje blokowaniem możliwości publikowania. W takiej sytuacji zwykle ponowienie za wiele nie pomoże, chyba że będziemy je robić aż do skutku. Może masz jakieś sprawdzone na to patenty, którymi mógłbyś się podzielić? Czy może moje rozumowanie podąża nie w tym kierunku co trzeba? 😁