Bir ekip monolitini mikroservislere böldü: her servis ayrı bir depoda duruyor, ayrı bir pipeline'da derleniyor ve ayrı container'larda çalışıyor. Yeni bir özellik geldiğinde ise hep aynı şey yaşanıyor: sipariş, stok ve fatura servislerinde aynı anda değişiklik yapılıyor, ekipler ortak bir sürüm notu hazırlıyor ve servisler sırayla aynı gece canlıya çıkıyor. Distributed monolith, yani dağıtık monolit, bu duruma verilen addır. Servisler ayrı process'lerde çalışır, ama birlikte değişme ve birlikte deploy edilme zorunluluğu sürer.

James Lewis ve Martin Fowler'ın 2014'te yayımladığı tanıma göre mikroservis mimarisi, tek bir uygulamayı küçük servisler halinde geliştirme yaklaşımıdır ve bu servislerin her biri bağımsız olarak deploy edilebilir. Sam Newman da QCon London 2020'deki konuşmasında monoliti deploy birimiyle tanımlar ve ayrıştırmada hedefin bağımsız deploy edilebilirlik olduğunu vurgular. Newman'ın "monolitlerin en kötüsü" dediği distributed monolith bu ölçütün tersine işler: servis sayısı artarken deploy birimi fiilen tek kalır. Sistem ağ gecikmesi ve kısmi hatalar gibi dağıtık mimari maliyetlerini taşırken monolitin birlikte değişme zorunluluğundan da kurtulamaz.

Bu tuzak geçişin ilk kararlarında kurulabilir. Servis sınırları iş yetenekleri yerine teknik katmanlara ya da tablolara göre çizildiğinde, veritabanı servisler arasında paylaşıldığında ya da monolitteki metot çağrıları birebir ağ çağrısına çevrildiğinde, monolitteki bağımlılıklar çözülmeden ağın iki ucuna taşınır. Bu yüzden önce bağımlılığın nerede olduğu ölçülmelidir.

Distributed monolith'i ayırt eden ölçüt: bağımsız deploy

Bir sistemin kaç servisten oluştuğu ya da kaç depoda tutulduğu, onun distributed monolith olup olmadığını göstermez. Belirleyici soru şudur: bir servis, diğer servislerde değişiklik yapılmadan ve onların deploy takvimine bakılmadan canlıya çıkabiliyor mu? Değişikliklerin çoğu için cevap hayırsa sistem dağıtılmış, ama ayrıştırılmamış demektir.

Bu durum modüler monolitle karıştırılmamalıdır. Modüler monolit tek parça halinde deploy edilir ve bunu açıkça kabul eder; modüller arasındaki sınırlar kod düzeyinde korunur. Distributed monolith'te ise deploy birimleri ayrı olduğu halde ekipler onları tek birim gibi yönetmek zorunda kalır. Modüler monolitte modüller arası çağrılar aynı process içinde kalır; distributed monolith'te aynı bağımlılık ağ çağrısı olarak ödenir.

Belirtiler: servisler ne zaman birlikte hareket ediyor?

Servislerin sırayla ve aynı bakım penceresinde deploy edilmesi gerekiyorsa aralarında deploy düzeyinde bir bağımlılık vardır; bu duruma lockstep deployment denir. Sağlayıcı servis bir alanın adını değiştirdiğinde tüketici servis eski adı okumaya devam ediyorsa, iki servis birlikte çıkmadan sistem çalışmaz. Newman'ın verdiği işarete göre kurumda tam zamanlı bir "release coordination manager" rolü oluşmuşsa, servislerin bağımsız deploy edilemediği örgüt yapısına da yansımış olabilir.

İş birimlerinden gelen tek bir istek, örneğin yeni bir indirim türü, düzenli olarak üç dört servisin kodunda değişiklik gerektiriyorsa iş kuralı birden fazla servise dağılmış demektir. Ekipler arası koordinasyon artar, bir ekibin kendi başına bitirebildiği iş azalır.

Bir servisin anlamlı bir testi ancak diğer bütün servislerin ayakta olduğu ortak bir entegrasyon ortamında yapılabiliyorsa, bu durum servisler arasında sıkı bir çalışma anı bağına işaret eder. Ortak ortam zamanla darboğaza da dönüşebilir, çünkü bir ekibin kırdığı servis diğer ekiplerin testlerini durdurur. Bir servisin yavaşlamasının ilgisiz görünen kullanıcı işlemlerini durdurması da aynı bağın çalışma anındaki görüntüsüdür.

Distributed monolith'e götüren ölümcül hatalar

Veritabanını servisler arasında paylaşmak

