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

Production'da Microservice: Distributed Tracing, Resilience ve Independent Deployment

Mert Özen 8 Kas 2026 10 dk 4 görüntülenme
Production'da Microservice: Distributed Tracing, Resilience ve Independent Deployment

Production'da microservice işletmek üç şey ister: bir isteği service'ler boyunca izleyen distributed tracing (W3C traceparent ile), service seviyesinde düşünülmüş timeout, retry ve circuit breaker, ve her service'in kendi pipeline'ıyla, backward compatibility ve contract test'lerle bağımsız deploy edilmesi. .NET'in Activity'siyle gerçek bir trace çıktısı, katman başına retry'ın 64 isteğe dönüşmesi, readiness'ı downstream'e bağlama tuzağı ve can-i-deploy'un CI'daki yeri.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Okul açılışı kampanyasının ilk sabahı. Destek ekibi "checkout dört saniye sürüyor, bazı siparişler hata veriyor" diyor. Checkout isteği Web BFF'ten (Backend for Frontend) Ordering'e, Ordering'den Inventory'ye ve Payments'a gidiyor; Payments da ödeme sağlayıcısını çağırıyor. Beş ekip kendi log'larına bakıyor ve hepsi aynı cevabı veriyor: "Bizde hata yok, yalnız downstream yavaş."

Ödeme sağlayıcısının cevap süresi 300 ms'den 3 saniyeye çıkmış. Ordering, BFF ve Payments'ın her biri, başarısız bir çağrıyı üç kez yeniden deniyor. Sence ödeme sağlayıcısı, kullanıcıların her bir checkout denemesi için kaç istek görüyor? Ve hangi service'in yavaş olduğunu bulmak için beş ekibin log'larını yan yana koymak yerine ne gerekirdi?

Bir İsteği Beş Service Boyunca İzlemek

Monolith'te bir isteğin yolculuğu tek bir process'te, tek bir log dosyasında ve tek bir stack trace'teydi. Microservice'lerde aynı yolculuk beş process'e, beş log'a ve beş ayrı ekibin dashboard'una dağılıyor. Distributed tracing bu yolculuğu tekrar tek bir parça hâline getiriyor. OpenTelemetry'nin (OTel) kavramlarıyla: trace, bir isteğin sistem boyunca bıraktığı izin tamamı; span, bu iz içindeki tek bir işlem (bir HTTP (Hypertext Transfer Protocol) çağrısı, bir veritabanı sorgusu); her span bir trace id, kendi span id'si ve bir parent span id taşır. Context propagation da bu kimliklerin service'ler arasında taşınması. HTTP'de taşıyıcı, W3C'nin (World Wide Web Consortium) Trace Context Recommendation'ındaki traceparent header'ı: sürüm, 32 hex karakterlik trace id, 16 karakterlik parent id ve flag'ler.

