Monolit modularny vs mikroserwisy - dlaczego nie potrzebujesz Kubernetesa
CTO pewnej firmy logistycznej opowiedział mi historię, którą słyszę co kwartał. Consultingowa firma doradcza zaproponowała im „nowoczesną architekturę mikroserwisową". 14 serwisów, Kubernetes, API Gateway, service mesh, distributed tracing. Budżet: 1,8 mln PLN, czas: 12 miesięcy.
Po 18 miesiącach mieli 9 z 14 serwisów, zerowy deployment na produkcję i zespół 11 osób, z czego 4 zajmowały się wyłącznie infrastrukturą. Budżet przekroczony dwukrotnie.
Zbudowaliśmy im ten sam system jako monolit modularny w Phoenix. 4 osoby, 5 miesięcy, rachunek za infrastrukturę: 2 400 PLN/miesiąc zamiast planowanych 18 000 PLN.
Mit mikroserwisów
Mikroserwisy rozwiązują prawdziwy problem - ale problem, którego 99% firm nie ma.
Netflix ma mikroserwisy, bo zatrudnia 2 000 inżynierów, którzy pracują nad różnymi częściami systemu jednocześnie i deployują 100 razy dziennie. Amazon ma mikroserwisy, bo obsługuje miliardy requestów i potrzebuje niezależnego skalowania poszczególnych komponentów.
Twoja firma ma 5-20 developerów i deployuje raz w tygodniu. Czy naprawdę potrzebujesz tej samej architektury co Netflix?
Co mikroserwisy naprawdę dają
| Korzyść | Kiedy ma sens | Kiedy nie ma sensu |
|---|---|---|
| Niezależne deployowanie | Zespół 50+ osób, wiele teamów | Zespół < 20 osób |
| Niezależne skalowanie | Radykalnie różne obciążenie per serwis | Podobne obciążenie wszędzie |
| Różne technologie per serwis | Specyficzne wymagania (ML w Pythonie, realtime w Elixirze) | Cały system w jednym stacku |
| Izolacja awarii | Brak izolacji w monolicie | BEAM daje izolację procesów natywnie |
Co mikroserwisy naprawdę kosztują
To jest lista, o której nikt nie mówi na konferencjach:
Infrastruktura mikroserwisów:
├── Kubernetes cluster → 3 000 - 8 000 PLN/mies.
├── Container registry → 200 PLN/mies.
├── API Gateway → 400 - 1 500 PLN/mies.
├── Service mesh (Istio/Linkerd) → Czas konfiguracji
├── Distributed tracing (Jaeger) → 500 - 2 000 PLN/mies.
├── Centralized logging (ELK) → 1 000 - 3 000 PLN/mies.
├── Message broker (Kafka/RabbitMQ) → 800 - 2 500 PLN/mies.
├── Service discovery → Wbudowane w K8s
├── Secret management (Vault) → 500 - 1 500 PLN/mies.
├── CI/CD per serwis (×14) → 2 000 PLN/mies.
└── Monitoring per serwis → 1 000 - 3 000 PLN/mies.
────────────────────────────────────────────────
Razem infrastruktura: 9 400 - 25 000 PLN/mies.
Infrastruktura monolitu modularnego:
├── 1-2 serwery aplikacyjne → 1 200 - 2 400 PLN/mies.
├── 1 serwer PostgreSQL → 1 200 PLN/mies.
├── CI/CD (1 pipeline) → 200 PLN/mies.
├── Monitoring → 500 PLN/mies.
└── Backup → 300 PLN/mies.
────────────────────────────────────────────────
Razem infrastruktura: 3 400 - 4 600 PLN/mies.Różnica: 6 000 - 20 000 PLN miesięcznie - tylko na infrastrukturze. Do tego dochodzi koszt zespołu.
Czym jest monolit modularny
Monolit modularny to jedna aplikacja podzielona na niezależne moduły (konteksty biznesowe), które komunikują się przez zdefiniowane interfejsy. Ma zalety mikroserwisów (separacja odpowiedzialności, czyste granice) bez ich kosztów (sieć, serializacja, distributed transactions).
Mikroserwisy: 3 bazy, 3 deploye, 3 CI/CD, API Gateway, service discovery... Monolit: 1 baza, 1 deploy, 1 CI/CD, zero API Gateway.
Jak wygląda monolit modularny w Phoenix
Konteksty jako moduły biznesowe
# lib/my_app/orders.ex - kontekst zamówień
defmodule MyApp.Orders do
@moduledoc "Kontekst zamówień - publiczny interfejs modułu"
alias MyApp.Orders.{Order, OrderItem}
alias MyApp.Repo
def create_order(attrs) do
%Order{}
|> Order.create_changeset(attrs)
|> Repo.insert()
|> notify_order_created()
end
def confirm_order(order_id, confirmed_by) do
order = get_order!(order_id)
Ecto.Multi.new()
|> Ecto.Multi.update(:order, Order.confirm_changeset(order))
|> Ecto.Multi.run(:stock, fn _, _ ->
MyApp.Inventory.reserve_stock(order)
end)
|> Repo.transaction()
|> notify_order_confirmed()
end
# Prywatne funkcje - inny moduł ich nie widzi
defp notify_order_created({:ok, order} = result) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_created, order})
result
end
defp notify_order_created(error), do: error
end# lib/my_app/inventory.ex - kontekst magazynu
defmodule MyApp.Inventory do
@moduledoc "Kontekst magazynowy - publiczny interfejs"
def reserve_stock(order) do
# Implementacja ukryta za interfejsem
# Orders nie wie JAK magazyn rezerwuje stock
# Wie tylko, że może wywołać tę funkcję
end
def check_availability(product_id, quantity) do
# ...
end
end# lib/my_app/payments.ex - kontekst płatności
defmodule MyApp.Payments do
@moduledoc "Kontekst płatności"
def process_payment(order) do
# ...
end
def refund(payment_id, reason) do
# ...
end
endKażdy kontekst:
- Ma publiczny interfejs - zestaw funkcji, które inne moduły mogą wywoływać
- Ukrywa implementację - schematy Ecto, prywatne funkcje, wewnętrzne procesy
- Posiada własne tabele -
orders.*,inventory.*,payments.* - Komunikuje się przez PubSub - luźne wiązanie między modułami
Granice modułów = granice biznesowe
lib/my_app/
├── orders/ # Kontekst zamówień
│ ├── order.ex # Schema + changesety
│ ├── order_item.ex
│ └── order_notifier.ex
├── orders.ex # Publiczny interfejs (facade)
│
├── inventory/ # Kontekst magazynu
│ ├── product.ex
│ ├── stock_level.ex
│ └── reservation.ex
├── inventory.ex # Publiczny interfejs
│
├── payments/ # Kontekst płatności
│ ├── payment.ex
│ ├── refund.ex
│ └── payment_gateway.ex
├── payments.ex # Publiczny interfejs
│
├── shipping/ # Kontekst wysyłki
│ ├── shipment.ex
│ ├── tracking.ex
│ └── carrier_api.ex
├── shipping.ex # Publiczny interfejs
│
└── accounts/ # Kontekst użytkowników
├── user.ex
├── user_token.ex
└── user_notifier.exReguła: moduł Orders może wywołać Inventory.reserve_stock/1, ale nie może importować MyApp.Inventory.StockLevel ani pisać zapytań do tabel inventory_* bezpośrednio. Granica kontekstu to granica interfejsu.
Komunikacja między modułami - PubSub
# Zamówienie potwierdzone → magazyn rezerwuje, płatności wystawiają fakturę,
# shipping przygotowuje etykietę, dashboard się aktualizuje
# W module Orders (producent zdarzenia):
defp notify_order_confirmed({:ok, %{order: order}} = result) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_confirmed, order})
result
end
# W module Shipping (konsument zdarzenia):
defmodule MyApp.Shipping.OrderSubscriber do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "orders")
{:ok, %{}}
end
def handle_info({:order_confirmed, order}, state) do
MyApp.Shipping.prepare_label(order)
{:noreply, state}
end
# Ignoruj inne zdarzenia
def handle_info(_, state), do: {:noreply, state}
endTo jest ten sam wzorzec co event-driven microservices, ale bez Kafki, bez serializacji JSON, bez opóźnień sieciowych. PubSub działa w pamięci - latencja w mikrosekundach.
Porównanie: co dostajesz w każdej architekturze
| Aspekt | Mikroserwisy | Monolit modularny (Phoenix) |
|---|---|---|
| Separacja modułów | Fizyczna (sieć) | Logiczna (konteksty Elixir) |
| Komunikacja | HTTP/gRPC + JSON | Wywołanie funkcji / PubSub |
| Latencja komunikacji | 1-10 ms (sieć) | < 0.01 ms (pamięć) |
| Transakcje cross-module | Saga pattern (złożone) | Ecto.Multi (proste) |
| Niezależny deploy | Tak | Nie (ale deploy trwa sekundy) |
| Niezależne skalowanie | Tak | Nie (ale 1 serwer wystarczy) |
| Izolacja awarii | Osobne procesy OS | Procesy BEAM (supervisor trees) |
| Baza danych | Osobna per serwis | Współdzielona (osobne schematy) |
| Monitoring | Distributed tracing | Telemetry + Logger |
| Złożoność DevOps | Bardzo wysoka | Niska |
Gdzie mikroserwisy bolą najbardziej
Problem 1: Distributed transactions
Klient składa zamówienie. Musisz: utworzyć zamówienie, zarezerwować stock, obciążyć kartę. W monolicie:
# Monolit - jedna transakcja, atomowa
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(attrs))
|> Ecto.Multi.run(:stock, fn _, %{order: order} ->
Inventory.reserve(order.items)
end)
|> Ecto.Multi.run(:payment, fn _, %{order: order} ->
Payments.charge(order)
end)
|> Repo.transaction()
# Albo WSZYSTKO się uda, albo NIC się nie zmieni.
# Zero niespójności. Zero "zapłacił ale stock nie zarezerwowany".W mikroserwisach:
Orders Service → "utwórz zamówienie" ✓
Orders → Inventory: "zarezerwuj stock" ✓
Orders → Payments: "pobierz płatność" ✗ (karta odrzucona)
# Teraz musisz:
# 1. Cofnąć rezerwację stocku (compensating transaction)
# 2. Oznaczyć zamówienie jako anulowane
# 3. Obsłużyć sytuację, gdy Inventory nie odpowiada na cofnięcie
# 4. Obsłużyć sytuację, gdy sieć padnie w trakcie cofania
# 5. Zaimplementować Saga pattern z choreografią lub orkiestracją
# 6. Dodać dead letter queue na nieprzetworzone kompensacje
# 7. Monitorować niespójności i naprawiać ręcznie
# To jest TYSIĄCE linii kodu infrastrukturalnego
# zamiast 10 linii Ecto.MultiProblem 2: Debugging
Użytkownik zgłasza: „zamówienie #4521 nie zostało wysłane".
Monolit: Jeden log, jedno miejsce, grep 4521 app.log - masz pełną historię.
Mikroserwisy: Request przeszedł przez API Gateway → Orders → Inventory → Payments → Shipping → Notifications. Logi w 6 różnych miejscach. Potrzebujesz distributed tracing (Jaeger/Zipkin), correlation ID propagowanego przez wszystkie serwisy, centralizowanego logowania (ELK stack). I modlisz się, żeby nikt nie zapomniał przekazać correlation ID.
Problem 3: Refactoring granic
Po 6 miesiącach okazuje się, że „Payments" powinno być częścią „Orders", a nie osobnym serwisem. W monolicie - przesuwasz pliki, zmieniasz import. W mikroserwisach - migrujesz bazę danych, przepisujesz API, aktualizujesz wszystkie serwisy klienckie, zmieniasz CI/CD, routing, monitoring. Tydzień pracy zamiast godziny.
BEAM daje izolację bez mikroserwisów
Główny argument za mikroserwisami to izolacja awarii. Ale BEAM daje ją natywnie:
# Supervisor tree - izolacja awarii w monolicie
children = [
# Każdy kontekst ma własny supervisor
{MyApp.Orders.Supervisor, []},
{MyApp.Inventory.Supervisor, []},
{MyApp.Payments.Supervisor, []},
{MyApp.Shipping.Supervisor, []}
]
Supervisor.start_link(children, strategy: :one_for_one)
# Jeśli Shipping.Supervisor padnie:
# → Orders, Inventory, Payments działają dalej
# → Supervisor restartuje Shipping w mikrosekundach
# → Zero utraty danych, zero downtime'u
#
# To jest TA SAMA izolacja co w mikroserwisach
# Bez sieci, bez Kubernetesa, bez distributed tracing| Aspekt izolacji | Mikroserwisy | BEAM (monolit) |
|---|---|---|
| Crash jednego modułu | Nie wpływa na inne | Nie wpływa na inne |
| Memory leak | Izolowany do kontenera | Izolowany do procesu BEAM |
| CPU spike | Izolowany (resource limits) | Scheduler preemptive (BEAM) |
| Restart po awarii | Kubernetes restartuje pod (sekundy) | Supervisor restartuje proces (μs) |
| Deployment nowej wersji | Rolling update (minuty) | Hot code reload (sekundy) |
Kiedy monolit, kiedy mikroserwisy
Zacznij od monolitu, gdy:
- Zespół < 20 programistów
- Firma ma 1-3 produkty/systemy
- Obciążenie jest przewidywalne
- Budżet na infrastrukturę < 15 000 PLN/miesiąc
- Czas dostarczenia ma znaczenie (time to market)
- Nie masz dedykowanego zespołu DevOps
Rozważ mikroserwisy, gdy:
- Zespół > 50 programistów, wiele niezależnych teamów
- Różne moduły wymagają radykalnie różnych technologii
- Jedno z usług wymaga 100× większego skalowania niż reszta
- Masz dedykowany zespół platformowy (DevOps/SRE)
- Deployujesz 10+ razy dziennie i potrzebujesz niezależnych cykli
Ścieżka ewolucji
Najlepsze podejście: zacznij od monolitu modularnego, wydziel mikroserwis gdy pojawi się realna potrzeba:
Wydzielasz mikroserwis gdy masz dowód, że jest potrzebny - nie „na zapas".
Porównanie kosztów (3 lata)
Scenariusz: system ERP/CRM z 5 modułami biznesowymi, 50-100 użytkowników.
| Składnik | Mikroserwisy (K8s) | Monolit (Phoenix) |
|---|---|---|
| Infrastruktura/mies. | 15 000 PLN | 4 000 PLN |
| DevOps (osoba/pół etatu) | 10 000 PLN/mies. | 0 (developer wystarczy) |
| Czas budowy MVP | 8-12 mies. | 4-6 mies. |
| Zespół budowy | 6-8 osób | 3-4 osoby |
| Koszt budowy | 800 000 - 1 200 000 PLN | 300 000 - 500 000 PLN |
| Utrzymanie/rok | 250 000 PLN | 100 000 PLN |
| TCO 3 lata | 2 050 000 - 2 650 000 PLN | 750 000 - 1 000 000 PLN |
Różnica: 1,3 - 1,6 mln PLN w 3 lata. Za te pieniądze możesz zatrudnić 5 programistów na rok albo zbudować drugi system.
Podsumowanie
Mikroserwisy to potężne narzędzie - dla firm, które ich potrzebują. Dla pozostałych 95% to overengineering, który kosztuje 2-3× więcej, trwa 2× dłużej i wymaga 2× większego zespołu.
Monolit modularny w Phoenix daje:
- Separację modułów - konteksty z czystymi interfejsami
- Izolację awarii - supervisor trees, nie Kubernetes
- Komunikację event-driven - PubSub, nie Kafka
- Transakcje atomowe - Ecto.Multi, nie Saga pattern
- Prosty deployment - jeden build, jedna komenda
- Ścieżkę ewolucji - wydziel mikroserwis, gdy pojawi się realna potrzeba
Nie potrzebujesz architektury Netflixa. Potrzebujesz systemu, który działa, jest łatwy w utrzymaniu i kosztuje rozsądnie.
Zastanawiasz się, jaka architektura będzie optymalna dla Twojego systemu? Porozmawiajmy - pomożemy wybrać rozwiązanie dopasowane do Twojej skali.