Geçişte en kolay görünen yol, monolitin veritabanını olduğu gibi bırakıp yalnızca uygulama kodunu servislere bölmektir. microservices.io'daki Shared Database deseni sayfası bu yolun sakıncalarını açıkça sıralar. Aynı tablolara erişen servislerin geliştiricileri şema değişikliklerini birbirleriyle koordine etmek zorunda kalır, bir servisin uzun süren transaction'ı başka bir servisi bloklayabilir ve tek bir veritabanı her servisin depolama ihtiyacına uymayabilir. Bu yapıda servisler arasındaki asıl sözleşme tablo şemasıdır, oysa şema monolitin iç veri yapısı olarak tasarlanmıştır. Bir sütunun anlamını değiştiren ekip, o sütunu okuyan diğer servislerden habersiz olabilir.

Servisleri teknik katmanlara göre bölmek

Arayüz servisi, iş kuralı servisi ve veri erişim servisi gibi katman bazlı bir bölünme, monolitin katmanlı yapısını ağa taşır. Bir iş özelliği bu katmanların hepsinden geçtiği için, ona yapılan küçük bir değişiklik de birden fazla servise dokunabilir. Lewis ve Fowler'ın tarifinde servisler iş yeteneklerine göre düzenlenir; bir iş yeteneğinin arayüzden veriye kadar bütün parçaları aynı servisin, dolayısıyla aynı ekibin sorumluluğundadır.

Metot çağrılarını birebir ağ çağrısına çevirmek ve senkron zincir kurmak

Monolitte maliyeti önemsenmeyen bir metot çağrısı, servislere bölündüğünde ağ üzerinden giden bir isteğe dönüşür. Lewis ve Fowler, uzak çağrıların in-process çağrılardan daha pahalı olduğunu ve bu yüzden uzak API'lerin daha coarse-grained tasarlanması gerektiğini belirtir. Monolitin fine-grained arayüzü olduğu gibi korunursa chatty API'ler ortaya çıkar: tek bir ekranı doldurmak için bir servis diğerine çok sayıda küçük istek gönderir. İstekler sırayla yapıldığında gecikmeleri de toplanır.

Kullanıcı isteği A servisinden B'ye, B'den C'ye, C'den D'ye senkron olarak aktarılıyorsa, isteğin başarılı olması için zincirdeki bütün servislerin o anda ayakta ve yeterince hızlı olması gerekir. Zincirin erişilebilirliği de bu yüzden en zayıf halkasından yüksek olamaz. Zincirdeki bir servis arayüzünde geriye dönük uyumsuz bir değişiklik yaparsa onu çağıran servis de aynı anda güncellenmek zorunda kalır; zincir uzadıkça bu tür eşleşmeler çoğalır.

Domain modelini ortak bir kütüphaneye koymak

Kod tekrarını önlemek için Customer ve Order gibi domain sınıflarını ortak bir kütüphaneye taşımak ilk bakışta düzenli görünür. Kütüphanedeki bir alan değiştiğinde, bu sınıfı servisler arasında taşınan verilerde kullanan servislerin yeni sürüme birlikte geçmesi ve birlikte deploy edilmesi gerekebilir. İş kuralı içermeyen logging gibi teknik kütüphanelerde servisler sürüm yükseltmesini kendi takvimlerine göre yapabilir. Domain modeli ise her bağlamda başka anlam taşıyabildiğinden, tek bir ortak sınıfta toplanması servisler arasında gizli bir bağımlılık kurar.

Atomikliği two-phase commit ile korumaya çalışmak

Monolitte tek bir veritabanı transaction'ı içinde yapılan sipariş, stok ve ödeme güncellemeleri servislere bölündüğünde, ekipler aynı atomikliği two-phase commit gibi protokollerle korumaya yönelebilir. Lewis ve Fowler dağıtık transaction'ların uygulanmasının zor olduğunu belirtir ve mikroservislerde transaction'sız koordinasyonu, eventual consistency'yi ve telafi işlemlerini öne çıkarır. Dağıtık transaction, katılan servislerin işlem anında erişilebilir olmasını ve aynı protokolü desteklemesini gerektirdiği için onları çalışma anında birbirine kilitler. Saga deseni önceki yazılarımızın konusuydu; burada önemli olan, two-phase commit gibi bir protokolle sürekli atomik güncelleme gerektiren bir iş akışının yanlış çizilmiş bir sınıra işaret edebilmesidir.

Distributed monolith nasıl tespit edilir?

Belirtiler sezgiyle fark edilebilir, ama hangi servislerin birlikte hareket ettiğini göstermek için veriye bakmak gerekir. Bunun için sürüm kontrol geçmişine, deploy kayıtlarına, çağrı izlerine ve veritabanı erişim kayıtlarına bakılır.

