Mert Özen Her Satırda Daha İleri
Backend Real-time Uygulamalar — #08

SignalR'ı Scale-Out Etmek ve Production'da İşletmek: Sticky Session, Redis Backplane, Azure SignalR Service ve İzleme

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
SignalR'ı Scale-Out Etmek ve Production'da İşletmek: Sticky Session, Redis Backplane, Azure SignalR Service ve İzleme

Birden fazla sunucuda SignalR için iki şey gerekir: negotiate ile bağlantıyı aynı sunucuya götüren sticky session ve mesajları sunucular arasında taşıyan bir backplane. Nginx ile gerçek hata mesajları, skipNegotiation istisnası, Redis backplane ve kanal prefix'i, Redis kesintisinde kaybolan mesajlar, Azure SignalR Service ve SignalR metric'leri ile tracing.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.AspNetCore.SignalR.StackExchangeRedis 10.0.14, Redis 8.2 ve Nginx 1.29 ile derlenip denenmiştir; Azure SignalR Service örneği Microsoft.Azure.SignalR 1.33.1 ile yalnızca derlenmiştir.

Önce Tahmin Et

Bookstore'un SignalR uygulaması tek sunucuda sorunsuz çalışıyor. Trafik arttı, önüne bir Nginx koyup aynı uygulamadan ikinci bir kopya çalıştırdın. Hiç kod değiştirmedin. İki soru: kullanıcıların bağlanması etkilenir mi? Bağlananlardan biri bir duyuru gönderdiğinde, diğer sunucuya bağlı kullanıcılar onu görür mü?

İki sorunun cevabı da "hayır, her zaman değil" ve ikisinin sebebi farklı. Bu yazıda iki uygulama kopyası, bir Nginx ve bir Redis container'ı ile bu sorunları tek tek üretip çözüyoruz, sonra SignalR'ı production'da nasıl izleyeceğimize bakıyoruz.

İkinci Sunucu: Mesajın Yarısı Nereye Gitti?

Deney düzeneği: aynı uygulamanın A (17681) ve B (17682) kopyaları. Ada A'ya, Alan B'ye bağlı. A'ya bir duyuru POST ediliyor:

app.MapHub<AnnouncementHub>("/hubs/announcements");

app.MapPost("/announce", async (string text, IHubContext<AnnouncementHub> hub) =>
{
    await hub.Clients.All.SendAsync("Announcement", text, instance);
    return TypedResults.Ok(new { sentBy = instance });
});
  ada  (on A) <- "Store closes at 18:00" (sent by A)
POST /announce to A "Store closes at 18:00": 200 {"sentBy":"A"}

Alan hiçbir şey almadı ve hiçbir yerde hata yok. Sebep basit: Clients.All "bu süreçteki bütün bağlantılar" demek. Her sunucu kendi bağlantı listesini bellekte tutuyor ve diğerinden habersiz. Serinin ikinci yazısında yazdığımız connection registry'nin ve beşinci yazıdaki Clients.User'ın da aynı sınırı vardı. Load balancer kullanıcıları sunuculara rastgele dağıttığı için production'da bu "bildirimler bazen geliyor" şeklinde görünür; hangi kullanıcının hangi sunucuya düştüğüne bağlı.

08-scale-out-tr.png

Sticky Session: Negotiate ile Bağlantı Aynı Sunucuya

İlk soru daha sinsi. Nginx'te iki location tanımladım: biri istekleri iki sunucuya dağıtan klasik bir upstream, diğeri ip_hash ile aynı istemci IP'sini (Internet Protocol) hep aynı sunucuya gönderen sticky bir upstream:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream bookstore_rr {
    server host.docker.internal:17681;
    server host.docker.internal:17682;
}

upstream bookstore_sticky {
    ip_hash;
    server host.docker.internal:17681;
    server host.docker.internal:17682;
}

server {
    listen 80;

    # sticky session yok: istekler iki sunucuya dağıtılır
    location /rr/ {
        proxy_pass http://bookstore_rr/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_buffering off;
        proxy_read_timeout 100s;
    }

    # ip_hash: aynı istemci IP'si hep aynı sunucuya
    location /sticky/ {
        proxy_pass http://bookstore_sticky/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_buffering off;
        proxy_read_timeout 100s;
    }
}

Upgrade ve Connection header'ları WebSocket için, proxy_buffering off SSE (Server-Sent Events) için, proxy_read_timeout long polling ve 15 saniyelik keep-alive'dan uzun olsun diye; hepsi Microsoft'un Nginx önerisiyle aynı. Her location üzerinden 10 kez bağlanıp hub'a "hangi sunucudasın?" diye sordum:

/rr/ default: {
 "failed: Unable to connect to the server with any of the available transports. Error: WebSockets failed: Error: WebSock": 6,
 "ok on B": 2,
 "ok on A": 2
}
/rr/ lp: {
 "failed: Unable to connect to the server with any of the available transports. WebSockets failed: Error: 'WebSockets' i": 10
}
/rr/ skip: {
 "ok on B": 5,
 "ok on A": 5
}
/sticky/ default: {
 "ok on A": 10
}

Sticky session olmadan bağlantıların 10'da 6'sı kurulamadı. Hata mesajının tamamı sebebi kendisi söylüyor:

Unable to connect to the server with any of the available transports. Error: WebSockets failed: Error: WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled. Error: ServerSentEvents failed: Error: EventSource failed to connect. The connection could not be found on the server, either the connection ID is not present on the server, or a proxy is refusing/buffering the connection. If you have multiple servers check that sticky sessions are enabled. Error: LongPolling failed: Error: No Connection with that ID: Status code '404'

Negotiate isteği A'ya gitti ve A bir connection token üretti; ardından gelen WebSocket isteği B'ye düştü ve B o token'ı hiç duymamıştı. Başarılı 4 bağlantı, iki isteğin şans eseri aynı sunucuya düştüğü durumlar. Long polling (lp) hiç çalışmadı, çünkü her poll ayrı bir HTTP (Hypertext Transfer Protocol) isteği ve onların da hepsinin aynı sunucuya gitmesi gerekiyor.

Sticky session'sız çalışmanın tek yolu skip satırında: istemcide transport: HttpTransportType.WebSockets ve skipNegotiation: true. Negotiate isteği hiç atılmıyor, bağlantı tek bir WebSocket isteği olarak kuruluyor ve hangi sunucuya düşerse orada kalıyor. Bedeli SSE ve long polling fallback'inin olmaması; WebSocket'i engelleyen bir proxy arkasındaki kullanıcı bağlanamaz.

/sticky/'de 10 bağlantının hepsi A'ya gitti: bütün istekler aynı IP'den (Docker'ın gateway'i) geldi. Bu, ip_hash'in production'daki zayıflığını da gösteriyor: aynı kurumsal NAT'ın (Network Address Translation) arkasındaki bin kullanıcı tek bir sunucuya yığılır. Cookie tabanlı sticky session (Nginx Plus'ta sticky cookie, Azure App Service'te ARR (Application Request Routing) affinity, Kubernetes ingress'lerinde session affinity annotation'ları) daha dengeli dağıtır.

Redis Backplane

Sticky session bağlantının kurulmasını çözdü; mesajın diğer sunucuya ulaşmasını çözmedi. Bunun için sunucuların birbirine "şu mesajı senin bağlantılarına da ilet" diyebilmesi gerekiyor. Redis backplane bunu Redis'in pub/sub özelliğiyle yapıyor:

ISignalRServerBuilder signalR = builder.Services.AddSignalR();
if (args.Contains("--redis"))
{
    signalR.AddStackExchangeRedis("localhost:17679", options =>
        options.Configuration.ChannelPrefix = RedisChannel.Literal("bookstore"));
}

İki kopyayı --redis ile yeniden başlatıp aynı testi tekrarladım:

POST /announce to A "Store closes at 18:00": 200 {"sentBy":"A"}
  ada  (on A) <- "Store closes at 18:00" (sent by A)
  alan (on B) <- "Store closes at 18:00" (sent by A)

Uygulama kodunda tek satır değişmedi; Clients.All, Clients.User ve Clients.Group artık bütün sunuculardaki bağlantılara ulaşıyor. İstemciler bağlıyken Redis'teki kanallara baktım:

$ redis-cli PUBSUB CHANNELS '*'
bookstoreBookstore.Realtime.AnnouncementHub:all
bookstoreBookstore.Realtime.AnnouncementHub:connection:7EQoJRwjog3T7L9Z1cQoEA
bookstoreBookstore.Realtime.AnnouncementHub:connection:hcYgsplz1qevkdtQawvsGg
bookstoreBookstore.Realtime.AnnouncementHub:internal:ack:Mac_03ac3ce396614dbc981319514892d7ac
bookstoreBookstore.Realtime.AnnouncementHub:internal:ack:Mac_0a1cbd74ba37477db52d96f893c6a8e2
bookstoreBookstore.Realtime.AnnouncementHub:internal:groups
bookstoreBookstore.Realtime.AnnouncementHub:internal:return:Mac_03ac3ce396614dbc981319514892d7ac
bookstoreBookstore.Realtime.AnnouncementHub:internal:return:Mac_0a1cbd74ba37477db52d96f893c6a8e2
bookstore__Booksleeve_MasterChanged

