Backend, Praca w IT

Ciemna strona mikroserwisów

Darth Vader

Mikroserwisy to temat, który w moim odczuciu jest nadal bardzo popularny na wszelkiego rodzaju meetupach, czy konferencjach programistycznych (sam się poniekąd przyczyniam do jego promocji). Implikacją tego jest fakt, że wielu programistów odchodzi od oklepanych i bardzo niemodnych monolitów na rzecz systemów rozproszonych. Pytanie brzmi, dlaczego?


Darek Pawlukiewicz. Absolwent wydziału Cybernetyki na Wojskowej Akademii Technicznej. Miłośnik języka C# oraz platformy .NET Core. Entuzjasta DDD, CQRS oraz wszelkich zagadnień związanych z systemami rozproszonymi. Regularnie prowadzi bloga foreverframe.net, a okazjonalnie pojawia się na spotkaniach oraz konferencjach programistycznych w roli prelegenta. Aktualnie pracuje jako Full Stack Developer w firmie Connectis_.


Osobiście uważam, że hype wszystkiego co „distributed” i „micro” jest poniekąd efektem kuli śnieżnej popchniętej kilka lat temu. Ktoś kiedyś wspomniał o SOA, nazwał to nieco inaczej i bum… mamy mikroserwisy. Ktoś dodał, że skalowanie horyzontalne to podstawa w dzisiejszych czasach i bum… mamy architekturę idealną, a w dodatku super łatwą do utrzymania, bo przecież nie mamy jednej, wielkiej kupy kodu, tylko małe reużywalne usługi! WOW!

Wydawać by się zatem mogło, że to podejście praktycznie nie posiada wad, prawda? Ile znasz prezentacji, artykułów czy książek, które opisują mikroserwisy w trochę mniej kolorowym świetle? Bardziej szarym, a czasami całkowicie czarnym? Powiem otwarcie, że kiedy myślę o mikroserwisach to bardzo szybko nasuwa mi się skojarzenie z tym obrazkiem (lekko zmodyfikowanym):

Wszyscy jak jeden mąż powtarzają w kółko, że ten typ architektury zapewnia nam wysoką dostępność systemu, łatwość w skalowaniu, transparentność, modularność, asynchroniczność i wiele innych, ale często przeważającym argumentem jest fakt, że… Netflix ma mikroserwisy, więc chyba musi to być coś fajnego. Gwoli ścisłości, nie twierdzę, że to podejście jest swoistą wydmuszką marketingową i nie posiada zastosowania we współczesnym świecie. Wręcz przeciwnie, jest to świetne narzędzie do zadań specjalnych, które dopiero rozkręca się tam, gdzie „typowe” systemy już się poddają. Jednak jak to w życiu bywa, każdy kij ma dwa końce.

Mikroserwisy modularność

Cała „zabawa” i uświadomienie sobie, że to wszystko może nie jest takie łatwe na jakie wygląda, zaczyna się już chwilę po tym jak uruchomimy nasze IDE. Przychodzi czas na utworzenie solucji, a następnie dodanie do niej N projektów, czyli naszych usług. Jak to wszystko podzielić, żeby było dobrze? Niestety w tym momencie jestem zmuszony udzielić odpowiedzi generycznej – to zależy. W głównej mierze od projektu i jego domeny. Oczywiście są pewne klasyki (jak sklep online :D), które możemy zaimplementować bez głębszego pochylenia się nad tematem i na 99% będzie to nawet składne, ale nie zawsze jednak mamy taką możliwość. Wtedy pozostaje rozbijanie domeny i całego systemu na mniejsze fragmenty i patrzenie czy ma to sens. W zasadzie istnieją trzy rezultaty takiego procesu:

Pierwsza opcja to stworzenie systemu, który stoi gdzieś pomiędzy dwoma konceptami. To okrzyknięty mianem antywzorcamikro monolit, który można określać zdaniem „robię mikroserwisy na 50%, bo trochę się boję”. Niby system został podzielony na mniejsze składowe, ale jakoś nie do końca widać zalety tego podziału. Usługi enkapsulują zbyt duże fragmenty domeny niż powinny, więc coupling dalej uniemożliwia ich szybkie podgrywanie czy chwilowe zatrzymanie, gdyż rzutowałoby to na duży fragment systemu. Baza danych dla usług jest wspólna, więc jest to nadal wąskie gardło systemu. Jedyne co się zmieniło to liczba aplikacji do deployowania na produkcji i utrudniony development.

