W tym artykule opowiem o tym, jak projekt, w którym pracuję, przekształcał się z dużego monolitu w zestaw mikroserwisów.
Projekt rozpoczął swoją historię dość dawno, na początku lat 2000. Pierwsze wersje były napisane w Visual Basic 6. Z biegiem czasu stało się jasne, że rozwój w tym języku w przyszłości będzie trudny do utrzymania, ponieważ IDE i sam język rozwijają się słabo. Pod koniec lat 2000 postanowiono przejść na bardziej obiecujący C#. Nowa wersja była tworzona równolegle z udoskonalaniem starej, stopniowo coraz więcej kodu było na .NET. Backend w C# początkowo koncentrował się na architekturze serwisowej, jednak w trakcie rozwoju wykorzystywano wspólne biblioteki z logiką, a usługi uruchamiane były w jednym procesie. Powstała aplikacja, którą nazywaliśmy „serwisowym monolitem”.
Jedną z nielicznych zalet takiego połączenia była możliwość wywoływania usług przez siebie za pomocą zewnętrznego API. Istniały wyraźne przesłanki do przejścia na bardziej poprawną architekturę serwisową, a w przyszłości mikroserwisową.
Pracę nad dekompozycją rozpoczęliśmy około 2015 roku. Wciąż nie osiągnęliśmy idealnego stanu — pozostały części dużego projektu, które trudno już nazwać monolitami, ale też nie przypominają mikroserwisów. Niemniej jednak, postęp jest znaczny.
O nim opowiem w artykule.

