Geriye Bakış: Distributed Monolith Belirtileri, Maliyet ve Neyi Tekrar Birleştirirdik?
Bookstore'un microservice sürümü distributed monolith'e dönmedi: paylaşılan kod 68 satır contract'ta kaldı ve checkout'ta tek senkron adım var; ama merkezi paket dosyası her değişiklikte bütün pipeline'ları tetikliyor. Monolith'e göre uygulama belleği 7,4 kat, sipariş isteğinin p50'si 5,8 ms'den 22,7 ms'ye çıktı; Notifications'ı BFF'ye birleştirir, Payments'ı ayrı tutardık.
Sürüm notu: Bu yazıdaki ölçümler .NET 10, Aspire 13.6 ve bu serinin 14. yazısının sonundaki kod ile yapılmıştır; karşılaştırma için sitenin referans Bookstore monolith'i (tek Bookstore.Api) aynı SQL Server container'ında çalıştırıldı.
Önce Tahmin Et
On dört yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'u (bir online kitapçı) altı service'e, bir BFF'ye (Backend for Frontend; Angular'a özel ara backend), dört veritabanına ve bir message broker'a böldük. Aynı katalog, sepet ve sipariş işini yapan monolith'e göre uygulama process'leri kaç kat bellek kullanıyor? Sipariş isteğinin süresi ne kadar uzadı? Ve en önemlisi: bu sistem gerçekten bağımsız deploy edilebilen service'lerden mi oluşuyor, yoksa Microservices serisinin son yazısında anlattığımız distributed monolith'e mi dönüştü? Bu yazıdaki her sayı gerçek bir ölçümden; tahmin olanlar tahmin diye işaretli.
Distributed Monolith Denetimi
Microservices serisinin "Monolith'ten Microservice'e" yazısında distributed monolith'in belirtilerini saydık: service'ler birlikte deploy ediliyor, paylaşılan kod büyüyor, istekler senkron zincirlerle service'ten service'e geçiyor, veritabanı paylaşılıyor. Kurduğumuz sistemi bu listeye karşı, tahmin değil, repository'nin geçmişi ve 12'deki trace'lerle denetledik.
Birlikte değişim. Repository'deki 16 commit'in her birinin hangi service klasörlerine dokunduğunu git diff-tree ile saydık. Service'lerin işlevini kuran yazılar doğal olarak birkaçına dokunuyor, ama asıl ilginç olan contract değişiklikleri: 06 (integration event'leri), 07 (saga) ve 10 (event'lere CustomerId) commit'lerinin her biri üç ile beş service'i birlikte değiştirdi. Bir event'e alan eklemek kırıcı değildi (06'daki kural), ama alanı dolduran üretici ile onu kullanan tüketiciler aynı özellik için birlikte değişti. Bunun deploy'da kilitlenmeye dönüşmemesinin sebebi sıra: önce üretici alanı ekler, tüketiciler sonra kullanır. 13'teki şema testi bu sırayı korumanın aracı.
Beklemediğimiz bir bulgu: Directory.Packages.props 16 commit'in 11'inde değişti. Central Package Management bütün paket sürümlerini tek dosyada topluyor ve 14'teki her service workflow'u bu dosyayı paths filtresinde izliyor; yani bir service'e paket eklemek bütün service'lerin pipeline'ını çalıştırıyor. Kod paylaşmıyoruz, ama build girdisi paylaşıyoruz. Bu bir hata değil, bir tercih: aynı sürümler bütün service'lerde, bir güvenlik yaması tek satır. Bedeli her paket değişikliğinde altı pipeline.
Paylaşılan kod. 01'deki kural paylaşılan kodu iki projeyle sınırlıyordu ve architecture test'i bunu her build'de denetledi. Ölçüm kuralın tuttuğunu gösteriyor:
| Yazı | Bookstore.Contracts (satır) | ServiceDefaults (satır) | Merkezi paket sürümü |
|---|---|---|---|
| 03 | 9 | 120 | 21 |
| 07 | 67 | 120 | 33 |
| 10 | 68 | 120 | 38 |
| 14 | 68 | 123 | 51 |
Contracts yalnız record'lardan oluşuyor ve 07'den sonra neredeyse hiç büyümedi; ServiceDefaults'a yalnız 12'deki trace ve metric satırları ve 14'teki health endpoint değişikliği girdi. "Ortak business library" yok.
Senkron zincirler. 12'deki checkout trace'inde kullanıcının beklediği tek senkron adım BFF'den Ordering'e; stok, ödeme ve bildirim mesajlarla ilerliyor. 05'teki Ordering'den Inventory'ye gRPC çağrısı 07'de kalktı. Kalan senkron çağrılar ekran besliyor: kitap sayfası Catalog ve Inventory'yi paralel çağırıyor ve Inventory cevap vermezse yine geliyor (09). Payments'ın ödeme sağlayıcısına çağrısı senkron, ama dışarıya ve bilinmeyen sonuç için tasarlandı (08). Veritabanı paylaşımı yok: lokalde tek SQL Server container'ında dört ayrı veritabanı var, hiçbir service başkasının veritabanının connection string'ini bilmiyor.
Maliyet: Lokal Kaynaklar
Bütün sistem aspire run ile açıkken, bir checkout'tan sonra ölçtük (Docker container'ları için docker stats, .NET process'leri için resident memory):
| Ne | Bellek |
|---|---|
| SQL Server (4 veritabanı) | 857 MB |
| Keycloak | 466 MB |
| RabbitMQ | 122 MB |
| Mailpit, Redis | 28 MB, 5 MB |
| Ordering, Payments, Inventory | 242, 227, 204 MB |
| Notifications, Catalog, BFF, sahte ödeme sağlayıcısı | 151, 114, 79, 64 MB |
| AppHost, DCP (Aspire'ın resource'ları çalıştıran orchestrator'ı) ve dashboard | 721 MB |
| Toplam | yaklaşık 3,3 GB |
Sistemin açılması, container'lar persistent olduğunda 53 saniye sürdü (13'teki uçtan uca testte container'lar sıfırdan kurulduğu için bir dakika). Bu sayıların etkisini seri boyunca gördük: Docker'ın VM'i başka container'larla paylaşıldığında SQL Server bellek yetmezliğiyle siparişleri düşürdü (11) ve kind'deki SQL Server bellek yetmezliğinden (OOMKilled) üç kez yeniden başladı (14). Bir geliştiricinin laptop'unda bu sistemi açık tutmak, 16 GB'lık bir makinenin önemli bir kısmı demek.
Maliyet: Gecikme ve Kod
Karşılaştırma için sitenin referans Bookstore monolith'ini (tek bir Bookstore.Api; Catalog, Inventory ve Ordering module'leri) aynı SQL Server'da kendi veritabanıyla çalıştırdık ve iki tarafta da 20 sipariş verdik (ilk üç warm-up isteği hariç; mikroservis tarafındaki ölçüm aracı repository'de tools/measure/checkout-latency.py, commit analizi tools/measure/commit-areas.sh):
monolith POST /api/orders: n=20 p50=5.8 ms p95=15.1 ms status={'Placed'}
monolith RSS 146 MB
microservices POST /api/orders: n=20 p50=22.7 ms p95=431.0 ms
outcomes: {'Confirmed': 20}
microservices POST to Confirmed: p50=1212 ms p95=2139 ms
Bu karşılaştırma birebir aynı işin karşılaştırması değil ve bunu açıkça söylemek gerekiyor. Monolith'in siparişi senkron ve tek transaction'da stok ayırıp Placed oluyor; ödeme, kimlik doğrulama, e-posta ve canlı bildirim yok. Mikroservis tarafında BFF, cookie oturumu, token doğrulaması ve outbox var ve onay, sahte sağlayıcının bilerek eklediği 1 saniyelik işlem süresini içeriyor; onu çıkarınca kendi zincirimiz yaklaşık 200 ms; 07'de sistem boştayken 0,13–0,15 saniye ölçmüştük. Yine de sayılar yön veriyor: kullanıcının beklediği istek yaklaşık 4 kat yavaşladı ve kuyruğu çok uzadı (p95'te 15 ms'ye karşı 431 ms), uygulama process'lerinin belleği 146 MB'tan yaklaşık 1,1 GB'a, yani 7,4 katına çıktı.
Kod tarafı: monolith'in src'si migration'lar hariç 813 satır C# ve tek proje. Mikroservis sürümü 62 dosyada 3.767 satır C#, 11 proje; buna AppHost, Kubernetes manifest'leri ve workflow'lar için 610 satır ve 1.179 satır test ekleniyor. Fazladan kodun büyük kısmı işlev değil, mesafe: outbox, saga, contract'lar, token taşıma, retry kuralları, migrator'lar.
CI Süresi ve Operasyon Yükü
Bütün solution temiz bir Release build'de 11 saniye (paketler cache'teyken), 56 test 82,7 saniye; testlerin süresini neredeyse tamamen uçtan uca test belirliyor (13). Image build'i service başına 8–10 saniye. Bunlar küçük sayılar; asıl yük işletilmesi gereken parçaların sayısında:
- Deploy edilen 6 service, 4 migration Job'ı ve 6 image; service başına bir workflow ve ortak bir reusable workflow.
- 4 veritabanı, 4 exchange, 2 command queue, 3 consumer queue ve 4 dead letter queue.
- Bir kimlik sağlayıcısı, iki client ve onların secret'ları; bir webhook secret'ı; RabbitMQ, SQL Server ve Redis şifreleri.
- İzlenmesi gereken yeni hata türleri: takılı saga'lar (07), sonucu bilinmeyen ödemeler (08), kopan WebSocket'ler (11), webhook'u gelmeyen ödemeler (12).
Bulut faturası için bir tahmin, çalıştırılmadı ve fiyat içermiyor; fiyat bölgeye ve sağlayıcıya göre değişiyor. 14'teki manifest'lere göre service başına 100m CPU ve 192 MiB request ve iki replica, altı service için toplam 1,2 vCPU ve yaklaşık 2,3 GiB request demek; buna kimlik sağlayıcısı ve gözlem altyapısı ekleniyor. Yanına managed SQL Server (dört veritabanı tek sunucuda ya da bir elastic pool'da), managed bir RabbitMQ, managed Redis ve trace, log, metric saklama geliyor. Monolith için aynı tablo: bir uygulama, bir veritabanı. Faturanın büyük kısmını uygulama pod'ları değil, managed bileşenlerin sayısı ve gözlem verisinin hacmi belirliyor. Hesaplamanın kendisi Cloud Temelleri serisinde.
Neyi Tekrar Birleştirirdik?
Notifications'ı BFF'ye. Notifications'ın iki işi var: tarayıcıya push ve e-posta. Push için tarayıcı zaten BFF'ye bağlı ve BFF hub trafiğini YARP (Microsoft'un .NET için reverse proxy library'si) ile Notifications'a taşıyor (11); bu bir ağ adımı, bir deployable, bir pipeline ve WebSocket'in yolunda bir proxy daha. Hub BFF'de olsaydı BFF RabbitMQ'ya bağlanmak zorunda kalırdı; bunu kabul ederdik. E-posta ise ayrı bir worker olarak kalabilir ya da bir e-posta sağlayıcısının queue'su olur. Bugün yeniden başlasak Notifications'ı ayrı bir service olarak açmazdık.
Inventory'nin gRPC'sini yeniden düşünürdük. 05'te gRPC'yi checkout'un sync çağrısı için kurduk, 07'de checkout mesajlara geçti; bugün gRPC yalnız kitap sayfasının stok sorgusu için duruyor. Bunun için HTTP/2'ye özel bir port, proto paylaşımı, TLS'siz (Transport Layer Security; trafiği şifreleyen protokol) call credentials ayarı ve 12'de gördüğümüz token gecikmesi var. Kitap sayfasında stoğu gösteren tek bir HTTP endpoint'i ya da Catalog'a event'le beslenen bir stok özeti daha basit olurdu.
Doğru çıkan sınırlar: Payments ayrı kalmalı. Dış sağlayıcıyla konuşan, kendi secret'ı, webhook'u ve reconciliation'ı olan ve PCI (Payment Card Industry; kart verisi güvenlik standartları) kapsamını küçük tutması gereken bir parça; onu ayırmanın bedelini 08'de ödedik ve karşılığını aldık. Ordering ile saga'nın aynı yerde olması da doğru: akışın sahibi siparişin sahibi. Catalog okuma ağırlıklı ve bağımsız ölçeklenebilmesi anlamlı. Shipping'i 01'de bilerek module olarak bıraktık ve bu seride bir kez bile ayırma ihtiyacı duymadık; "her module service değildir" kararı tuttu.
Modular Monolith serisinin "Module'ü Service'e Çıkarmak" yazısındaki sıra burada da geçerli: bir module'ü ayırmak için önce o module'ün ayrı ölçeklenmesi, ayrı ekibi ya da ayrı güvenlik sınırı gibi somut bir sebep aranır. Bookstore'da bu sebebi en net Payments'ta bulduk.
Production'a Çıkmadan Önce: Eksikler
Seri boyunca bilerek ya da zaman yetmediği için bıraktıklarımız, Bookstore'u gerçek müşteriye açmadan önce kapanması gerekenler:
- Token yenileme ve sunucu tarafında oturum: access token 10 dakika sonra 401 veriyor (11'de ölçtük), cookie üç parça (10).
- Ordering'in POST'una Idempotency-Key: ağ hatasında tekrar edilen istek ikinci sipariş açıyor (04, 11).
- Wolverine'in tablolarını migration Job'ında oluşturmak; açılışta şema oluşturmak 07 ve 14'te sorun çıkardı.
- Yedekleme ve geri dönüş: dört veritabanının yedeği, geri yükleme provası ve bir RPO (Recovery Point Objective; göze alınan en fazla veri kaybı) ve RTO (Recovery Time Objective; göze alınan en uzun kesinti) hedefi.
- SLO'lar (Service Level Objective; servisin hedeflenen güvenilirlik seviyesi) ve alarmlar: checkout süresi (12'deki histogram), takılı saga'lar, DLQ (Dead Letter Queue; işlenemeyen mesajların ayrıldığı queue) derinliği, sonucu bilinmeyen ödemeler.
- Runbook'lar: DLQ'daki bir mesajı yeniden işlemek, takılı bir saga'yı kapatmak, webhook secret'ını döndürmek.
- NetworkPolicy, service başına RabbitMQ kullanıcısı, image imzası ve secret store (10, 14).
Alarm ve runbook'ların nasıl yazılacağı SRE (Site Reliability Engineering; sistemlerin güvenilirliğini mühendislik işi olarak ele alan yaklaşım) ve Production Incident'ları serisinde.
Üretimde Dikkat
Ölç, sonra karar ver. Bu yazıdaki sayılar bir laptop'tan; production'da gecikme dağılımı, bellek ve fatura farklı olacak. Ama yöntem aynı: distributed monolith belirtilerini repository'nin geçmişinden ve trace'lerden ölçmek, görüşten daha güvenilir.
Birleştirmek de bir migration. Notifications'ı BFF'ye almak, iki deploy arasında hem eski hem yeni yolun çalışmasını gerektirir: önce BFF hub'ı açar ve olayları dinler, Angular yeni adrese geçer, sonra Notifications kapanır. Strangler'ın ters yönü.
Sık Düşülen Tuzaklar
Microservice sayısını başarı ölçüsü saymak. Altı service'in değeri, altı ekibin birbirini beklemeden deploy edebilmesinde; Bookstore'un ekibi üç geliştirici.
Paylaşılan build girdilerini unutmak. Paylaşılan kod olmasa bile tek bir paket dosyası, tek bir pipeline şablonu ya da tek bir base image bütün service'leri birlikte hareket ettirebilir.
Karşılaştırmayı adil olmayan sayılarla yapmak. Monolith'te olmayan işlevleri (ödeme, kimlik, bildirim) sayılara katmadan "7 kat bellek" demek eksik; hangi farkın dağıtıktan, hangisinin yeni işlevden geldiğini ayırmak gerekiyor.
Geri dönüşü kapatmak. Bir service'i ayırmak kolay, birleştirmek zor; contract'ları ve veritabanlarını ayırırken bir gün birleştirme ihtimalini hesaba kat.
Küçük Bir Deneme
★☆☆ Kendi repository'nde son 50 commit'in kaç service klasörüne dokunduğunu say (git diff-tree --no-commit-id --name-only -r). Üçten fazla service'e dokunan commit'lerin ortak noktası ne?
★★☆ Notifications'ın hub'ını BFF'ye taşı: BFF RabbitMQ'dan OrderConfirmed ve OrderCancelled'ı dinlesin, YARP'taki /hubs route'u kalksın. 11'deki tarayıcı denemesini tekrarla; push'un gecikmesi ve sistemin belleği nasıl değişti?
★★★ Bookstore'un monolith sürümüne bu serinin ödeme akışını module olarak ekle: Payments module'ü, aynı sahte sağlayıcı, aynı webhook ve aynı reconciliation, ama outbox ve saga yerine tek bir transaction ve bir background job. Kod satırı, bellek ve sipariş gecikmesini bu yazıdaki ölçümle karşılaştır.
Bu seri burada bitiyor. Bookstore'u önce sınırlarını çizerek böldük, sonra service'leri, mesajları, saga'yı, ödeme akışını, gateway'i, kimliği, arayüzü, trace'leri, testleri ve deploy'u kurduk ve en sonunda bedelini ölçtük. Kendi projende benzer bir yola çıkmadan önce Microservices serisinin "Microservice'lerin Bedeli" yazısındaki karar listesine dön: o listedeki sebeplerden biri senin için bu yazıdaki bedelden büyük değilse, iyi ayrılmış module'leri olan bir monolith çoğu zaman daha iyi bir başlangıç.