Production'da gRPC: Interceptor, Authentication, Load Balancing, Retry ve Health Check
gRPC'yi production'a taşırken en büyük sürpriz load balancing'dir: HTTP/2 bütün çağrıları tek bağlantıda taşıdığı için L4 balancer ve Kubernetes ClusterIP trafiği tek pod'a yığar; çözüm client-side LB ya da L7 proxy. Interceptor'ları, JWT ve call credentials'ı, retry'ın katmanlarda çarpılmasını, health check ve reflection'ı gerçek ölçümlerle gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Grpc.AspNetCore 2.85, Grpc.Net.ClientFactory 2.85, Grpc.AspNetCore.HealthChecks 2.85, Microsoft.AspNetCore.Authentication.JwtBearer 10.0 ve Microsoft.Extensions.Http.Resilience 10.11 ile derlenip denenmiştir.
Önce Tahmin Et
Inventory'yi Kubernetes'e üç pod olarak deploy ettin, önüne de standart bir ClusterIP Service koydun. Ordering saniyede yüzlerce GetStock çağrısı yapıyor. Grafana'da üç pod'un CPU grafiğine bakıyorsun. Ne görürsün: üç eşit çizgi mi, yoksa bir pod yanarken diğer ikisi boşta mı?
İkinci soru: Ordering'in gRPC client'ına "Unavailable gelirse 3 kez dene" diye bir retry policy koydun. Ekip arkadaşın da aynı client'a AddStandardResilienceHandler() ekledi, o da varsayılan olarak 3 kez tekrar deniyor. Inventory'nin önündeki proxy 503 dönmeye başladığında tek bir GetStock çağrısı Inventory'ye kaç HTTP isteği olarak ulaşır?
Interceptor'lar: Her Çağrının Etrafına Kod Koymak
Interceptor gRPC'nin middleware'i. ASP.NET Core middleware'i de gRPC isteklerinde çalışır, ama HTTP isteğini görür; interceptor ise deserialize edilmiş mesajı, method adını ve ServerCallContext'i görür. Inventory'ye iki server interceptor ekliyoruz. Birincisi her çağrının sonucunu ve süresini logluyor, yanıta hangi instance'ın cevap verdiğini trailer olarak ekliyor:
public sealed class ServerLoggingInterceptor(ILogger<ServerLoggingInterceptor> logger, IConfiguration configuration) : Interceptor
{
private readonly string _instance = configuration["Inventory:InstanceName"] ?? "inventory";
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
long started = Stopwatch.GetTimestamp();
StatusCode status = StatusCode.OK;
try
{
context.ResponseTrailers.Add("x-served-by", _instance);
return await continuation(request, context);
}
catch (RpcException ex)
{
status = ex.StatusCode;
throw;
}
finally
{
logger.LogInformation("{Method} {Status} in {Elapsed:0.0} ms, peer {Peer}, attempt {Attempt}",
context.Method, status, Stopwatch.GetElapsedTime(started).TotalMilliseconds, context.Peer,
context.RequestHeaders.GetValue("grpc-previous-rpc-attempts") ?? "0");
}
}
}
İkincisi domain exception'larını status'a çeviriyor. Service kodu ReservationNotFoundException fırlatıyor ve gRPC'yi hiç bilmiyor; çeviri tek yerde:
public sealed class ExceptionMappingInterceptor(ILogger<ExceptionMappingInterceptor> logger) : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
try
{
return await continuation(request, context);
}
catch (RpcException)
{
throw;
}
catch (ReservationNotFoundException ex)
{
throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// ayrıntı loga, client'a yalnız genel mesaj
logger.LogError(ex, "Unhandled exception in {Method}", context.Method);
throw new RpcException(new Status(StatusCode.Internal, "Internal error. Correlation: " + context.GetHttpContext().TraceIdentifier));
}
}
}
builder.Services.AddGrpc(options =>
{
// kayıt sırası = çalışma sırası: logging en dışta
options.Interceptors.Add<ServerLoggingInterceptor>();
options.Interceptors.Add<ExceptionMappingInterceptor>();
});
Sıra önemli: logging dışta olduğu için exception mapping'in ürettiği status'u görüyor. Bu iki interceptor yalnız UnaryServerHandler'ı override ediyor; streaming çağrılar için ServerStreamingServerHandler gibi ayrı method'lar var ve override etmediğin türler interceptor'a hiç uğramaz. Client tarafında da aynı model var. Bizim client interceptor'ımız her çağrıya bir correlation id ekliyor:
public sealed class CorrelationInterceptor : Interceptor
{
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request, ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
// her çağrıya correlation id ekle; çağıran kod bunu bilmek zorunda değil
Metadata headers = context.Options.Headers ?? [];
headers.Add("x-correlation-id", Guid.NewGuid().ToString("N")[..8]);
var options = context.Options.WithHeaders(headers);
return continuation(request, new ClientInterceptorContext<TRequest, TResponse>(context.Method, context.Host, options));
}
}
Authentication: JWT Bearer ve Call Credentials
gRPC'de authentication ASP.NET Core'un kendi altyapısıyla yapılır; ASP.NET Core Web API serisinde JWT ile kurduğumuz yapının aynısı. Okuma serbest, stok değiştiren method'lar inventory.write scope'u istiyor:
builder.Services.AddAuthentication().AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "bookstore-identity",
ValidAudience = "bookstore-inventory",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!))
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("inventory.write", policy => policy.RequireClaim("scope", "inventory.write"));
[Authorize(Policy = "inventory.write")]
public override async Task<ReserveStockResponse> ReserveStock(ReserveStockRequest request, ServerCallContext context)
Simetrik anahtar burada denemeyi kendi içinde tutmak için. Production'da token'ı bir identity provider üretir, Inventory de Authority ile onun public key'lerini doğrular. Client tarafında token'ı her çağrıya eklemenin yolu call credentials:
services
.AddGrpcClient<InventoryService.InventoryServiceClient>(o => o.Address = new Uri("http://localhost:5101"))
.AddInterceptor(() => new CorrelationInterceptor())
.AddCallCredentials((context, metadata) =>
{
if (token is not null)
{
metadata.Add("Authorization", $"Bearer {token}");
}
return Task.CompletedTask;
})
.ConfigureChannel(o => o.UnsafeUseInsecureChannelCallCredentials = allowInsecure);
Dört durumu sırayla denedim, sonuncusunda da var olmayan bir rezervasyonu bırakmaya çalıştım:
no token -> Unauthenticated Bad gRPC response. HTTP status code: 401
token without scope -> PermissionDenied Bad gRPC response. HTTP status code: 403
write token, insecure channel -> InvalidOperationException: Call credential configured for gRPC client 'InventoryServiceClient' requires TLS, and the client isn't configured to use TLS
write token -> OK r-67f3a33f
release unknown reservation -> NotFound Reservation r-missing not found.
İlk iki satır ilginç: yanıtlar grpc-status taşımıyor, authorization middleware'i isteği gRPC'ye ulaşmadan HTTP 401 ve 403 ile kesiyor. Client bunları bir önceki yazıdaki eşleme tablosuyla Unauthenticated ve PermissionDenied'a çeviriyor. Üçüncü satır bir güvenlik önlemi: call credentials, TLS kullanmayan bir channel'da varsayılan olarak çalışmıyor, çünkü token düz metin olarak gidecek. UnsafeUseInsecureChannelCallCredentials yalnız lokal deneme ya da TLS'in önündeki bir proxy'de sonlandığı ortamlar için. Son satır exception mapping interceptor'ının işi.
Service'ler arası iletişimde bir alternatif de mTLS (mutual TLS): client da bir sertifika sunar, server onu doğrular; token yönetimi yerine sertifika yönetimi gelir. .NET'te client sertifikası ConfigurePrimaryHttpMessageHandler ile handler'a, server tarafı Kestrel'in ClientCertificateMode ayarıyla kurulur. Service mesh kullanan ortamlarda bu işi çoğu zaman mesh'in sidecar'ı üstlenir.
Load Balancing: HTTP/2 Neden Tek Pod'a Yapışır?
Baştaki ilk soruya dönelim. HTTP/2'de bir channel tek bir TCP bağlantısı açar ve bütün çağrıları onun üzerinden çoklar. L4 (transport katmanı) bir load balancer yalnız bağlantıları dağıtır; bağlantı bir backend'e atandıktan sonra içindeki her çağrı oraya gider. Kubernetes'in ClusterIP Service'i de varsayılan olarak bağlantı düzeyinde dağıtım yapar.
Bunu görmek için Inventory'yi üç instance (inv-1, inv-2, inv-3) olarak başlattım ve önlerine her yeni TCP bağlantısını sıradaki backend'e veren küçük bir test proxy'si koydum. Tek channel'la 30 GetStock çağrısı yapıp yanıtlardaki x-served-by trailer'larını saydım; client'ı iki kez çalıştırdım:
inv-1=30
inv-2=30
Her çalıştırmada bütün çağrılar tek bir instance'a gitti. Proxy iki bağlantı gördü, ikisini iki farklı backend'e verdi; o kadar. Baştaki sorunun cevabı: bir pod yanar, diğerleri boşta durur. Üstelik yeni pod eklemek de mevcut client'lara yaramaz, çünkü onların bağlantıları zaten kurulmuş.