Spis treści
Architektura i problemy istniejącego rozwiązania
Początkowo architektura wyglądała następująco: UI — osobna aplikacja, część monolityczna napisana w Visual Basic 6, aplikacja na .NET stanowiła zestaw powiązanych usług, działających z dość dużą bazą danych.
Wady wcześniejszego rozwiązania
Jednolita znacznie awarii
Mieliśmy punkt awarii: aplikacja na .NET działała w jednym procesie. Jeśli w którymkolwiek z modułów wystąpił błąd, cała aplikacja przestawała działać i trzeba było ją ponownie uruchomić. Ponieważ automatyzujemy wiele procesów dla różnych użytkowników, z powodu awarii jednego z nich przez pewien czas nie mogły działać wszystkie. A w przypadku błędu programowego również rezerwacja nie pomagała.
Kolejka wdrożeń
Ta wada była raczej organizacyjna. W naszej aplikacji jest wielu klientów, którzy chcą jak najszybciej wprowadzić poprawki. Wcześniej nie dało się tego robić równolegle, więc wszyscy klienci stawali w kolejce. Proces ten wywoływał negatywne skutki w biznesie, ponieważ musieli udowodnić, że ich zadanie ma wartość. Zespół deweloperski tracił czas na organizowanie tej kolejki. Odbywało się to kosztem wiele czasu i energii, a produkt nie mógł na koniec zmieniać się tak szybko, jakby tego oczekiwano.
Nieoptymalne wykorzystanie zasobów
Podczas uruchamiania usług w jednym procesie zawsze kopiowaliśmy konfigurację z serwera na serwer. Chcieliśmy umieścić najbardziej obciążone usługi oddzielnie, aby nie marnować zasobów i uzyskać bardziej elastyczne zarządzanie naszym schematem wdrożenia.
Trudności we wdrażaniu nowoczesnych technologii
Znany wszystkim deweloperom problem: chęć wprowadzenia nowoczesnych technologii do projektu, ale brak możliwości. W przypadku dużego monolitu każda aktualizacja obecnej biblioteki, nie mówiąc już o przejściu na nową, staje się dość złożonym zadaniem. Trzeba długo przekonywać lidera zespołu, że przyniesie to więcej korzyści niż nerwów.
Trudności w wydawaniu zmian
To był największy problem – wydawaliśmy nowe wersje co dwa miesiące.
Każda wersja stawała się prawdziwą katastrofą dla banku, mimo testów i wysiłków deweloperów. Biznes wiedział, że na początku tygodnia część funkcjonalności nie będzie działać. Deweloperzy zdawali sobie sprawę, że czeka ich tydzień poważnych incydentów.
Wszyscy chcieli zmienić tę sytuację.
Oczekiwania wobec mikroserwisów
Wydawanie komponentów według gotowości. Wydawanie komponentów w miarę gotowości dzięki dekompozycji rozwiązania i oddzieleniu różnych procesów.
Małe zespoły produktowe. To ważne, ponieważ dużym zespołem pracującym nad starym monolitem trudno było zarządzać. Taki zespół musiał pracować według ścisłego procesu, podczas gdy chciano więcej kreatywności i niezależności. To mogły sobie pozwolić tylko małe zespoły.
Izolacja usług w osobnych procesach. Idealnie chciałoby się izolować w kontenerach, ale duża liczba usług napisanych w .NET Framework uruchamiana jest tylko pod Windows. Teraz pojawiają się usługi oparte na .NET Core, ale jest ich jeszcze mało.
Elastyczność wdrażania. Chcielibyśmy łączyć usługi tak, jak to jest dla nas potrzebne, a nie tak, jak wymusza kod.
Wykorzystanie nowych technologii. To interesuje każdego programistę.
Problemy przejścia
Oczywiście, gdyby podział monolitu na mikrousługi był prosty, nie trzeba by o tym mówić na konferencjach i pisać artykułów. W tym procesie jest wiele pułapek, opiszę te główne, które nam przeszkadzały.
Pierwszy problem typowe dla większości monolitów: spójność logiki biznesowej. Gdy piszemy monolit, chcemy ponownie wykorzystać nasze klasy, aby nie pisać zbędnego kodu. A przy przejściu na mikrousługi staje się to problemem: cały kod jest dość mocno powiązany i trudno jest podzielić usługi.
W momencie rozpoczęcia prac w repozytorium było ponad 500 projektów i ponad 700 tys. linii kodu. To dość duże rozwiązanie i drugi problem. Po prostu wzięcie i podzielenie go na mikrousługi nie wydawało się możliwe.
The third problem — brak niezbędnej infrastruktury. Faktycznie zajmowaliśmy się ręcznym kopiowaniem kodu źródłowego na serwery.
Jak przejść od monolitu do mikroserwisów
Wydzielanie mikrousług
Po pierwsze, od razu określiliśmy, że podział mikrousług jest procesem iteracyjnym. Od nas zawsze wymagano równoległego prowadzenia rozwoju zadań biznesowych. Jak zrealizujemy to technicznie — to już nasz problem. Dlatego przygotowaliśmy się na proces iteracyjny. W przeciwnym razie się nie uda, jeśli masz dużą aplikację, która z założenia nie jest gotowa do tego, aby ją przepisać.
Jakie metody stosujemy do wydzielania mikrousług?
Pierwszy sposób — wprowadzać istniejące moduły jako usługi. W tym przypadku mieliśmy szczęście: już istniały sformatowane usługi działające na protokole WCF. Były one rozdzielone na oddzielne zestawy. Przenosiliśmy je oddzielnie, dodając do każdego zestawu mały moduł uruchamiający. Został on napisany przy użyciu doskonałej biblioteki Topshelf, która pozwala uruchamiać aplikację zarówno jako usługę, jak i konsolę. To jest wygodne do debugowania, ponieważ nie wymagane są dodatkowe projekty w rozwiązaniu.
Usługi były powiązane logicznie w zależności od biznesu, ponieważ wykorzystywały wspólne zestawy i pracowały z wspólną bazą danych. Trudno je było nazwać mikrousługami w czystym tego słowa znaczeniu. Niemniej jednak, mogliśmy te usługi udostępniać oddzielnie, w różnych procesach. Już to pozwoliło zmniejszyć jednolite wpływy między nimi, ograniczając problem związany z równoległym rozwojem i pojedynczym punktem awarii.
Zestaw z hostem to zaledwie jedna linia kodu w klasie Program. Pracę z Topshelf schowaliśmy w pomocniczej klasie.
namespace RBA.Services.Accounts.Host
{
internal class Program
{
private static void Main(string[] args)
{
HostRunner.Run("RBA.Services.Accounts.Host");
}
}
}
Drugi sposób wyodrębnienia mikrousług: tworzenie ich w celu rozwiązania nowych zadań. Jeśli przy tym monolit nie rośnie, to już dobrze, oznacza to, że idziemy w dobrym kierunku. W celu rozwiązania nowych zadań staraliśmy się tworzyć oddzielne usługi. Jeśli była taka możliwość, tworzyliśmy bardziej "kanoniczne" usługi, które w pełni zarządzają swoim modelem danych oraz oddzielną bazą danych.
My, jak wielu innych, zaczynaliśmy od usług uwierzytelniania i autoryzacji. Idealnie się do tego nadają. Są niezależne, zazwyczaj mają odrębny model danych. Nie interagują bezpośrednio z monolitem, to on zwraca się do nich w celu realizacji pewnych zadań. Na tych usługach można rozpocząć przejście na nową architekturę, zdebugować infrastrukturę, wypróbować różne podejścia związane z bibliotekami sieciowymi itd. W naszej organizacji nie ma zespołów, które nie byłyby w stanie zbudować usługi uwierzytelniania.
Trzeci sposób wyodrębnienia mikrousług, z którego korzystamy, jest dla nas nieco specyficzny. To wydzielenie logiki biznesowej z warstwy UI. Nasza główna aplikacja UI to aplikacja desktopowa, napisana, podobnie jak backend, w C#. Programiści od czasu do czasu popełniali błędy, umieszczając w UI części logiki, które powinny istnieć w backendzie i być wielokrotnie wykorzystywane.
Jeśli spojrzymy na prawdziwy przykład z kodu części UI, widać, że znaczna część tego rozwiązania zawiera prawdziwą logikę biznesową, która jest przydatna w innych procesach, nie tylko do budowania formularza UI.

