Yavaş İstek Nereden Geliyor: curl -w, DevTools Timing ve Wireshark ile Network Teşhisi
Yavaş bir isteğin süresi DNS, TCP, TLS, sunucu (TTFB) ve indirme fazlarına bölünür; her fazın tanınabilir bir imzası var. netem ile 50 ms RTT altında curl -w ile beş vaka (keep-alive yok, IPv6 denemesi, TLS 1.2, yavaş sunucu, büyük cevap), .NET'in System.Net metric'leri, dotnet-counters ve Wireshark için TLS key log dosyası.
Sürüm notu: Bu yazıdaki ölçümler curl 8.5.0 ve .NET 10 ile, mcr.microsoft.com/dotnet/sdk:10.0 container'ında Linux tc netem ile eklenen 50 ms RTT (round-trip time) altında; dotnet-counters 10.0.750501 ve OpenSSL 4.0.3 ile macOS'ta alınmıştır.
Önce Tahmin Et
Destek ekibinden bir ticket: "Sipariş özeti sayfası yavaş, bazen yarım saniye sürüyor." Ekibin eklediği tek veri, sorunlu bir isteğin curl -w çıktısı:
dns=0.000015 connect=0.051334 tls=0.108429 ttfb=0.466217 total=0.466325
Toplam 466 milisaniye. Sorun ağda mı, TLS'te (Transport Layer Security) mi, sunucuda mı, yoksa indirmede mi? Bu yazının sonunda bu satırı bir bakışta okuyabileceksin. Serinin bütün yazılarında gördüğümüz parçaları burada bir teşhis yöntemine çeviriyoruz.
Gecikmeyi Fazlara Bölmek
Bir HTTP (Hypertext Transfer Protocol) isteğinin süresi birbirinin ardına eklenen fazlardan oluşur: isim çözümleme, TCP (Transmission Control Protocol) bağlantısı, TLS handshake'i, sunucunun ilk byte'ı göndermesi ve cevabın inmesi. İlk yazıda gördüğümüz curl -w değişkenleri bu fazların bittiği anları, isteğin başından itibaren kümülatif olarak verir. Bir fazın süresi iki ardışık değerin farkı:
| Faz | Hesap | Neye bağlı |
|---|---|---|
| DNS (Domain Name System) | time_namelookup | Resolver ve cache'ler |
| TCP connect | time_connect − time_namelookup | 1 RTT (ve başarısız adres denemeleri) |
| TLS handshake | time_appconnect − time_connect | TLS 1.3'te 1 RTT, TLS 1.2'de 2 RTT, artı CPU (Central Processing Unit) |
| Sunucu (TTFB) | time_starttransfer − time_pretransfer | 1 RTT + sunucunun işleme süresi |
| Download | time_total − time_starttransfer | Boyut, bant genişliği, congestion window |
Loopback'te bu fazların hepsi mikrosaniyeler sürer ve ilk yazıda gördüğümüz gibi hiçbir şey öğretmez. Gerçekçi bir ağ için kendi container'ımda Linux'un tc netem aracıyla loopback arayüzüne her pakette 25 milisaniye gecikme ekledim, yani RTT 50 milisaniye. Sunucu container'ın içinde çalışan Kestrel; istemci aynı container'daki curl:
$ tc qdisc add dev lo root netem delay 25ms
$ tc qdisc show dev lo
qdisc netem 8001: root refcnt 2 limit 1000 delay 25ms
$ F="dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n"
$ curl -s -o /dev/null -w "$F" --resolve bookstore.localhost:5492:127.0.0.1 --cacert /certs/ca.crt https://bookstore.localhost:5492/whoami
dns=0.000012 connect=0.052158 tls=0.109888 ttfb=0.163227 total=0.163305
Ders kitabındaki gibi: TCP 52 milisaniye (1 RTT), TLS 1.3 58 milisaniye (1 RTT), ilk byte 53 milisaniye sonra (1 RTT, sunucu neredeyse anında cevap veriyor). Toplam üç RTT, 163 milisaniye. Bu çizgi, bütün vakaları karşılaştırdığımız referans. --resolve, ismi doğrudan IPv4 adresine bağlıyor; nedenini ikinci vakada göreceksin.