Po drugiej strony barykady mamy wzięcie sobie idei mikrousług zbytnio do serca. Ja lubię określać to mianem nanoserwisów, choć ten termin raczej nie występuje w publikacjach naukowych. Osobiście nie widziałem na żywo takiego „potworka”, ale czytałem o przypadkach gdy jedna usługa równała się praktycznie jednej metodzie. To duża przesada, która może być dopuszczalna w bardzo specyficznych przypadkach, ale zdecydowanie dla małego fragmentu systemu. Efektem tych prac będzie kod bardzo trudny do analizowania, utrzymania, rozwijania i debugowania.

Widzisz więc, że sztuką jest dobranie rozmiaru konkretnej usługi tak, aby logicznie zamknąć w niej część domeny aplikacyjnej, przy jednoczesnym zachowaniu komfortu pracy z kodem. To ważne, bo co jak co, ale to Ty będziesz go rozwijał przez następne miesiące.

Myślę, że z powodu „względności” całego procesu, nie powinniśmy przyjmować jakiś ogólnych zasad projektowania systemu opartego na mikroseriwsach jak np. takiej: „serwis nie powinien być większy niż 1000 linii kodu”. To system, procesy biznesowe i Twoje umiejętności jako programisty powinny wyznaczyć ich granice.

Mikroserwisy asynchroniczna komunikacja

Posiadasz już usługi. Teraz czas na sprawienie, aby w jakiś sposób ze sobą rozmawiały. To jest istota tej architektury – wiele małych usług, które dla użytkownika końcowego mają zachowywać się jak spójna jednostka. Przyjęło się, że komunikacja odbywa się na dwóch płaszczyznach:

  • do odczytu danych wykorzystujemy synchroniczny protokół http,
  • do zapisu danych wykorzystujemy komunikację asynchroniczną np. poprzez kolejki.

Dlaczego tak? Zauważ, że z perspektywy użytkownika końcowego tak się to właśnie odbywa. Kiedy wchodzisz na konkretną stronę to czekasz na jej pobranie i wyrenderowanie w przeglądarce. Wiesz dobrze, że przejście na inną stronę/zakładkę powoduje przerwanie całego procesu i nie dojdzie do sytuacji gdy nagle przeglądarka podmieni Ci widok na ten, który próbowałeś otworzyć 10 min temu tylko dlatego, że ukończyła jego pobieranie. Jest to więc proces synchroniczny.

Sprawa ma się zgoła odmiennie przy zapisie danych. Ile razy dokonywałeś jakichś zmian w systemie (opłaciłeś zamówienie na jedzenie, przegenerowałeś plan zajęć na uczelni) i myślałeś sobie „Kto to projektował?! Dlaczego od trzech minut muszę oglądać spiner z napisem “proszę czekać”? W większości przypadków chcemy, aby cały proces trwał możliwie krótko i jesteśmy w stanie zaakceptować fakt, że jakiś pop-up wyskoczy nam np. za 5 minut z informacją, że wszystko przebiegło pomyślnie. Tak działa spora część portali jak np. Twitter, który wyświetla czerwone serduszko (like) zaraz po kliknięciu, mimo że tak naprawdę w systemie nie jest to jeszcze odnotowane. Użytkownicy szczęśliwi, a ewentualna szkoda w przypadku niepowodzenia mała (w tym konkretnym przypadku).

No dobrze, ale dlaczego właściwie o tym wspominam? Implikacją opisanego modelu komunikacji jest to, że propagowanie informacji z serwera do użytkownika jest znacznie utrudnione. Zobrazuję to klasycznym przykładem – użytkownik zakłada konto w aplikacji podając maila i hasło. Dane zostają odebrane w API i przekazane asynchronicznie (przez kolejkę) do konkretnej usługi zajmującej się tym właśnie fragmentem domeny. Usługa przed utworzeniem użytkownika dokonuje walidacji posługując się jakimiś przyjętymi kryteriami. Pytanie, co w przypadku gdy z jakiegoś powodu nie będziemy mogli zaakceptować danych użytkownika, bo np. email jest już zajęty albo hasło jest za krótkie? W klasycznym (synchronicznym) modelu jest to relatywnie proste:

W przypadku asynchronicznej komunikacji wygląda to w ten sposób:

Widzisz zatem, że odpowiedź z serwera nie ma charakteru „kontraktu”, który poświadcza o persystencji danych na serwerze. Jest to jedynie promesa. Trzeba zatem w jakiś sposób otrzymać z API informację czy cała operacja przetworzyła się poprawnie. Do problemu warto podejść w zasadzie na dwóch płaszczyznach. Po pierwsze możesz zaimplementować odpowiednie mechanizmy walidacyjne po stronie klienta (aplikacji frontendowej), w myśl zasady, że lepiej zapobiegać niż leczyć. Dzięki temu już na poziomie uzupełniania danych dokonasz dynamicznej walidacji, która pozwoli (w większości przypadków) uniknąć błędów stricte domenowych, zanim użytkownik wyśle je na serwer.

Warto jednak pamiętać, że mimo relatywnie wysokiej skuteczności tej techniki, nadal istnieje szansa, że dane w momencie odczytu były niespójne (znów przez asynchroniczność) i walidacja po stronie klienta powiedzie się mimo iż nie powinna. Warto zatem również zadbać o infrastrukturę, która poinformuje użytkownika o niepowodzeniu operacji po czasie, gdy proces „wyłoży się” już na serwerze. Istnieje kilka sposobów na rozwiązanie tego problemu, a jeden z nich opiszę wkrótce na swoim blogu.

Istnieje jeszcze jedna, „negatywna” implikacja wynikająca z asynchronicznej natury mikrousług oraz ich modularności. Mowa tu o rozproszeniu transakcji biznesowych, które są dużo trudniejsze w zarządzaniu. O co konkretnie chodzi? Pozwolę sobie posłużyć się „oklepanym”, jednakże bardzo prostym przykładem rezerwacji wycieczki na wakacje. Na ów rezerwację składają się:

  • znalezienie hotelu w odpowiednim mieście w odpowiednim przedziale czasowym,
  • znalezienie lotu w odpowiednim przedziale czasowym,
  • rezerwacja samochodu na cały pobyt na wakacjach.

Proces biznesowy w tym konkretnym przypadku składa się z trzech, mniejszych „podprocesów”, z których każdy musi zostać ukończony pomyślnie, aby finalnie użytkownik zobaczył powiadomienie o powodzeniu całej operacji. Nie może przecież dojść do sytuacji gdzie np. hotel i samochód zostały wynajęte, podczas gdy nie ma dostępnego lotu w zadanym terminie. Teraz, gdy pomyślimy o faktycznej implementacji takiego procesu w monolicie, to nie wydaje się to nadto skomplikowane.

Wystarczy prosty serwis aplikacji, który wywoła odpowiednie fazy całego procesu po czym zwróci rezultat: powiodło się lub nie powiodło się. W przypadku mikroserwisów ten problem nie już tak oczywisty do rozwiązania. Po pierwsze, sama domena jest rozproszona pomiędzy wieloma usługami (np. usługa odpowiedzialna stricte za obsługę hotelową itd.), a po drugie sam proces jest rozciągnięty w czasie za sprawą asynchronicznych wiadomości, którymi komunikują się kolejne usługi. Do tego wszystkiego dochodzi czysto „estetyczny” element — w przypadku monolitu i wcześniej wspomnianego serwisu aplikacji, programista bez trudu zrozumie, co składa się na kolejne kroki rezerwacji wycieczki.

Wszystkie bowiem znajdą się w ramach jednej metody i będą wywoływane jeden pod drugim. W przypadku mikroserwisów (i braku odpowiedniej infrastruktury) luźne powiązania pomiędzy kolejnymi usługami sprawiają, że zrozumienie ogólnej logiki aplikacji staje się o wiele trudniejsze.