Realna logika UI to tylko ostatnia para wierszy. Przenieśliśmy ją na serwer, aby można ją było wielokrotnie wykorzystywać, zmniejszając tym samym UI i osiągając prawidłową architekturę.
Czwarty, najważniejszy sposób wydzielania mikroserwisów, który pozwala zmniejszyć monolit — to wydzielenie istniejących serwisów z przeróbką. Gdy wydzielamy istniejące moduły takimi, jakie są, rezultat nie zawsze podoba się programistom, a proces biznesowy od czasu stworzenia funkcjonalności mógł się zestarzeć. Dzięki refaktoryzacji możemy wspierać nowy proces biznesowy, ponieważ wymagania biznesowe stale się zmieniają. Możemy poprawić kod źródłowy, usunąć znane defekty, stworzyć bardziej jakościowy model danych. Zyskujemy wiele korzyści.
Wydzielanie serwisów z przeróbką jest nierozerwalnie związane z pojęciem ograniczonego kontekstu. To pojęcie pochodzi z projektowania zorientowanego na przedmiot. Oznacza to fragment modelu dziedziny, w którym wszystkie terminy jednolitego języka są jednoznacznie zdefiniowane. Weźmy za przykład kontekst ubezpieczeń i rachunków. Mamy monolityczną aplikację i konieczne jest w ubezpieczeniach praca ze sprawą rachunku. Oczekujemy, że programista znajdzie w innej kompilacji istniejącą klasę „Rachunek”, zrobi do niej odniesienie z klasy „Ubezpieczenie”, a my otrzymamy działający kod. Zasada DRY będzie przestrzegana, zadanie dzięki wykorzystaniu istniejącego kodu będzie zrealizowane szybciej.
Okazuje się, że konteksty rachunków i ubezpieczeń są ze sobą powiązane. Kiedy pojawią się nowe wymagania, to powiązanie utrudni rozwój, zwiększając złożoność i tak już skomplikowanej logiki biznesowej. Aby rozwiązać ten problem, należy w kodzie znaleźć granice między kontekstami i usunąć ich naruszenia. Na przykład, dla kontekstu ubezpieczeń wystarczy 20-cyfrowy numer rachunku NBP i data otwarcia rachunku.
Aby oddzielić te ograniczone konteksty od siebie i rozpocząć proces wydzielania mikroserwisów z monolitycznego rozwiązania, zastosowaliśmy podejście polegające na tworzeniu wewnętrznych API w aplikacji. Jeśli wiedzieliśmy, że jakiś moduł powinien stać się mikroserwisem lub zmienić się w ramach procesu, od razu wykonywaliśmy wywołania logiki, która należy do innego ograniczonego kontekstu, poprzez zewnętrzne wywołania. Na przykład, przez REST lub WCF.
Postanowiliśmy, że nie będziemy unikać kodu, który będzie wymagał realizacji rozproszonych transakcji. W naszym przypadku okazało się to wystarczająco łatwe do przestrzegania. Do tej pory nie mieliśmy takich sytuacji, w których rzeczywiście potrzebne byłyby rygorystyczne rozproszone transakcje — wystarcza końcowa spójność między modułami.
Rozważmy konkretny przykład. Mamy pojęcie orkiestratora — procesu, który przetwarza obiekt "wniosku". Po kolei tworzy klienta, rachunek i kartę bankową. Jeśli klient i rachunek zostały pomyślnie utworzone, a tworzenie karty się nie powiodło, wniosek nie przechodzi w status "zrealizowany" i pozostaje w statusie "karta nie została utworzona". W przyszłości aktywność poboczna przechwyci go i zakończy. System przez pewien czas znajduje się w stanie niespójności, ale ogólnie nam to odpowiada.
W przypadku, gdy pojawi się sytuacja, w której należy spójnie zapisać część danych, prawdopodobnie zdecydujemy się na rozbudowanie usługi, aby obsłużyć to w jednym procesie.
Rozważmy przykład wydzielania mikroserwisu. Jak można relatywnie bezpiecznie doprowadzić go do produkcji? W tym przykładzie mamy oddzielną część systemu — moduł obsługi płac, z którego jeden fragment kodu chcielibyśmy przekształcić w mikroserwis.