Her hub için bir all kanalı, her bağlantı için bir kanal (kullanıcı ve grup kanalları da bağlantı ya da üyelik oldukça açılıyor) ve her sunucu için iç kanallar var. ChannelPrefix'in neden önemli olduğu da burada görünüyor: aynı Redis'i paylaşan iki uygulama farklı prefix kullanmazsa, birinin Clients.All'u diğerinin kullanıcılarına da gider. Prefix'in kanal adına ayraçsız eklendiğine dikkat; okunur olsun istersen sonuna kendin bir : koy.

Unutulmaması gereken nokta: backplane sticky session ihtiyacını kaldırmıyor. Negotiate ve bağlantının aynı sunucuya gitmesi hâlâ şart; backplane yalnızca mesajları sunucular arasında taşıyor. Bir de her mesaj her sunucuya gidiyor: 10 sunucu varsa, yalnızca birinde alıcısı olan bir mesaj bile Redis üzerinden 10 sunucuya ulaşıyor. Microsoft, Redis'in uygulamayla aynı veri merkezinde olmasını öneriyor; aradaki her milisaniye her mesaja ekleniyor.

Redis Kesintisinde Ne Olur?

Duyurular gönderilirken kendi Redis container'ımı 6 saniyeliğine durdurdum:

redis stopped at 05:51:54
redis started at 05:52:00
  ada  (on A) <- "before outage" (sent by A)
  alan (on B) <- "before outage" (sent by A)
POST /announce to A "before outage": 200 {"sentBy":"A"}
  ada  (on A) <- "during outage" (sent by A)
POST /announce to A "during outage": 200 {"sentBy":"A"}
  ada  (on A) <- "after recovery" (sent by A)
POST /announce to A "after recovery": 200 {"sentBy":"A"}
  alan (on B) <- "after recovery" (sent by A)
fail: Microsoft.AspNetCore.SignalR.StackExchangeRedis.RedisHubLifetimeManager[9]
      Connection to Redis failed.
      StackExchange.Redis.RedisConnectionException: SocketClosed (ReadEndOfStream, last-recv: 0) on localhost:17679/Subscription, ...

Kesinti sırasındaki duyuru Alan'a hiç ulaşmadı ve sonradan da gelmedi. Benim denememde POST yine 200 döndü ve aynı sunucudaki Ada mesajı aldı; yani kayıp gönderen tarafa hata olarak yansımadı. Microsoft'un dokümantasyonu da net: SignalR, Redis kapalıyken gönderilen mesajları tamponlamıyor, bunlar kayboluyor; Redis geri geldiğinde ise SignalR kendiliğinden yeniden bağlanıyor (son duyuru iki tarafa da ulaştı). Kesinti sırasında kullanıcıların bağlantıları açık kaldı; kaybolan yalnızca sunucular arası mesajlar.

