Problemy i konsekwencje związane z rozproszeniem aplikacji

W dzisiejszych czasach wiele firm/deweloperów/architektów decyduje się na rozpraszanie aplikacji. Decyzje te spowodowane są często tym, aby zachęcić specjalistów do dołączenia do firmy i projektu, reklamując ją jako nowoczesną, podążającą za nowoczesnymi rozwiązaniami. Z biznesowego punktu widzenia najprawdopodobniej ma to sens, ponieważ na rynku brakuje specjalistów. Projekt, który na papierze wygląda, że rozwiązuje problemy skali, wydaje się ciekawy i nietrywialny. Oczywiście zdarza się, że rozproszenie aplikacji jest wymogiem aplikacyjnym, ponieważ liczba użytkowników zaczyna przeciążać obecne zasoby. Niezależnie od powodów, wybór takiej architektury ma wpływ na klienta końcowego i zespół tworzący projekt. W systemie rozproszonym musimy pójść na kompromisy, a tym samym także nasi klienci. Przy systemie dużej skali, nie mamy wyjścia. Poniżej kilka najważniejszych problemów i ich konsekwencji związanych z systemem rozproszonym z perspektywy aplikacji, jak i baz danych. Najpierw jednak odpowiedzmy sobie na pytanie, co czyni system rozproszonym ?
Co czyni system rozproszonym ?
Cechą charakterystyczną dla systemu rozproszonego jest umiejscowienie jego modułów na różnych serwerach. Komunikacja pomiędzy modułami odbywa się poprzez sieć.
W aplikacji monolitycznej komunikacja pomiędzy konkretnymi modułami odbywa się w pamięci na jednym serwerze. Po rozproszeniu systemu musimy podjąć decyzje jak komunikować się pomiędzy rozdzielonymi częściami systemu. Mamy dwie opcje: synchronicznie (najczęściej za pomocą http) lub asynchronicznie (messaging). Dla całego przepływu jednej biznesowej operacji możemy używać obydwu form komunikacji. To właśnie komunikacja poprzez sieć i jej awarie będzie powodować większość problemów.
Spójność
Z perspektywy rozproszonej aplikacji obowiązuje nas CAP Theorem i jego rozszerzenie PACELC. Z trzech właściwości CAP, możemy wybrać tylko dwie. Jednak system rozproszony musi być odporny na podzielenie sieci (P – Partition tolerance). Więc w przypadku podziału sieci nasz system może być dostępny, czyli odpowiadać na żądania bez błędów (AP – Available) i niespójny, czyli różne serwery posiadają dane, które nie muszą być aktualne lub spójny (wszystkie serwery posiadają aktualne dane), ale nie musi być dostępny. Nie istnieje rozproszony system typu CA. Dodatkowo PACELC rozszerza CAP o przypadek, jeśli (E- Else) podzielenie sieci nie występuje, musimy wybrać pomiędzy niskim opóźnieniem (L- Latency) a spójnością (C-Consistency). Litera C w obu twierdzeniach oznacza silną spójność (strong consistency). Oznacza to, że gdy system potwierdzi operacje modyfikacji danych, będzie ona dostępna przy odczycie natychmiast. Jednak zapewnienie takiej spójności w systemie rozproszonym wiąże się z zaakceptowaniem sporych opóźnień. Jako że głównym założeniem systemu rozproszonego jest odpowiedź na problem skali, to z perspektywy aplikacji biznesowej jest to scenariusz rzadziej spotykany.
Z drugiej strony mamy spójność ostateczną (eventual consistency), co oznacza, że system będzie spójny “kiedyś”, jednak nie musi i nie będzie spójny od razu. Ten rodzaj spójności zapewnia nas, że jeśli nastąpiła zmiana jakiegoś obiektu i po tej zmianie nie nastąpiła żadna inna zmiana tego obiektu, to ta zmiana będzie “kiedyś” widoczna na każdym serwerze/aplikacji. Czas potrzebny do osiągnięcia spójności jest zależny od wielu czynników tj. obciążenie, awarie sieci i złożoność operacji. Jednak problemem tutaj jest widoczność zmian użytkownika. Użytkownik inicjujący zmianę stanu oraz inni użytkownicy systemu nie zobaczą części lub całości zmian od razu. Może to być dla nich mylące, ponieważ system może wyglądać na niespójny.
Pomiędzy spójnością silną i ostateczną istnieją także inne rodzaje spójności tj. consistent prefix, monothonic, read my writes itd. Jeśli jesteś ciekaw jak, niektóre z nich działają, to polecam obejrzeć tę prezentację.
Walidacja po stronie serwera
Używając komunikacji asynchronicznej, użytkownik nie dostanie natychmiast odpowiedzi z serwera, czy operacja się udała oraz, czy jego dane były poprawne. Do uzyskania potwierdzenia musimy posłużyć się mechanizmem, który poinformuje go o statusie operacji np. web sockety lub (sporo gorsze rozwiązanie) cykliczne odpytywanie serwera o dany zasób. Może to być dla niego mylące. Ponieważ nie jest on ponownie pewny co do spójności systemu. Zastosowanie web socketów oczywiście powoduje następne problemy. Np. wymuszenie, aby jeden klient łączył się zawsze do tego samego serwera w jednej sesji (sticky session).
Kontrakty
Niezależnie od rodzaju komunikacji, usługi potrzebują kontraktów, aby mogły się ze sobą komunikować. Pytanie, gdzie je trzymać ? Mamy dwie opcje: lokalnie lub globalnie. Lokalne kontrakty oznaczają, że każda usługa ma swoją lokalną kopię, natomiast globalne kontrakty są dostępne np. we współdzielonej paczce nuget. Przy kontraktach lokalnych, mamy duplikacje kodu przynajmniej w dwóch usługach. Dla kontraktów globalnych musimy podjąć decyzje, czy tworzyć kontrakty per komunikacja dla np. 2 usług, czy dla wszystkich w naszym systemie. Zmiany w kontraktach muszą być także bardzo ostrożne, ponieważ przy wprowadzeniu zmiany łamiącej kontrakt (np. usuniecie pola lub zmiana typu), część systemu przestanie działać poprawnie. Dodatkowo nie mamy pewności, jaka usługa używa danego kontraktu. Modyfikacje kontraktów wymuszają także odpowiednią i przemyślaną kolejność wdrożeń. Jeśli nowy publikowany kontrakt ma nowe pola, to usługa odbierająca wiadomość może nie być gotowa, aby go przetworzyć. Z drugiej strony, jeśli usługa publikująca kontrakt, nie jest wdrożona jako pierwsza to nowe pola nie będą wypełnione i usługa odbierająca wiadomość musi być na to przygotowana. W przeciwnym wypadku żądania klientów z nowym kontraktem nie zostaną poprawnie przetworzone.
Podział domeny
Podział domeny monolitycznej aplikacji jest moim zdaniem z jednym z najtrudniejszych zadań. Jeśli zrobimy to niepoprawnie, skończymy z systemem, którego moduły komunikują się między sobą za dużo. Przy dużym systemie nie wiemy jak zdarzenie w jednym module, wpływa na resztę systemu. Prowadzi to do powstania bardzo wielu zależności pomiędzy komponentami. Taki system staje się koszmarny w utrzymaniu i niestabilny. Byłem tam, nie polecam.
Mapa komunikacji między usługami, pewnego projektu który miałem przyjemność analizować i przygotowywać do przepisania. Około 20 usług, gdzie każda komunikuje się z wieloma innymi usługami kilkukrotnie.
Transakcje bazodanowe
Po rozproszeniu zazwyczaj każda usługa posiada własną bazę danych. Transakcje rozproszone pomiędzy bazami danych są kosztowne i nie zawsze wspierane. Dają silną spójność, ale w większości przypadków dają także nieakceptowalne opóźnienia i częste błędy. Musimy tutaj pójść na następny kompromis. Prawdopodobnie musimy zrezygnować z tradycyjnych transakcji bazodanowych i ich poziomów izolacji. Jeśli zapisywaliśmy dane w jednej transakcji do 3 tabel/dokumentów, a po podziale odpowiedzialność za zapisanie tych danych spoczywa w 3 różnych serwisach, to musimy zapewnić, że te dane zostaną dostarczone do tych 3 usług. Co wcale nie jest takim prostym zadaniem. Dlaczego ? Pisałem o tym już we wpisie Czy Twoja aplikacja na pewno będzie ostatecznie spójna ? Większość dostępnych narzędzi nie jest w stanie zapewnić przynajmniej jednokrotnego opublikowania wiadomości/requestu po zapisaniu danych do bazy. Mowa tutaj o outbox pattern. Dodatkowo outbox pattern zapewnia dostarczenie wiadomości przynajmniej raz, co oznacza, że wiadomość może zostać dostarczona więcej niż jeden raz. Zmusza nas to do zaimplementowania następnego wzorca inbox pattern, czyli deduplikacji wiadomości po stronie odbierającego. Następnie w przypadku niepowodzenia jakiejś operacji, musimy imitować wycofanie transakcji (kompensacja), by nie pozostawić w systemie błędnych danych. W skomplikowanych przepływaw komunikacji, zaczniemy także tracić wizje, gdzie dana część transakcji ma miejsce, więc najprawdopodobniej będziemy musieli ten proces scentralizować.
Rozpoznawanie problemów w systemie
Niestety, w systemie rozproszonym bez dobrego logowania, metryk i śledzenia jesteśmy zgubieni. Ta architektura zmusza nas do zbierania szeregu informacji na temat usług i serwerów, ich zdrowia, błędów i przepływu komunikacji. Jesteśmy zmuszeni do przechowywania bardzo dużych ilości szeroko pojętych logów. Analiza tych danych w poszukiwaniu problemów też nie jest najłatwiejsza. Jeśli wystąpił problem w usłudze N, musimy prześledzić przepływ komunikacji, jaki doprowadził do tego błędu. Być może to inna usługa wysłała wiadomość, która spowodowała ten błąd ? Skąd przyszła ta wiadomość ? Nie mamy mapy, w jaki sposób wiadomości przepływają pomiędzy usługami. Musimy użyć dodatkowych technik i narzędzi, żeby zaadresować te problemy.
Konfiguracje usług
W jednej aplikacji mamy zazwyczaj jeden plik konfiguracyjny per środowisko. Przy N aplikacjach mamy takich plików N per środowisko. Dodatkowo bardzo często pliki te mają dużo części wspólnych np. klastry brokerów wiadomości, czy connection stringi do serwerów baz danych. Warto by było tym jakoś centralnie zarządzać. Co w przypadku gdy będziemy chcieli zmienić jedną ze wspólnych wartości tych plików ? Musimy dokonać zmian w N plikach oraz prawdopodobnie wdrożyć ponownie N aplikacji.
Repozytoria kodu
Trzymanie kodu systemu w repozytorium to kolejne wyzwanie. Jeśli zdecydujemy na trzymanie wielu aplikacji w jednym repozytorium, to musimy rozwiązać problem budowania/testowania/wdrożenia tylko kodu aplikacji, która się zmieniła (zakładam, że mamy jakiś pipeline CI/CD w tych czasach). Dodatkowo przy współdzielonej solucji istnieje ryzyko łatwiejszego dodania współdzielonego kodu pomiędzy projektami, które z założenia powinny być niezależne. Z kolei repozytorium per projekt powoduje, że musimy pobierać/klonować N projektów. Jeśli funkcjonalność/poprawka dotyczy kilku projektów to dla każdego z nich, musimy stworzyć branche, wypchnąć zmiany i stworzyć dla każdego pull request.
Współdzielona biblioteka/kod
Błędy we współdzielonej części kodu wymagają ponownego wdrożenia wszystkich aplikacji. Podobnie z dodaniem jakiejś nowej funkcjonalności. Zadanie stworzenia 20 pull requestów i 20 wdrożeń brzmi wspaniale. No, chyba że pchamy bezpośrednio na mastera 🙂
Czas i kolejność zdarzeń
Określenie “dokładnego” czasu i kolejności dla zdarzeń/logów w systemie rozproszonym może być niełatwym zadaniem. Wymagać to będzie synchronizacji czasu pomiędzy serwerami i najprawdopodobniej użycia protokołu NTP. Jednak margines błędu podczas awarii sieci może powodować desynchronizacje czasu na poziomie 100ms i więcej. Google posiada swoje rozwiązanie – TrueTime, używane wewnętrznie oraz w bazie danych Spanner. Zapewnia ono margines błędu poniżej 10ms. Jednak, jeśli nie jesteśmy googlem i nie mamy swoich zegarów atomowych, to bazowanie na czasie w systemie rozproszonym może prowadzić do różnych problemów. Na przykład wersja 3.4.1 MongoDB potrafiła tracić większość potwierdzonych danych w przypadku awarii sieci i desynchronizacji czasu pomiędzy serwerami. Jeśli potrzebujemy określić, czy jakieś zdarzenie było przed innym zdarzeniem to będziemy musieli użyć np. vector clocków. Zapewniają one jednak tylko kolejność częściową (powiązanych ze sobą zdarzeń), a nie globalną.
https://docs.microsoft.com/en-us/dotnet/api/system.datetime.utcnow?redirectedfrom=MSDN&view=netcore-3.1#System_DateTime_UtcNow
“The resolution of this property depends on the system timer, which depends on the underlying operating system. It tends to be between 0.5 and 15 milliseconds.”
Trudność i złożoność
Systemy rozproszone są po prsotu trudne. Wymagają dokładnej analizy, jak dane narzędzie/koncept działa i jakie problemy mogą w nim wystąpić. Dla przykładu używając RabbitMQ, domyślnie przy zeskalowaniu konsumentów kolejki, nie mamy 100% pewności jeśli chodzi o kolejność przetworzenia wiadomości. Może to powodować szereg błędów tj. aktualizacje zasobu w złej kolejności.
Kolejnym przykładem może być MongoDB, które domyślnie (ver 4.2) używa konfiguracji, która może tracić potwierdzone zapisy oraz pozwala odczytywać zmiany, które nie zostały potwierdzone (dirty reads). Odczytywanie niepotwierdzonych danych może doprowadzić do stanu w bazie, który nigdy nie powinien istnieć.
Podobnych smaczków jest mnóstwo i warto być ich świadomym podczas wyboru narzędzia i implementacji systemu. W innym przypadku możemy być niemiło zaskoczeni. Trudności, jakie występują podczas tworzenia aplikacji rozproszonych, pokazują także testy niektórych baz danych i narzędzi. Takie testy przeprowadza twórca narzędzia Jepsen. Ich analizy dowodzą jak złożone i trudne są systemy rozproszone. Większość testów pokazuje, że nawet tak popularne narzędzia, jak MongoDB, RabbitMQ, Redis, Elasticsearch są/były w stanie tracić dane i mają problemy ze spójnością w przypadku awarii sieci.
Rozproszone blokowanie dostępu do zasobu
Czasami nasza monolityczna aplikacja posiada sekcję krytyczną, która wymaga, aby tylko jeden wątek w aplikacji mógł dostać się do jakiegoś zasobu i wykonać na nim jakieś operacje w tym samym czasie. W przypadku jednej instancji aplikacji jest to dosyć proste zadanie, ponieważ większość języków ma wbudowane mechanizmy synchronizacji wątków. Jednak przy wielu instancjach osiągniecie tego celu, jest trudne, a niekiedy niemożliwe. Przed wielowątkową, równoczesną modyfikacją zasobu może nas chronić optymistic i pesymistic locking. Jest to popularne rozwiązanie w bazach danych czy niektórych narzędziach tj. Azure Storage. Jednak, jeśli chcemy w tym samym czasie, wywołać tylko raz żądanie do zewnętrznego serwisu lub modyfikować fizyczny plik na dysku to może to być nie lada wyzwanie. Dodatkowo blokowanie dostępu do zasobu może być nie tylko wymagane ze względu na zapewnienie poprawności danych (wiele modyfikacji w tym samym czasie), ale także, ponieważ taki mechanizm może nas chronić przed marnowaniem zasobów. Na przykład, jeśli nasza usługa ma 10 instancji i wszystkie 10 wykonają jakąś obciążającą, długotrwałą pracę, ale tylko jednej usłudze uda się zapisać dane, a reszta dostanie błąd przy zapisie lub ta praca jest niepotrzebnie powtórzona 9 razy, to 9 usług zmarnowało czas, zasoby i być może pieniądze.
Testowanie
Testy integracyjne wielu usług są bardzo kruche. Mają one bardzo wiele zależności i wiele rzeczy podczas testu może pójść nie tak. Dodatkowo takie testy są ciężkie do napisania, a ich uruchomienie trwa długo. Jeśli dodamy takie testy do procesu CI/CD to znacznie spowolnimy wdrożenia. Z kolei ręczne testowanie wymaga od nas postawienia całej infrastruktury lokalnie (baz danych, kolejek, cache), odpalenia N usług i debugowania ich kodu krok po kroku. Dodatkowo taka konfiguracja jest obciążająca dla jednego komputera, potrzebujemy dobrej maszyny, by sprawnie debugować taką aplikację.
Wdrożenia
Jeśli do tej pory nowe wersja aplikacji była wdrażana bez zatrzymania jej działania, to ten proces trzeba zapewnić dla każdego nowego modułu. Jako że mamy teraz wiele aplikacji musimy mieć wiele procesów ich wdrożenia. Zapewnienie wdrożenia nowej wersji usługi bez jej zatrzymania jest o tyle ważne, w przypadku gdy jakieś usługi komunikują się synchronicznie z usługą wdrażaną (temporal coupling). Zatrzymanie usługi podczas wdrożenia powoduje, że usługi od niej zależne nie będą działać poprawnie na czas wdrożenia, co może doprowadzić do kaskadowej awarii systemu. Dodaktowo jeśli chcemy, aby nasze usługi były wysoko dostępne, to musimy wdrażać je na oddzielne serwery.
Odkrywanie dostępnych instacji i zarządzanie ruchem
Przy wielu instancjach jednej aplikacji, usługi od niej zależne nie mogą mieć zapisanego sztywnego adresu drugiej usługi w konfiguracji/kodzie. Muszą dynamicznie dowiedzieć się, o adresie instancji, która jest na ten moment dostępna. Dodatkowo ten ruch musi być rozprowadzony w sposób równy tak, aby każda instancja dostała podobne obciążenie. Jesteśmy zmuszeni zapewnić rejestr dostępnych instancji aplikacji, a przypadku awarii jednej instancji musi ona zostać usunięta z rejestru. Aby to zapewnić, potrzebujemy monitoringu, który zweryfikuje czy dana instancja jest dostępna. Jeśli dodajemy nową instancję aplikacji, to powinna ona zostać do takiego rejestru dodana.
Bezpieczeństwo
Przy jednej aplikacji i serwerze mogliśmy skupić całą uwagę jeśli chodzi o bezpieczeństwo w jednym miejscu. Po rozproszeniu aplikacji na wiele modułów i serwerów musimy zabezpieczyć wiele miejsc. Musimy zdecydować czy komunikacja pomiędzy usługami powinna być w jakiś sposób szyfrowana. Czy ruch powinien być filtrowany i wycinany na poszczególnych usługach i serwerach? Co z autoryzacją i uwierzytelnianiem pomiędzy usługami ? Wraz z rozproszeniem systemu otwieramy się na więcej możliwości popełnienia błędów związanych z bezpieczeństwem.
Koszty
Tworzenie i rozwijanie systemu rozproszonego jest kosztowne. Potrzebujemy rozbudowanej infrastruktury do wdrożeń oraz dodatkowych specjalistów, którzy znają się na tej architekturze i narzędziach potrzebnych do jej działania. Same narzędzia także nie są za darmo.
Podsumowanie
W zależności, w jakiej domenie operujemy, niektóre z tych problemów i ich konsekwencji mogą być pomijalne, a niektóre niedopuszczalne, ale trzeba o nich wiedzieć, projektując system. Niestety zdarza się, że dokumentacja zapewnia o pewnych cechach narzędzia, a w praktyce nie zawsze jest to prawda. Nie chciałbym zabrzmieć w tym wpisie, że jestem przeciwnikiem systemów rozproszonych. Wręcz przeciwnie, są świetne do rozwiązywania problemów skali. Jednak moje przesłanie jest następujące, technologie są tworzone do konkretnych celów i odpowiadają na konkretne zagadnienia. Jeśli nie ma ewidentnej potrzeby rozproszenia systemu, nie polecam tego robić. W innym wypadku będziesz musiał odpowiedzieć na większość powyższych problemów. Co z czasem może stać się frustrujące, jeśli z tyłu głowy wiesz, że praca nad nimi jest tak naprawdę niepotrzebna. Dobrze napisane systemy monolityczne mogą obsłużyć większość obciążeń dzisiejszych aplikacji.