İki çözüm var. Birincisi client-side load balancing: client bütün backend adreslerini bilir, her birine bağlantı açar ve çağrıları dağıtır. Adresleri bir resolver verir:
public static GrpcChannel CreateStaticBalancedChannel()
{
var services = new ServiceCollection();
services.AddSingleton<ResolverFactory>(new StaticResolverFactory(_ =>
[
new BalancerAddress("localhost", 5201),
new BalancerAddress("localhost", 5202),
new BalancerAddress("localhost", 5203)
]));
return GrpcChannel.ForAddress("static:///inventory", new GrpcChannelOptions
{
Credentials = ChannelCredentials.Insecure,
ServiceConfig = new ServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } },
ServiceProvider = services.BuildServiceProvider()
});
}
inv-1=10, inv-2=10, inv-3=10
Adres artık http:// ile başlamadığı için TLS olup olmadığını Credentials ile söylemek gerekiyor. Load balancer belirtmezsen varsayılan pick_first: ilk bağlanabildiği adrese her şeyi gönderir, yani yine tek instance.
Kubernetes'te adres listesini DNS verir. Headless Service (clusterIP: None) DNS sorgusuna Service'in tek sanal IP adresi yerine pod'ların IP'lerini döner; dns:/// resolver'ı da bunlara dağıtır:
public static GrpcChannel CreateDnsBalancedChannel() =>
GrpcChannel.ForAddress("dns:///localhost:5201", new GrpcChannelOptions
{
Credentials = ChannelCredentials.Insecure,
ServiceConfig = new ServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } }
});
Lokalde localhost iki adrese çözülüyor (127.0.0.1 ve ::1); ikisi de aynı instance'a gidiyor, ama server logundaki peer alanı çağrıların iki ayrı bağlantıya sırayla dağıtıldığını gösteriyor:
peer ipv4:127.0.0.1:56220, attempt 0
peer ipv4:127.0.0.1:56220, attempt 0
peer ipv6:[::1]:56221, attempt 0
peer ipv4:127.0.0.1:56220, attempt 0
peer ipv6:[::1]:56221, attempt 0
peer ipv4:127.0.0.1:56220, attempt 0
Dikkat edilecek bir nokta: DNS resolver adresleri önbelleğe alır ve varsayılan olarak yalnız bir bağlantı koptuğunda yeniden sorgular. Ölçeklenen bir deployment'ta yeni pod'ları görmek için DnsResolverFactory'yi refreshInterval ile kaydet.
İkinci çözüm L7 (uygulama katmanı) bir proxy: Envoy, Linkerd ya da .NET tarafında YARP (Microsoft'un .NET tabanlı reverse proxy kütüphanesi) HTTP/2'yi anlar, tek bağlantıdan gelen çağrıları ayrı ayrı backend'lere dağıtır. Client basit kalır, karşılığında her çağrıya bir ağ adımı ve proxy'nin işletme yükü eklenir. Bu yolu API Gateway ve YARP serisinde ayrıntılı ele alıyoruz. Hangisini seçersen seç, bir streaming çağrısı kurulduktan sonra bütün mesajları aynı backend'e gider; dağıtım çağrı düzeyinde.
Retry ve Hedging
gRPC client'ının kendi retry mekanizması var ve channel'ın service config'inde, method bazında tanımlanıyor:
public static ServiceConfig CreateRetryServiceConfig() => new()
{
MethodConfigs =
{
new MethodConfig
{
// yalnız okuma: tekrar çalışması zararsız
Names = { new MethodName { Service = "bookstore.inventory.v1.InventoryService", Method = "GetStock" } },
RetryPolicy = new RetryPolicy
{
MaxAttempts = 3,
InitialBackoff = TimeSpan.FromMilliseconds(200),
MaxBackoff = TimeSpan.FromSeconds(2),
BackoffMultiplier = 2,
RetryableStatusCodes = { StatusCode.Unavailable }
}
}
}
};
MethodName.Default ile bütün method'lara uygulamak kolay ama tehlikeli: ReserveStock idempotent değil, ilk deneme server'da başarıyla bitip yanıt yolda kaybolduysa retry ikinci bir rezervasyon açar. Retry'ı tekrar çalışması zararsız method'larla sınırla. Inventory'yi ilk iki GetStock'u Unavailable ile düşürecek şekilde başlatıp tek bir çağrı yaptım. Client'ta tek satır, server'da üç:
GetStock OK, available=12
/bookstore.inventory.v1.InventoryService/GetStock Unavailable in 3.7 ms, peer ipv6:[::1]:56346, attempt 0
/bookstore.inventory.v1.InventoryService/GetStock Unavailable in 0.1 ms, peer ipv6:[::1]:56346, attempt 1
/bookstore.inventory.v1.InventoryService/GetStock OK in 5.2 ms, peer ipv6:[::1]:56346, attempt 2
attempt sütunu client'ın otomatik eklediği grpc-previous-rpc-attempts header'ı. Retry'ın sınırları da var: server response header'larını gönderdiği an (örneğin bir server streaming çağrısında ilk mesaj geldiğinde) çağrı "commit" olur ve artık tekrar denenmez. Deadline da bütün denemeleri kapsar. Hedging ise aynı çağrının birden çok kopyasını beklemeden gönderip ilk başarılı yanıtı kullanır; gecikmeyi düşürür ama yükü katlar. Retry ile hedging aynı method'da birlikte tanımlanamaz.
Peki AddStandardResilienceHandler? Microsoft.Extensions.Http.Resilience'ın handler'ı HttpClient düzeyinde çalışır ve Grpc.Net.ClientFactory 2.64 ve sonrasıyla gRPC client'larına eklenebilir (daha eski sürümlerde runtime'da exception veriyordu). Ama HTTP'yi görür, gRPC status'unu değil. Önce yalnız resilience handler'la iki deneme yaptım. Inventory Unavailable status'u döndüğünde:
Unavailable: Simulated outage.
/bookstore.inventory.v1.InventoryService/GetStock Unavailable in 4.2 ms, peer ipv6:[::1]:56352, attempt 0
Hiç retry yok: HTTP status 200, handler'ın gözünde başarılı bir istek. Inventory'nin önündeki bir proxy'nin 503 dönmesini taklit eden bir test middleware'iyle tekrar denediğimde handler devreye girdi ve server dört HTTP isteği gördü. Sonra ikinci sorunun senaryosu: aynı client'ta hem gRPC retry policy'si hem resilience handler:
Unavailable: Bad gRPC response. HTTP status code: 503
4
Unavailable: Bad gRPC response. HTTP status code: 503
12
Tek bir GetStock, Inventory'ye 12 HTTP isteği olarak ulaştı: gRPC katmanı 3 deneme yapıyor, her denemede handler 4 istek gönderiyor. Bunu bir de Ordering'in kendi çağıranının retry'ı ile çarparsan, zaten zorlanan bir service'e yük dalgası gönderirsin. System Design serisinde retry'ların katmanlarda çarpılmasını genel olarak konuştuk; gRPC'deki karşılığı bu. Kural basit: retry'ı tek bir katmanda yap. gRPC status'larına göre karar vermek istiyorsan bu katman gRPC'nin kendi retry policy'si. Circuit breaker ve timeout gibi diğer pattern'lerin derin anlatımı External Service Integration ve Resilience serisinin konusu.
Health Check, Reflection ve Limitler
Load balancer'ların ve Kubernetes'in bir instance'a trafik göndermeden önce sağlığını sorması gerekiyor. gRPC'nin bunun için standart bir protokolü var (grpc.health.v1.Health). Grpc.AspNetCore.HealthChecks paketi bunu ASP.NET Core health check'lerine bağlar; Kubernetes'in yerleşik gRPC probe'u da aynı protokolü konuşur:
builder.Services.AddGrpcReflection();
builder.Services.AddGrpcHealthChecks()
.AddCheck("stock-store", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy());
app.MapGrpcHealthChecksService();
if (app.Environment.IsDevelopment())
{
app.MapGrpcReflectionService();
}
Reflection ise server'ın kendi contract'ını sorgulanabilir hâle getirir. grpcurl ya da Postman gibi araçlar .proto dosyası olmadan method'ları listeleyip çağırabilir. Ben hem health check'i hem reflection'ı Grpc.HealthCheck ve Grpc.Reflection paketlerinin client'larıyla sorguladım:
health: Serving
service: bookstore.inventory.v1.InventoryService
service: grpc.health.v1.Health
service: grpc.reflection.v1alpha.ServerReflection
service: grpc.reflection.v1.ServerReflection
Aynı uygulamayı Production ortamında başlatınca health check yine Serving, reflection ise Unimplemented: Service is unimplemented. döndü. Reflection'ı production'da açmak, contract'ını bilmeyen herkese bütün API yüzeyini anlatmak demek; Development ile sınırladık.
Son olarak mesaj boyutu. Server varsayılan olarak 4 MB'tan büyük gelen mesajı reddediyor. 5 MB'lık bir book_id gönderdiğimde:
ResourceExhausted: Received message exceeds the maximum configured message size.
Bu hata interceptor'lara hiç uğramadı; mesaj deserialize edilmeden reddediliyor. Sınırı AddGrpc(o => o.MaxReceiveMessageSize = ...) ile service bazında değiştirebilirsin, ama büyütmeden önce mesajı bölmenin (streaming ya da sayfalama) daha doğru olup olmadığını düşün. Bir önceki yazıda gördüğümüz eşzamanlı stream limiti için de SocketsHttpHandler.EnableMultipleHttp2Connections var. GrpcChannel kendi oluşturduğu handler'da bunu zaten açıyor, kendi handler'ını verdiğinde ise açıkça true yapman gerekiyor.
Üretimde Dikkat
Load balancing'i deploy'dan önce test et. Üç pod'luk bir staging ortamında tek bir client'tan yük gönderip pod başına istek sayısına bak. Bağlantı düzeyinde dengelenen bir kurulum ilk günden belli olur.
Client-side load balancing'de (LB) adres güncelliği senin sorumluluğunda. DNS önbelleği, yeni pod'ların ne zaman trafik alacağını belirler. Scale-out sonrası yeni pod'lar boşta duruyorsa önce resolver'ın yenilenme ayarına bak.
Health check'i gerçekten anlamlı yap. Her zaman Healthy dönen bir check, load balancer'a hiçbir şey söylemez. Veritabanı bağlantısı ya da kritik bir bağımlılık gibi, bozulduğunda trafik almaman gereken durumları kontrol et. Ama her downstream'i de ekleme, yoksa bir bağımlılığın sorunu bütün instance'ları aynı anda devre dışı bırakır.
Interceptor'da ağır iş yapma. Her çağrıda çalışırlar; senkron I/O, büyük nesne serileştirme ya da mesajı loglamak (içinde kişisel veri olabilir) hem gecikme hem risk ekler.
Sık Düşülen Tuzaklar
Kubernetes ClusterIP arkasında gRPC. Bütün trafik bağlantının ilk düştüğü pod'a gider. Headless Service ile client-side LB ya da bir L7 proxy (veya service mesh) kullan.
Load balancer belirtmeden dns:/// kullanmak. Varsayılan pick_first; birden çok adres çözülse de hepsi tek bağlantıya gider.
Retry'ı katmanlara dağıtmak. gRPC retry policy'si, resilience handler'ı ve çağıranın kendi retry'ı birbiriyle çarpılır. Biz tek çağrıdan 12 istek ürettik.
İdempotent olmayan method'ları MethodName.Default ile retry'a sokmak. Rezervasyon, ödeme ve sipariş oluşturma gibi çağrılar ya retry dışında kalmalı ya da idempotency key ile korunmalı.
Production'da reflection ve EnableDetailedErrors. İkisi de geliştirme kolaylığı; production'da saldırgana harita verir.
Küçük Bir Deneme
★☆☆ ServerLoggingInterceptor'a ServerStreamingServerHandler override'ını ekle ve WatchStock çağrılarının ne kadar süre açık kaldığını logla.
★★☆ Static resolver'daki üç instance'tan birini durdur ve 30 çağrıyı tekrarla. Round robin'in kapanan adresi nasıl devre dışı bıraktığını ve instance tekrar açıldığında ne olduğunu gözlemle.
★★★ ReserveStock'u idempotency key ile koru (aynı order_id ikinci kez gelirse ilk rezervasyonu dön) ve ardından bu method için de retry policy'si tanımla. Test middleware'iyle yanıtı yolda düşürüp çift rezervasyon oluşmadığını göster.
Inventory artık production'a hazır, ama yalnız gRPC konuşan client'lara açık. Bir sonraki yazıda aynı service'i tarayıcıdan gRPC-Web ile Angular'a, JSON transcoding ile de REST (Representational State Transfer) bekleyen client'lara açıyoruz.