HTTP/3 ve QUIC: UDP Üzerinde Güvenli Transport, Alt-Svc ve .NET'te HTTP/3
HTTP/3, TCP yerine UDP üzerinde çalışan QUIC'i kullanır: TLS 1.3 gömülü, sıralama stream başına, yeni bağlantıda bir RTT daha az. Alt-Svc ve DNS HTTPS kaydıyla keşif, macOS'ta QuicConnection.IsSupported=false, Linux container'da libmsquic ile çalışan Kestrel HTTP/3'ü loopback'te HTTP/3'ün neden daha hızlı çıkmadığı ve 50 ms RTT'de kazandırdığı tam bir RTT gerçek ölçümlerle.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10 Kestrel) ile, HTTP (Hypertext Transfer Protocol) sürüm 3 denemeleri mcr.microsoft.com/dotnet/sdk:10.0 (Ubuntu 24.04) container'ında Microsoft'un paket deposundan kurulan libmsquic 2.6.3 ile yapılmıştır. QUIC (HTTP/3'ün altındaki yeni transport protokolü) anlatımı RFC (Request for Comments) 9000, 9001 ve 9002'yi, HTTP/3 anlatımı RFC 9114'ü esas alır.
Önce Tahmin Et
Kestrel'de HTTP/3'ü açmak için bir endpoint'in protokolünü HttpProtocols.Http1AndHttp2AndHttp3 yapman yeterli. Bunu bir Mac'te yapıp HttpClient ile HTTP/3 istersen ne olur: istek HTTP/3 ile mi gider, uygulama açılırken mi hata verir, yoksa sessizce başka bir şey mi olur?
İkinci soru: HTTP/3, TCP (Transmission Control Protocol) ve TLS (Transport Layer Security) handshake'lerini birleştirdiği için yeni bir bağlantıda bir RTT (round-trip time) kazandırıyor. Aynı makinede, loopback üzerinde ölçtüğünde HTTP/3'ün ilk isteği HTTP/2'ninkinden hızlı mı çıkar?
QUIC: UDP Üzerinde Güvenilir Transport
Önceki yazının sonunda HTTP/2'nin çözemediği sorunu bırakmıştık: bütün stream'ler tek bir TCP byte akışını paylaştığı için kaybolan tek bir paket hepsini durdurur. Bunu düzeltmek için TCP'yi değiştirmek gerekiyordu; ama TCP işletim sisteminin çekirdeğinde ve aradaki sayısız router, firewall ve NAT (Network Address Translation) cihazında yaşıyor, değiştirmek yıllar sürüyor. QUIC bu yüzden UDP (User Datagram Protocol) üzerine kuruldu: UDP'yi her cihaz geçirir ve QUIC'in kendisi işletim sistemi yerine uygulama tarafında, bir library olarak çalışır.
QUIC (RFC 9000) TCP'nin verdiği garantileri yeniden kurar: güvenilir ve sıralı teslim, flow control, congestion control (RFC 9002). Farkı, sıralamanın bağlantı başına değil stream başına olması. Stream 3'ün paketi kaybolursa yalnızca stream 3 bekler; stream 1 ve 5'in gelen verisi hemen uygulamaya verilir. Head-of-line blocking'in transport katmanındaki gerçek çözümü bu.
İkinci büyük fark şifreleme. TLS 1.3, QUIC'in içine gömülüdür (RFC 9001): QUIC paketlerinin neredeyse tamamı, başlıklarının çoğu dahil, şifrelidir. Bu, aradaki cihazların QUIC'i "anlayıp" davranışını değiştirememesi demek; TCP'nin yıllardır değiştirilememesinin sebebi tam olarak bu tür müdahalelerdi.
Üçüncüsü connection ID. TCP bağlantısı dört değerle (kaynak IP (Internet Protocol), kaynak port, hedef IP, hedef port) tanımlanır; telefonun Wi-Fi'dan mobil ağa geçip IP'si değişince bağlantı kopar. QUIC bağlantısı ise kendi connection ID'siyle tanımlanır ve istemcinin adresi değişse de devam edebilir: connection migration.
Handshake: 1-RTT ve 0-RTT