Na początku tworzymy mikroserwis, przepisywując kod. Poprawiamy niektóre elementy, które nam nie odpowiadały. Realizujemy nowe wymagania biznesowe od klienta. Dodajemy do połączenia między UI a backendem API Gateway, który będzie zapewniał przekazywanie wywołań.

Następnie wprowadzamy tę konfigurację do eksploatacji w trybie pilota. Większość naszych użytkowników wciąż korzysta ze starych procesów biznesowych. Dla nowych użytkowników opracowujemy nową wersję monolitycznej aplikacji, która już nie zawiera tego procesu. W zasadzie w trybie pilota działa połączenie monolitu i mikroserwisu.

Przy udanym pilocie rozumiemy, że nowa konfiguracja naprawdę działa, możemy usunąć stary monolit z równania i zostawić nową konfigurację na miejscu starego rozwiązania.

Podsumowując, używamy praktycznie wszystkich istniejących metod podziału kodu źródłowego monolitu. Wszystkie one pozwalają nam zmniejszać rozmiar części aplikacji i przenosić je na nowe biblioteki, tworząc lepszej jakości kod źródłowy.
Utwórzmy bazę danych – MySQL. Jeśli masz zainstalowane PhpMyAdmin, utwórz nową bazę danych "
Baza danych podlega podziałowi w gorszym stopniu niż kod źródłowy, ponieważ zawiera nie tylko bieżącą schemę, ale także zgromadzone dane historyczne.
Nasza baza danych, jak wiele innych, miała jeszcze jeden ważny problem — ogromny rozmiar. Ta baza danych została zaprojektowana zgodnie ze skomplikowaną logiką biznesową monolitu, a między tabelami różnych ograniczonych kontekstów nagromadziły się powiązania.
W naszym przypadku w dodatku do wszystkich problemów (duża baza danych, wiele powiązań, czasami niejasne granice między tabelami) pojawił się problem, który występuje w wielu dużych projektach: użycie wzorca shared database. Dane były pobierane z tabel przez widoki, za pomocą replikacji i były przekazywane do innych systemów, gdzie była potrzebna ta replikacja. W rezultacie nie mogliśmy przenieść tabel do osobnej schemy, ponieważ były one aktywnie wykorzystywane.
W podziale pomaga nam właśnie rozbicie na ograniczone konteksty w kodzie. Zazwyczaj daje nam to dość dobre wyobrażenie o tym, jak dzielimy dane na poziomie bazy danych. Rozumiemy, które tabele odnoszą się do jednego ograniczonego kontekstu, a które do drugiego.
Zastosowaliśmy dwa globalne sposoby separacji bazy danych: oddzielenie istniejących tabel i oddzielenie z przebudową.
Oddzielenie istniejących tabel to metoda, którą dobrze zastosować, gdy struktura danych jest wysokiej jakości, spełnia wymagania biznesowe i wszystkim odpowiada. W takim przypadku możemy wyróżnić istniejące tabele w osobnej schemacie.
Separacja z przebudową jest potrzebna wtedy, gdy model biznesowy znacznie się zmienił, a tabele już nas nie satysfakcjonują.
Oddzielenie istniejących tabel. Musimy określić, co będziemy oddzielać. Bez tej wiedzy nie uda się nic zrealizować, a tutaj pomoże nam separacja ograniczonych kontekstów w kodzie. Zazwyczaj, jeśli uda się zrozumieć granice kontekstów w kodzie źródłowym, stanie się jasne, które tabele powinny być umieszczone na liście do oddzielenia.
Załóżmy, że mamy rozwiązanie, w którym dwa moduły monolitu współdziałają z jedną bazą danych. Musimy zorganizować, aby z częścią oddzielanych tabel współdziałał tylko jeden moduł, a drugi zaczął z nim komunikować się przez API. Na początku wystarczy, aby przez API odbywał się tylko zapis. To jest warunek konieczny, abyśmy mogli mówić o niezależności mikrousług. Powiązania do odczytu mogą pozostać, dopóki nie będzie to stanowiło dużego problemu.