Beş Vaka
Vaka 1: Keep-alive yok, her istekte handshake
Aynı endpoint'e üç istek: önce üç ayrı curl process'iyle (her biri yeni bağlantı), sonra tek bir curl komutunda (bağlantı yeniden kullanılıyor):
# 3 requests, 3 processes
dns=0.000015 connect=0.103984 tls=0.159989 ttfb=0.212692 total=0.212727
dns=0.000017 connect=0.105402 tls=0.160724 ttfb=0.213717 total=0.213809
dns=0.000017 connect=0.104159 tls=0.161287 ttfb=0.215353 total=0.215391
# reuse
dns=0.000017 connect=0.052227 tls=0.111365 ttfb=0.164948 total=0.165025
dns=0.000015 connect=0.000000 tls=0.000000 ttfb=0.056755 total=0.056843
Açık bağlantıdaki ikinci istek 57 milisaniye, yani tek RTT; connect ve tls sıfır. Her istekte yeni bağlantı açan bir istemci ise her seferinde bütün handshake'leri öder. İmza şu: connect ve tls her istekte sıfırdan büyük. .NET'te bunun en yaygın sebebi üçüncü yazıda gördüğümüz her istekte new HttpClient(); diğer sebepler Connection: close gönderen bir sunucu veya araya giren ve bağlantıları erken kapatan bir proxy. Bu üç satırda connect'in 104 milisaniye olduğuna da dikkat et; bir sonraki vakanın konusu.
Vaka 2: IPv6 denemesi, sonra IPv4
Yukarıdaki üç istekte --resolve kullanmadım ve connect 52 değil 104 milisaniye sürdü. -v sebebini gösteriyor:
$ curl -sv -o /dev/null --cacert /certs/ca.crt https://bookstore.localhost:5492/whoami 2>&1 | grep -E "Trying|connect to|Connected"
* Trying [::1]:5492...
* connect to ::1 port 5492 from ::1 port 36740 failed: Connection refused
* Trying 127.0.0.1:5492...
* Connected to bookstore.localhost (127.0.0.1) port 5492
curl .localhost ile biten isimleri hem ::1'e hem 127.0.0.1'e çözüyor ve önce IPv6'yı deniyor. Sunucu yalnızca IPv4'te dinlediği için deneme reddediliyor ama ret de bir RTT sonra geliyor: bağlantı kurulumu iki katına çıkıyor. Gerçek dünyadaki karşılığı daha kötü: bir alan adının AAAA (IPv6 adres) kaydı var ama IPv6 yolu bir yerde sessizce düşürülüyorsa, istemci ret yerine timeout bekler. İkinci yazıda gördüğümüz bu tuzağın imzası: connect RTT'nin katları kadar uzun ve -v'de başarısız bir Trying satırı.
Vaka 3: Eski TLS sürümü
$ curl -s -o /dev/null -w "$F" --resolve bookstore.localhost:5492:127.0.0.1 --tls-max 1.2 --http1.1 --cacert /certs/ca.crt https://bookstore.localhost:5492/whoami
dns=0.000015 connect=0.052807 tls=0.167522 ttfb=0.221019 total=0.221107
TLS fazı 58 milisaniyeden 115 milisaniyeye çıktı: TLS 1.2'nin iki RTT'lik handshake'i. İmza: appconnect − connect RTT'nin iki katı. Beşinci yazıda gördüğümüz gibi bunun sebebi bazen sunucu değil istemcinin platformu olabilir; macOS'taki .NET'in TLS 1.2'de kalması bunun bir örneğiydi.
Vaka 4: Asıl sorun sunucu
$ curl -s -o /dev/null -w "$F" --resolve bookstore.localhost:5492:127.0.0.1 --cacert /certs/ca.crt "https://bookstore.localhost:5492/slow?ms=300"
dns=0.000015 connect=0.051334 tls=0.108429 ttfb=0.466217 total=0.466325
Tahmin bölümündeki satır bu. Bağlantı fazları referansla aynı (51 ve 57 milisaniye). İlk byte ise TLS bittikten 358 milisaniye sonra geliyor: bir RTT (50) artı sunucunun 300 milisaniyelik işi. Ağda sorun yok; ticket'ı uygulama ekibine yönlendir. Sunucunun içinde neyin yavaş olduğu artık bir tracing ve profiling sorusu; bu yazının değil ilerideki Observability serisinin konusu. İmza: bağlantı fazları normal, TTFB RTT'den çok büyük.
Vaka 5: Büyük cevap
$ curl -s -o /dev/null -w "$F size=%{size_download}\n" --resolve bookstore.localhost:5492:127.0.0.1 --cacert /certs/ca.crt "https://bookstore.localhost:5492/big?kb=5120"
dns=0.000018 connect=0.051587 tls=0.112227 ttfb=0.185814 total=3.069849
size=5242880
5 MB'lık bir cevap: ilk byte 186 milisaniyede geldi ama indirme 2,9 saniye sürdü. Bu ortamda bant genişliği sınırı yok; süreyi belirleyen RTT ve TCP'nin penceresi. Her RTT'de gönderilebilecek veri congestion window ve alıcının penceresiyle sınırlı, ve 50 milisaniyelik her tur bekleniyor. İmza: total − starttransfer baskın. Çözümler sıkıştırma (JSON (JavaScript Object Notation) çok iyi sıkışır), pagination, gereksiz alanları göndermemek ve büyük dosyalar için CDN (Content Delivery Network).
DevTools Timing'de Aynı Fazlar
Tarayıcıda aynı teşhis DevTools'un Network panelindeki Timing sekmesiyle yapılır; ilk yazıdaki eşleştirme tablosu burada da geçerli: Initial connection ve SSL (Secure Sockets Layer, TLS'in eski adı), connect ve appconnect'e; "Waiting for server response" TTFB'ye; Content Download, total − starttransfer'a karşılık gelir. Tarayıcıya özgü iki faz daha var. Queueing ve Stalled, isteğin tarayıcı içinde beklediği süre: HTTP/1.1'de host başına 6 bağlantı dolduysa veya tarayıcı isteğe düşük öncelik verdiyse burada görünür. Uzun bir Stalled süresi gördüğünde ilk soru, bu origin'in HTTP/1.1 mi HTTP/2 mi konuştuğu olmalı.
.NET Tarafı: System.Net Metric'leri
Bir .NET servisinin dışarıya yaptığı çağrılarda curl çalıştıramazsın, ama aynı fazları metric olarak görebilirsin. System.Net.Http ve System.Net.NameResolution meter'ları .NET 8'den beri yerleşik. Bunları process içinde bir MeterListener ile dinleyen küçük bir demo:
// Yerleşik System.Net metric'lerini kaydedildikleri anda yazdırır
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name is "System.Net.Http" or "System.Net.NameResolution"
&& instrument.Name is "http.client.request.duration" or "http.client.connection.duration"
or "http.client.request.time_in_queue" or "dns.lookup.duration")
{
l.EnableMeasurementEvents(instrument);
}
};
Aynı 50 milisaniyelik container'da, bir client ile sırasıyla hızlı, yavaş ve tekrar hızlı bir istek:
$ dotnet /lab/Bookstore.Net.Lab.dll metrics /certs/ca.crt https://bookstore.localhost:5492/whoami "https://bookstore.localhost:5492/slow?ms=300" https://bookstore.localhost:5492/whoami
dns.lookup.duration 0.7 ms dns.question.name=bookstore.localhost
http.client.request.time_in_queue 194.5 ms network.protocol.version=1.1 server.address=bookstore.localhost
http.client.request.duration 255.8 ms server.address=bookstore.localhost http.response.status_code=200 network.protocol.version=1.1
http.client.request.duration 357.7 ms server.address=bookstore.localhost http.response.status_code=200 network.protocol.version=1.1
http.client.request.duration 61.9 ms server.address=bookstore.localhost http.response.status_code=200 network.protocol.version=1.1
http.client.connection.duration 484.0 ms network.protocol.version=1.1 server.address=bookstore.localhost
time_in_queue, isteğin bir bağlantı beklediği süre; burada yeni bağlantının kurulması (TCP, TLS ve process'teki ilk kullanımın maliyeti) dahil 194,5 milisaniye. Bu metric yalnızca ilk istekte görünüyor, sonraki istekler hazır bağlantıyı buldu. Yavaş istek 357,7, üçüncü istek 61,9 milisaniye: tek RTT ve biraz işlem. connection.duration bağlantı kapanınca kaydediliyor. Production'da bu metric'leri OpenTelemetry ile toplayıp server.address'e göre bölmek, "hangi bağımlılık yavaş?" sorusunun cevabını verir; time_in_queue'nun yükselmesi bağlantı havuzunun tükendiğini, dns.lookup.duration'ın yükselmesi resolver'ın sorun çıkardığını gösterir.
Çalışan bir process'e kod eklemeden anlık bakmak için dotnet-counters var. Global tool olarak değil, çalışma klasörüme --tool-path ile kurdum ve 20 milisaniyelik bir endpoint'e döngüyle istek atan bir process'e bağladım:
$ dotnet tool install dotnet-counters --tool-path ./tools
$ ./tools/dotnet-counters collect -p 98955 --counters System.Net.Http,System.Net.NameResolution --format csv -o logs/counters.csv --duration 00:00:06
Starting a counter session. Press Q to quit.
File saved to logs/counters.csv
$ grep "Percentile" logs/counters.csv | head -2
11/08/2026 13:49:09,System.Net.Http,http.client.request.duration (s)[http.request.method=GET;http.response.status_code=200;network.protocol.version=1.1;server.address=127.0.0.1;server.port=5491;url.scheme=http;Percentile=50],Metric,0.022216796875
11/08/2026 13:49:09,System.Net.Http,http.client.request.duration (s)[http.request.method=GET;http.response.status_code=200;network.protocol.version=1.1;server.address=127.0.0.1;server.port=5491;url.scheme=http;Percentile=95],Metric,0.022491455078125
p50 (isteklerin yarısının altında kaldığı süre) 22,2 milisaniye, p95 22,5 milisaniye: endpoint'in kendi 20 milisaniyesi ve birkaç milisaniyelik ek. dotnet-counters ile monitor komutunu kullanırsan aynı değerleri canlı bir tablo olarak görürsün. Derin metric, trace ve log korelasyonu ilerideki Observability serisinin konusu.
Kendi Trafiğini Çözmek: Key Log Dosyası
Bazen fazlar yetmez ve kablodaki paketlere bakman gerekir: hangi frame ne zaman gitti, TCP yeniden gönderim yaptı mı, sunucu bağlantıyı kim kapattı. Wireshark bunu gösterir ama TLS şifreli olduğu için içeriği göremez; ancak oturum anahtarlarını bir dosyada verirsen çözebilir. Bu dosyanın standart biçimine NSS (Network Security Services) key log formatı denir ve birçok istemci SSLKEYLOGFILE environment variable'ını veya bir seçeneği destekler. OpenSSL'in s_client'ı ile kendi yerel sunucuma bağlanırken bir key log dosyası ürettim; değerleri kısaltarak gösteriyorum:
$ openssl s_client -connect 127.0.0.1:5592 -servername localhost -CAfile certs/ca.crt -alpn http/1.1 -quiet -keylogfile logs/keys.log
$ awk '{print $1, substr($2,1,16)"...", substr($3,1,16)"..."}' logs/keys.log
# SSL/TLS... secrets...
SERVER_HANDSHAKE_TRAFFIC_SECRET 1a355a43e5fa8940... 3af855412c923699...
EXPORTER_SECRET 1a355a43e5fa8940... e512645cbcb55c0b...
SERVER_TRAFFIC_SECRET_0 1a355a43e5fa8940... d20dd99b2ee35cf5...
CLIENT_HANDSHAKE_TRAFFIC_SECRET 1a355a43e5fa8940... 1e3bb0dcdf87e16c...
CLIENT_TRAFFIC_SECRET_0 1a355a43e5fa8940... 63510801a2ca28a4...
Her satır bir TLS 1.3 secret'ı: ikinci alan ClientHello'nun rastgele değeri (bağlantıyı tanımlar), üçüncüsü secret'ın kendisi. Wireshark'ta bu dosyayı TLS protokol ayarlarındaki "(Pre)-Master-Secret log filename" alanına verirsen aynı bağlantının içindeki HTTP mesajlarını düz metin olarak görürsün. Bu makinede Wireshark kurulu olmadığı için o adımı çalıştırmadım. Bu dosya, içindeki bağlantıların bütün trafiğini çözmeye yeter: yalnızca kendi ortamında, kendi test trafiğinde üret, iş bitince sil ve production'da hiçbir zaman açık bırakma.
Üretimde Dikkat
Ölçümü istemcinin olduğu yerden yap. Sunucunun kendi üzerinde ölçülen bir istekte RTT sıfıra yakındır ve bağlantı fazları hiçbir şey söylemez. Kullanıcının deneyimi için tarayıcıdaki Timing verisi (Real User Monitoring ile toplanabilir) ve farklı bölgelerden yapılan sentetik ölçümler gerekir.
Ortalamaya değil percentile'lara bak. Yavaşlık şikâyetleri genelde p95 ve p99'dan gelir: yeni bağlantı kuran, DNS cache'i soğuk olan, kayıplı ağdaki istekler. Ortalama bunları gizler.
Teşhisin sırası basit: önce fazları ayır, sonra fazın imzasını tanı. connect uzunsa adres ve yol (IPv6, firewall, uzak bölge); tls uzunsa sürüm, sertifika zinciri ve bağlantı yeniden kullanımı; TTFB uzunsa sunucu; download uzunsa boyut ve pencere. Ağa suç atmadan önce çoğu zaman bir curl -w satırı yeter.
Sık Düşülen Tuzaklar
Toplam süreye bakıp ağı suçlamak. 466 milisaniyelik istekte ağın payı 150 milisaniyeydi; geri kalan sunucu.
TTFB'yi saf sunucu süresi sanmak. İçinde en az bir RTT var; uzak istemcide bu pay büyür.
Loopback'te veya sunucunun üzerinde ölçmek. Bağlantı fazları sıfıra yakın çıkar ve gerçek sorunu gizler.
Key log dosyasını açık bırakmak. Dosyayı ele geçiren, kaydedilmiş trafiği çözebilir.
Tek bir ölçümle karar vermek. İlk istek JIT (Just-In-Time) derlemesi, DNS cache'i ve bağlantı kurulumu yüzünden her zaman farklıdır; birkaç ölçüm al ve ilkini ayrı değerlendir.
Küçük Bir Deneme
★☆☆ Kendi API'ne (Application Programming Interface) bu yazıdaki curl -w formatıyla istek at; aynı komutta iki URL (Uniform Resource Locator) vererek bağlantı yeniden kullanımını ve iki ayrı komutla yeni bağlantıyı karşılaştır.
★★☆ Bir Linux container'ında tc qdisc add dev lo root netem delay 25ms loss 2% ile gecikmeye ek olarak yüzde 2 paket kaybı ekle ve 5 MB'lık indirmeyi tekrarla. Süre ve dağılım nasıl değişiyor? HTTP/2 ile HTTP/3'ü karşılaştır.
★★★ Bu yazıdaki MeterListener demosunu, dns.lookup.duration ve http.client.request.time_in_queue için p50/p95 hesaplayan küçük bir özet tablo üretecek şekilde genişlet ve 100 eşzamanlı istekte MaxConnectionsPerServer = 2 ile çalıştır. Kuyrukta bekleme süresi neyi gösteriyor?
Networking ve HTTP Nasıl Çalışır serisi burada bitiyor. Bir URL'e Enter'a basmanın arkasındaki adresleri, TCP'yi, DNS'i, TLS'i ve HTTP'nin üç sürümünü kablodan okuduk; şimdi bu yazıdaki fazları kendi servislerinde ölçebilirsin. Sunucu tarafındaki caching ve HTTPS (HTTP Secure) ayarları için ASP.NET Core Web API serisine, resilience ve gerçek zamanlı iletişim için ilerideki External Service Integration ve Real-time serilerine devam edebilirsin.