Sonuç: Redis backplane'li bir sistemde Redis kritik bir bağımlılık. Ya Redis'i yüksek erişilebilir kur (Sentinel ya da Redis Cluster; backplane cluster'da kod değişikliği olmadan çalışıyor), ya da kaçırılmaması gereken mesajları önceki yazılarda konuştuğumuz gibi kalıcı bir kaynaktan (veritabanı, sıra numaralı event log) telafi edilebilir tasarla.

Azure SignalR Service

// bağlantı dizesi "Azure:SignalR:ConnectionString" configuration anahtarından okunur
builder.Services.AddSignalR()
    .AddAzureSignalR();

Azure SignalR Service yerelde çalıştırılamıyor ve bu yazı için bir Azure kaynağı açmadım; bu kodu yalnızca Microsoft.Azure.SignalR 1.33.1 paketiyle derledim, çalıştırmadım. Mekanizma dokümantasyona göre şöyle: istemci uygulamanın negotiate endpoint'ine geliyor, ama yanıt onu servisin adresine ve bir erişim token'ına yönlendiriyor. İstemcinin kalıcı bağlantısı uygulama sunucusuna değil servise açılıyor. Uygulama sunucuları servise az sayıda sabit bağlantı tutuyor; hub method çağrıları ve Clients.* mesajları bu bağlantılardan akıyor.

Sonuçları önemli. Sticky session gerekmiyor, çünkü istemci zaten servise bağlı. Uygulama sunucusu binlerce kalıcı bağlantı taşımadığı için onu bağlantı sayısına göre değil iş yüküne göre ölçekliyorsun. Servis aynı zamanda backplane görevi görüyor. Microsoft, Azure'da barındırılan SignalR uygulamaları için Redis backplane yerine bunu öneriyor. Karşılığında bir bulut bağımlılığı, birim başına fiyatlandırma ve servis ile uygulama arasındaki ağ gecikmesi var; kendi veri merkezinde çalışan ve düşük gecikme isteyen sistemler için Redis backplane daha uygun. Redis dışında topluluğun sunduğu backplane'ler de var (SQL Server, Orleans, NCache, Rebus), ama hiçbiri Microsoft'un kendi paketi değil.

İzleme: Metric'ler ve Tracing

.NET 8'den beri ASP.NET Core, SignalR ve Kestrel için yerleşik metric'ler yayınlıyor; .NET 9'dan beri hub method'ları için bir ActivitySource da var. Production'da bunları OpenTelemetry ile Prometheus'a ya da bir APM'e (Application Performance Monitoring) gönderirsin; burada ne yayınlandığını görmek için süreç içinde bir MeterListener ile okudum:

// SignalR ve Kestrel metric'lerini süreç içinden okumak için (gerçekte OpenTelemetry/Prometheus)
public sealed class ConnectionMetrics : IDisposable
{
    private readonly MeterListener _listener = new();
    private readonly ConcurrentDictionary<string, double> _values = new();

    public ConnectionMetrics()
    {
        _listener.InstrumentPublished = (instrument, listener) =>
        {
            if (instrument.Meter.Name is "Microsoft.AspNetCore.Http.Connections" or "Microsoft.AspNetCore.Server.Kestrel")
            {
                listener.EnableMeasurementEvents(instrument);
            }
        };
        _listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) => Record(instrument, value, tags));
        _listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) => Record(instrument, value, tags));
        _listener.Start();
    }

    private void Record(Instrument instrument, double value, ReadOnlySpan<KeyValuePair<string, object?>> tags)
    {
        string? transport = null;
        foreach (KeyValuePair<string, object?> tag in tags)
        {
            if (tag.Key == "signalr.transport")
            {
                transport = tag.Value?.ToString();
            }
        }
        string key = transport is null ? instrument.Name : $"{instrument.Name}{{transport={transport}}}";
        // histogram'larda ölçüm sayısını, sayaçlarda toplamı tut
        double delta = instrument is Histogram<double> ? 1 : value;
        _values.AddOrUpdate(instrument is Histogram<double> ? key + ".count" : key, delta, (_, current) => current + delta);
    }

    public SortedDictionary<string, double> Snapshot() => new(_values);

    public void Dispose() => _listener.Dispose();
}

A'ya iki WebSocket ve bir SSE bağlantısı açıp birini kapatmadan önce aldığım görüntü:

{
  "kestrel.active_connections": 5,
  "kestrel.connection.duration.count": 7,
  "kestrel.queued_connections": 0,
  "kestrel.upgraded_connections": 2,
  "signalr.server.active_connections{transport=server_sent_events}": 1,
  "signalr.server.active_connections{transport=web_sockets}": 2,
  "signalr.server.connection.duration{transport=web_sockets}.count": 3
}

İzlenmesi gerekenler bunlar: signalr.server.active_connections transport'a göre açık SignalR bağlantıları (SSE ve long polling oranının artması, WebSocket'i engelleyen bir proxy'nin işareti), signalr.server.connection.duration bağlantıların ömrü (kısalması, kopuşların arttığını gösteriyor), kestrel.upgraded_connections WebSocket'e yükseltilmiş TCP (Transmission Control Protocol) bağlantıları ve kestrel.active_connections toplam bağlantı. Kestrel sayısının SignalR sayısından büyük olması normal; negotiate ve SSE'nin yanındaki POST'lar gibi kısa ömürlü HTTP bağlantıları da sayılıyor.

Tracing tarafında, Microsoft.AspNetCore.SignalR.Server kaynağını dinleyen bir ActivityListener her hub çağrısı için şu span'i yazdı:

[trace] Bookstore.Realtime.AnnouncementHub/WhereAmI 0,0 ms rpc.method=WhereAmI rpc.system=signalr rpc.service=Bookstore.Realtime.AnnouncementHub