Kolejnym krokiem możemy już wydzielić część kodu, pracującą z oddzielanymi tabelami, z przebudową lub bez niej, w osobną mikrousługę i uruchomić ją w osobnym procesie, kontenerze. To będzie osobna usługa z połączeniem z bazą danych monolitu i tymi tabelami, które nie są bezpośrednio związane z nim. Monolit wciąż współdziała w trybie odczytu z oddzieloną częścią.

Później usuniemy to połączenie, to znaczy odczyt danych aplikacji monolotowej z oddzielanych tabel również zostanie przeniesiony na API.

Następnie wyróżnimy z ogólnej bazy danych tabele, z którymi pracuje tylko nowa mikrousługa. Możemy przenieść tabele do osobnej schemy lub nawet do oddzielnej fizycznej bazy danych. Pozostało połączenie do odczytu między mikrousługą a bazą danych monolitu, ale nie ma w tym nic złego, w takiej konfiguracji może ona funkcjonować wystarczająco długo.

Ostatni krok to całkowite usunięcie wszystkich powiązań. W takim przypadku może być konieczna migracja danych z głównej bazy. Czasami chcemy ponownie wykorzystać w różnych bazach dane replikowane z systemów zewnętrznych lub słowniki. Spotykamy się z tym okresowo.

Oddział z przeróbką. Ta metoda jest bardzo podobna do pierwszej, tylko przebiega w odwrotnym kierunku. Natychmiast tworzymy nową bazę danych i nowy mikrousługę, który współdziała z monolitem przez API. Jednak pozostaje zestaw tabel bazy danych, które chcemy w przyszłości usunąć. Nie będą nam już potrzebne, w nowym modelu je zastąpiliśmy.

Aby ten schemat zadziałał, prawdopodobnie będziemy potrzebować okresu przejściowego.
Dalej są dwa możliwe podejścia.
Pierwszy: duplikujemy wszystkie dane w nowej i starej bazie. W takim przypadku pojawia się nadmiarowość danych, mogą wystąpić problemy z synchronizacją. Ale możemy zyskać dwóch różnych klientów. Jeden będzie pracował z nową wersją, drugi ze starą.
Drugi: dzielimy dane według jakiegoś kryterium biznesowego. Na przykład, w naszym systemie było 5 produktów, które były przechowywane w starej bazie danych. Szósty w ramach nowego zadania biznesowego umieszczamy w nowej bazie danych. Potrzebujemy jednak API Gateway, który zsynchronizuje te dane i pokaże klientowi, skąd i co pobierać.
Obydwa podejścia są wykonalne, wybierz w zależności od sytuacji.
Po upewnieniu się, że wszystko działa, część monolitu obsługującego stare struktury bazy danych można wyłączyć.