Rozwiązaniem wszystkich powyższych problemów może być wprowadzenie do projektu wzorca Sagi, który w dużym uproszczeniu możemy nazwać listą TODO. Saga reprezentuje nasz skomplikowany proces biznesowy i wykonuje kolejne jego kroki. Jeżeli wszystkie się powiodą — Saga się dokonuje i emituje odpowiednie zdarzenie (dzięki któremu poinformuje użytkownika o powodzeniu operacji). Jeżeli jednak, któryś z kroków nie ukończy się sukcesem, Saga zadba o tzw. Rollback całego procesu i anuluje wszystkie poprzednie kroki, a i odpowiednie zdarzenie (reprezentujące niepowiedzenie) zostanie wyemitowane. Brzmi banalnie?

Nic dziwnego, ponieważ sama idea nie jest zbytnio skomplikowana. Problemy pojawiają się jednak kiedy zaczniemy rozmyślać nad bardziej „zaawansowanymi” scenariuszami jak np. przechowywanie stanu konkretnej Sagi w bazie danych na wypadek resetu aplikacji, czy chociażby sposób dostępu do konkretnej Sagi z poziomu naszej aplikacji backendowej. Nim się obejrzysz może okazać się, że napisałeś tonę kodu „tylko” po to, aby obsłużyć prosty case, nad którym nawet byś się nie zastanawiał w swoim monolicie.

Mikroserwisy dostępność systemu

No i przyszedł czas na koronny argument „za” mikroserwisami, czyli dostępność systemu. O co chodzi? W przypadku monolitu mamy de facto do czynienia z jedną, dużą usługą, która odpowiedzialna jest za obsługę całego systemu. W praktyce oznacza to, że jej niedostępność spowodowana np. podgrywaniem wersji lub awarią, jest równoznaczna niedostępnością całego serwera (i tym samym aplikacji). Sprawa ma się zgoła odmiennie w przypadku mikroserwisów, ponieważ dzięki modularyzacji posiadamy wiele, niezależnych usług, z których każda odpowiedzialna jest tylko za mały fragment systemu. Zatem, jeżeli jedna z takich usług nie będzie dostępna to w najgorszym przypadku tylko mały fragment naszej aplikacji nie powinien działać. W teorii wszystko brzmi super… a potem przychodzi CAP:

Trzy składowe tego schematu to:

  • Consistency (pol. spójność) – każdy odczyt z systemu skutkować będzie pobraniem najnowszych danych lub otrzymaniem błędu,
  • Availability (pol. dostępność) – każdy odczyt z systemu skutkować będzie pobraniem danych, ale bez gwarancji że są one najnowsze,
  • Partition tolerance (pol. tolerancja na partycje) – system działa mimo zakłócenia propagacji wiadomości między węzłami.

Jeżeli kiedykolwiek widziałeś podobny schemat to zapewne gdzieś obok umieszczona była cięta riposta „wybierz dwa!”. To trochę jak w tym starym żarcie:

Przychodzi klient do programisty i mówi:

– Chciałbym tani i dobry system.

Programista na to:

– A po co panu dwa systemy?

Przepraszam, musiałem 🙂 Tak czy siak, moim zdaniem powinniśmy patrzeć na ten magiczny trójkąt z trochę innej perspektywy. Nie jako „wybierz dwa”, bo zakładanie, że problemy natury sieciowej nie będą się pojawiać tylko dlatego, że „ja tak mówię” jest nadto optymistyczne. Bardziej powinniśmy rozważać to w ten sposób – jak powinien zachować się system, w przypadku gdy problemy sieciowe między węzłami wystąpią? Czy odpytany o dane powinienem zwrócić to co aktualnie znajduje się w bazie danych (dostępność) czy zwrócić błąd, ale mieć pewność, że stare dane nie zostały zwrócone do klienta (spójność). Odpowiedź na to pytanie znów w głównym stopniu brzmi od charakterystyki systemu.