Sürüm kontrol geçmişinde change coupling

Change coupling, iki ya da daha fazla modülün zaman içinde birlikte değişmesidir. CodeScene dokümantasyonu iki modülü üç durumda birlikte değişmiş sayar: aynı commit'te değiştirildiklerinde, aynı geliştirici ikisini tanımlı bir zaman aralığında değiştirdiğinde ya da commit mesajları aynı ticket numarasına atıf yaptığında. Servisler ayrı depolarda tutuluyorsa aynı commit ölçütü işe yaramaz; en doğrudan yöntem, commit mesajlarına ticket numarası yazılıyorsa, üçüncüsüdür. Her servis deposunun son birkaç aylık geçmişi çıkarılır, commit'ler ticket numarasına göre gruplanır ve her ticket için dokunulan servisler listelenir. Aynı servis çiftinin sürekli birlikte görünmesi, kodda görünmeyen bir bağa işaret edebilir.

CodeScene dokümantasyonuna göre change coupling kendi başına iyi ya da kötü değildir; hangi modüllerin, hangi nedenle birlikte değiştiğine bağlıdır. Dokümantasyon bu yüzden change coupling verisinin mimari ilkelerle karşılaştırılmasını önerir; ilkelerden sapmalar gerçek bir soruna işaret edebilir. Servisler açısından dikkat edilmesi gereken, birlikte değişmesi için mimari bir neden bulunmayan servislerin düzenli olarak birlikte değişmesidir.

Change coupling kodun birlikte değiştiğini gösterir, deployment coupling ise birlikte canlıya çıkma zorunluluğunu. CI/CD kayıtlarında aynı ticket için kısa aralıklarla deploy edilen servis grupları ve sürüm notlarındaki "önce X, sonra Y deploy edilmeli" türünden talimatlar bu bağın izleridir. Geri alma sırasında birden fazla servisin birlikte geri alınması gerekiyorsa ya da hangi servis sürümlerinin birbiriyle uyumlu olduğunu gösteren bir tablo tutuluyorsa, servisler sürüm düzeyinde birbirine bağlı demektir.

Çağrı grafiği ve veritabanı erişim haritası

Service mesh metrikleri hangi servisin hangisini çağırdığını gösterir; tek bir kullanıcı isteğinin hangi servislerden senkron olarak geçtiğini ise distributed tracing verisi gösterir. İkisi birlikte bir çağrı grafiği çıkarmaya yeter. Grafikte önce en uzun senkron zincire, sonra servisler arasındaki döngülere bakılır. A'nın B'yi, B'nin de A'yı çağırdığı bir döngü, sorumluluğun iki servise bölündüğüne işaret edebilir. Tek bir istek sırasında iki servis arasında yapılan çağrı sayısı da chatty API'leri ortaya çıkarır.

Paylaşılan veritabanı kullanılıyorsa her servisin hangi tablolara okuma ve yazma yaptığı da çıkarılmalıdır. Servisler veritabanına ayrı kullanıcılarla bağlanıyor ve audit kaydı açıksa bu bilgi audit kayıtlarından elde edilebilir. Birden fazla servisin yazdığı tablolarda veri sahipliği belirsizdir ve ayrıştırmada önce bu tablolar ele alınır.

Çıkış yolu: sınırları ve veri sahipliğini yeniden kurmak

Bounded context'e göre sınır çizmek

Domain-Driven Design büyük bir domain modelini, her birinin kendi tutarlı modeli olan bounded context'lere böler. Fowler'ın aktardığına göre Eric Evans, büyük bir sistemde domain modelini tümüyle birleştirmenin ne uygulanabilir ne de maliyetine değer olduğunu söyler. Fowler ayrıca "Customer" ve "Product" gibi çok anlamlı kavramların tek bir anlama zorlandığında karışıklık yarattığını not eder. Satış bağlamındaki müşteriyle destek bağlamındaki müşterinin alanları ve kuralları aynı olmayabilir; ikisini tek modelde birleştirme çabası, ortak domain kütüphanesinin ürettiği bağımlılığın kaynaklarından biridir.

Servis sınırları bu bağlamlara göre yeniden çizilirken tespit verisi doğrudan kullanılır. Sürekli birlikte değişen ve birbirini döngüsel olarak çağıran iki servis aynı bağlamın parçaları olabilir. Bunları birleştirmek de distributed monolith'ten çıkış yollarından biridir. Servis sayısını azaltmak geri adım gibi görünebilir, ama birlikte değişen kodu tek bir deploy biriminde toplamak ağ maliyetini de koordinasyon yükünü de azaltır.