Ostatnim krokiem będzie usunięcie starych struktur danych.

Podsumowując, można powiedzieć, że mamy problemy z bazą danych: trudniej się z nią pracuje w porównaniu z kodem źródłowym, trudniej jest dzielić, ale można i należy to zrobić. Znaleźliśmy pewne sposoby, które pozwalają to robić wystarczająco bezpiecznie, w końcu z danymi łatwiej popełnić błąd niż z kodem źródłowym.
Praca z kodem źródłowym
Tak wyglądał schemat kodu źródłowego, kiedy zaczęliśmy analizować monolityczny projekt.

Można ją w zasadzie podzielić na trzy warstwy. To warstwa uruchamianych modułów, wtyczek, serwisów i różnych aktywności. Faktycznie, były to punkty dostępu w ramach monolitycznego rozwiązania. Wszystkie były trwale połączone warstwą Common. Zawierała logikę biznesową, która była wspólnie wykorzystywana przez serwisy oraz wiele powiązań. Każdy serwis i wtyczka korzystały z 10 i więcej wspólnych zbiorów, w zależności od ich rozmiaru i sumienia twórców.
Mieliśmy szczęście, posiadaliśmy biblioteki infrastrukturalne, które można było wykorzystywać samodzielnie.
Czasami zdarzała się sytuacja, w której niektóre obiekty Common w rzeczywistości nie odnosiły się do tej warstwy, a były bibliotekami infrastrukturalnymi. Rozwiązywano to przez ich przemianowanie.
Najwięcej zmartwień budziły ograniczone konteksty. Zdarzało się, że 3-4 konteksty były mieszane w jednym zbiorze Common i korzystały z siebie nawzajem w ramach tych samych funkcji biznesowych. Należało zrozumieć, gdzie można to podzielić oraz według jakich granic, i co dalej zrobić z mapowaniem tego podziału na zbiory kodu źródłowego.
Sformułowaliśmy kilka zasad dla procesu dzielenia kodu.
Pierwsze: nie chcieliśmy już dzielenia logiki biznesowej pomiędzy serwisami, aktywnościami i wtyczkami. Chcieliśmy, aby logika biznesowa była niezależna w ramach mikrousług. Z drugiej strony, mikrousługi, w idealnym przypadku, postrzegane są jako serwisy, które istnieją całkowicie niezależnie. Uważam, że to podejście jest nieco marnotrawne, a jego osiągnięcie jest trudne, ponieważ na przykład serwisy w C# będą w każdym przypadku połączone standardową biblioteką. Nasz system jest napisany w C#, innych technologii na razie nie musieliśmy używać. Dlatego zdecydowaliśmy, że możemy sobie pozwolić na korzystanie z wspólnych zbiorów technicznych. Najważniejsze, aby nie zawierały żadnych fragmentów logiki biznesowej. Jeżeli macie wygodną osłonę nad ORM, którego używacie, to skopiowanie jej z serwisu do serwisu jest bardzo kosztowne.
Nasz zespół jest fanatykami projektowania opartego na domenach, dlatego „architektura cebuli” doskonale do nas pasowała. Podstawą naszych usług nie była warstwa dostępu do danych, lecz zestawienie z logiką domenową, która zawiera tylko logikę biznesową i jest wolna od powiązań z infrastrukturą. Dzięki temu możemy niezależnie rozwijać domenowy zestaw dla rozwiązywania problemów związanych z frameworkami.
Na tym etapie napotkaliśmy pierwszy poważny problem. Usługa miała korzystać z jednego zestawu domenowego, chciałem, aby logika była niezależna, a zasada DRY bardzo nam w tym przeszkadzała. Programiści chcieli, aby uniknąć duplikacji, ponownie wykorzystywali klasy z sąsiednich zestawów, w rezultacie domeny znowu zaczęły się ze sobą wiązać. Przeanalizowaliśmy wyniki i zdecydowaliśmy, że problem może leżeć także w sposobie przechowywania kodu źródłowego. Mieliśmy duży repozytorium, w którym znajdowały się wszystkie kody źródłowe. Solution dla całego projektu było bardzo trudno zbudować na lokalnej maszynie. Dlatego dla części projektu tworzono oddzielne małe solution, i nikt nie zabraniał dodawania do nich jakiegokolwiek zestawu Common lub domenowego i ponownego używania. Jedynym narzędziem, które uniemożliwiało nam to, była recenzja kodu. Ale czasami i ona zawodziła.
Wtedy zaczęliśmy przechodzić na model z oddzielnymi repozytoriami. Logika biznesowa przestała wyciekać z usługi do usługi, a domeny naprawdę stały się niezależne. Ograniczone konteksty są bardziej wyraźnie wspierane. Jak w tym przypadku ponownie wykorzystujemy biblioteki infrastrukturalne? Wydzieliliśmy je do oddzielnego repozytorium, a następnie umieściliśmy w pakietach Nuget, które umieściliśmy w Artifactory. Przy każdej zmianie, budowa i publikacja odbywa się automatycznie.