TCP ve TLS 1.3 ile yeni bir bağlantıda istek, ikinci RTT'nin sonunda gider: bir RTT TCP handshake'i, bir RTT TLS. QUIC'te transport ve kriptografik handshake aynı paketlerde yürür: istemcinin ilk paketi (Initial) TLS ClientHello'yu taşır, sunucunun cevabı hem QUIC bağlantı parametrelerini hem ServerHello'dan Finished'a kadar TLS mesajlarını. İstemci kendi Finished'ı ile birlikte ilk HTTP/3 isteğini gönderir. Daha önce bağlanılmış bir sunucuda, beşinci yazıda TLS için gördüğümüz 0-RTT burada da var: istek ilk uçuşta gider, aynı replay riskiyle ve aynı kuralla (yalnız idempotent istekler).
HTTP/3 (RFC 9114) bu transport'un üzerinde HTTP semantiğini taşır. HTTP/2'nin stream'leri artık QUIC'in stream'leri; frame yapısı sadeleşti. Header sıkıştırması için HPACK (HTTP/2'nin header sıkıştırması) yerine QPACK (RFC 9204) kullanılır. Sebep yine sıra: HPACK'in dynamic table'ı header'ların sırayla geldiğini varsayar; QUIC'te stream'ler bağımsız ilerlediği için QPACK, table güncellemelerini ayrı stream'lerde taşıyarak bu bağımlılığı kontrol altına alır.
HTTP/3'ün Keşfi: Alt-Svc ve DNS HTTPS Kaydı
İstemci bir sunucunun HTTP/3 konuştuğunu nereden bilir? HTTP/3 ayrı bir URL (Uniform Resource Locator) scheme'i kullanmaz; adres yine https://. İki keşif yolu var. Birincisi Alt-Svc header'ı: sunucu HTTP/1.1 veya HTTP/2 cevabına "aynı servis şu port'ta HTTP/3 olarak da var" bilgisini ekler. Kestrel, HTTP/3 açıkken bunu kendisi yapıyor; container'daki sunucuya curl ile HTTP/2 isteği atınca:
$ curl -sv --cacert /certs/ca.crt https://127.0.0.1:5492/whoami 2>&1 | grep -iE "alt-svc|HTTP/2 200"
< HTTP/2 200
< alt-svc: h3=":5492"; ma=86400
h3=":5492" "aynı host'un 5492 port'unda, UDP üzerinde HTTP/3", ma=86400 ise bu bilginin bir gün geçerli olduğunu söylüyor. Bu yolun bir eksiği var: ilk istek her zaman TCP ile gider, HTTP/3 ancak ikinci bağlantıdan itibaren kullanılabilir. İkinci yol, dördüncü yazıda cloudflare.com için çözdüğümüz DNS (Domain Name System) HTTPS (HTTP Secure) kaydı: alpn değeri h3 içeriyorsa tarayıcı daha ilk bağlantıda QUIC deneyebilir.
UDP her ağda açık değil. Birçok kurumsal firewall yalnızca TCP 443'e izin verir. Tarayıcılar bu yüzden QUIC ile TCP'yi yarıştırır veya QUIC başarısız olunca sessizce TCP'ye düşer; kullanıcı fark etmez, yalnızca HTTP/3'ün kazancı kaybolur.
Komut satırında HTTP/3 denemek için HTTP/3 destekli bir curl build'i gerekiyor. Bu makinedeki curl'ün de container'dakinin de bu desteği yok:
$ curl -sS --http3 https://127.0.0.1:5492/whoami
curl: option --http3: the installed libcurl version doesn't support this
.NET 10'da HTTP/3: Kestrel ve HttpClient
Sunucu tarafı tek satır; demo sunucumda bir environment variable ile açılıyor:
options.Listen(bindAddress, 5492, o =>
{
// HTTP3=1 ile aynı port'ta UDP üzerinden HTTP/3 de dinlenir (QUIC için libmsquic gerekir)
o.Protocols = Environment.GetEnvironmentVariable("HTTP3") == "1"
? HttpProtocols.Http1AndHttp2AndHttp3
: HttpProtocols.Http1AndHttp2;
o.UseHttps(certificate);
});
HTTP/3 varsayılan olarak kapalı ve Microsoft'un dokümanına göre her zaman HTTP/1.1 ve HTTP/2 ile birlikte açılmalı, çünkü UDP'yi geçirmeyen ağlar var. Kestrel QUIC için MsQuic library'sini kullanır; platform bunu sağlamıyorsa HTTP/3 sessizce devre dışı kalır ve endpoint diğer protokollerle çalışmaya devam eder. İstemci tarafında HTTP/3'ü ve QUIC desteğini sınayan bir demo yazdım:
public static async Task RunAsync(string url, string caPath)
{
#pragma warning disable CA2252 // System.Net.Quic platform kontrolü
Console.WriteLine($"QuicConnection.IsSupported = {QuicConnection.IsSupported}");
#pragma warning restore CA2252
// 1) İlk istek HTTP/2 ile gider; sunucu Alt-Svc ile HTTP/3'ü ilan eder
using var discovery = new HttpClient(LocalCaTrust.CreateHandler(caPath))
{
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using (var response = await discovery.GetAsync(url))
{
var altSvc = response.Headers.TryGetValues("Alt-Svc", out var values) ? string.Join(", ", values) : "(none)";
Console.WriteLine($"HTTP/{response.Version} {(int)response.StatusCode} Alt-Svc: {altSvc}");
}
// 2) HTTP/3'ü doğrudan iste ve yeni bağlantının süresini ölç
foreach (var version in new[] { HttpVersion.Version20, HttpVersion.Version30, HttpVersion.Version20, HttpVersion.Version30 })
{
using var client = new HttpClient(LocalCaTrust.CreateHandler(caPath))
{
DefaultRequestVersion = version,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
var first = Stopwatch.StartNew();
using var r1 = await client.GetAsync(url);
var firstMs = first.Elapsed.TotalMilliseconds;
var second = Stopwatch.StartNew();
using var r2 = await client.GetAsync(url);
Console.WriteLine($"HTTP/{r1.Version}: first request (new connection) {firstMs:F1} ms, second request {second.Elapsed.TotalMilliseconds:F1} ms, body={(await r2.Content.ReadAsStringAsync()).Trim()}");
}
}
Tahmin bölümündeki birinci soru, macOS'ta çalışan bir Kestrel'e karşı:
$ dotnet Bookstore.Net.Lab.dll http3 https://127.0.0.1:5492/whoami certs/ca.crt
QuicConnection.IsSupported = False
HTTP/2.0 200 Alt-Svc: (none)
HTTP/2.0: first request (new connection) 52.2 ms, second request 1.1 ms, body=blue
Unhandled exception. System.Net.Http.HttpRequestException: Requesting HTTP version 3.0 with version policy RequestVersionExact while unable to establish HTTP/3 connection.
Bu makinede (macOS, .NET 10.0.13) QUIC desteklenmiyor: IsSupported false, sunucu Alt-Svc ilan etmiyor ve RequestVersionExact ile HTTP/3 isteyen client açık bir hatayla duruyor. Microsoft'un HTTP/3 dokümanı desteği MsQuic'in platform gereksinimlerine bağlıyor: Windows'ta Windows 11 veya Windows Server 2022 ve sonrası, Linux'ta libmsquic paketi.
Aynı kodu bir Linux container'ında çalıştırdım. .NET SDK (Software Development Kit) image'ında libmsquic kurulu gelmiyor; Microsoft'un paket deposunu ekleyip 2.6.3 sürümünü kurdum, sunucuyu HTTP3=1 ile başlattım ve aynı container'da client'ı çalıştırdım:
$ dotnet /lab/Bookstore.Net.Lab.dll http3 https://127.0.0.1:5492/whoami /certs/ca.crt
QuicConnection.IsSupported = True
HTTP/2.0 200 Alt-Svc: h3=":5492"
HTTP/2.0: first request (new connection) 3.1 ms, second request 2.4 ms, body=h3-kestrel
HTTP/3.0: first request (new connection) 53.0 ms, second request 0.6 ms, body=h3-kestrel
HTTP/2.0: first request (new connection) 1.4 ms, second request 0.2 ms, body=h3-kestrel
HTTP/3.0: first request (new connection) 2.3 ms, second request 0.2 ms, body=h3-kestrel
HTTP/3 çalışıyor. Aynı port numarası artık iki ayrı transport'ta dinleniyor; container'ın socket tablosunda 5492 (onaltılık 1574) hem TCP'de hem UDP'de görünüyor:
$ cat /proc/net/tcp /proc/net/udp6 | grep -i ":1574 " | cut -c1-80
0: 0100007F:1574 00000000:0000 0A 00000000:00000000 00:00000000 00000000
3292: 00000000000000000000000000000000:1574 00000000000000000000000000000000:00
3292: 00000000000000000000000000000000:1574 00000000000000000000000000000000:00
...
TCP tarafında tek bir LISTEN socket'i, UDP tarafında ise MsQuic'in açtığı on socket var; bu container'ın gördüğü işlemci çekirdeği sayısı da on.
Tarayıcıyla denemek için bu kurulum yetmez: Microsoft'un dokümanının da belirttiği gibi tarayıcılar HTTP/3'te self-signed geliştirme sertifikalarını kabul etmiyor. Yerel test için HttpClient ve HttpRequestMessage.Version = 3.0 doğru yol.
Loopback'te HTTP/3 Hızlı mı?
Tahmin bölümündeki ikinci sorunun cevabı yukarıdaki çıktıda ve "hayır". İlk HTTP/3 bağlantısı 53 milisaniye sürdü; bunun neredeyse tamamı process içinde bir kerelik maliyet: MsQuic'in yüklenmesi, QUIC katmanının JIT (Just-In-Time) ile derlenmesi. Isındıktan sonra yeni bir HTTP/3 bağlantısı 2,3 milisaniye, yeni bir HTTP/2 bağlantısı 1,4 milisaniye. Loopback'te RTT mikrosaniyeler olduğu için QUIC'in kazandırdığı bir RTT burada görünmez; geriye kalan, QUIC'in şifrelemeyi ve paket işlemeyi kullanıcı alanında yapmasının CPU (Central Processing Unit) maliyeti.
HTTP/3'ün kazancı RTT'nin büyük olduğu yerde görünür. Bunu ölçmek için kendi container'ımda Linux'un tc netem aracıyla loopback arayüzüne her paket için 25 milisaniye gecikme ekledim; böylece RTT 50 milisaniye oldu (container'ı NET_ADMIN yetkisiyle başlatıp iproute2 paketini kurmak gerekti). Aynı demo:
$ tc qdisc add dev lo root netem delay 25ms
$ dotnet /lab/Bookstore.Net.Lab.dll http3 https://127.0.0.1:5492/whoami /certs/ca.crt
QuicConnection.IsSupported = True
HTTP/2.0 200 Alt-Svc: h3=":5492"
HTTP/2.0: first request (new connection) 164.4 ms, second request 56.1 ms, body=lab-kestrel
HTTP/3.0: first request (new connection) 195.9 ms, second request 52.9 ms, body=lab-kestrel
HTTP/2.0: first request (new connection) 167.2 ms, second request 56.7 ms, body=lab-kestrel
HTTP/3.0: first request (new connection) 108.6 ms, second request 56.7 ms, body=lab-kestrel
Tablo değişti. Yeni bir HTTP/2 bağlantısında ilk cevap 167 milisaniye: TCP için bir, TLS için bir, istek için bir RTT, yani üç kere 50 ve biraz işlem. Isınmış HTTP/3'te 108,6 milisaniye: iki RTT. Ölçüm, diyagramdaki tam bir RTT'lik farkı gösteriyor. Açık bağlantıdaki ikinci istekler ise iki protokolde de tek RTT, yaklaşık 55 milisaniye. Kayıplı ağda stream'lerin birbirini bekletmemesi ise ayrı bir kazanç; onu bu ortamda ölçmedim.
Üretimde Dikkat
HTTP/3'ün değmediği yerler var. Bir datacenter'ın içinde servisten servise trafikte RTT zaten milisaniyenin altında ve paket kaybı yok denecek kadar az; QUIC'in ek CPU maliyeti kazancından büyük olabilir. Bu yolda HTTP/2 genelde daha iyi bir seçim. HTTP/3'ün asıl yeri internet üzerinden, özellikle mobil kullanıcılara hizmet veren uç noktalar ve bu uç çoğu zaman bir CDN (Content Delivery Network) veya load balancer'dır, uygulama sunucusu değil.
Altyapıyı UDP için hazırla. Firewall'da UDP 443'ü aç, load balancer'ın QUIC'i desteklediğinden emin ol; QUIC bağlantıları IP ve port yerine connection ID ile yönlendirilmeli, yoksa connection migration ve hatta normal trafik bozulabilir. Alt-Svc'nin ma değeri tarayıcıların bu bilgiyi ne kadar hatırlayacağını belirler; HTTP/3'ü kapatacaksan bunu da hesaba kat.
Gözlemlenebilirliği gözden geçir. QUIC paketleri şifreli olduğu için TCP'ye göre çalışan paket analiz araçları ve ağ cihazlarının metrikleri QUIC trafiğinde çok daha az şey görür; teşhisi uygulama ve tarayıcı tarafındaki metric'lere dayandırmak gerekir.
Sık Düşülen Tuzaklar
HTTP/3'ü tek başına açmak. UDP'nin engellendiği ağlardaki kullanıcılar için her zaman HTTP/1.1 ve HTTP/2 ile birlikte aç.
Geliştirme makinesindeki sonucu genellemek. macOS'ta QUIC yok; Linux'ta libmsquic kurulu değilse Kestrel HTTP/3'ü sessizce kapatır. Alt-Svc header'ını görmüyorsan önce platformu kontrol et.
Loopback ölçümüyle karar vermek. HTTP/3'ün kazancı RTT ve kayıpla ortaya çıkar; yerelde ölçtüğün yalnızca CPU maliyeti.
Tarayıcıda self-signed sertifikayla HTTP/3 beklemek. Tarayıcılar buna izin vermez; yerel testte HttpClient kullan.
0-RTT'yi her isteğe açmak. TLS'teki replay riski QUIC'te de aynı.
Küçük Bir Deneme
★☆☆ Tarayıcında DevTools Network panelinde Protocol kolonunu aç ve büyük bir sitede h3 gelen istekleri bul. İlk istek h2, sonrakiler h3 mi?
★★☆ Bir Linux container'ında libmsquic kurmadan sunucuyu HTTP3=1 ile başlat. Uygulama logu ve Alt-Svc header'ı ne gösteriyor? Ardından paketi kurup tekrar dene.
★★★ Bu yazıdaki demoyu DefaultRequestVersion = HttpVersion.Version30 ve RequestVersionOrLower ile çalıştır ve sunucunun UDP port'unu kapattığında (HTTP/3'ü kapatıp yeniden başlatarak) istemcinin HTTP/2'ye düşüp düşmediğini gözlemle.
Serinin haritasındaki bütün durakları gördük: adresler, TCP, DNS, TLS, HTTP/1.1, caching, HTTP/2 ve HTTP/3. Son yazıda bunları bir araya getiriyoruz: yavaş bir isteğin süresini fazlara bölüp sorunun nerede olduğunu curl -w, DevTools Timing ve .NET metric'leriyle buluyoruz.