Jak zdusić monolit i przejść na mikroserwisy? - Część 2
28.02.2022 | Rafał Danowski
Wstęp
W pierwszej części artykułu porównałem architekturę mikroserwisową oraz monolitową, przedstawiłem największe zalety oraz wady obu rozwiązań, a także zobrazowałem na czym polega zasada Avoid Big Bang. Link do poprzedniej części znajdziesz tutaj. W dalszej części artykułu przeczytasz, na czym polega wzór Strangler Application Pattern, który pozwala zmigrować architekturę aplikacji na mikroserwisy.
Strangler Application Pattern
Zastosowanie Strangler Application Pattern, wzorca dusiciela, pozwala nam uzyskać płynne przejście z architektury monolitycznej do mikroserwisowej. Polega on na tym, że stopniowo „zduszamy” monolit, przenosząc jego funkcjonalności do nowo wydzielanych serwisów, co powoduje, że zmiana jest niemalże niewidoczna dla zewnętrznego obserwatora. Dobrą analogią jest porównanie tego do drzew – monolit jest zwykłym drzewem, pojedynczy mikroserwis to z kolei jedna gałązka, wiele takich serwisów przekłada się natomiast na całą działającą aplikację.
Rysunek 1. Strangler Application Pattern na przykładzie drzewek
Źródło: cmfirstgroup.com, data dostępu: 2022-01-12
Rysunek 2. Drzewo figowe – tak, to istnieje, drzewo w architekturze mikroserwisowej
Źródło: materiały wewnętrzne ING
Jak działa ten wzorzec?
Głównym założeniem jest użycie małych kroków. Należy wydzielać maksymalnie jeden serwis w danym czasie i obserwować jego działanie. Jeśli będzie zachowywać się odpowiednio, można kontynuować proces, wydzielając kolejne serwisy. W pewnym momencie monolit już będzie tak „zduszony”, że sam będzie tak mały jak pozostałe mikroserwisy. Zaletą takiego podejścia jest możliwość przerwania procesu zmiany architektury w przypadku jakichkolwiek problemów bez utraty funkcjonalności. Powinno się tak planować prace, aby pod koniec sprintów mieć stan 1x działających monolit + Xx działających mikroserwisów.
Wzorzec jest bardzo prosty i można go opisać za pomocą trzech kroków:
- Transform;
- Coexist;
- Eliminate.
Należy je wykonywać w pętli do czasu, aż monolit nie zostanie „zduszony” do wielkości pozostałych serwisów.
Transform: identyfikacja nowego mikroserwisu
Kluczowym aspektem tego podejścia do migracji architektury jest identyfikowanie części monolitu, która będzie nadawać się do wyodrębnienia jako mikroserwis.
Można stosować parę podejść, np. identyfikację kluczowych konceptów domenowych. W tym przypadku można sprawdzić, gdzie użyto naturalnego grupowania języka (np. w pewnym zakresie klas często stosuje się słowo invoice – może to sugerować powiązanie domenowe klas). Można też sugerować się tym, jak są powiązane dane z funkcjonalnościami (np. jak mamy jakieś webowe API, to mamy rozdzielenie rzeczownikami, co daje nam naturalny podział). Odmiennym podejściem do wyodrębnienia poszczególnych komponentów jest kierowanie się podziałem UI lub grup użytkowników.
Innym sposobem jest sugerowanie się grupowaniem punktów interakcji. W takim przypadku należy zorientować się jak są pogrupowane punkty interakcji wokół kluczowych konceptów w aplikacji (często są już podzielone np. za pomocą pakietów w Javie, wtedy jeden główny koncept = docelowy mikroserwis). Po wyodrębnieniu przewodnich konceptów należy zgrupować pozostałe dane i funkcje (np. klasy pomocnicze i transferu danych w aplikacji) wokół zbudowanych wcześniej komponentów. Na sam koniec należy sprawdzić jakie są powtarzające się mniejsze koncepty w już istniejących komponentach (np. wysyłka powiadomień może pojawiać się w wielu miejscach) i z nich utworzyć osobny główny koncept, w celu uniknięcia duplikacji implementacji funkcjonalności.
Transform: wybranie pierwszego komponentu do stworzenia mikroserwisu
Udało się nam powyodrębniać komponenty w ramach monolitu. Teraz pora na wybranie pierwszego szczęśliwca, który zostanie wydzielony z monolitu i zostanie z niego utworzony pierwszy mikroserwis. Tylko jak tego dokonać? Tutaj użyteczna może być technika polegająca na ustaleniu różnych reguł ekstrakcji serwisu, następnie sprawdzeniu, ile punktów z maksa dostały poszczególne komponenty i ten z największą liczbą punktów jest najlepszym kandydatem do wyodrębnienia.
Przykładowa lista zasad, jakie musi spełniać komponent, by mógł być wydzielony do osobnego mikroserwisu:
- Komponent o jak najmniejszym użyciu (mierzone np. za pomocą metryk);
- Komponent, który będzie najłatwiej zrefactorować do wymaganego stanu;
- Komponent o jak najmniejszym ryzyku;
- Komponent o jak największej częstotliwości zmiany (powodujący dużo instalacji);
- Komponent z dobrym pokryciem kodu testami;
- Jak najmniejszy komponent, żeby zminimalizować ilość potrzebnej pracy;
- Jak największy komponent, by uprościć integracje.
Oczywiście to nie jest stała lista, Święty Graal ekstrakcji mikroserwisów. Należy indywidualnie dobrać odpowiednie reguły dla każdego przypadku, jednakże lista ta pozwala dać podgląd, jakiego typu powinny być reguły i dlaczego właśnie takie.
Transform: mój pierwszy mikroserwis
Podzieliliśmy monolit na komponenty, przydzieliliśmy też im odpowiednie priorytety ekstrakcji. Teraz czas na „mięso”, czyli sam proces wydzielenia części kodu z głównego projektu i stworzenie nowego serwisu.
Przede wszystkim polecam nie eksperymentować przesadnie – lepiej stworzyć taki mikroserwis na zasadzie kopiuj-wklej. Zbyt dużo niestabilnych funkcjonalności w pierwszej fazie migracji może się negatywnie odbić na całym procesie. Refactor czy dopisanie testów – w porządku – ale jeśli chcesz przepisać cały moduł w nowej technologii, to jest to raczej zła praktyka, przynajmniej na starcie. Tego typu wstrzemięźliwość ułatwia krok drugi, czyli Coexist – szczególnie gdy nastąpi moment, że trzeba będzie poprawić buga, dodać funkcjonalność w nowym miejscu albo gdy coś pójdzie nie tak i trzeba będzie cofnąć wdrożenie nowego komponentu.
Jeśli nie wiesz, od czego zacząć przy wydzielaniu nowego serwisu, warto najpierw zerknąć na endpointy sieciowe (głównie przy podejściu polegającym na rozbiciu monolitu na podstawie punktów interakcji). Z tego miejsca można trafić do wszystkich elementów, które nowy komponent powinien zawierać, tworząc logiczną ścieżkę przejścia procesu. Gdy będziesz tak kopiować klasy do nowego tworu, może się wydarzyć, że będziesz potrzebować logiki, zawartej w klasie, która powinna znaleźć się w zupełnie innym serwisie. W takim przypadku warto skorzystać ze wzorca Branch by abstraction. Polega on na tym, że gdy zabraknie jakichś zależności, tworzymy pewną abstrakcję (klasę abstrakcyjną, interfejs), która będzie zatykać tymczasowe dziury w logice. Na samym początku nie powinniśmy się skupiać na implementacji brakującej logiki, jedynie na tymczasowym zapewnieniu kompilowalności kodu – wystarczy rzucić wyjątek typu NotImplementedException. Gdy już skończymy proces kopiowania kodu do nowego serwisu, nadejdzie moment poradzenia sobie z utworzonymi abstrakcjami. W miejscu, gdzie znajduje się brakujący kod (zazwyczaj to będzie monolit), powinniśmy wystawić dane funkcjonalności tak, aby nowy mikroserwis mógł je wykorzystać – najłatwiej można to uzyskać, tworząc proste API sieciowe, np. restowe. To samo można zastosować, gdy monolit będzie wymagał jakiegoś działania, które jednak jest realizowane w wydzielonym komponencie. Można wtedy zastosować wyżej wspomniany wzorzec, jednak wymiana danych będzie następowała w odwrotnym kierunku – monolit będzie wywoływać API wystawione w serwisie.
Dobrym pomysłem jest również planowanie przyszłości w kontekście baz danych – jak wiemy, w architekturze mikroserwisowej każdy jeden serwis powinien mieć swoją bazą danych. Rozdzielenie baz danych jest jednym z najtrudniejszych kroków w procesie migracji architektury, dlatego warto jak najwcześniej o tym pomyśleć, wprowadzając pewną abstrakcję. Przykładowo można to uzyskać przez posiadanie jednej bazy danych, lecz przed nią wystawienie pewnej usługi zapewniającej abstrakcję (z tym trzeba uważać, często jest to SPOF) albo posiadanie wielu użytkowników o różnych uprawnieniach. Wykonanie tej czynności pozwoli przygotować się do późniejszego kroku, jakim jest otrzymanie osobnej bazy danych per serwis. Oczywiście mowa o przypadku idealnym i gdy jest to możliwe, warto to wykonać, jednocześnie zapewniając odpowiedni mechanizm rozproszonych transakcji i spójności danych, takich jak: Saga, CQRS czy Event sourcing. Jednakże zdarzają się projekty, gdy jest to niemożliwe lub niezwykle kosztowne. W takich sytuacjach posiadanie pojedynczej bazy jest właściwie koniecznością. Warto jednak mieć jak największą abstrakcję, o której wspominałem wyżej.
Coexist: pora na proda
Gdy w końcu po ciężkich bojach uzyskaliśmy nasz pierwszy mikroserwis, czas rozpocząć myślenie o wdrożeniu na produkcję. Jeśli masz już zaimplementowany jakiś flow CI/CD, warto na jego podstawie stworzyć nowy, by był gotowy do wdrożenia mikroserwisów. Jeśli nie masz i ręcznie wrzucasz zbudowane lokalnie jarki na vm, może najwyższy czas pomyśleć o jakiejś automatyzacji. ;)
Bardzo ważne jest to, aby wydzielenie pewnej bazy kodu do nowego serwisu odbyło się na zasadzie kopiuj-wklej, nie wytnij-wklej – to znaczy, że nie powinniśmy usuwać istniejącego kodu w monolicie. Pozwoli nam to na duplikację funkcjonalności. Na pierwszy rzut oka to działanie może wydawać się bez sensu, ale już spieszę z wyjaśnieniami. Na początku potrzebujesz utworzyć pewien komponent, który będzie znajdować się między monolitem i mikroserwisem a frontem – API Gateway. Mając tę samą funkcjonalność zarówno w monolicie, jak i w mikroserwisie, możemy powoli wdrażać nowe rozwiązanie, stosując odpowiedni load balancing. Dobrym pomysłem może być tzn. canary release lub friends & family release (F&F; wdrożenie polegające na tym, że pewna funkcjonalność jest dostępna tylko dla znanej, ograniczonej puli użytkowników będących pracownikami organizacji, którzy potem dają feedback po testach na PRD). Po pozytywnym odbiorze wdrożenia nowego rozwiązania można powoli zwiększać procent ruchu kierowanego do mikroserwisu, cały czas badając ważne metryki – przede wszystkich czas zapytań oraz liczbę błędów. Jeśli wszystko będzie dobrze, można dążyć do 100% wykorzystania mikroserwisu. Kiedy już dojdziemy do tego momentu, warto poczekać jeszcze jakiś czas, dalej monitorując stan rozwiązania, i jeśli po około tygodniu dalej wszystko będzie stabilne, można uznać, że pomyślnie udało się wydzielić pierwszy mikroserwis. Gratulacje! :)
Oczywiście programowanie to nie same kwiatki i jednorożce i rzadko kiedy wszystko wychodzi za pierwszym razem, dlatego należy być przygotowanym na moment, gdy zauważymy nadmierne błędy lub zbyt długie czasy procesowania zapytania. Dobrym pomysłem jest utworzenie łatwej konfiguracji load balancingu, aby szybko móc sterować ruchem i w razie problemów przełączyć na kierowanie 100% ruchu na monolit. Jeśli byłoby to zbyt trudne do zrealizowania lub czasochłonne, innym sposobem jest utworzenie „wajchy” w mikroserwisie, za pomocą której będzie można prosto wyłączyć ruch na nowym serwisie.
Coexist: za dużo zajęcy
W pewnym momencie na produkcji będzie już tyle utworzonych mikroserwisów, że zdecydowanie zbyt trudno będzie zarządzać ręcznie adresami, portami, certyfikatami itp. W takim przypadku (a najlepiej jeszcze wcześniej) należy pomyśleć o pewnych mechanizmach charakterystycznych dla architektury, do której się dąży, czyli m.in. Service Discovery, automatyczne zarządzanie sekretami, Auth Service, agregacja logów czy monitoring. Warto poświęcić odpowiednio dużo czasu, aby wybrać jak najlepsze rozwiązanie i zaimplementować je najlepiej jak się da. Nieczęsto zmienia się np. Keycloaka na CAS-a albo Consula na Zookeepera, więc odpowiedni wybór technologii jest istotny. Równie ważne jest zadbanie o odpowiednią abstrakcję tych elementów infrastruktury, aby proces zmiany rozwiązania przebiegł jak najszybciej i najłatwiej.
Eliminate: pora na duszenie
Ten krok z pewnością jest najłatwiejszy i najprzyjemniejszy. Polega na tym, że jak już „wygrzejemy” nowy serwis na produkcji, dostajemy zielone światło na trwałe usunięcie funkcji realizowanej przez mikroserwis z monolitu, zmniejszając jego rozmiar. Powinno się tak długo powtarzać cały proces, aż po którymś z kolei kroku Eliminate pozostały monolit będzie miał zbliżony rozmiar do pozostałych komponentów.
Przed pełnym usunięciem kodu z monolitu należy upewnić się, że wykonaliśmy wszystko z poniższej checklisty:
- Jeśli jeszcze nie przenieśliśmy testów jednostkowych (shame on you!), to ostatni moment by to zrobić.
- Należy zadbać, by testy integracyjne obejmowały nowy mikroserwis.
- To samo dotyczy testów kontraktowych, komponentowych oraz testów end-to-end.
- Jeśli jest to możliwe, zadbaj o jak najwyższą automatyzację testów, zaczynając od jednostkowych, a kończąc na e2e.
Warto ponadto wstrzymać się z usunięciem zduplikowanej części funkcjonalności, póki nie wykonamy następujących kroków:
- W pełni przetestowaliśmy nowe rozwiązanie wszystkimi dostępnymi testami.
- Pomyślnie wdrożyliśmy nowy serwis przynajmniej dla F&F/canary users.
- Zaimplementowaliśmy system monitoringu i agregacji logów, który obejmuje nowy serwis.
- Przeprowadziliśmy dogłębne badania działania aplikacji na PRD sprawdziliśmy metryki, wyeliminowaliśmy błędy i upewniliśmy się, że nowe rozwiązanie jest całkowicie stabilne.
Gdy już przejdziemy przez obie listy i będziemy przekonani, że wszystko jest w porządku, możnaspokojnie usuwać kod z monolitu.
Eliminate: a co z bazą danych?
Jak wspominałem w części Transform: mój pierwszy mikroserwis, odpowiednie wydzielenie bazy danych per mikroserwis jest jednym z najtrudniejszych wyzwań, jakie stoją przed zespołem zmieniającym monolit na mikroserwisy. Wynika to z szeregu wyzwań, jakie ten temat wprowadza.
Najważniejsze z nich to:
- Relacje między tabelami;
- Raportowanie (rozwiązaniem może być wysyłka danych do hurtowni danych);
- Złączenia (joins);
- NoSQL;
- Nieznormalizowane tabele;
- Triggery;
- Logika biznesowa na bazie;
- Bezpieczeństwo i audyt danych;
- Rozproszone transakcje;
- Kaskadowe operacje;
- Wydajność.
Oczywiście wyzwań jest dużo więcej, co przedstawia poniższa grafika.
Rysunek 3. Wyzwania przy dekompozycji bazy danych
Źródło: pluralsight.com, data dostępu: 2022-01-12.
Należy również pamiętać, że raz rozdzielona baza danych może być ekstremalnie trudna do złączenia, dlatego trzeba myśleć o tej operacji jako jednokierunkowej i dokładnie przeanalizować wszystkie za i przeciw tego rozwiązania.
Jeśli nie decydowaliśmy się na modyfikacje związane z bazą danych w momencie wydzielania kodu z monolitu do serwisu, warto jeszcze raz się zastanowić nad tym w momencie usuwania duplikatu bazy kodu ze starej wersji aplikacji. Jednym ze sposobów na dekompozycję bazy może być wykorzystanie wcześniej wspominanego wzorca Database by Abstraction, który pokrótce opiszę poniżej (instrukcje jednak odnoszą się do tzw. happy path, ponieważ trudno jest przewidzieć, jakie problemy napotkamy podczas całego procesu – wszystko zależy od obecnego stanu DB):
- Utworzenie nowej bazy;
- Skopiowanie procedur do nowej bazy;
- Utworzenie widoków/synonimów, by nowa baza odnosiła się jeszcze do starej;
- Usunięcie problemu nieistniejących powiązań;
- Przepięcie mikroserwisu na nową bazę.
Rezultatem będzie powstanie nowej bazy, do której zostanie przepięty nowy mikroserwis, ale to w dalszym ciągu będzie stara baza ukryta za pewną abstrakcją widoków/synonimów.
W rzeczywistych przypadkach liczba powiązań, procedur i innych zawiłości może spowodować, że temat stanie się dużo bardziej skomplikowany. Należy pamiętać o tym, że:
- Dzielenie tylko części bazy zamiast całej jest o wiele lepsze niż podzielenie całej, ale w nieodpowiedni sposób (np. ze względu na zbyt duże koszty, by zrobić to dobrze).
- Należy unikać dzielenia bazy poprzez mechanizmy synchronizacji (gdy są te same tabelki w bazie starej i nowej).
- W razie ogromnych problemów można myśleć o wydzieleniu osobnego serwisu do zarządzania bazą.
Zarządzanie bieżączką
Niemożliwe jest, by podczas tych paru miesięcy, kiedy będziesz przeprowadzać migrację architektury, nie wpadały zgłoszenia o istniejących błędach lub propozycje nowych funkcjonalności. W takim wypadku przydatna wydaje się decyzja o pozostawieniu stacka technologicznego bez zmian - łatwiej będzie przeprowadzić poprawkę buga lub wprowadzić nową funkcję, jeśli i w monolicie i w nowym serwisie kod będzie wyglądał identycznie. Przyspieszy to dowożenie zmian, co uszczęśliwi nie tylko klientów, dla których przecież piszemy programy, ale także osoby, które płacą nam pensję.
Podsumowanie
Migracja z architektury monolitycznej na mikroserwisową jest sporym wyzwaniem niezależnie od wielkości i złożoności obecnej aplikacji. Przede wszystkim należy się zastanowić, czy taka zmiana jest potrzebna w danym konkretnym przypadku, analizując, czy zalety mikroserwisów przeważą ich wady oraz koszt migracji. Jeśli jednak zdecydujemy się na taki krok, z pomocą przychodzi wzorzec Strangler Application Pattern, z wykorzystaniem którego możemy płynnie przeprowadzić zmianę. Daje on wskazówki, w jaki sposób podzielić monolit na logiczne komponenty, jak wybrać pierwszy z nich do utworzenia mikroserwisu oraz jakich błędów unikać przy wdrażaniu na produkcję. Wprowadza on także wskazówki, jak upewnić się, że migracja przebiegła pomyślnie.
Nie miałeś okazji zapoznać się z pierwszą częścią artykułu? Link do poprzedniej części znajdziesz tutaj.