Nasze usługi zaczęły odnosić się do wewnętrznych pakietów infrastrukturalnych tak samo, jak do zewnętrznych. Zewnętrzne biblioteki pobieramy z Nuget. Do pracy z Artifactory, gdzie umieszczaliśmy te pakiety, zastosowaliśmy dwa menedżery pakietów. W małych repozytoriach również używaliśmy Nuget. W repozytoriach z wieloma usługami korzystaliśmy z Pakietu, który zapewnia większą spójność wersji między modułami.

W ten sposób, pracując nad kodem źródłowym, nieco zmieniając architekturę i dzieląc repozytoria, czynimy nasze usługi bardziej niezależnymi.
Problemy infrastruktury
Większość problemów przy przejściu na mikroserwisy wiąże się z infrastrukturą. Będziesz potrzebować zautomatyzowanego wdrożenia oraz nowych bibliotek do obsługi infrastruktury.
Ręczna instalacja w środowiskach
Początkowo instalowaliśmy rozwiązania w środowiskach ręcznie. Aby zautomatyzować ten proces, stworzyliśmy pipeline CI/CD. Wybraliśmy proces continuous delivery, ponieważ continuous deployment jest dla nas na razie nieakceptowalne z perspektywy procesów biznesowych. Dlatego wdrożenie odbywa się za pomocą przycisku, a testowanie — automatycznie.

Używamy Atassian, Bitbucket do przechowywania kodów źródłowych oraz Bamboo do budowania. Lubimy pisać skrypty budowlane w Cake, ponieważ to ten sam C#. W Artifactory trafiają już gotowe pakiety, a Ansible automatycznie ląduje na serwerach testowych, po czym można je od razu testować.

Oddzielne logowanie
W swoim czasie jednym z pomysłów monolitu było zapewnienie wspólnego logowania. Musieliśmy również zrozumieć, co zrobić z oddzielnymi logami, które znajdują się na dyskach. Logi piszemy w plikach tekstowych. Postanowiliśmy użyć standardowego stosu ELK. Nie pisaliśmy bezpośrednio do ELK przez dostawców, lecz uznaliśmy, że przerobimy logi tekstowe, dodając identyfikator śledzenia w postaci ID, dodając nazwę usługi, aby te logi można było później analizować.