Veri sahipliği

Her verinin tek bir sahibi olmalı, diğer servisler o veriye yalnızca sahibinin API'si ya da yayınladığı olaylar üzerinden erişmelidir. microservices.io'daki Database per Service deseni ilişkisel veritabanları için üç seçenek tanımlar: yalnızca bir servisin eriştiği tablolar, servise özel şema ya da servise özel veritabanı sunucusu. İlk iki seçenekle, ayrı sunuculara geçmeden de sahiplik netleştirilebilir. Belirleyici adım, başka bir servisin tabloya doğrudan erişiminin kapatılmasıdır.

Ayrıştırmanın bedeli de aynı sayfada yazılıdır: birden fazla servisi kapsayan iş transaction'ları ve birden fazla veritabanındaki verileri birleştiren sorgular zorlaşır. Sayfa bunlar için Saga, API Composition ve CQRS desenlerini önerir.

Senkron çağrıyı olaya çevirmek: ne zaman?

Çağıran servisin işini bitirmek için karşı servisin yanıtı o anda gerekmiyorsa, senkron çağrı yerine bir olay yayınlamak servisleri çalışma anında birbirinden ayırır. Sipariş onaylandıktan sonra kesilen fatura bu türdendir: sipariş servisi bir OrderConfirmed olayı yayınlar, fatura ve bildirim servisleri olayı kendi hızlarında işler; fatura servisi kısa süreliğine durduğunda da sipariş almaya devam edilebilir.

Kullanıcıya anında kesin yanıt verilmesi gereken durumlarda, örneğin stok rezervasyonunda ya da ödeme onayında, senkron çağrı uygun kalabilir. Asenkron iletişimin de bedeli var. Fowler'ın Microservice Premium yazısında mikroservislerin ek maliyetleri arasında saydığı eventual consistency, kullanıcı arayüzünde ve raporlamada açıkça ele alınmalıdır. Olayın veritabanı değişikliğiyle aynı transaction'da kaydedilip kaybolmadan yayınlanmasını sağlayan Transactional Outbox desenini Event-Driven Mimarilerde Veri Tutarlılığı yazımızda anlattık.

Sözleşme ve sürüm disiplini

Lockstep deployment'ı doğrudan doğuran nedenlerden biri geriye dönük uyumsuz arayüz değişiklikleridir. Servisler arasındaki API ve olay sözleşmeleri geriye dönük uyumlu eklemelerle geliştirilirse ve uyumu bozan bir değişiklik kaçınılmaz olduğunda eski ve yeni sürüm bir süre birlikte sunulursa, sağlayıcı ve tüketici kendi takvimlerine göre deploy edilebilir. Ortak domain kütüphanesinin yerini de bu sözleşmeler alır: her servis kendi modelini tutar, servisler arasında yalnızca sözleşmede tanımlı veri taşınır. Sözleşmenin koddan önce tasarlanması ve consumer-driven contract testleriyle doğrulanması API-First yazımızın konusu.

Modüler monolit: ara adım ve bazen doğru varış noktası

Fowler, 2015 tarihli Microservice Premium yazısında mikroservislerin otomatik deployment, izleme, hatalarla başa çıkma ve eventual consistency gibi ek işler getirdiğini yazar. Kararı sistemin karmaşıklığına bağlar ve monolit olarak yönetilemeyecek kadar karmaşık olmayan bir sistem için mikroservislerin düşünülmemesini önerir. Aynı yıl yayımlanan Monolith First yazısında ise deneyimli mimarların tanıdık domain'lerde bile sınırları baştan doğru çizmekte zorlandığını, servisler arasında işlev taşımanın monolit içinde taşımaktan çok daha zor olduğunu belirtir. Fowler kanıtların henüz az olduğunu ve bu konudaki her tavsiyenin, ne kadar emin dile getirilirse getirilsin, temkinli okunması gerektiğini de ekler.

İki yazı birlikte okunduğunda modüler monolitin rolü netleşir: sınırlar henüz belirsizken onlar tek bir deploy birimi içinde, ağ maliyeti ödenmeden denenebilir. Newman da aynı QCon konuşmasında mikroservislerin çoğu girişim için iyi bir seçim olmadığını söyledi ve bunun yerine birden fazla veritabanı kullanan bir modüler monolit önerdi.

Modüler monoliti sıradan bir monolitten ayıran şey, sınırların gerçekten korunmasıdır. Her modül yalnızca tanımlı bir public arayüzü dışa açar, modüller birbirinin tablolarına doğrudan erişmez ve CI hattında çalışan mimari testler, örneğin paketler arası bağımlılık kontrolleri, bu kuralları denetler. Fowler'ın uyardığı gibi modüller arası bağımlılıkları denetimsiz biriken bir monolit, sonradan servislere kolayca ayrılamayabilir.