.NET'te bu mekanizma System.Diagnostics.Activity üzerine kurulu ve büyük kısmı kutudan çıkıyor: ASP.NET Core gelen her istek için bir server span'i açıyor, HttpClient giden her çağrı için bir client span'i açıp traceparent'ı header'a yazıyor. OpenTelemetry SDK'sı (Software Development Kit) bu span'leri toplayıp bir backend'e (Jaeger, Zipkin, Grafana Tempo, Azure Monitor ya da Aspire dashboard'u) gönderen parça. Aşağıdaki örnekte exporter yerine biten her span'i ekrana yazan küçük bir ActivityListener kullandım; BFF bir checkout span'i açıyor, Ordering'i çağırıyor, Ordering de Payments'ı:

// OpenTelemetry exporter'ının yaptığını taklit eden küçük bir listener: biten her span'i yazar
using ActivityListener listener = new()
{
    ShouldListenTo = source => source.Name is "Bookstore.WebBff" or "Microsoft.AspNetCore" or "System.Net.Http",
    Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
    ActivityStopped = a => Console.WriteLine(
        $"span {a.SpanId} parent {(a.ParentSpanId == default ? "(root)          " : a.ParentSpanId)} {a.Kind,-8} {a.GetTagItem("service") ?? a.GetTagItem("url.full") ?? a.DisplayName}"),
};
ActivitySource.AddActivityListener(listener);

WebApplication ordering = Service("http://localhost:5601", app =>
    app.MapPost("/api/orders", async (HttpRequest request, IHttpClientFactory http) =>
    {
        Activity.Current?.SetTag("service", "ordering");
        Console.WriteLine($"ordering sees traceparent: {request.Headers.TraceParent}");
        await http.CreateClient().PostAsync("http://localhost:5602/api/payments", null);
        return Results.Ok();
    }));

using HttpClient bff = new();
using (Activity? checkout = new ActivitySource("Bookstore.WebBff").StartActivity("checkout"))
{
    Console.WriteLine($"trace id: {Activity.Current?.TraceId}");
    await bff.PostAsync("http://localhost:5601/api/orders", null);
}
trace id: cd70b8894c875bf7e4e6d1a7615845e1
ordering sees traceparent: 00-cd70b8894c875bf7e4e6d1a7615845e1-b79988a62487d768-01
payments sees traceparent: 00-cd70b8894c875bf7e4e6d1a7615845e1-42f8ea4d84425e41-01
span 7eb30198bfd38144 parent 42f8ea4d84425e41 Server   payments
span 42f8ea4d84425e41 parent 012e3be395663d75 Client   http://localhost:5602/api/payments
span 012e3be395663d75 parent b79988a62487d768 Server   ordering
span b79988a62487d768 parent be3a07970f6f68ea Client   http://localhost:5601/api/orders
span be3a07970f6f68ea parent (root)           Internal checkout

Çıktıyı aşağıdan yukarı oku. Kök span BFF'teki checkout. Onun altında BFF'in Ordering'e attığı client span'i (b799…) var ve Ordering'in gördüğü traceparent'taki parent id tam olarak bu. Ordering'in server span'i (012e…) o client span'inin çocuğu, Ordering'in Payments'a attığı çağrı (42f8…) da Ordering'in çocuğu. Beş span'in hepsi aynı trace id'yi (cd70…) taşıyor ve hiçbir service'te bunun için tek satır kod yazmadım; SetTag("service", ...) yalnız çıktıyı okunur kılmak için, gerçekte bu bilgi OpenTelemetry'nin service.name resource attribute'u. Bir tracing backend'i bu beş satırı bir zaman çizelgesi olarak çizer ve Önce Tahmin Et'teki soru bir bakışta cevaplanır: dört saniyenin 3,5 saniyesi Payments'ın ödeme sağlayıcısına giden span'inde.

Asenkron akışlarda, yani event'ler bir broker'dan geçtiğinde, trace context message header'larında taşınır; bunu ve event-driven akışların observability'sini Messaging ve Event-Driven Architecture serisinin "Event-Driven Architecture'ın Bedeli: Gözlemlenebilirlik, Hata Ayıklama ve Ne Zaman Kullanmamalı" yazısında işledik. OpenTelemetry SDK'sının kurulumu, sampling ve backend seçimi ilerideki DevOps kategorisindeki Observability serisinin konusu.

Log, Metric ve Health Check

Trace "nerede" sorusunu cevaplar; "neden" sorusunun cevabı çoğu zaman log'dadır. Microservice'lerde log'ların işe yaraması için iki koşul var. Birincisi, log'lar tek bir yerde toplanır (log aggregation); beş sunucuya SSH (Secure Shell) ile bağlanıp dosya okumak bir incident sırasında işe yaramaz. İkincisi, her log satırı trace id'yi taşır; böylece tracing backend'inde yavaş span'i bulduğunda o span'in log'larına tek tıkla geçersin. ASP.NET Core'un ILogger'ı, aktif bir Activity varken trace id ve span id'yi log scope'una kendisi ekleyebiliyor; ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core'da Logging: ILogger, Log Seviyeleri, Structured Logging ve Serilog" yazısındaki structured logging bu yüzden microservice'lerde bir tercih değil bir zorunluluk. Correlation id için ayrı bir header icat etmek yerine W3C trace id'yi kullan; ayrı bir id, ikinci bir propagation mekanizması ve unutulan bir header demek.

Metric'ler ise "şu an ne kadar kötü?" sorusunun cevabı: service başına istek sayısı, hata oranı ve süre dağılımı (p50, p95, p99). Google'ın SRE (Site Reliability Engineering) kitabındaki dört golden signal (latency, traffic, errors, saturation) iyi bir başlangıç listesi. Alarm, ortalama süreye değil p95/p99'a ve hata oranına kurulur; Mimariye Giriş serisinin "Quality Attribute Senaryoları: 'Hızlı Olsun'u Ölçülebilir Bir Gereksinime Çevirmek" yazısında Bookstore için yazdığımız "checkout p95 < 800 ms" gibi hedefler tam burada ölçülebilir hâle gelir.

Health check API'si de platformun service'i nasıl gördüğünü belirliyor. Liveness "process'i yeniden başlatmalı mıyım?", readiness "bu instance'a trafik göndermeli miyim?" sorusunu cevaplar. ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Health Check: Liveness, Readiness ve Veritabanı Kontrolü" yazısında ikisini ayrı endpoint'ler olarak yazdık. Microservice'lere özgü kural şu: readiness check'ine başka service'leri koyma. Ordering'in readiness'ı Payments'a bağlıysa, Payments'taki bir sorun bütün Ordering instance'larını trafikten çıkarır ve kısmi bir arıza tam bir kesintiye döner; Ordering'in kendi veritabanı ise readiness'ın meşru bir parçası.

Cascading Failure: Bir Service Yavaşlayınca

Bir service'in çökmesi çoğu zaman yavaşlamasından daha az tehlikelidir. Çöken service hemen hata döner ve çağıranlar bunu fark eder. Yavaşlayan service ise çağıranların kaynaklarını tutar: Payments 3 saniyede cevap verirken Ordering'deki her checkout isteği 3 saniye boyunca açık bir bağlantı, bellekte bir istek ve bekleyen bir task tutar. Kampanya trafiğinde (dakikada 120 sipariş) bekleyen istekler birikir, Ordering'in bağlantı havuzu dolar, Ordering yavaşlar ve aynı şey BFF'te tekrarlanır. Sonunda katalog araması gibi Payments'la hiç ilgisi olmayan sayfalar da yavaşlar, çünkü aynı BFF instance'ının kaynaklarını paylaşıyorlar. Michael Nygard'ın Release It! (2. baskı, 2018) kitabı bu zinciri cascading failure adıyla anlatıyor ve savunmayı stability pattern'leri olarak sıralıyor.

İlk savunma timeout. Her network çağrısının bir üst süresi olmalı ve bu süre, çağıranın kendi bütçesinden küçük olmalı: BFF'in checkout için 2 saniyesi varsa, Ordering'in Payments'a 5 saniye beklemesinin anlamı yok. İkinci savunma bulkhead: farklı downstream'ler için ayrı kaynak havuzları, böylece Payments'ı bekleyen istekler katalog çağrılarının bağlantılarını tüketmez. Üçüncüsü circuit breaker.

Timeout, Retry ve Circuit Breaker'ın Service Seviyesindeki Anlamı

Retry, geçici hatalar için doğru araç; ama service seviyesinde düşünülmediğinde yangını büyütür. Önce Tahmin Et'teki zincirde BFF, Ordering ve Payments'ın her biri bir çağrıyı ilk deneme dahil dört kez yapıyorsa, ödeme sağlayıcısı tek bir checkout denemesi için 4 × 4 × 4 = 64 istek görür. Sağlayıcı zaten yavaşken trafiği 64 katına çıkarmak, onu toparlanamaz hâle getirir. Kural basit: retry zincirin tek bir katmanında olur, tercihen hatayı en iyi tanıyan ve isteğin idempotent olduğunu bilen katmanda. POST gibi idempotent olmayan çağrılar ancak bir idempotency key ile yeniden denenir; bunu Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısında işledik.

Circuit breaker, Martin Fowler'ın 2014'te bliki'sinde Nygard'a atıfla anlattığı hâliyle, hata oranı bir eşiği geçtiğinde downstream'e istek göndermeyi bir süre tamamen keser ve hemen hata döner. Kazanç iki yönlü: çağıran beklemekle kaynak harcamaz, downstream de toparlanmak için nefes alır. Kırık devre sırasında ne gösterileceği ise bir ürün kararı; Bookstore'da bu karar Mimariye Giriş serisinde verilmişti: ödeme sağlayıcısı 5 saniyede cevap vermezse sipariş PaymentPending olur ve kullanıcıya hata dönmez, ödeme arka planda tamamlanır.

.NET'te bu pattern'lerin hazır bir paketi var: Microsoft.Extensions.Http.Resilience'ın AddStandardResilienceHandler()'ı. Microsoft Learn'deki varsayılanları: dıştan içe rate limiter (1.000 eşzamanlı istek), 30 saniyelik toplam timeout, üç retry (exponential backoff, jitter, 2 saniye taban gecikme), circuit breaker (30 saniyelik pencerede en az 100 istekte %10 hata oranı, 5 saniye açık kalma) ve 10 saniyelik deneme timeout'u. Bu varsayılanlar genel amaçlı; checkout gibi kullanıcının beklediği bir akışta 30 saniye fazla uzun, ve her katmana aynı handler'ı eklemek yukarıdaki 64'e geri götürür. Değerleri service'in kendi bütçesinden türet. Polly ile resilience pipeline kodu ilerideki Backend kategorisindeki "External Service Integration ve Resilience" serisinin, bu pattern'lerin sistem tasarımındaki yeri de ilerideki System Design serisinin reliability yazısının konusu.

Independent Deployment: Service Başına Bir Pipeline

Serinin ilk yazısında microservice'in merkezine independent deployability'yi koymuştuk; production'da bunun somut karşılığı her service'in kendi build ve deploy pipeline'ı. Catalog'daki bir değişiklik yalnız Catalog'un pipeline'ını tetikler, yalnız Catalog'un artifact'ını (container image'ını) üretir ve yalnız Catalog'u deploy eder. "Bütün service'leri aynı gece, sırayla deploy et" adımı içeren bir release planı, Mimariye Giriş serisinin "Monolith'i Doğru Anlamak: Single-Process, Modular ve Distributed Monolith" yazısındaki tanımıyla bir distributed monolith belirtisi.

Bağımsız deploy'un ön koşulu backward compatibility: yeni sürüm, eski consumer'larla ve eski veriyle çalışmak zorunda. Bunun üç ayağı var. API'ler için serinin "Service Contract'ları ve Versioning" yazısındaki expand-contract. Veritabanı için aynı fikrin şema karşılığı: kolonu yeniden adlandırmak yerine yeni kolonu ekle, iki sürüm bir süre birlikte çalışsın, eskisini sonra kaldır; çünkü rolling deploy sırasında eski ve yeni instance'lar aynı veritabanına aynı anda yazar. Davranış için de feature flag: kodu deploy etmek ile özelliği açmak birbirinden ayrılır ve özellik kapatılarak geri alınabilir. Çözüm Desenleri serisinin "Feature Flag'leri Kodda Tasarlamak: Toggle Türleri, Microsoft.FeatureManagement ve Flag Borcu" yazısı bu ayrımı anlatıyor. Canary ve blue-green gibi deploy stratejileri ilerideki Deployment Stratejileri serisinde.

Contract Test'in CI'daki Yeri

Serinin "Service Contract'ları ve Versioning" yazısında Pact ile consumer-driven contract test yazmış ve bir Pact Broker'ın hangi sürümün hangisiyle doğrulandığını tuttuğunu söylemiştik. CI (continuous integration) pipeline'ında bu test'ler integration test ortamının yerini alıyor. Akış kabaca şöyle:

  • Consumer (Notification) pipeline'ı, consumer test'ini koşar ve pact'i kendi sürüm numarası ve branch'iyle broker'a yayınlar.
  • Pact değiştiyse broker, contract_requiring_verification_published event'iyle bir webhook tetikler ve provider'ın (Catalog) yalnız doğrulama yapan bir job'ını çalıştırır.
  • Provider pipeline'ı her build'de, broker'daki ilgili pact'leri kendi koduna karşı doğrular ve sonucu broker'a yazar.
  • Her iki taraf deploy etmeden önce pact-broker can-i-deploy --pacticipant Catalog --version 2.4.0 --to-environment production sorar; cevap hayırsa deploy durur.
  • Deploy'dan sonra pact-broker record-deployment ile broker'a hangi sürümün hangi ortamda olduğu bildirilir; bir sonraki can-i-deploy sorusu bu bilgiye dayanır.

Bu düzenin değeri, ortak ve her zaman biraz bozuk olan bir "integration" ortamına ihtiyaç bırakmaması. Beş service'i aynı sürümlerle bir ortamda ayağa kaldırıp uçtan uca test koşmak, hem yavaş hem kırılgan, hem de dolaylı yoldan bütün service'leri birlikte release etmeye iten bir alışkanlık. Uçtan uca test'lerin tamamen kalkması gerekmiyor; birkaç kritik akış (checkout) için production benzeri bir ortamda kalabilirler, ama deploy kararını onlar değil contract'lar verir.

Sık Düşülen Tuzaklar

Trace'i sonradan eklemek

Tracing'i ilk incident'tan sonra kurmaya karar vermek, en çok ihtiyaç duyduğun anda veri olmaması demek. Yeni bir service'in iskeletinde OpenTelemetry, structured logging ve health check ilk günden olmalı; Aspire'ın service defaults'u tam bu iskeleti veriyor.

Her katmanda retry

Yukarıdaki 64 istek. Retry'ın nerede yapılacağını sistem seviyesinde kararlaştır ve diğer katmanlarda kapat.

Timeout'suz çağrı

HttpClient'ın varsayılan timeout'u 100 saniye. Kullanıcının beklediği bir akışta bu, bir downstream yavaşladığında bütün kaynakların 100 saniye boyunca kilitlenmesi demek.

Readiness'ı downstream'e bağlamak

Payments'ın sorunu yüzünden Ordering'in bütün instance'larının trafikten çıkması. Readiness yalnız service'in kendi bağımlılıklarını (kendi veritabanı, kendi cache'i) kontrol eder.

Ortak bir integration ortamına güvenmek

"Test ortamında çalıştı" cümlesi, o ortamdaki diğer service sürümlerinin production'dakilerle aynı olduğunu varsayar. Hiçbir zaman değildir.

Küçük Bir Deneme

★☆☆ Örnekteki Payments endpoint'ine await Task.Delay(500) ekle ve listener'ı her span'in Duration'ını da yazacak şekilde değiştir. Hangi span'ler 500 ms'den uzun sürüyor ve neden birden fazla?

★★☆ Ordering'deki HttpClient'a AddStandardResilienceHandler() ekle, Payments'ı ilk iki çağrıda 503 dönecek şekilde değiştir ve listener çıktısına bak. Kaç client span'i görüyorsun, hepsi aynı trace'te mi?

★★★ Bookstore'un checkout akışı için bir resilience bütçesi yaz: BFF'in toplam süresi, her katmanın timeout'u, retry'ın hangi katmanda olduğu ve hangi çağrının idempotency key gerektirdiği. Ödeme sağlayıcısı 3 saniyeye çıktığında kullanıcı ne görüyor?

Service'leri işletmeyi de konuştuk. Geriye serinin başından beri ertelediğimiz soru kaldı: Bookstore'un elinde zaten çalışan bir monolith var; bütün bunlara oradan, sistemi durdurmadan nasıl gidilir? Serinin son yazısında strangler fig, branch by abstraction ve distributed monolith tuzağına bakacağız.