Dzięki Filebeat uzyskujemy możliwość zbierania naszych logów z serwerów, przekształcania ich, a za pomocą Kibany budowania zapytań w interfejsie użytkownika i obserwowania, jak przebiegało wywołanie między usługami. W tym bardzo pomaga identyfikator śledzenia.
Testowanie i debugging powiązanych usług
Na początku nie do końca rozumieliśmy, jak debugować rozwijane serwisy. Z monolitem wszystko było proste, uruchamialiśmy go na lokalnej maszynie. Tak samo próbowaliśmy robić z mikrousługami, ale czasami, aby uruchomić jedną mikrousługę, trzeba uruchomić kilka innych, co jest niewygodne. Zrozumieliśmy, że musimy przejść do modelu, w którym na lokalnej maszynie zostawiamy tylko serwis lub serwisy, które chcemy debugować. Pozostałe serwisy są używane z serwerów, które mają taką samą konfigurację jak prod. Po debugowaniu, przy testowaniu, na serwer testowy wydawane są tylko zmienione serwisy dla każdego zadania. W ten sposób testowane jest rozwiązanie w takiej postaci, w jakiej w przyszłości znajdzie się na prod.
Są serwery, na których działają tylko produkcyjne wersje serwisów. Serwery te są potrzebne na wypadek incydentów, do weryfikacji dostaw przed wdrożeniem oraz do szkoleń wewnętrznych.
Wprowadziliśmy proces automatycznego testowania za pomocą popularnej biblioteki Specflow. Testy są uruchamiane automatycznie za pomocą NUnit zaraz po wdrożeniu z Ansible. Jeśli pokrycie zadania jest całkowicie automatyczne, nie ma potrzeby przeprowadzania testów manualnych. Chociaż czasami nadal wymagane są dodatkowe testy manualne. Aby określić, które testy uruchomić dla konkretnego zadania, używamy tagów w Jira.
Dodatkowo wzrosła potrzeba testowania obciążeniowego, wcześniej odbywało się to tylko w rzadkich przypadkach. Do uruchamiania testów używamy JMeter, do ich przechowywania – InfluxDB, a do tworzenia wykresów procesu – Grafana.
Co osiągnęliśmy?
Po pierwsze, pozbyliśmy się pojęcia „wydanie”. Zniknęły dwumiesięczne, ogromne wydania, kiedy ta machina była wdrażana w środowisku produkcyjnym, czasowo łamiąc procesy biznesowe. Obecnie wdrażamy serwisy średnio co 1,5 dnia, grupując je, ponieważ wchodzą do eksploatacji po akceptacji.
W naszym systemie nie ma krytycznych awarii. Jeśli wydaliśmy mikrousługę z błędem, związana z nią funkcjonalność będzie uszkodzona, natomiast cała pozostała funkcjonalność nie ucierpi. To znacznie poprawia doświadczenia użytkowników.
Możemy zarządzać schematem wdrażania. Można wydzielać grupy usług oddzielnie od reszty rozwiązania, jeśli zajdzie taka potrzeba.
Ponadto znacznie zminimalizowaliśmy problem z długimi kolejkami poprawek. Pojawiły się oddzielne zespoły produktowe, które pracują z częścią usług niezależnie. Tu świetnie sprawdza się proces Scrum. Każdy zespół może mieć oddzielnego właściciela produktu, który wyznacza mu zadania.
Podsumowanie
- Mikrousługi są idealne do dekompozycji złożonych systemów. W trakcie tego procesu zaczynamy rozumieć, co znajduje się w naszym systemie, jakie występują ograniczone konteksty, gdzie przebiegają ich granice. To pozwala poprawnie przydzielać poprawki do modułów i zapobiegać zawirowaniom w kodzie.
- Mikrousługi dają przewagi organizacyjne. Często mówi się o nich tylko w kontekście architektury, ale każda architektura ma na celu zaspokojenie potrzeb biznesowych, a nie jest wartością samą w sobie. Dlatego możemy powiedzieć, że mikrousługi są dobrze dopasowane do rozwiązywania zadań przez małe zespoły, biorąc pod uwagę, że obecnie bardzo popularny jest Scrum.
- Podział to proces iteracyjny. Nie można po prostu wziąć aplikacji i podzielić ją na mikrousługi. Otrzymany produkt raczej nie będzie działał poprawnie. Przy wydzielaniu mikrousług korzystnie jest przepisać istniejące legacy, czyli przekształcić je w kod, który nam odpowiada i lepiej spełnia potrzeby biznesowe dotyczące funkcjonalności i szybkości.
Małe ostrzeżenie: koszty przejścia na mikrousługi są dość znaczące. Tylko rozwiązanie problemu infrastruktury zajęło dużo czasu. Dlatego, jeśli masz małą aplikację, która nie wymaga specyficznego skalowania, i nie ma dużej liczby klientów, którzy walczą o uwagę i czas twojego zespołu, to być może mikrousługi nie są tym, czego potrzebujesz dzisiaj. To dość drogie. Jeśli zaczynasz proces z mikrousługami, to koszty na początku będą wyższe niż w przypadku, gdy ten sam projekt zaczynasz z rozwoju monolitu.
P.S. Bardziej emocjonalna opowieść (jakby osobista) – na .
Tutaj znajduje się pełna wersja wystąpienia.
Źródło: habr.com
