Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Microservices — #09

Service Discovery: Client-Side, Server-Side, Kubernetes DNS ve .NET Service Discovery

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
Service Discovery: Client-Side, Server-Side, Kubernetes DNS ve .NET Service Discovery

Service discovery, client'ın "Inventory" gibi mantıksal bir adı o an sağlıklı çalışan instance adreslerine çevirmesidir; Kubernetes bunu Service ve DNS ile server-side yapar, .NET'te Microsoft.Extensions.ServiceDiscovery aynı kodla geliştirme ortamında configuration'dan, cluster'da platformun DNS'inden adres bulur. Round robin'in gerçek çıktısı, listede kalan ölü bir instance'ın isteklerin üçte birini düşürmesi ve service mesh'in ne zaman gerekmediği.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; service discovery örneği Microsoft.Extensions.ServiceDiscovery 10.11.0 ile koşturuldu.

Önce Tahmin Et

Bir önceki yazıdaki Web BFF (Backend for Frontend), Inventory'ye http://localhost:5302 gibi sabit bir adresle gidiyordu. Production'da bu adres http://10.0.4.17:8080 oldu ve BFF'in configuration dosyasına yazıldı. Okul açılışı kampanyası başlıyor ve Inventory üç instance'a çıkarılıyor. Bir instance gece bir bellek sorunuyla yeniden başlatılıyor ve yeni bir IP (Internet Protocol) adresi alıyor. Ertesi gün biri, üç adresi de BFF'in configuration'ına elle yazıyor; bu kez instance'lardan biri çöküyor ama listede kalıyor.

Üç soru: Ölçeklendirmeden sonra BFF kaç Inventory instance'ına istek gönderir? Yeniden başlayan instance'a ne olur? Ve üç adresli listede bir instance çökükken, BFF'in Inventory'ye attığı isteklerin yaklaşık ne kadarı hata alır?

Neden Sabit URL Yetmez

Monolith'te bir module'ün başka bir module'ü çağırması bir method çağrısıydı; adres sorunu yoktu. Microservice'lerde her çağrı bir network adresine gider ve bu adres üç sebeple sabit kalmaz. Birincisi ölçekleme: aynı service'in instance sayısı yüke göre artıp azalır, her instance'ın kendi adresi vardır. İkincisi yaşam döngüsü: container'lar ve Kubernetes pod'ları ölür, yeniden doğar, başka bir makinede ve başka bir IP ile ayağa kalkar; bir deployment sırasında eski ve yeni instance'lar bir süre yan yana yaşar. Üçüncüsü ortamlar: geliştiricinin makinesinde localhost:5302 olan şey test ortamında başka, production'da başka bir adrestir.

Service discovery bu üç sorunun cevabı: client, "Inventory" gibi mantıksal bir adı kullanır, bir mekanizma da bu adı o an sağlıklı çalışan fiziksel adreslere çevirir. Chris Richardson'ın microservices.io'daki pattern dili bu mekanizmayı iki soruya ayırıyor: instance'lar adreslerini nereye ve nasıl kaydeder (registration), client bu adresleri nasıl bulur (discovery).

Service Registry ve Registration

Service registry, her service'in o an çalışan instance'larının adreslerini tutan bir veritabanı. Registry'nin kendisi kritik bir component: çökerse yeni bağlantılar kurulamaz, bu yüzden yüksek availability ile çalışmak zorunda. Consul, etcd ve Netflix'in Eureka'sı bu rolün bilinen örnekleri; Kubernetes'te bu iş platformun içinde, API (Application Programming Interface) server ve etcd'de.

Bir instance'ın registry'ye girmesinin iki yolu var. Self registration'da instance açılırken kendini kaydeder, belirli aralıklarla "hâlâ yaşıyorum" (heartbeat) der ve kapanırken kaydını siler. Basit ama her service'in, her dilde, registry'nin client library'sini taşıması gerekir. Third-party registration'da kaydı instance değil bir registrar yapar: deployment platformu instance'ı başlattığını bildiği için onu kaydeder, health check'ine bakarak kaydını siler. Service'in kodu registry'den habersizdir. Richardson'ın bu pattern için verdiği örneklerden biri doğrudan Kubernetes; bugün .NET ekiplerinin çoğu discovery'yi bu yoldan alıyor.

Client-Side ve Server-Side Discovery

Adres registry'de durduktan sonra client'ın onu nasıl kullandığına göre iki model var:

ModelNasıl çalışırLoad balancing neredeBedeli
Client-side discoveryClient registry'yi sorgular, adres listesinden birini kendisi seçerClient'ın içindeHer client'ta discovery library'si; dil başına ayrı implementation
Server-side discoveryClient sabit bir adrese (load balancer, router) gider, o registry'ye bakıp isteği iletirAradaki component'teFazladan bir network hop ve yönetilecek, yüksek availability'li bir component

Client-side discovery'nin kazancı kontrol: client hangi instance'a gideceğine kendisi karar verir, aradaki hop'u atlar, zone'a göre yakın instance'ı seçebilir. Server-side discovery'nin kazancı sadelik: client'ın tek bildiği bir isim ve bir adres, dili ne olursa olsun. Pratikte iki model sık sık iç içe geçiyor; aşağıda .NET'in client-side discovery library'sinin Kubernetes'te nasıl server-side discovery'ye devrettiğini göreceğiz.

Kubernetes'te Discovery: Service ve DNS

Kubernetes'te bir Deployment'ın pod'larının önüne bir Service object'i koyarsın ve bu Service kalıcı bir sanal IP (cluster IP) ile kalıcı bir DNS (Domain Name System) adı alır: inventory.bookstore.svc.cluster.local (service adı, namespace, svc ve cluster domain'i). Aynı namespace'teki bir pod için kısaca inventory yeterli. Pod'lar ölüp yeniden doğdukça Kubernetes Service'in arkasındaki endpoint listesini kendisi günceller; readiness probe'u başarısız olan pod listeden çıkar. Client'ın gördüğü tek şey sabit bir isim; isteği sağlıklı bir pod'a dağıtma işi node'lardaki kube-proxy'nin (ya da kullanılan network eklentisinin) kuralları. Yani Kubernetes, kutudan çıktığı hâliyle third-party registration ve server-side discovery yapıyor.

İki ayrıntı daha işe yarıyor. Headless Service (clusterIP: None) sanal IP vermez; DNS sorgusu doğrudan pod'ların IP'lerini döndürür ve seçimi client'a bırakır, yani client-side discovery'ye kapı açar. İsimlendirilmiş port'lar için de SRV (service record) kaydı oluşur: _http._tcp.inventory.bookstore.svc.cluster.local hem adresi hem port'u verir. ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Health Check: Liveness, Readiness ve Veritabanı Kontrolü" yazısında yazdığımız readiness endpoint'i, Kubernetes'in bir pod'u Service'in arkasına alıp almayacağına karar verirken baktığı yer; discovery ile health check'in birbirine bağlandığı nokta burası. Kubernetes'in kendisi ilerideki DevOps kategorisindeki Kubernetes serisinin konusu.

.NET'te Service Discovery: Microsoft.Extensions.ServiceDiscovery

.NET'in kendi çözümü, Aspire ile birlikte gelen ama Aspire'a bağımlı olmayan Microsoft.Extensions.ServiceDiscovery paketi. HttpClient'a bir message handler ekliyor: base address'teki host adını bir service adı olarak yorumluyor, endpoint provider'lardan adres listesini alıyor ve her istekte listeden birini seçiyor. Varsayılan provider'lar iki tane: configuration'dan okuyan provider ve aşağıda göreceğimiz pass-through provider. Web BFF'in Inventory client'ı:

HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Configuration.AddJsonFile(file);
builder.Services.AddServiceDiscovery();
builder.Services.AddHttpClient("inventory", c => c.BaseAddress = new Uri("https+http://inventory"))
    .AddServiceDiscovery();
{
  "Services": {
    "inventory": {
      "http": [ "http://localhost:5402", "http://localhost:5403" ]
    }
  }
}

https+http://inventory bir öncelik listesi: önce inventory service'inin HTTPS (HTTP Secure) endpoint'lerini ara, hiç yoksa HTTP'ye (Hypertext Transfer Protocol) düş. Microsoft'un dokümantasyonu bunun gerekçesini geliştirme ortamında HTTP, production'da HTTPS kullanmanın yaygınlığı olarak veriyor; HTTPS endpoint'i bulunursa HTTP endpoint'leri hiç kullanılmıyor. Production'da yalnız HTTPS'e izin vermek istiyorsan ServiceDiscoveryOptions.AllowAllSchemes = false ve AllowedSchemes = ["https"] ile scheme'leri kısıtlayabilirsin. Configuration'da Services:inventory:http bölümündeki her satır bir instance. İki Inventory instance'ı kendi port'unu döndüren birer stub; altı istek:

services.two.json
  call 1: served by 5403
  call 2: served by 5402
  call 3: served by 5403
  call 4: served by 5402
  call 5: served by 5403
  call 6: served by 5402

İstekler iki instance'a sırayla dağıldı; paketin varsayılan endpoint selector'ı RoundRobinServiceEndpointSelector. Bu, client-side discovery ve client-side load balancing'in ta kendisi.

Aspire'ın buradaki rolü, bu configuration'ı senin yerine üretmek: AppHost'ta bir project'e WithReference(inventory) ile referans verdiğinde Aspire, Inventory'nin endpoint'lerini services__inventory__http__0 biçiminde environment variable'lar olarak enjekte eder; .NET configuration sistemi çift alt çizgiyi : olarak okuduğu için bu, yukarıdaki JSON'un (JavaScript Object Notation) birebir aynısı. Aspire'ın kendisi ilerideki Backend kategorisindeki ".NET Aspire ile Dağıtık Uygulamalar" serisinin konusu.

Production'da Kubernetes'e gittiğinde ise aynı kodu değiştirmen gerekmiyor. Configuration'da inventory için kayıt yoksa pass-through provider devreye giriyor: service adını olduğu gibi bir DNS adı olarak bırakıyor ve istek inventory host'una gidiyor, yani Kubernetes Service'ine. Geliştirici makinesinde configuration ya da Aspire, cluster'da platformun DNS'i; kodda hiçbir koşul yok. Bir ayrıntıya dikkat: pass-through, öncelik listesindeki ilk scheme'i kullanıyor. Configuration'ı tamamen boşaltıp aynı client'ı çalıştırınca istek doğrudan HTTPS port'una gitti (geliştirici makinesinde inventory adında bir host olmadığı için DNS hatası aldı):

services.none.json
  call 1: HttpRequestException: nodename nor servname provided, or not known (inventory:443)

Yani cluster'da https+http://inventory, Inventory'nin 443'te TLS (Transport Layer Security) konuşmasını bekler. Cluster içi trafik düz HTTP ise base address'i production configuration'ında http://inventory yap ya da service'ler arası TLS'i baştan planla. Kubernetes'te pod'ların tek tek adresine gitmek istersen Microsoft.Extensions.ServiceDiscovery.Dns paketindeki DNS SRV provider'ı headless Service'lerle kullanılabiliyor; çoğu senaryoda buna gerek yok.

Discovery Health Check Değildir

Önce Tahmin Et'teki son soruya geri dönelim. Aynı configuration'a üçüncü bir instance ekledim, ama 5404'te dinleyen hiçbir şey yok: çökmüş ama listeden silinmemiş bir instance.

services.three.json
  call 1: served by 5403
  call 2: HttpRequestException: Connection refused (localhost:5404)
  call 3: served by 5402
  call 4: served by 5403
  call 5: HttpRequestException: Connection refused (localhost:5404)
  call 6: served by 5402

Altı istekten ikisi, yani üçte biri hata aldı. Round robin, listedeki adresin canlı olup olmadığını bilmez; listeyi kim tutuyorsa sağlık bilgisi de onun sorumluluğu. Configuration provider statik bir liste okuduğu için ölü instance'ı hiçbir zaman çıkarmaz. Kubernetes Service'i ise readiness probe'u başarısız olan pod'u endpoint listesinden çıkarır, Consul da kendi health check'lerine göre aynı şeyi yapar. Bu yüzden discovery kararı, load balancing ve health check kararıyla birlikte verilir: "adresi nereden buluyorum" sorusunun hemen arkasından "o adresin ölü olduğunu kim fark ediyor ve ne kadar sürede" sorusu gelir. Kubernetes'te bile bir pod'un çöktüğü an ile listeden çıktığı an arasında birkaç saniye vardır; o aralıktaki istekler için client tarafında timeout ve retry gerekir. Bunun service seviyesindeki anlamı serinin "Production'da Microservice" yazısında.

Consul ve Service Mesh: Ne Zaman Gerekir?

Consul, Kubernetes'in dışında ya da birden fazla platformu kapsayan ortamlarda registry rolünü üstleniyor: sanal makinelerde çalışan service'ler, birden fazla Kubernetes cluster'ı, on-premise ile bulutun karışık olduğu kurulumlar. Kendi health check'leri, DNS interface'i ve key/value store'u var. Bookstore gibi tamamı tek bir Kubernetes cluster'ında koşan bir sistemde Consul eklemek, platformun zaten verdiği bir şeyi ikinci kez yönetmek olur.

Service mesh (Istio, Linkerd) ise discovery'yi bir adım öteye taşıyor: her pod'un yanına bir sidecar proxy (ya da yeni nesil mesh'lerde node seviyesinde bir proxy) konur ve service'ler arası bütün trafik bu proxy'lerden geçer. Uygulama kodu düz HTTP ile inventory'ye gider; proxy discovery'yi, load balancing'i, retry ve timeout policy'lerini, service'ler arası mTLS (mutual TLS) şifrelemesini ve trafiğin yüzdesel bölünmesini (canary deploy) üstlenir. Kazanç, bu davranışların her dilde aynı şekilde ve uygulama kodu değişmeden gelmesi. Bedel, her pod'a eklenen bir proxy, her çağrıya eklenen birkaç milisaniye, öğrenilmesi ve işletilmesi gereken yeni bir control plane ve bir sorun olduğunda "hata bende mi, mesh'te mi?" sorusu.

Mesh'in yeri, service sayısının onlarla ölçüldüğü, birden fazla dilin kullanıldığı ve mTLS'in compliance gereği olduğu sistemler. Dört ekipli, tamamı .NET olan, bir düzine service'lik Bookstore için Kubernetes Service'leri, Microsoft.Extensions.ServiceDiscovery ve Microsoft.Extensions.Http.Resilience çok daha az parçayla aynı ihtiyacı karşılıyor. Mesh'e mTLS için ihtiyaç duyup duymayacağımızı bir sonraki yazıda, güvenlik tarafında tekrar konuşacağız.

Sık Düşülen Tuzaklar

Discovery'yi health check ve load balancing'den ayrı düşünmek

Yukarıdaki çıktı tam bu: adres listesi doğru, ama listedeki bir adres ölü. Discovery seçerken "ölü instance listeden ne zaman ve kim tarafından çıkarılıyor?" sorusunun cevabı da seçilmiş olmalı.

Endpoint'i scheme'siz yazmak

Bu yazının örneğini hazırlarken adresleri önce "localhost:5402" diye, scheme'siz yazdım. Client https+http://inventory ile HTTPS'i önce denediği için bu adreslere HTTPS ile bağlanmaya çalıştı ve altı isteğin hepsi HttpRequestException: The SSL connection could not be established, see inner exception. ile düştü. Configuration'da her adresi scheme'iyle (http://localhost:5402) yaz; Aspire'ın ürettiği değerler de böyle.

Load balancer'ın önüne bir load balancer daha koymak

Kubernetes Service'inin cluster IP'sine giden bir client'ta client-side round robin yapmak bir işe yaramaz: client'ın listesinde tek bir adres vardır ve asıl load balancing'i kube-proxy yapar. Hangi katmanın load balancing yaptığını bil ve yalnız birinde yap.

DNS cache'ini unutmak

DNS cevapları cache'lenir; uzun ömürlü bağlantılar da eski adreste kalabilir. .NET'te IHttpClientFactory'nin handler'ları varsayılan olarak iki dakikada bir yeniler; kendi HttpClient'ını SocketsHttpHandler ile tutuyorsan PooledConnectionLifetime'ı ayarla, yoksa yeni pod'lar trafik almaz.

Registry'yi tek nokta olarak bırakmak

Kendi registry'ni (Consul, Eureka) işletiyorsan o artık sistemdeki en kritik component; cluster'lı kurulmadıysa bütün service'lerin ortak tek hata noktası olur.

Küçük Bir Deneme

★☆☆ Örnekte configuration'daki http bölümünü https olarak yeniden adlandır ama adresleri http:// ile bırak. https+http://inventory şimdi hangi adresleri buluyor? Sonra base address'i yalnız http://inventory yap ve tekrar dene.

★★☆ ServiceDiscoveryOptions ile AllowAllSchemes = false ve AllowedSchemes = ["https"] ayarla. Yalnız HTTP endpoint'leri olan configuration'la istek attığında ne oluyor? Bu ayarı production'da açmanın gerekçesini bir cümleyle yaz.

★★★ Bookstore'un Kubernetes'teki Inventory'si için bir kâğıt üstü tasarım yap: Service tipi (normal mi headless mi), readiness probe'un baktığı endpoint, BFF'in timeout ve retry ayarı. Bir pod çöktüğünde ilk birkaç saniyede gelen istekler ne yaşar ve bunu hangi katman tolere eder?

Service'ler artık birbirini isimle buluyor. Ama Inventory'ye gelen bir isteğin gerçekten BFF'ten geldiğini, kullanıcı adına mı yoksa kendi adına mı geldiğini nereden bileceğiz? Bir sonraki yazıda microservice'lerde güvenliğe, edge authentication'dan service-to-service trust'a bakacağız.