Nginx ile ASP.NET Core Reverse Proxy: proxy_pass, Header'lar, WebSocket/SignalR ve Timeout'lar
Nginx 1.29.7'den beri upstream keepalive ve HTTP/1.1 varsayılan, ama yalnız upstream block'u kullanırsan: doğrudan adrese giden proxy_pass her istekte yeni bağlantı açıyor. Microsoft'un referans config'ini satır satır açıyor, WebSocket upgrade map'i olmadan SignalR'ın sessizce long polling'e düştüğünü, SSE'de buffering'i, Nginx ile Kestrel'in body ve header limit'lerini hizalamayı ve 502 ile 504'ü log'dan ayırt etmeyi ölçüyoruz.
Sürüm notu: Bu yazıdaki örnekler Nginx 1.30.6 ve 1.28.3 (resmi image'lar), .NET 10 (ASP.NET Core runtime 10.0.14) ve curl 8.23 ile Docker 29 üzerinde denenmiştir.
Önce Tahmin Et
Bookstore'un API'si (Application Programming Interface), yani Bookstore.Api, Nginx'in arkasında ve client art arda 100 istek gönderiyor. Kestrel'deki /api/whoami endpoint'i her istekte HttpContext.Connection.Id'yi döndürüyor; farklı ID sayısı, Nginx'in Kestrel'e kaç ayrı TCP (Transmission Control Protocol) bağlantısı açtığını söylüyor. Dört kurulum var: Nginx 1.28 ve 1.30, her birinde proxy_pass ya bir upstream block'una ya da doğrudan http://172.30.85.21:8080 adresine gidiyor.
Tahmin et: dört kurulumun her birinde Kestrel kaç bağlantı görür? Nginx 1.29.7'nin sürüm notunda "upstream keepalive artık varsayılan olarak açık" yazıyor; bu cümle dördüne de uyuyor mu?
Microsoft'un Referans Config'i Satır Satır
Microsoft Learn'deki "Host ASP.NET Core on Linux with Nginx" sayfasının config'i, bu yazının çıkış noktası:
map $http_connection $connection_upgrade {
"~*Upgrade" $http_connection;
default keep-alive;
}
server {
listen 80;
server_name bookstore.local;
location / {
proxy_pass http://172.30.85.21:8080/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Satır satır:
map: client'ınConnectionheader'ında "Upgrade" geçiyorsa onu olduğu gibi aktarır, geçmiyorsakeep-aliveyazar. WebSocket el sıkışması için gerekli, aşağıda göreceğiz.proxy_pass http://...:8080/: sondaki slash,location /ile birleşince/'ı/ile değiştiriyor; yani etkisiz. Önceki yazıda gördüğün kural.proxy_http_version 1.1: Nginx 1.29.7'den önce upstream'e varsayılan olarak HTTP (Hypertext Transfer Protocol) 1.0 ile gidiyordu; keepalive ve WebSocket için 1.1 şart. 1.29.7'den beri varsayılan zaten 1.1.UpgradeveConnection: hop-by-hop header'lar; proxy bunları kendiliğinden iletmez, açıkça set etmek gerekir.Host $host: client'ın istediği host'u uygulamaya taşır. Vermezsen önceki yazıdaki gibi upstream'in adresi gider.proxy_cache_bypass $http_upgrade: yalnızproxy_cacheaçıksa anlamlı; upgrade isteğini cache'ten cevaplamamak için.X-Forwarded-ForveX-Forwarded-Proto: uygulamanın client'ı doğru görmesi için; üçüncü yazının konusu.
Config kısa ve doğru, ama bir şeyi yapmıyor: Kestrel'e giden bağlantıları yeniden kullanmıyor. Tahminin cevabı bunu gösteriyor.
Upstream Keepalive ve 1.29.7 Varsayılanları
Dört kurulumun sonuçları (her biri 100 istek, Kestrel'in gördüğü farklı bağlantı sayısı):
$ for i in $(seq 1 100); do curl -s -H "Host: bookstore.local" http://172.30.85.10/api/whoami | jq -r .connectionId; done | sort -u | wc -l
# upstream block'u ile
nginx:1.28 -> 100
nginx:1.30 -> 10
# doğrudan adres (Microsoft config'i dahil)
nginx:1.28 -> 100
nginx:1.30 -> 100
1.28'de her istek yeni bir TCP bağlantısı; Kestrel'in protocol alanı da bunu doğruluyor, istekler HTTP/1.0 olarak geliyordu. 1.30'da upstream block'u ile 100 istek 10 bağlantıya sığdı: bu makinede 10 worker process var ve her worker kendi keepalive havuzunu tutuyor. 1.29.7'nin sürüm notundaki iki değişiklik birlikte çalışıyor: "upstream block'undaki keepalive directive'i varsayılan olarak açık" ve "proxy modülü varsayılan olarak keepalive destekliyor; proxy_http_version varsayılanı 1.1 ve Connection header'ı artık gönderilmiyor".
Ama doğrudan adrese giden proxy_pass'te 1.30 bile her istekte yeni bağlantı açtı. Varsayılan keepalive, upstream block'unun bir özelliği; Microsoft'un referans config'i upstream block'u kullanmadığı için bundan faydalanmıyor. Bağlantı başına maliyet loopback'te küçük, ama yük altında Kestrel'in her istekte bağlantı kabul etmesi, Nginx tarafında da TIME_WAIT'te bekleyen binlerce socket demek. Bookstore'un config'i bu yüzden upstream block'uyla yazılıyor:
upstream bookstore_api {
server 172.30.85.21:8080;
}
server {
listen 80;
server_name bookstore.local;
location /api/ {
proxy_pass http://bookstore_api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
1.30'dan eski bir Nginx kullanıyorsan aynı etkiyi upstream'e keepalive 16; ve location'a proxy_http_version 1.1; ile proxy_set_header Connection ""; ekleyerek alırsın. Kestrel'in boştaki bağlantıyı kapatma süresi (KeepAliveTimeout, varsayılan 2 dakika 10 saniye) Nginx'in upstream bağlantıyı boşta tutma süresinden (keepalive_timeout, varsayılan 60 saniye) uzun olmalı; tersi olursa Nginx, Kestrel'in kapattığı bir bağlantıya istek yazmaya çalışır.
WebSocket ve SignalR: Upgrade Map'i
Bookstore.Api'de stok değişikliklerini yayan bir SignalR hub'ı var (/hubs/stock). SignalR client'ı önce negotiate endpoint'ine POST atıyor, sonra WebSocket'e upgrade istiyor. Aynı iki adımı curl ile, önce yalnız temel header'ları ileten bir config'le, sonra Microsoft'un map'li config'iyle yaptım:
#!/bin/sh
# SignalR negotiate + Nginx üzerinden WebSocket upgrade; status satırını ve önemli header'ları göster
tok=$(curl -s -X POST -H "Host: bookstore.local" "http://172.30.85.10/hubs/stock/negotiate?negotiateVersion=1" | jq -r .connectionToken)
echo "connectionToken: $tok"
curl -s -i -N --http1.1 --max-time 2 -H "Host: bookstore.local" -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"http://172.30.85.10/hubs/stock?id=$tok" | tr -d '\r' | grep -E "^HTTP|^Upgrade|^Connection|^Sec-WebSocket-Accept|WebSocket|websocket" | head -6
# Upgrade ve Connection iletilmiyor
connectionToken: AsASzzYCFVtEHEyxARfVqA
HTTP/1.1 200 OK
Connection: keep-alive
# Microsoft config'i (map ile)
connectionToken: J9TzWq8-fnLEzrK8hcNZhw
HTTP/1.1 101 Switching Protocols
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
İlk durum sinsi, çünkü hata yok. Upgrade header'ı Kestrel'e ulaşmadığı için istek düz bir GET olarak gidiyor, SignalR onu long polling isteği sayıp 200 dönüyor. Gerçek bir SignalR client'ı WebSocket başarısız olunca Server-Sent Events'e, o da olmazsa long polling'e düşüyor ve uygulama çalışmaya devam ediyor; yalnız daha yavaş, daha çok istekle ve daha çok bağlantıyla. Tarayıcının network sekmesinde transport=longPolling görüyorsan önce proxy'ye bak.
İkinci durumda 101 Switching Protocols geliyor ve bağlantı WebSocket'e dönüyor. Map'in amacı, upgrade olmayan isteklerde Connection'a "upgrade" yazmamak: tüm isteklere sabit Connection: upgrade göndermek keepalive'ı bozar. Uzun yaşayan WebSocket bağlantısı Nginx'in proxy_read_timeout'una (varsayılan 60 saniye) takılır; SignalR sunucusu varsayılan olarak 15 saniyede bir keep-alive mesajı gönderdiği için bağlantı açık kalıyor, ama kendi yazdığın çıplak WebSocket'te bu süreyi ya ping'le ya da timeout'u artırarak yönetmen gerekiyor. Birden çok instance'ta SignalR için sticky session gereksinimi Real-time Uygulamalar serisinin "SignalR'ı Scale-Out Etmek" yazısında; Nginx tarafındaki ip_hash ve sticky yedinci yazıda.
Server-Sent Events ve Buffering
Bookstore.Api'deki /api/stock/stream endpoint'i .NET 10'un TypedResults.ServerSentEvents'i ile saniyede bir stok güncellemesi gönderiyor. Kestrel'in doğrudan yanıt header'ları:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Sun, 08 Nov 2026 04:06:30 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Pragma: no-cache
Transfer-Encoding: chunked
Nginx'in varsayılanı proxy_buffering on. Event'lerin client'a ne zaman ulaştığını ölçen küçük bir script'le, config'e hiçbir SSE (Server-Sent Events) ayarı eklemeden:
+1s data: 04:07:22 Dune stock=19
+2s data: 04:07:23 Dune stock=18
+3s data: 04:07:24 Dune stock=17
+5s data: 04:07:25 Dune stock=16
+6s data: 04:07:26 Dune stock=15
Event'ler gecikmeden geldi. Nginx'in buffering'i "yanıtı tamamen topla, sonra gönder" demek değil; client hızlı okuduğu sürece gelen veriyi bekletmeden iletiyor, buffer yalnız client yavaş kaldığında doluyor. SSE'yi asıl bozan şey sıkıştırma: gzip modülü veriyi biriktiriyor. .NET 10'un SSE yanıtı Content-Encoding: identity ile geldiği için Nginx onu sıkıştırmaya kalkmıyor. Bu ayrıntıların hepsi, X-Accel-Buffering header'ı ve uzun bağlantılarda timeout'lar Real-time Uygulamalar serisinin "Server-Sent Events Derinlemesine" yazısında ayrıntılı ölçüldü; .NET 10'daki API'nin kendisi ASP.NET Core Web API serisinin ".NET 10 Yenilikleri" yazısında.
Yine de SSE ve uzun süren stream'ler için ayrı bir location açıp proxy_buffering off; ve uzun bir proxy_read_timeout vermek iyi bir alışkanlık: yavaş bir mobil client'ta da davranış öngörülebilir olur ve bu ayarlar API'nin geri kalanına sızmaz.
Body ve Header Limit'leri: Nginx ile Kestrel'i Hizalamak
İki katmanın da limit'i var ve önce Nginx'inki devreye giriyor. 2 MB ve 40 MB'lık iki dosyayı /api/uploads'a gönderdim:
$ for f in 2mb 40mb; do curl -s -o /tmp/b -w "$f -> %{http_code} uploaded=%{size_upload} " -H 'Host: bookstore.local' \
-H 'Content-Type: application/octet-stream' --data-binary @/w/www/$f.bin http://172.30.85.10/api/uploads; grep -o '<title>[^<]*\|{.*}' /tmp/b; done
# Nginx varsayılanı: client_max_body_size 1m
2mb -> 413 uploaded=0 <title>413 Request Entity Too Large
40mb -> 413 uploaded=0 <title>413 Request Entity Too Large
# client_max_body_size 50m
2mb -> 200 uploaded=2097152 {"bytes":2097152}
40mb -> 413 uploaded=41943040
Nginx'in error log'u ilk denemedeki reddi şöyle yazıyor:
[error] 21#21: *2 client intended to send too large body: 41943040 bytes, client: 172.30.85.50, server: bookstore.local, request: "POST /api/uploads HTTP/1.1", host: "bookstore.local"
Nginx'in varsayılan 1 MB'ı, Kestrel'in 30.000.000 byte'lık varsayılanından çok daha küçük; Nginx'i ilk kurduğunda dosya yükleme özelliği "Kestrel'de çalışıyordu, sunucuda 413 veriyor" diye geri döner. Limit'i 50 MB'a çıkarınca 2 MB geçti, 40 MB'ı bu kez Kestrel reddetti:
[warn] 21#21: *3 a client request body is buffered to a temporary file /var/cache/nginx/client_temp/0000000002, client: 172.30.85.50, server: bookstore.local, request: "POST /api/uploads HTTP/1.1", host: "bookstore.local"
172.30.85.50 "POST /api/uploads HTTP/1.1" 413 rt=0.101 urt=0.048 us=413 ua=172.30.85.21:8080
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNPT9PQVOK4A", Request id "0HNPT9PQVOK4A:00000001": An unhandled exception was thrown by the application.
Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Request body too large. The max request body size is 30000000 bytes.
Dikkat çekici olan uploaded=41943040: client 40 MB'ın tamamını yükledi, Nginx onu diske yazdı (buffered to a temporary file), sonra Kestrel'e iletti ve Kestrel reddetti. Kullanıcı açısından boşa giden bir yükleme, sunucu açısından boşa giden disk ve bant genişliği. Kural: Nginx'in limit'i Kestrel'inkine eşit ya da küçük olsun, büyük dosya kabul eden endpoint'ler ayrı bir location'da kendi limit'iyle tanımlansın. Kestrel tarafında endpoint bazlı limit ASP.NET Core Web API serisinin "Dosya Yükleme ve İndirme" yazısında.
Header'larda da aynı hikâye. Nginx tek bir header satırı için varsayılan olarak 8 KB'lık buffer kullanıyor (large_client_header_buffers 4 8k), Kestrel ise bütün header'ların toplamına 32 KB izin veriyor:
# varsayılan
cookie=10000B -> 400 <title>400 Request Header Or Cookie Too Large
cookie=40000B -> 400 <title>400 Request Header Or Cookie Too Large
# large_client_header_buffers 4 64k;
cookie=10000B -> 200
cookie=40000B -> 431
Büyük token'lar ya da çok sayıda claim taşıyan cookie'ler (Microsoft Entra ID ile çalışan uygulamalar sık rastlıyor) önce Nginx'e takılıyor. Buffer'ı büyütünce 40 KB'lık cookie bu kez Kestrel'den 431 (Request Header Fields Too Large) aldı. Microsoft'un dokümanı buffer'ları gerekmedikçe büyütmemeyi, büyütürsen de DoS (Denial of Service) riskini hesaba katmayı söylüyor. Önce cookie'yi küçültmeyi dene; olmuyorsa iki katmanı birlikte ayarla.
Timeout'lar: 502 ile 504'ü Ayırt Etmek
Gateway hataları, Nginx'in "upstream'le konuşamadım" deme biçimi; hangisinin geldiği sorunun nerede olduğunu söylüyor. Ayırt etmek için access log'a upstream süresini ve status'ünü ekledim:
log_format upstream_timing '$remote_addr "$request" $status '
'rt=$request_time urt=$upstream_response_time '
'us=$upstream_status ua=$upstream_addr';
Location'da proxy_connect_timeout 2s ve proxy_read_timeout 5s varken üç durum:
# 2 saniyelik rapor: sorun yok
172.30.85.50 "GET /api/reports/slow?seconds=2 HTTP/1.1" 200 rt=2.589 urt=2.589 us=200 ua=172.30.85.21:8080
# 10 saniyelik rapor: Kestrel ayakta ama yanıt 5 saniyede gelmedi
172.30.85.50 "GET /api/reports/slow?seconds=10 HTTP/1.1" 504 rt=5.016 urt=5.016 us=504 ua=172.30.85.21:8080
[error] 22#22: *3 upstream timed out (110: Connection timed out) while reading response header from upstream, ...
# process ayakta değil, port kapalı
172.30.85.50 "GET /api/books HTTP/1.1" 502 rt=0.011 urt=0.011 us=502 ua=172.30.85.21:8080
[error] 27#27: *9 connect() failed (111: Connection refused) while connecting to upstream, ...
502, bağlantının hiç kurulamadığı ya da upstream'in bozuk yanıt verdiği durum: process çökmüş, yanlış port, Unix socket izni, Kestrel'in yanıtı yarıda kesmesi. Süre milisaniyeler. 504 ise bağlantının kurulduğu ama yanıtın zamanında gelmediği durum: yavaş sorgu, kilitlenmiş thread pool, veritabanı beklemesi. Süre tam olarak timeout kadar. Bir ayrıntı daha: container'ı tamamen durdurduğumda Nginx 502 değil 504 verdi, çünkü IP (Internet Protocol) adresi artık yanıt vermiyordu ve hata while connecting to upstream ile proxy_connect_timeout'tan geldi. Error log'daki "while connecting" ile "while reading response header" ayrımı, status code'dan daha çok şey anlatıyor.
504 görünce ilk refleks proxy_read_timeout'u artırmak oluyor. Bir rapor 3 dakika sürüyorsa timeout'u 5 dakikaya çıkarmak sorunu yalnız taşır: Kestrel'de bir request thread'i 3 dakika meşgul, kullanıcı bekliyor, bulut load balancer'ının da kendi timeout'u var. Uzun işin doğru yeri bir background job: endpoint işi kuyruğa koyup 202 Accepted ve bir durum adresi döner, client durumu sorgular ya da SSE ile haber alır. Bu yaklaşım Background Job'lar ve Scheduled Job'lar serisinin ilk yazısında anlatılıyor.
Üretimde Dikkat
Ortak proxy ayarlarını tek bir dosyada topla ve her location'a include et. Microsoft'un örneğindeki proxy.conf bu işi görüyor; içinde proxy_redirect off, header seti, client_max_body_size 10m ve 90 saniyelik timeout'lar var. Dikkat: proxy_set_header bir location'da tekrar tanımlanırsa üst context'tekilerin hepsi düşüyor, bu yüzden include'u location içinde yap ve location'a özel header'ları include'dan sonra ekle.
$upstream_response_time ile $request_time arasındaki fark, Nginx ile client arasında geçen süreyi gösterir. Fark büyükse yavaş olan uygulaman değil, client'ın ağı ya da yanıtın boyutu. Bu iki değeri log'a eklemek, "API yavaş" şikâyetlerinin yarısını ilk bakışta çözüyor.
Nginx'i güncellerken sürüm notlarındaki "Change" satırlarını oku. 1.29.7'deki keepalive değişikliği çoğu kurulum için bedava performans; ama upstream'deki uygulama keepalive'ı yanlış yönetiyorsa (örneğin kısa KeepAliveTimeout), güncellemeden sonra ara sıra 502 görmeye başlarsın.
Sık Düşülen Tuzaklar
Upstream block'u yerine doğrudan adres yazmak. Config çalışıyor, ama 1.30'un keepalive varsayılanından faydalanmıyorsun; her istek yeni bağlantı.
WebSocket header'larını unutmak. Hata yok, SignalR sessizce long polling'e düşüyor; performans sorununu proxy'de değil uygulamada ararsın.
Nginx ile Kestrel'in limit'lerini ayrı ayrı ayarlamak. Nginx 1 MB'ta kesiyor, sen Kestrel'de 50 MB'a çıkarıyorsun; ya da Nginx 50 MB'ı diske yazıp Kestrel'e iletiyor, Kestrel 30 MB'ta reddediyor.
504'ü timeout'u şişirerek çözmek. Uzun işi request'ten çıkar; timeout'u artırmak bekleyen kullanıcıyı ve dolan bağlantıları çoğaltır.
502 ile 504'ü status code'dan teşhis etmek. Container'ı durdurmak 504 üretebiliyor; error log'daki "while connecting" ve "while reading" ayrımına bak.
Küçük Bir Deneme
★☆☆ Microsoft'un referans config'indeki proxy_pass'i bir upstream block'una çevir ve 100 isteklik deneyi tekrarla. Kaç bağlantı görüyorsun? Sonra Nginx config'inin en üstüne worker_processes 1; koyduğunda sayı nasıl değişir?
★★☆ /api/stock/stream için ayrı bir location aç, gzip on; gzip_types text/event-stream; ekle ve event'lerin varış zamanlarını ölç. Sonra Bookstore.Api'de SSE yerine elle text/event-stream yazan bir endpoint (yani Content-Encoding: identity olmadan) ile tekrarla. Hangi durumda gecikme başlıyor?
★★★ /api/reports/slow'u background job'a taşı: endpoint bir job ID ile 202 Accepted dönsün, /api/reports/{id} durumu versin. Nginx'teki proxy_read_timeout'u 5 saniyede bırakıp 30 saniyelik bir raporu uçtan uca çalıştır.
API, Nginx arkasında artık doğru header'larla, keepalive ile ve hizalı limit'lerle çalışıyor. Bir sonraki yazıda Bookstore'un diğer yarısına geçiyoruz: Angular uygulamasını Nginx ile yayınlamak, SPA (Single Page Application) fallback, hash'li bundle'lar ve index.html için cache header'ları, gzip ve önceden sıkıştırılmış dosyalar, runtime config.json ve aynı origin'de /api proxy'si ile CORS'u (Cross-Origin Resource Sharing) ortadan kaldırmak.