OpenTelemetry'de bunu tracing.AddSource("Microsoft.AspNetCore.SignalR.Server") ile açıyorsun. Hub method span'lerinin bir parent'ı olmadığını, yani uzun ömürlü bağlantının altında toplanmadığını dokümantasyon özellikle belirtiyor. .NET client'ında Microsoft.AspNetCore.SignalR.Client kaynağı istemci span'leri üretiyor ve context'i sunucuya taşıyor; JavaScript client'ında henüz tracing yok. Metric'leri geçici olarak terminalden izlemek istersen dotnet-counters monitor --counters Microsoft.AspNetCore.Http.Connections da aynı veriyi gösterir.

Üretimde Dikkat

Yük testini bağlantı sayısıyla yap, istek sayısıyla değil. SignalR'ın darboğazı genelde saniyedeki istek değil, eşzamanlı açık bağlantı ve saniyedeki mesaj. Serinin ilk yazısında 2.000 boşta SignalR bağlantısının yaklaşık 62 MB bellek eklediğini ölçmüştük; hedef bağlantı sayın için bellek, dosya tanımlayıcı limiti (ulimit -n), load balancer'ın bağlantı limiti ve Redis'in pub/sub trafiğini birlikte test et. Bunun araçlarını ilerideki Performance ve Load Testing serisinde ele alacağız.

Deploy'u planla. Bir sunucu kapatıldığında ona bağlı bütün istemciler yeniden bağlanır ve sticky session onları kalan sunuculara dağıtır; rolling deploy'da her adımda bir yeniden bağlanma dalgası var. Bir önceki yazıdaki jitter'lı retry policy burada işe yarıyor.

Load balancer ve proxy timeout'larını SignalR'ın keep-alive'ıyla uyumlu tut: sunucu varsayılan olarak 15 saniyede bir keep-alive gönderiyor, istemci 30 saniye mesaj alamazsa bağlantıyı kopmuş sayıyor. Aradaki herhangi bir idle timeout bunlardan kısaysa bağlantılar düzenli aralıklarla kopar.

Sık Düşülen Tuzaklar

Backplane'i sticky session'ın yerine geçer sanmak. Backplane mesajları taşır; negotiate ile bağlantının aynı sunucuya gitmesini sağlamaz.

Sticky session olmadan "çoğu zaman çalışıyor" diye production'a çıkmak. Bağlantıların bir kısmı rastgele başarısız olur ve hata yalnızca istemcide görünür.

Birden fazla uygulamada aynı Redis'i ChannelPrefix olmadan kullanmak. Bir uygulamanın yayını diğerinin kullanıcılarına gider.

Redis kesintisinin gönderen tarafta hata olarak görüneceğini varsaymak. Benim denememde POST 200 döndü, diğer sunucudaki kullanıcı mesajı kaybetti.

ip_hash'i tek sticky çözüm sanmak. NAT arkasındaki kullanıcılar tek sunucuya yığılır; cookie tabanlı affinity daha dengeli.

Metric'leri yalnızca HTTP istek sayısı üzerinden izlemek. SignalR'da asıl sinyaller açık bağlantı sayısı, transport dağılımı ve bağlantı ömrü.

Küçük Bir Deneme

★☆☆ İki kopyayı Redis'siz çalıştırıp /sticky/ üzerinden iki istemci bağla. İkisi de aynı sunucuya düştüğü için duyuruların neden ikisine de gittiğini açıkla; sonra istemcilerden birini doğrudan diğer kopyanın portuna bağla.

★★☆ ConnectionMetrics'teki değerleri OpenTelemetry ile bir Prometheus endpoint'ine taşı (AddMeter("Microsoft.AspNetCore.Http.Connections")) ve 200 bağlantı açıp kapatarak signalr.server.active_connections grafiğini izle.

★★★ Redis bağlantısı koptuğunda bir sağlık kontrolünü (/health) "Degraded" yapan bir ConnectionMultiplexer.ConnectionFailed handler'ı yaz ve load balancer'ın bu durumda ne yapması gerektiğini tartış: trafiği kesmek mi, yalnızca uyarı üretmek mi?

Serinin son yazısında sürüm yeniliklerine dönüyoruz: .NET 8'den .NET 11'e real-time tarafında gelenler; stateful reconnect, Native AOT (Ahead-of-Time), SSE ve bağlantıyı koparmadan token yenileyen SignalR authentication refresh.