Kiedyś słyszałem nawet bardzo trafne porównanie. Reddit jest przykładem portalu, dla którego pierwsza opcja ma większy sens. Co się stanie jeżeli kilka tysięcy osób zobaczy wpisy sprzed kilku minut, a nie najnowsze? Nic. Ludzie najprawdopodobniej tego nie zauważą, a przynajmniej nie będą narzekać, że coś nie działa. Dla porównania weźmy systemy medyczne, które przechowują dane pacjentów. W tym przypadku kluczową rolę odgrywa spójność danych, ponieważ nie można pozwolić sobie na sytuację, w której pacjent otrzymuje np. dwie dawki leków tylko dlatego, że system nie wyświetlił aktualnych danych. Widzisz więc, że mikroserwisy znów komplikują nam życie, przedstawiając problemy, o których nie myśleliśmy nawet pisząc monolity.

Mikroserwisy legendarny development

Ostatni akapit z tej wyliczanki chciałbym poświęcić nietechnicznemu aspektowi. Wielu programistom ślinka cieknie od samej informacji zawartej w ofercie pracy, że będą pracowali przy mikroserwisach. O ile mogę się zgodzić, że fajnie jest się rozwijać w tym kierunku i eksplorować tę tematykę, ponieważ jest bardzo rozległa i ciekawa, o tyle muszę powiedzieć to otwarcie – praca z tym rodzajem architektury nie należy do najprzyjemniejszych. Szczególnie odczuwalne jest to na UNIX-owych systemach, które nie posiadają VS.

Po pierwsze, uruchamianie systemu już wymaga od nas dużo więcej pracy niż zwykłe kliknięcie „Build & Run” w VS, ponieważ musimy najpierw uruchomić naszą infrastrukturę (bazy danych, kolejkę itd.), a następnie N usług jednocześnie. W tym miejscu możemy albo:

  • otworzyć N konsol i uruchomić usługi poprzez dotnet CLI,
  • napisać skrypt, który zrobi to za nas,
  • uruchomić to poprzez docker compose.

W niedalekiej przyszłości planuję publikację wpisu zawierającego kompletną instrukcję uruchomienia projektu DShop, która dokładniej przedstawi dwie, ostatnie metody. Oczywiście to nie koniec niespodzianek! Do tego dochodzą problemy z:

  • debugowaniem konkretnych mikroserwisów,
  • utrzymywaniem spójności między usługami,
  • commitowaniem zmian dla GITa. Znów, albo piszemy własne skrypty, albo myślimy o submodułach GITa.

A to dopiero początek…

Jak zapewne się domyślasz to co dziś przedstawiłem jest zaledwie czubkiem góry lodowej i to bardzo rozległej. Nie poruszyłem takich zagadnień jak partycjonowaniu danych, obsługa niedostępności konkretnych serwisów, modele wysyłania wiadomości, czy problemy natury DevOps jak orkiestracja. Czy oznacza to, że mikroserwisy są zatem złem koniecznym i powinniśmy od nich stronić? Oczywiście, że nie. Moim celem było jedynie zwrócenie Twojej uwagi na fakt, że wszystkie gloryfikowane cechy tego podejścia ma swoją cenę, która często pogrąża nie jednego programistę. Z tego względu przed pochopnym pakowaniem się w mikroserwisy, zadaj sobie pytanie czy faktycznie jesteś zmuszony do wytaczania tak ciężkiego oręża… zwłaszcza gdy ma być to system dla Pani Krysi z lokalnego spożywczaka.


baner

Artykuł pierwotnie ukazał się na blogu autora. Zdjęcie główne artykułu pochodzi z clip2art.com.

Programista od 2014 roku. Bloger piszący o technologiach Microsoftu (.NET Core, ASP.NET Core, EF, SQL Server), frontendzie (TypeScript, Aurelia, Angular), architekturze, systemach rozproszonych, wzorcach projektowych, bezpieczeństwie i wielu innych. Uwielbia Open Source. Jest prelegentem na największych polskich konferencjach i lokalnych meetupach. Jest organizatorem warszawskiego meetupu C_tech. Poza programowaniem dźwiga ciężary, gra na gitarze/perkusji i słucha ciężkiej muzyki.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/ciemna-strona-mikroserwisow/" order_type="social" width="100%" count_of_comments="8" ]