Kestrel'i Tanımak: Endpoint'ler, ASPNETCORE_HTTP_PORTS, Limit'ler ve HTTP/2–HTTP/3
Kestrel'in dinlediği adresi kodda Listen, Kestrel:Endpoints, --urls/ASPNETCORE_URLS ve ASPNETCORE_HTTP_PORTS belirler; öncelik bu sırayla ve ezilen her ayar log'a uyarı olarak düşer. Dokuz koşuyla önceliği ölçüyor, PEM certificate ve SNI'yi, restart'sız certificate yenilemeyi, varsayılan limit'leri, libmsquic ile HTTP/3'ü, Nginx'e Unix socket ile bağlanmayı ve container'da localhost tuzağını görüyoruz.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (ASP.NET Core runtime 10.0.14, mcr.microsoft.com/dotnet/aspnet:10.0 image'ı), libmsquic 2.6.3, Nginx 1.30.6 ve curl 8.23 ile Docker 29 üzerinde denenmiştir.
Önce Tahmin Et
Bookstore.Api'yi container'da çalıştırıyorsun. Image'ın kendisi ASPNETCORE_HTTP_PORTS=8080 ile geliyor. Sen docker run'a -e ASPNETCORE_URLS=http://+:5000 ekliyorsun, komutun sonuna --urls http://+:6000 yazıyorsun, bir de ortam değişkeniyle Kestrel__Endpoints__Api__Url=http://+:7000 veriyorsun.
Uygulama hangi portu dinler: 8080, 5000, 6000, 7000, yoksa hepsini mi? Log'da bir uyarı görür müsün? Ve aynı uygulamayı ASPNETCORE_URLS=http://localhost:8080 ile başlatırsan, aynı Docker network'teki başka bir container ona ulaşabilir mi?
Kestrel'in Port'u Öğrendiği Beş Yer
Kestrel'e "şu adresi dinle" demenin beş yolu var ve hepsi aynı uygulamada aynı anda bulunabilir:
| Kaynak | Örnek | Not |
|---|---|---|
Kodda Listen* | kestrel.ListenAnyIP(9000) | ConfigureKestrel içinde; en güçlüsü |
Configuration'da Kestrel:Endpoints | Kestrel__Endpoints__Api__Url=http://+:7000 | Adı olan endpoint; certificate, protocol ve SNI (Server Name Indication) ayarı burada |
urls anahtarı | --urls, ASPNETCORE_URLS, UseUrls | Noktalı virgülle ayrılmış URL (Uniform Resource Locator) listesi |
HTTP_PORTS ve HTTPS_PORTS | ASPNETCORE_HTTP_PORTS=8080 | .NET 8 ile geldi; yalnız port, en zayıfı |
| Hiçbiri | - | http://localhost:5000 |
Önceliği dokümandan okumak yerine ölçelim. Aynı publish çıktısını her seferinde farklı bir ayarla başlatıp yalnız binding'le ilgili log satırlarını alan küçük bir script yazdım:
#!/bin/bash
# uygulamayı verilen env/argümanlarla bir kez çalıştır, binding satırlarını göster
W="$(cd "$(dirname "$0")" && pwd)"
name=$1; shift
echo "### $name"
docker rm -f webhwork-prec >/dev/null 2>&1
docker run -d --name webhwork-prec --network webhwork-net -v "$W/out/app":/app:ro -w /app "$@" >/dev/null
for i in $(seq 1 20); do docker logs webhwork-prec 2>&1 | grep -q -E "Application started|Unhandled|fail" && break; sleep 0.5; done
docker logs webhwork-prec 2>&1 | grep -v -E "DataProtection|unencrypted|aka.ms|Hosting environment|Content root|Press Ctrl" | grep -v '^\s*at ' | head -12
docker rm -f webhwork-prec >/dev/null 2>&1
Hiçbir şey vermeden, yalnız image'ın kendi ortam değişkeniyle, sonra o değişkeni de boşaltarak:
$ ./prec.sh "1 image default" mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 1 image default
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
$ ./prec.sh "2 nothing" -e ASPNETCORE_HTTP_PORTS= mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 2 nothing
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
Container'daki 8080'i senin kodun değil, image seçiyor. Microsoft'un .NET 8'den beri bütün ASP.NET Core image'larında tanımladığı ASPNETCORE_HTTP_PORTS=8080 değişkeni bunu yapıyor. Değişkeni boşaltınca Kestrel kendi varsayılanına, localhost:5000'e dönüyor. Container içinde localhost'u dinlemenin ne demek olduğuna birazdan geleceğiz.
Şimdi kaynakları üst üste koyalım:
$ ./prec.sh "3 URLS + HTTP_PORTS" -e ASPNETCORE_URLS=http://+:5000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 3 URLS + HTTP_PORTS
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://+:5000'.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:5000
$ ./prec.sh "4 --urls" -e ASPNETCORE_URLS=http://+:5000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll --urls http://+:6000
### 4 --urls
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://+:6000'.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:6000
$ ./prec.sh "5 Kestrel:Endpoints" -e ASPNETCORE_URLS=http://+:5000 -e Kestrel__Endpoints__Api__Url=http://+:7000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll --urls http://+:6000
### 5 Kestrel:Endpoints
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://+:6000'.
warn: Microsoft.AspNetCore.Server.Kestrel[0]
Overriding address(es) 'http://+:6000'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:7000
Tahminin cevabı beşinci koşuda: yalnız 7000. Sıra iki adımda işliyor. Önce urls anahtarı HTTP_PORTS'u eziyor; --urls ile ASPNETCORE_URLS arasındaki seçimi ise ASP.NET Core Web API (Application Programming Interface) serisinin "Configuration ve Options Pattern" yazısında gördüğün provider sırası yapıyor: command line, ortam değişkeninden sonra eklendiği için kazanıyor. Sonra Kestrel'in kendi endpoint tanımları (configuration'daki Kestrel:Endpoints ve koddaki Listen*) devreye giriyor ve urls'ten gelen her şeyi bırakıyor. İki ezilme de log'a warn seviyesinde yazılıyor; production log'unda bu iki satırı görüyorsan, birinin ayarı boşa gidiyor demektir.
Kodda dinlemek de Kestrel'in kendi tanımı sayılıyor ve configuration'daki endpoint'lerle birlikte çalışıyor. Bookstore.Api'de bunun için küçük bir anahtar var:
if (int.TryParse(builder.Configuration["Demo:ListenPort"], out int listenPort))
{
builder.WebHost.ConfigureKestrel(kestrel => kestrel.ListenAnyIP(listenPort));
}
$ ./prec.sh "6 Listen in code" -e Kestrel__Endpoints__Api__Url=http://+:7000 -e Demo__ListenPort=9000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 6 Listen in code
warn: Microsoft.AspNetCore.Server.Kestrel[0]
Overriding address(es) 'http://*:8080'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:9000
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:7000
$ ./prec.sh "7 same port twice" -e Kestrel__Endpoints__Api__Url=http://+:9000 -e Demo__ListenPort=9000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 7 same port twice
warn: Microsoft.AspNetCore.Server.Kestrel[0]
Overriding address(es) 'http://*:8080'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
System.IO.IOException: Failed to bind to address http://[::]:9000: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: Address already in use
Altıncı koşuda HTTP_PORTS=8080'in Kestrel'e http://*:8080 olarak ulaştığını da görüyorsun: port listesi aslında urls'ün kısaltması. Yedinci koşu, iki kaynağın aynı portu istediği durum. Burada ezilme yok, ikisi de bind etmeye çalışıyor ve uygulama hiç ayağa kalkmıyor. Hata mesajı "address already in use" dediği için insan önce başka bir process'i arıyor; oysa portu tutan, aynı process'in diğer endpoint'i.
0.0.0.0, [::], localhost ve +
URL'deki host kısmı Kestrel için "hangi network interface'ini dinleyeyim" sorusunun cevabı, domain eşleştirmesi değil. 0.0.0.0 bütün IPv4 adreslerini, [::] IPv6 ve dual-mode socket ile hepsini dinler. localhost yalnız loopback'i (127.0.0.1 ve [::1]) dinler. Tanınmayan her şey, + ve * dahil, joker sayılır; Kestrel log'da bunu http://[::]:5000 olarak gösteriyor. http://api.bookstore.example:80 yazmak Kestrel'i o domain'e kısıtlamaz; host'a göre ayırmak istiyorsan bu iş host filtering'in ya da önündeki proxy'nin.
Container'da localhost özel bir tuzak, çünkü container'ın loopback'i yalnız o container'a ait:
$ docker run -d --name webhwork-api1 --network webhwork-net --ip 172.30.85.21 \
-e ASPNETCORE_URLS=http://localhost:8080 ... mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
$ docker logs webhwork-api1 2>&1 | grep -A1 listening
Now listening on: http://localhost:8080
$ curl -sS http://172.30.85.21:8080/api/books/1
curl: (7) Failed to connect to 172.30.85.21:8080 after 0 ms: Could not connect to server
Log her şeyin yolunda olduğunu söylüyor, container ayakta, health check'i container'ın içinden yapıyorsan o da geçiyor. Ama network'teki hiçbir şey uygulamaya ulaşamıyor. Container'da http://+:8080 (ya da hiç dokunmayıp image'ın HTTP_PORTS'unu kullanmak) doğru seçim. VM'de (virtual machine) ise tam tersi: Nginx arkasındaki Kestrel'i http://127.0.0.1:5000 ile loopback'e kapatmak, proxy'nin atlanmasını engelleyen en ucuz önlem.
Certificate: PEM, PFX ve SNI
Kestrel HTTPS'i (HTTP Secure) kendisi sonlandırabilir. Bunun için certificate'ı configuration'dan vermek, kodda UseHttps çağırmaktan daha esnek: dosya yolu ortama göre değişir, kod değişmez. Deneme için çalışma klasöründe OpenSSL ile 30 günlük bir test CA'sı (Certificate Authority) ve api.bookstore.test ile admin.bookstore.test için iki certificate ürettim; özel anahtarlar o klasörden çıkmıyor. appsettings.Production.json:
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://+:8080"
},
"Https": {
"Url": "https://+:8443",
"Protocols": "Http1AndHttp2",
"Certificate": {
"Path": "/certs/api.bookstore.test.crt",
"KeyPath": "/certs/api.bookstore.test.key"
},
"Sni": {
"admin.bookstore.test": {
"Certificate": {
"Path": "/certs/admin.bookstore.test.crt",
"KeyPath": "/certs/admin.bookstore.test.key"
}
},
"*": {
"Certificate": {
"Path": "/certs/api.bookstore.test.crt",
"KeyPath": "/certs/api.bookstore.test.key"
}
}
}
}
}
}
}
Path ve KeyPath ikilisi PEM (Privacy-Enhanced Mail) dosyaları için; PFX (PKCS #12) kullanıyorsan Path ile Password veriyorsun, Windows'ta certificate store'dan almak için Subject ve Store. Sni bölümü, TLS (Transport Layer Security) el sıkışmasında client'ın söylediği host adına göre certificate seçiyor. Aynı IP (Internet Protocol) adresi ve portta iki host:
$ for h in api.bookstore.test admin.bookstore.test; do
curl -sv --cacert /w/certs/ca.crt --resolve $h:8443:172.30.85.21 https://$h:8443/api/whoami -o /dev/null 2>&1 \
| grep -E "ALPN|subject:|SSL connection|HTTP/2 200|using HTTP"; done
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* subject: CN=api.bookstore.test
* using HTTP/2
< HTTP/2 200
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* subject: CN=admin.bookstore.test
* using HTTP/2
< HTTP/2 200
Certificate yenilendiğinde uygulamayı yeniden başlatman gerekiyor mu? Denedim: container çalışırken api.bookstore.test için 60 günlük yeni bir certificate üretip eski dosyaların üstüne yazdım ve el sıkışmada sunulan certificate'a önce ve sonra baktım:
$ echo | openssl s_client -connect 172.30.85.21:8443 -servername api.bookstore.test 2>/dev/null | openssl x509 -noout -serial -enddate
serial=4F1C62F7875DFE93B63D7ACB853D244745B382FD
notAfter=Dec 8 03:32:35 2026 GMT
$ cp new.key api.bookstore.test.key; cp new.crt api.bookstore.test.crt
$ echo | openssl s_client -connect 172.30.85.21:8443 -servername api.bookstore.test 2>/dev/null | openssl x509 -noout -serial -enddate
serial=4F1C62F7875DFE93B63D7ACB853D244745B38300
notAfter=Jan 7 03:41:31 2027 GMT
Restart olmadan yeni certificate sunuluyor. Kestrel configuration'dan verilen certificate dosyalarını izliyor; Debug log seviyesinde Created file watcher for '/certs/api.bookstore.test.crt' satırını görebilirsin, Information seviyesinde ise yeniden yükleme hakkında hiçbir şey yazmıyor. Yenileme script'inin dosyanın üstüne yazması yeterli, ama "yeni certificate gerçekten sunuluyor mu" kontrolünü yukarıdaki gibi dışarıdan yap. Certificate'ın nereden ve nasıl edinileceği, yenileme takvimi ve chain ayrıntıları ilerideki SSL (Secure Sockets Layer)/TLS Certificate'ları ve HTTPS serisinin konusu.
Development certificate'ın production'da yeri yok, ve production'da zaten ortada olmuyor. HTTPS_PORTS'u certificate vermeden açınca uygulama hiç başlamıyor:
$ ./prec.sh "8 HTTPS_PORTS no cert" -e ASPNETCORE_HTTPS_PORTS=8443 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 8 HTTPS_PORTS no cert
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
System.InvalidOperationException: Unable to configure HTTPS endpoint. No server certificate was specified, and the default developer certificate could not be found or is out of date.
Port listesiyle HTTPS açmak istiyorsan certificate'ı Kestrel:Certificates:Default altına koyuyorsun:
$ ./prec.sh "9 HTTPS_PORTS with default cert" -e ASPNETCORE_HTTPS_PORTS=8443 \
-e Kestrel__Certificates__Default__Path=/certs/api.bookstore.test.crt \
-e Kestrel__Certificates__Default__KeyPath=/certs/api.bookstore.test.key \
-v "$PWD/certs":/certs:ro mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 9 HTTPS_PORTS with default cert
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://[::]:8443
Bir isim karışıklığına dikkat: ASPNETCORE_HTTPS_PORTS (çoğul) Kestrel'in dinleyeceği portlar, ASPNETCORE_HTTPS_PORT (tekil) ise HTTPS redirection middleware'inin client'ı yönlendireceği port. Biri diğerinin yerine geçmiyor.
Limit'ler ve Timeout'lar
Kestrel'in varsayılan limit'lerini hafızadan yazmak yerine uygulamaya sordum:
app.MapGet("/api/server/limits", (IOptions<KestrelServerOptions> options) =>
{
KestrelServerLimits limits = options.Value.Limits;
return new
{
limits.MaxRequestBodySize,
limits.MaxRequestHeadersTotalSize,
limits.MaxRequestHeaderCount,
limits.MaxRequestLineSize,
limits.MaxConcurrentConnections,
limits.MaxConcurrentUpgradedConnections,
limits.KeepAliveTimeout,
limits.RequestHeadersTimeout,
MinRequestBodyDataRate = limits.MinRequestBodyDataRate?.BytesPerSecond,
limits.Http2.MaxStreamsPerConnection,
options.Value.AddServerHeader,
};
});
$ curl -s http://172.30.85.21:8080/api/server/limits | jq .
{
"maxRequestBodySize": 30000000,
"maxRequestHeadersTotalSize": 32768,
"maxRequestHeaderCount": 100,
"maxRequestLineSize": 8192,
"maxConcurrentConnections": null,
"maxConcurrentUpgradedConnections": null,
"keepAliveTimeout": "00:02:10",
"requestHeadersTimeout": "00:00:30",
"minRequestBodyDataRate": 240,
"maxStreamsPerConnection": 100,
"addServerHeader": true
}
Request body için 30.000.000 byte (yaklaşık 28,6 MB), header'ların toplamı için 32 KB, request line için 8 KB, header'ların tamamının gelmesi için 30 saniye, boştaki bir keepalive bağlantısı için 2 dakika 10 saniye. Bağlantı sayısında üst sınır yok. MinRequestBodyDataRate'in 240 byte/saniye olması, body'yi bundan yavaş gönderen client'ın (kısa bir başlangıç toleransından sonra) kesilmesi demek; slowloris tarzı saldırılara karşı Kestrel'in kendi korumasının bir parçası.
Bu değerleri Kestrel:Limits bölümünden ya da ConfigureKestrel ile değiştirebilirsin. Dosya yükleme gibi tek bir endpoint daha büyük body kabul edecekse, limit'i global büyütmek yerine o endpoint'e [RequestSizeLimit] ya da IHttpMaxRequestBodySizeFeature ile özel limit ver; ayrıntısı ASP.NET Core Web API serisinin "Dosya Yükleme ve İndirme" yazısında. Önde Nginx varsa onun client_max_body_size'ı (varsayılanı 1 MB) çok daha önce devreye girer; iki limitin uyumu beşinci yazıda.
HTTP/2 ve HTTP/3
Yukarıdaki SNI çıktısında ALPN: server accepted h2 satırı var: ALPN (Application-Layer Protocol Negotiation), TLS el sıkışması sırasında client'la server'ın hangi HTTP sürümünü konuşacağına karar verdiği uzantı. Kestrel'in varsayılanı Http1AndHttp2; TLS'li endpoint'te client destekliyorsa HTTP/2 seçiliyor. TLS'siz endpoint'te ise ALPN olmadığı için aynı ayar fiilen HTTP/1.1 demek:
$ curl -s --http1.1 --cacert /w/certs/ca.crt --resolve api.bookstore.test:8443:172.30.85.21 https://api.bookstore.test:8443/api/whoami | jq -c "{protocol,scheme,host}"
{"protocol":"HTTP/1.1","scheme":"https","host":"api.bookstore.test:8443"}
$ curl -s --cacert /w/certs/ca.crt --resolve api.bookstore.test:8443:172.30.85.21 https://api.bookstore.test:8443/api/whoami | jq -c "{protocol,scheme,host}"
{"protocol":"HTTP/2","scheme":"https","host":"api.bookstore.test:8443"}
$ curl -sS --http2-prior-knowledge http://172.30.85.21:8080/api/whoami
curl: (16) Remote peer returned unexpected data while we expected SETTINGS frame. Perhaps, peer does not support HTTP/2 properly.
Şifresiz HTTP/2 (h2c) gerekiyorsa, örneğin gRPC'yi iç ağda TLS'siz konuşturacaksan, endpoint'i ayrıca Http2 olarak tanımlaman gerekiyor.
HTTP/3 ise TCP (Transmission Control Protocol) yerine UDP (User Datagram Protocol) üstündeki QUIC'i (adı Quick UDP Internet Connections'tan geliyor) kullanıyor. Microsoft Learn'e göre ASP.NET Core'un HTTP/3 desteği MsQuic'e dayanıyor ve Linux'ta libmsquic paketi gerekiyor; bu paket Microsoft'un Linux paket deposunda (packages.microsoft.com). Denediğim mcr.microsoft.com/dotnet/aspnet:10.0 image'ında (Ubuntu 24.04) libmsquic yok. Paketi ekleyen küçük bir image:
FROM mcr.microsoft.com/dotnet/aspnet:10.0
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -sSL -o /tmp/ms.deb https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb \
&& dpkg -i /tmp/ms.deb && apt-get update && apt-get install -y --no-install-recommends libmsquic \
&& rm -rf /var/lib/apt/lists/* /tmp/ms.deb
Endpoint'te "Protocols": "Http1AndHttp2AndHttp3" ile HTTP/3'ü açınca Kestrel, HTTP/1.1 ve HTTP/2 yanıtlarına alt-svc header'ını ekliyor; tarayıcı ilk isteği TCP üzerinden yapıp bu header'ı görünce sonrakileri QUIC'e taşıyor:
$ curl -s -o /dev/null -D - --cacert /w/certs/ca.crt --resolve api.bookstore.test:8443:172.30.85.21 https://api.bookstore.test:8443/api/books
HTTP/2 200
content-type: application/json; charset=utf-8
date: Sun, 08 Nov 2026 03:37:37 GMT
server: Kestrel
alt-svc: h3=":8443"; ma=86400
Container'daki curl HTTP/3 desteğiyle derlenmediği için asıl isteği .NET'in HttpClient'ıyla gönderdim. Test CA'sına güvenen, HttpVersion.Version30 isteyen küçük bir console uygulaması:
using System.Net;
using System.Net.Quic;
using System.Security.Cryptography.X509Certificates;
// kullanım: H3Client <url> <ca.crt>
string url = args[0];
X509Certificate2 ca = X509CertificateLoader.LoadCertificateFromFile(args[1]);
Console.WriteLine($"QuicConnection.IsSupported: {QuicConnection.IsSupported}");
SocketsHttpHandler handler = new()
{
SslOptions =
{
RemoteCertificateValidationCallback = (_, cert, _, _) =>
{
using X509Chain chain = new();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(ca);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
return cert is not null && chain.Build(new X509Certificate2(cert));
},
},
};
using HttpClient client = new(handler);
using HttpRequestMessage request = new(HttpMethod.Get, url)
{
Version = HttpVersion.Version30,
VersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using HttpResponseMessage response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} HTTP/{response.Version}");
Console.WriteLine(await response.Content.ReadAsStringAsync());
$ dotnet H3Client.dll https://api.bookstore.test:8443/api/whoami /certs/ca.crt
QuicConnection.IsSupported: True
200 HTTP/3.0
{"instance":"api1","remoteIp":"172.30.85.51","remotePort":47337,"connectionId":"0HNPT96B6GG81","protocol":"HTTP/3","scheme":"https","host":"api.bookstore.test:8443","pathBase":"","path":"/api/whoami","xForwardedFor":"","xOriginalFor":"","xForwardedProto":"","link":"https://api.bookstore.test:8443/api/books/1"}
Aynı config'i libmsquic olmayan düz image'da çalıştırınca Kestrel'in log'unda Debug seviyesinde bile HTTP/3 ile ilgili tek satır yok; yalnız Now listening on: https://[::]:8443. Yanıtlarda alt-svc kayboluyor ve HTTP/3 isteyen client UDP portunda kimseyi bulamıyor:
QuicConnection.IsSupported: True
Unhandled exception. System.Net.Http.HttpRequestException: No route to host (api.bookstore.test:8443)
---> System.Net.Sockets.SocketException (0x00000071): No route to host
Kestrel, dokümanın dediği gibi platform hazır değilse HTTP/3'ü sessizce kapatıp HTTP/1.1 ve HTTP/2 ile devam ediyor. "HTTP/3'ü açtım" demeden önce alt-svc header'ına ve gerçek bir HTTP/3 isteğine bak. Firewall'da TCP 443'ün yanında UDP 443'ü de açmayı unutma.
Unix Socket ile Nginx'e Bağlanmak
Nginx ile Kestrel aynı makinedeyse aralarında TCP'ye gerek yok. Microsoft Learn, Nginx ile daha iyi performans için Unix socket'i öneriyor. Kestrel tarafında yalnız URL değişiyor:
$ docker run -d --name webhwork-api1 -e ASPNETCORE_URLS=http://unix:/run/bookstore/kestrel.sock \
-v webhwork-sock:/run/bookstore ... mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
$ docker logs webhwork-api1 2>&1 | grep -A1 listening
Now listening on: http://unix:/run/bookstore/kestrel.sock
$ docker exec webhwork-api1 ls -l /run/bookstore/
total 0
srwxr-xr-x 1 root root 0 Nov 8 03:38 kestrel.sock
Nginx tarafında proxy_pass'e socket'in yolunu veriyorsun:
location /api/ {
proxy_pass http://unix:/run/bookstore/kestrel.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
İlk istek:
HTTP/1.1 502 Bad Gateway
2026/11/08 03:38:26 [crit] 20#20: *1 connect() to unix:/run/bookstore/kestrel.sock failed (13: Permission denied) while connecting to upstream, client: 172.30.85.50, server: bookstore.local, request: "GET /api/whoami HTTP/1.1", upstream: "http://unix:/run/bookstore/kestrel.sock:/api/whoami", host: "bookstore.local"
Socket'e bağlanmak için yazma izni gerekiyor; socket'i root oluşturdu ve rwxr-xr-x izniyle bıraktı, Nginx'in worker process'leri ise nginx kullanıcısıyla çalışıyor. Dokümandaki çözüm socket'e chmod ile yazma izni vermek; daha temizi Kestrel'i ve Nginx'i ortak bir grupta çalıştırıp socket'i o gruba yazılabilir yapmak. İzni verince istek geçiyor ve bir ayrıntı daha görünüyor:
$ curl -s -H "Host: bookstore.local" http://172.30.85.10/api/whoami | jq -c "{remoteIp,remotePort,host,xForwardedFor}"
{"remoteIp":null,"remotePort":0,"host":"bookstore.local","xForwardedFor":"172.30.85.50"}
Unix socket'te IP adresi yok; RemoteIpAddress null. Client IP'si için tamamen X-Forwarded-For'a muhtaçsın ve forwarded headers middleware'inin proxy'ye güvenme kuralları bu durumda farklı işliyor. Bir sonraki yazının konusu.
Üretimde Dikkat
Binding'i tek yerde tanımla. Ekip appsettings.json'da Kestrel:Endpoints, systemd unit'inde ASPNETCORE_URLS, Dockerfile'da ASPNETCORE_HTTP_PORTS kullanıyorsa, hangisinin çalıştığını yalnız log söyler. Deploy sonrası kontrol listesine Overriding kelimesini log'da aramayı ekle.
Uygulama ayağa kalkınca dinlediği adresleri log'luyor ama sonradan sormak istersen IServerAddressesFeature var; Bookstore.Api'deki /api/server endpoint'i onu döndürüyor. Health check'in dışarıdan, gerçek network yolundan yapılması ise localhost tuzağını ilk dakikada yakalar.
Limit'leri ölçerek değiştir. MaxConcurrentConnections'ın null olması sınırsız demek; Kestrel'i doğrudan internete açıyorsan bağlantı sayısını işletim sisteminin file descriptor limit'i belirler. Proxy arkasında bu limit Nginx'te verilir. KeepAliveTimeout'u proxy'nin upstream keepalive süresinden kısa tutarsan, proxy kapanmış bir bağlantıyı kullanmaya çalışır ve ara sıra 502 görürsün.
Sık Düşülen Tuzaklar
Aynı portu iki kaynaktan tanımlamak. Yedinci koşudaki gibi biri kodda, biri configuration'da 9000'i istediğinde ezilme değil çakışma oluyor ve uygulama açılmıyor. Hata başka bir process'i işaret ediyor gibi görünüyor.
Container'da localhost'a bind etmek. Uygulama kendi içinde sağlıklı görünüyor, dışarıdan erişilemiyor. VM'de proxy arkasında doğru olan ayar, container'da yanlış.
HTTP/3'ü açıp çalıştığını varsaymak. libmsquic yoksa ya da UDP portu kapalıysa Kestrel bunu söylemiyor. Bu denemede bir şey daha gördüm: endpoint'te Sni bölümü varken HTTP/3 isteği Application layer protocol negotiation error was encountered hatasıyla düştü; aynı certificate'ı Sni olmadan, doğrudan Certificate ile verince çalıştı. Microsoft Learn'de bu kombinasyon için bir not bulamadım; SNI ile HTTP/3'ü birlikte kullanacaksan kendi ortamında dene.
dotnet dev-certs ile üretilmiş certificate'ı sunucuya kopyalamak. Development certificate yalnız localhost için, kısa ömürlü ve kendi makinende güvenilir; sunucuda ne client'lar ona güvenir ne de yenilemesini biri takip eder.
Küçük Bir Deneme
★☆☆ prec.sh'yi appsettings.json'a "urls": "http://+:5500" ekleyerek ve aynı anda ASPNETCORE_URLS vererek çalıştır. Hangisi kazanıyor? Sonucu configuration provider sırasıyla açıkla.
★★☆ Kestrel:Limits:MaxRequestBodySize'ı 1048576 yap ve /api/uploads'a curl --data-binary @file ile 2 MB'lık bir dosya gönder. Hangi status code geliyor ve Kestrel log'a ne yazıyor? Sonra limit'i yalnız bu endpoint için IHttpMaxRequestBodySizeFeature ile 50 MB'a çıkar.
★★★ HTTP/3'lü endpoint'i ListenAnyIP(8443, o => { o.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; o.UseHttps(...); }) ile koda taşı ve certificate seçimini ServerCertificateSelector ile host'a göre yap. HTTP/3 isteği iki host için de çalışıyor mu? Sonucu configuration'daki Sni denemesiyle karşılaştır.
Kestrel artık doğru adresi dinliyor, ama önüne Nginx koyduğumuz an uygulama client'ı yanlış görüyor: IP Nginx'in, scheme http, Unix socket'te IP hiç yok. Bir sonraki yazıda forwarded headers middleware'ini, KnownProxies ve .NET 10'daki KnownIPNetworks'ü, HTTPS redirection döngüsünü ve header'lara kimden güvenileceğini ele alıyoruz.