Fowler'ın Microservice Prerequisites yazısı ise mikroservis tarzına geçmeden önce gereken temel yetenekleri hızlı sunucu hazırlama, temel izleme ve hızlı uygulama deployment'ı olarak sıralar. Bizim önerimiz şudur: bir modül farklı bir ölçekleme ihtiyacı ya da ayrı bir sürüm temposu gerektiriyorsa ve bu yetenekler hazırsa, onu servis olarak ayırmak anlam kazanır.

Varsayımsal bir senaryo: tek veritabanını paylaşan üç servis

Aşağıdaki örnek gerçek bir şirkete ait değil; yöntemi göstermek için kurgulandı. Bir e-ticaret şirketi monolitini sipariş, stok ve fatura servislerine ayırdı, veritabanını ise ortak bıraktı. Sipariş servisi orders tablosuna yazıyor, fatura servisi aynı tabloyu okuyup fatura durumunu güncelliyor. Sipariş servisi sipariş oluşurken her sipariş satırı için stok servisini ayrı ayrı ve senkron olarak çağırıyor. Order sınıfı ortak bir kütüphanede duruyor.

Ekip son altı ayın commit geçmişini ticket numarasına göre grupladığında, sipariş servisine dokunan ticket'ların çoğunun fatura servisinde de değişiklik gerektirdiğini görüyor. Deploy kayıtları bu iki servisin çoğunlukla aynı gün ve hep aynı sırayla çıktığını gösteriyor. Ekip bu bulgulara göre şu adımları planlıyor:

  1. Fatura servisi kendi invoices tablosunu sahipleniyor, siparişin faturalanmaya hazır olduğunu OrderConfirmed olayından öğreniyor ve orders tablosuna erişimi kapatılıyor.
  2. Ortak kütüphanedeki Order sınıfı kaldırılıyor; her servis olay sözleşmesindeki alanları kendi modeline çeviriyor.
  3. Stok rezervasyonu siparişin onayı için anında yanıt gerektirdiğinden senkron kalıyor, ama satır başına yapılan çağrılar tek ve coarse-grained bir rezervasyon isteğine indiriliyor.
  4. Değişikliklerden sonra aynı ölçümler tekrarlanıyor: hem sipariş hem fatura servisine dokunan ticket'ların payı ve iki servisin birlikte deploy edildiği gün sayısı izleniyor.

Olay sözleşmesine rağmen iki servis birlikte değişmeye devam ederse, sipariş ve faturayı tek serviste birleştirmek de masadaki seçeneklerden biri olur.

Ayrıştırmayı adım adım yürütmek

Tek seferlik bir yeniden yazım, aynı sınır hatalarını yeni kodda tekrarlama riski taşır. Newman ayrıştırmanın kademeli yapılmasını ve işe "mevcut mimari neyi yapmanıza engel oluyor?" sorusuyla başlanmasını önerir. Bir başlangıç noktası, tespit verisinde en güçlü bağı gösteren servis çiftidir; o çift için veri sahipliği ve sözleşme netleştirilir, ardından ölçüm yeniden alınır. Yeni sınırları eski sistemin yanında kademeli olarak devreye almak için Strangler Fig deseni kullanılabilir.

İlerlemeyi gösteren ölçütler tespitte kullanılanlarla aynıdır: birden fazla servise dokunan değişikliklerin payı, birlikte yapılması gereken deploy sayısı ve senkron çağrı zincirlerinin derinliği. Bu değerler düştükçe ekiplerin sürüm koordinasyonuna ayırdığı zamanın da azalması beklenir.

Kaynaklar: Microservices, James Lewis ve Martin Fowler (https://martinfowler.com/articles/microservices.html), Microservice Premium (https://martinfowler.com/bliki/MicroservicePremium.html), Monolith First (https://martinfowler.com/bliki/MonolithFirst.html), Microservice Prerequisites (https://martinfowler.com/bliki/MicroservicePrerequisites.html), Bounded Context (https://martinfowler.com/bliki/BoundedContext.html), Shared Database (https://microservices.io/patterns/data/shared-database.html), Database per Service (https://microservices.io/patterns/data/database-per-service.html), InfoQ, Sam Newman QCon London 2020 (https://www.infoq.com/news/2020/05/monolith-decomposition-newman/), CodeScene Change Coupling (https://docs.enterprise.codescene.io/versions/6.0.0/guides/technical/change-coupling.html)