Ukryty koszt granic między mikroserwisami: jak wycenić granicę, zanim ją narysujesz
Podział systemu na serwisy przenosi złożoność w inne miejsce, ale jej nie zmniejsza. Kod, który wołał funkcję, wysyła teraz żądanie przez sieć, a każda zmiana dotykająca obu stron tego wywołania staje się zmianą w dwóch repozytoriach, dwóch pipeline’ach wdrożeniowych i jednym kontrakcie między nimi. Później granicę przesuwa się bardzo trudno, bo to oznacza migrację danych i przepisanie kontraktu.
Zanim ją narysujesz, zapytaj: jaka jest najmniejsza zmiana, która będzie tę granicę przekraczać, i jak często będzie się zdarzać? Jeśli odpowiedź brzmi „większość funkcji, po obu stronach naraz”, linia jest w złym miejscu.
Co kosztuje granica
W monolicie zmiana sygnatury metody razem z wywołaniami to jeden commit, sprawdzony przez kompilator albo analizę statyczną i wdrożony atomowo. Przez granicę serwisów ta sama zmiana robi się dużo droższa.
Producent musi obsługiwać stary i nowy kontrakt jednocześnie, bo oba serwisy nie wdrażają się w tej samej chwili. Wdrożenia idą w ustalonej kolejności: rozszerz producenta i wdróż, przenieś konsumentów i wdróż, usuń stary kontrakt i wdróż jeszcze raz. Jedna zmiana logiczna, trzy wydania.
Analiza statyczna nie widzi już obu stron, więc to, że pole zmieniło nazwę, musi wykryć coś innego: testy kontraktowe albo środowisko integracyjne.
Awarie też się komplikują. Wywołanie funkcji zwraca wynik albo rzuca wyjątek. Wywołanie sieciowe może dodatkowo skończyć się timeoutem, kiedy druga strona zdążyła już zrobić commit, więc przy każdym zapisie przez granicę trzeba zdecydować o ponowieniach, kluczach idempotencji i o tym, co się dzieje ze stanem częściowym.
Raport, który łączył dwie tabele, potrzebuje teraz wywołań API (w najgorszym razie po jednym na wiersz), replikowanego modelu do odczytu albo strumienia zdarzeń zasilającego osobny magazyn. Żądanie przechodzące przez trzy serwisy debuguje się bez distributed tracingu dużo wolniej, z logów sklejanych po correlation ID.
Każdy z tych kosztów osobno jest niewielki. Płacisz jednak wszystkie przy każdej zmianie przekraczającej linię, więc suma zależy od tego, jak często to się dzieje.
Model kosztu
cost(boundary) =
f_change * cost_per_change # skoordynowane zmiany po obu stronach linii
+ f_failure * blast_radius # jak często i jak mocno jedna strona psuje drugą
- autonomy_gained # niezależne wdrożenia, skalowanie, własność
# dominuje pierwszy składnik -> granica jest w złym miejscu
# dominuje drugi składnik -> granica może być dobra; zainwestuj w izolację (timeouty, kolejki, fallbacki)
# dominuje trzeci składnik -> granica zarabia na siebie
Nikt tego nie liczy do jednej liczby. Chodzi o to, żeby przed decyzją zgrubnie oszacować składniki i zapisać szacunek:
f_change: ile zmian miesięcznie wymaga ruszenia obu stron.cost_per_change: opisany wyżej narzut, w godzinach pracy na jedną skoordynowaną zmianę.f_failureiblast_radius: jak często awaria albo spowolnienie jednej strony dotyka drugiej i jak daleko się to rozlewa.autonomy_gained: co każda strona może teraz zrobić samodzielnie, czego wcześniej nie mogła. Gdy oba serwisy należą do tego samego zespołu, część wynikająca z własności jest bliska zera, bo zespół i tak musi się dogadać sam ze sobą. Zostają powody operacyjne, opisane niżej.
Weźmy dwa serwisy jednego zespołu, bez różnic operacyjnych, gdzie większość funkcji dotyka obu. f_change jest wysokie, autonomy_gained bliskie zera. W modelu dominuje pierwszy składnik: granica dokłada wersjonowanie i wdrożenia w kolejności do większości funkcji.
Pomiar sprzężenia zmian przed narysowaniem linii
f_change nie trzeba zgadywać, da się go zmierzyć z historii repozytorium. Jeśli system jest jeszcze monolitem z katalogiem na moduł, policz, jak często commity dotykają obu kandydatów.
#!/usr/bin/env bash
# Sprzężenie zmian między dwoma katalogami w zadanym okresie.
# Użycie: ./coupling.sh src/Billing src/Fraud '6 months ago'
a="$1"; b="$2"; since="${3:-6 months ago}"
total_a=$(git log --no-merges --since="$since" --format='%H' -- "$a" | wc -l)
both=0
for h in $(git log --no-merges --since="$since" --format='%H' -- "$a"); do
if [ -n "$(git show --name-only --format= "$h" -- "$b")" ]; then
both=$((both + 1))
fi
done
echo "commits touching $a: $total_a"
echo "of which also touch $b: $both"
Sprzężenie liczone po commitach zaniża wynik, gdy jedna funkcja jest rozbita na kilka commitów. Jeśli commity mają ID zadania, grupuj po zadaniu. Jeśli PR-y wchodzą jako merge commity, grupuj po merge commicie. PR-y squash-merge’owane to już jeden commit na zmianę i nie wymagają grupowania. Przy większej historii code-maat i podobne narzędzia liczą sprzężenie dla wszystkich par plików z wyjścia git log.
Orientacyjny próg na początek: jeśli ponad jedna trzecia zmian po jednej stronie dotyka też drugiej, obie strony są jedną jednostką zmiany i powinny zostać w jednym deployu.
Kiedy granica się opłaca
Granica zarabia na siebie, gdy obie strony różnią się czymś, co ma znaczenie operacyjne.
Najprościej widać to na rytmie wdrożeń. Serwis płatności wychodzi kilka razy w tygodniu, a model scoringu fraudów dopiero po kilkutygodniowej walidacji. Po rozdzieleniu płatności nie czekają na walidację modelu.
To własność sprawia, że autonomy_gained jest realne: inne zespoły, inne dyżury, inne priorytety.
Do tego dochodzą powody czysto operacyjne. Parser dokumentów obciążający CPU i API wrażliwe na opóźnienia potrzebują innych zasobów, a w osobnych procesach każde z nich skaluje się i pada osobno. Komponent wołający niestabilną usługę zewnętrzną można schować za kolejką, żeby jej awarie nie blokowały ścieżki żądania.
Czasem wystarcza sama technologia. Model serwowany z Pythona obok aplikacji w PHP to naturalna granica procesu, bo inaczej trzeba by osadzać jeden runtime w drugim.
Jeśli nic z tego nie zachodzi, kandydat na granicę to granica modułu i jej miejsce jest w jednym deployu.
Zacznij od modułów
Modularny monolit trzyma granice w kodzie bez płacenia kosztu sieci. Każdy moduł ma własne tabele, wystawia interfejs i nie ma prawa sięgać do wnętrza innego modułu. W PHP można to wymusić w CI deptrakiem:
# deptrac.yaml
deptrac:
paths:
- ./src
layers:
- name: Billing
collectors:
- type: directory
value: src/Billing/.*
- name: Fraud
collectors:
- type: directory
value: src/Fraud/.*
- name: Shared
collectors:
- type: directory
value: src/Shared/.*
ruleset:
Billing: [Shared]
Fraud: [Shared]
Przy takiej konfiguracji Billing i Fraud mogą zależeć tylko od Shared, a pull request, który importuje klasę z Fraud do Billing, wywala build.
Późniejsze wydzielenie będzie tanie, jeśli moduły nie robią joinów po cudzych tabelach. Moduł czyta dane innego modułu wyłącznie przez jego publiczny interfejs, a po wydzieleniu ten interfejs staje się API. Efekty uboczne między modułami idą przez zdarzenia. Jeśli Billing potrzebuje reakcji Fraud na płatność, wysyła zdarzenie, a Fraud na nie reaguje. Przeniesienie tego zdarzenia później na brokera wiadomości to wtedy zmiana transportu plus obsługa duplikatów i opóźnień, bez przepisywania obu modułów. Zdarzenie w procesie idzie zwykle synchronicznie, w tej samej transakcji, a broker dostarcza je asynchronicznie, co najmniej raz i poza nią, więc odbiorca musi być idempotentny, a nadawca potrzebuje outboxa.
Wydzielenie dobrze odseparowanego modułu to koszt jednorazowy. Sklejenie dwóch serwisów, których nie należało rozdzielać, też jest możliwe, ale do tego czasu każda strona zwykle zdąży zbudować własną wersję wspólnego modelu domeny. Pogodzenie ich wymaga migracji danych i przepisania każdego miejsca, w którym oba modele się rozjeżdżają.
Najpierw platforma, potem serwisy
Każdy nowy serwis potrzebuje pipeline’u budowania, konfiguracji wdrożenia, logów, metryk, tracingu, health checków, sekretów i alertów. Jeśli to wszystko nie jest wspólne i oparte na szablonie, każdy serwis wymyśla to trochę inaczej, a koszt operacyjny rośnie z liczbą serwisów, niezależnie od ilości logiki biznesowej. Przed podziałem miej gotowy jeden szablon CI/CD, który nowy serwis przyjmuje bez zmian, strukturalne logi z correlation ID przekazywanym między wywołaniami, distributed tracing i standard timeoutów, ponowień oraz kluczy idempotencji dla każdego wywołania między serwisami.
Przekonałem się o tym w małej skali, na jednym dodatkowym procesie. W grze z pojedynkami online w Laravelu WebSockety obsługuje Reverb, czyli osobny proces. Bez niego „pojedynek online” dawał 500, a gdy już działał, ale bez proxy w vhoście, gość dołączał po HTTP, a kanał presence przez WebSocket dostawał 404. Po deployu, który zmieniał eventy, Reverb trzymał stary kod i trzeba go było ręcznie zrestartować. Do tego pułapka przy diagnozie: curl przez Cloudflare szedł po HTTP/2 i gubił nagłówek Upgrade, więc wracało fałszywe 404, a pomagało dopiero --http1.1.
Z wszystkich danych do tej decyzji częstotliwość zmian najłatwiej zmierzyć przed podziałem: skryptem z góry, na historii z ostatnich sześciu miesięcy. Reszta (zespoły, rytm wdrożeń, skalowanie, gotowość platformy) wymaga w dużej części oceny, i jeśli zmierzone sprzężenie wychodzi „większość funkcji”, rzadko zmienia wynik.