.NET Diagnostics Haritası: Hangi Soruya Hangi Araç? EventPipe, Diagnostic Port ve Güvenli Toplama
Araç seçimi sorudan başlar: bir şey ters mi diye dotnet-counters, zaman nereye gidiyor diye dotnet-trace, bellekte ne kalıyor diye dotnet-gcdump, process'in tam hâli için dotnet-dump. EventPipe, ETW ve Linux'ta user_events farkını, diagnostic port'un TMPDIR ve kullanıcı şartını, container'da sidecar'ı, dnx ve tool manifest ile kurulumu, trace'in gerçek overhead'ini ve dosya boyutunu Bookstore lab'ında ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (container'da runtime 10.0.15) ile derlenip denenmiştir; dotnet-counters, dotnet-trace ve dotnet-gcdump 10.0.750501, yük üretmek için k6 2.4.0 kullanılmıştır.
Önce Tahmin Et
Bookstore.Api'nin arama endpoint'i 4 CPU'luk bir container'da saniyede 1.450 civarı isteğe cevap veriyor. Yük sürerken dotnet-trace'i hiçbir parametre vermeden, yalnızca --duration ile 23 saniyeliğine çalıştırıyorsun. Ortaya çıkan .nettrace dosyası kaç MB olur: 2, 20, 200? Aynı kaydı GC (garbage collector; kullanılmayan object'leri temizleyen runtime bileşeni) olaylarını ve allocation örneklemesini açan gc-verbose profile'ıyla alırsan ne değişir? Bir de şunu düşün: bu kayıt servisi ne kadar yavaşlatır? Cevaplar yazının ortasındaki ölçüm tablosunda; ikinci sorunun cevabı çoğu kişinin beklediğinden farklı.
Dört Soru, Dört Araç
"Servis yavaş" ya da "bellek şişiyor" şikâyetiyle gelen biri genelde ilk aklına gelen aracı açar. Benim gördüğüm en pahalı hata da bu: belirtiye bakmadan profiler açmak, iki saat flame graph inceleyip sorunun GC pause'unda olduğunu sonradan fark etmek. Araç seçimini belirtiden değil, sorduğun sorudan başlatmak gerekiyor. .NET'in komut satırı diagnostics araçları her biri tek bir soruya cevap verecek şekilde ayrılmış:

| Soru | Araç | Ne verir | Maliyeti |
|---|---|---|---|
| Bir şey ters mi, nerede? | dotnet-counters | Saniyelik metrik: CPU, GC, thread pool, istek süresi | Çok düşük, saatlerce açık kalabilir |
| Zaman nereye gidiyor? | dotnet-trace | Stack örnekleri ve runtime event'leri (.nettrace) | Düşük-orta; dosya hızla büyür |
| Bellekte ne kalıyor? | dotnet-gcdump | Managed heap'teki object'lerin tip istatistiği ve referans grafiği | Tam bir gen2 GC tetikler |
| Process'in tam hâli ne? | dotnet-dump | Bütün bellek, thread'ler, stack'ler | Büyük dosya, kısa donma, hassas veri |
| Kod satırında ne oluyor? | Visual Studio, Rider profiler'ları | Görsel CPU, allocation, database, async analizi | Geliştirici makinesinde rahat, production'da değil |
Sıra hep aynı: önce ucuz ve geniş bakış (counters), sonra pahalı ve dar kayıt (trace, gcdump, dump). dotnet-dump ve SOS (.NET'in debugger extension'ı) komutlarıyla dump analizi Debugging ve Problem Çözme serisinin "Crash ve Memory Dump Analizi" yazısında, donma ve thread pool starvation teşhisi aynı serinin "Hang ve Yavaşlık Teşhisi" yazısında anlatıldı; bu seri onları tekrar etmiyor. IDE profiler'ları serinin 07. yazısında, PerfView 06'da geliyor.
Altyapı: EventPipe, ETW ve Linux'ta user_events
dotnet-counters, dotnet-trace ve dotnet-gcdump'ın üçü de aynı runtime bileşenini kullanıyor: EventPipe. EventPipe, runtime'ın içine gömülü bir tracing mekanizması. JIT (Just-In-Time; kodu çalışma anında makine koduna çeviren) derleyicisinin, GC'nin, thread pool'un ürettiği event'leri ve senin EventSource'larınla Meter'larının yayınladıklarını toplayıp .nettrace formatında bir dosyaya ya da bir araca akıtıyor. Runtime'ın parçası olduğu için Windows, Linux ve macOS'ta aynı şekilde çalışıyor ve admin yetkisi istemiyor.
Bedeli kapsamı: EventPipe yalnızca managed kodu ve runtime'ı görür. Stack'lerde native frame yok, kernel event'i yok, başka process yok. Bunlar için işletim sisteminin kendi tracing altyapısı gerekiyor: Windows'ta ETW (Event Tracing for Windows; işletim sisteminin kendi tracing altyapısı), Linux'ta perf_events. İkisi de yönetici yetkisi ister. PerfView (06) ve Visual Studio'nun profiler'ı Windows'ta ETW'yi kullanır; native frame'leri ve kernel tarafını da bu yüzden görebilirler.
.NET 10 bu ayrıma Linux'ta bir orta yol ekledi: EventPipe event'lerini kernel'in user_events mekanizmasına yazabiliyor. dotnet-trace'in collect-linux komutu bunu kullanıyor; managed event'ler, native stack'ler ve kernel event'leri tek bir trace'te birleşiyor, aynı anda makinedeki bütün process'ler izlenebiliyor. Şartları ağır: kernel 6.4 veya üstü ve CONFIG_USER_EVENTS desteği, root yetkisi, .NET 10 runtime ve glibc 2.27 veya üstü (.NET 10'un desteklediği dağıtımlardan yalnızca Alpine 3.22 bu şartı karşılamıyor). Bu yazıyı denediğim Docker ortamının kernel'i 6.12 olmasına rağmen user_events desteği olmadan derlenmişti; yani kernel sürümüne bakmak yetmiyor, özelliğin açık olduğunu da kontrol etmek gerekiyor. Ayrıntısı 03'te.
Diagnostic Port: Araç Process'i Nasıl Buluyor?
Araçların process'le konuştuğu kanalın adı diagnostic port. Runtime açılırken bir IPC (Inter-Process Communication; process'ler arası iletişim) uç noktası açıyor: Windows'ta \\.\pipe\dotnet-diagnostic-{pid} adında bir named pipe, Linux ve macOS'ta TMPDIR klasöründe (tanımlı değilse /tmp) bir Unix domain socket. Araçlar bu uç noktaya bağlanıp "şu provider'larla bir EventPipe oturumu aç", "GC heap'ini dök", "dump al" gibi komutlar gönderiyor.
Seri boyunca kullanacağımız Bookstore lab'ını production'a benzesin diye Linux container'ında çalıştırıyorum. Windows'ta Docker Desktop da Linux container'ı çalıştırdığı için aynı komutlar orada da geçerli. Araçları image'a, SDK'sız runtime katmanına kopyalanacak şekilde kuruyorum:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Bookstore.Api/Bookstore.Api.csproj Bookstore.Api/
RUN dotnet restore Bookstore.Api/Bookstore.Api.csproj
COPY Bookstore.Api/ Bookstore.Api/
RUN dotnet publish Bookstore.Api/Bookstore.Api.csproj -c Release -o /app --no-restore
RUN for tool in dotnet-counters dotnet-trace dotnet-gcdump; do \
dotnet tool install $tool --version 10.0.750501 --tool-path /tools; \
done
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
COPY --from=build /tools /tools
ENV PATH="$PATH:/tools"
USER $APP_UID
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]
docker build -t profseries-api:lab .
docker run -d --name profseries-api --cpus 4 --memory 2g -p 5481:8080 profseries-api:lab
docker exec profseries-api dotnet-counters ps
docker exec profseries-api ls -la /tmp
1 dotnet /usr/share/dotnet/dotnet dotnet Bookstore.Api.dll
drwxrwxrwt 1 root root 4096 Nov 13 23:25 .
drwxr-xr-x 1 root root 4096 Nov 13 23:25 ..
prwx------ 1 app app 0 Nov 13 23:25 clr-debug-pipe-1-891387-in
prwx------ 1 app app 0 Nov 13 23:25 clr-debug-pipe-1-891387-out
srw------- 1 app app 0 Nov 13 23:25 dotnet-diagnostic-1-891387-socket
Socket'in adı dotnet-diagnostic-{pid}-{disambiguation-key}-socket kalıbında; ortadaki sayı process'in başlangıç zamanından türetiliyor ki aynı PID (process'in işletim sistemindeki numarası) yeniden kullanıldığında eski socket'le karışmasın. İzinlere dikkat: srw-------, yani yalnız sahibi olan app kullanıcısı okuyup yazabiliyor. Bu iki ayrıntı, araçların en sık verdiği iki hatanın kaynağı.
Birincisi farklı TMPDIR. Araç socket'i kendi TMPDIR'ında arıyor; uygulamanınkiyle aynı değilse process'i hiç göremiyor:
docker exec -e TMPDIR=/var/tmp profseries-api dotnet-counters ps
docker exec -e TMPDIR=/var/tmp profseries-api dotnet-trace collect -p 1 --duration 00:00:00:02
No supported .NET processes were found
[ERROR] Microsoft.Diagnostics.NETCore.Client.ServerNotAvailableException: Unable to connect to Process 1. Please verify that /var/tmp/ is writable by the current user. If the target process is in a different container, ensure this process runs with 'pid: host' and has access to /proc/{pid}/root/ (requires CAP_SYS_PTRACE or privileged mode). Please see https://aka.ms/dotnet-diagnostics-port for more information.
İkincisi farklı kullanıcı. Process listede görünüyor, çünkü PID'yi bulmak için /proc yetiyor; ama socket'e bağlanmak izin istiyor:
docker exec -u 1000 profseries-api dotnet-trace collect -p 1 --duration 00:00:00:02
[ERROR] Microsoft.Diagnostics.NETCore.Client.ServerNotAvailableException: Unable to connect to the server. Permission denied
---> System.Net.Sockets.SocketException (13): Permission denied
Aynı komutu -u 0 ile root olarak çalıştırınca trace sorunsuz alındı. Kural basit: araç, uygulamayı başlatan kullanıcıyla ya da root ile çalışmalı. Windows'ta TMPDIR derdi yok, named pipe adı yalnız PID'ye bağlı; ama kullanıcı kuralı orada da geçerli.
Container'da: Aynı PID Namespace'i, Aynı /tmp
docker exec ya da kubectl exec ile uygulamanın container'ına girdiğinde ikisi de zaten aynı. Araçları uygulama image'ına koymak istemiyorsan, onları ayrı bir container'dan (sidecar) çalıştırabilirsin; o zaman sidecar'ın iki şeyi görmesi gerekiyor: uygulamanın process'ini (paylaşılan PID namespace'i) ve socket dosyasını (paylaşılan /tmp). Uygulamayı /tmp bir volume'e bağlanmış şekilde yeniden başlatıp denedim:
docker run -d --name profseries-api --cpus 4 --memory 2g -p 5481:8080 \
-v profseries-diag:/tmp profseries-api:lab
# yalnız volume: socket dosyası görünüyor ama process görünmüyor
docker run --rm -v profseries-diag:/tmp --entrypoint dotnet-counters profseries-api:lab ps
# volume + PID namespace: ikisi birlikte yetiyor
docker run --rm -v profseries-diag:/tmp --pid container:profseries-api \
--entrypoint dotnet-counters profseries-api:lab ps
No supported .NET processes were found
1 dotnet /usr/share/dotnet/dotnet dotnet Bookstore.Api.dll
Yalnız PID namespace'ini paylaşmak da yetmedi: --pid container:profseries-api ile ama volume'süz denediğimde, root ve SYS_PTRACE yetkisi versem bile araç process'i bulamadı. Kubernetes'te bunun karşılığı pod'da shareProcessNamespace ve iki container'a bağlanan bir emptyDir volume'ü. Kalıcı çözüm olan dotnet-monitor sidecar'ı ve DOTNET_DiagnosticPorts ile sabit bir port tanımlamak serinin 08. yazısının konusu; pod tarafının ayrıntısı Kubernetes serisinin 09. yazısında.
Ters yönde bir ayar da var: DOTNET_EnableDiagnostics=0 ile başlatılan process'te ne diagnostic port açılıyor ne debugger bağlanabiliyor. Denedim; /tmp boş kaldı ve dotnet-counters ps "No supported .NET processes were found" dedi. Aynı makinedeki aynı kullanıcıya güvenmediğin bir ortamda bu doğru tercih olabilir, ama o process'te bu yazıdaki araçların hiçbiri çalışmaz; production'a koymadan önce olay anında ne kaybedeceğini bil.
Araçları Kurmak: Global Tool, dnx, Manifest ve SDK'sız Image
Dört yol var ve hangisini seçeceğin nerede çalıştığına bağlı.
Global tool (dotnet tool install --global dotnet-trace) geliştirici makinesinde en rahatı ama sürümü ekipte herkeste farklı olur. Tek seferlik iş için .NET 10 SDK'sıyla gelen dnx var: aracı kurmadan indirip çalıştırıyor. SDK image'ında lab uygulamasını başlatıp denedim:
dnx dotnet-counters@10.0.750501 ps
8 dotnet /usr/share/dotnet/dotnet dotnet Bookstore.Api.dll
135 dotnet /usr/share/dotnet/dotnet exec /usr/share/dotnet/sdk/10.0.404/dotnet.dll dnx dotnet-counters@10.0.750501 ps
Listede ikinci satır dnx'in kendisi: o da bir .NET process'i. PID'yi elle seçerken yanlışlıkla aracın kendisini izlememek için komut satırı sütununa bak. Sürümü @10.0.750501 ile sabitlemek de iyi alışkanlık; vermezsen dnx her seferinde en son sürümü getirir.
Ekipte herkesin aynı sürümü kullanması için repo'ya bir tool manifest koymak daha iyi:
dotnet new tool-manifest
dotnet tool install dotnet-trace --version 10.0.750501
dotnet tool install dotnet-counters --version 10.0.750501
dotnet tool restore
dotnet dotnet-trace --version
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-trace": {
"version": "10.0.750501",
"commands": [
"dotnet-trace"
],
"rollForward": false
},
"dotnet-counters": {
"version": "10.0.750501",
"commands": [
"dotnet-counters"
],
"rollForward": false
}
}
}
.NET 10 SDK'sı manifest'i bulunduğun klasörde dotnet-tools.json olarak oluşturdu. .NET 10'dan itibaren dotnet tool install manifest bulamazsa onu kendisi de oluşturuyor; ilk komutu atlasan da aynı dosya çıkıyor, bunu boş bir klasörde ayrıca denedim. Repo'ya commit'lenen bu dosya sayesinde CI makinesi de geliştirici de dotnet tool restore ile aynı sürümü alıyor. Son yol SDK'sız image'lar için: yukarıdaki Dockerfile'daki gibi build aşamasında --tool-path ile kurup runtime image'ına kopyalamak ya da Microsoft'un her platform için doğrudan indirilebilir olarak yayınladığı çalıştırılabilir dosyaları kullanmak (Windows x64 için https://aka.ms/dotnet-trace/win-x64 gibi). Tool-path ile kurulan sürüm framework-dependent; image'daki runtime'ı kullanıyor, bu yüzden aspnet image'ında ek bir şey gerekmedi.
Bookstore Lab'ı: Serinin Kasıtlı Hataları
Seri boyunca aynı uygulamayı kullanacağız. Veritabanı yok; katalog ve siparişler bellekte, böylece ölçtüğümüz şey yalnızca bizim kodumuz. Her yazının kendi kasıtlı hatası var:
using Bookstore.Api.Catalog;
using Bookstore.Api.Ordering;
using Bookstore.Api.Reports;
using Bookstore.Api.Shipping;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<CatalogStore>();
builder.Services.AddSingleton<OrderStore>();
builder.Services.AddSingleton<OrderingMetrics>();
builder.Services.AddSingleton<OrderEvents>();
builder.Services.AddScoped<ShippingEmailHandler>();
WebApplication app = builder.Build();
app.MapGet("/health", () => Results.Ok("ok"));
app.MapOrderEndpoints(); // 02: metric'ler
app.MapSearchEndpoints(); // 03: CPU
app.MapReportEndpoints(); // 04: allocation ve LOH
app.MapShippingEndpoints(); // 05: memory leak
app.Run();
POST /api/orders(02): kendiMeter'ımız; sipariş sayısı ve ödeme süresi histogram'ı.GET /api/books/search(03): her istekte derlenen birRegexve 50 kitap için 787 KB'lık JSON cevap; aynı sorunun-v2sürümü 4 KB.GET /api/reports/orders.csv(04): 200.000 siparişi önce birStringBuilder'da, sonra birbyte[]'de toplayan CSV (virgülle ayrılmış düz metin) export'u; LOH (Large Object Heap; 85.000 byte ve üstü object'lerin ayrıldığı heap bölgesi) baskısı.POST /api/orders/{id}/ship(05): singleton bir event'e abone olup hiç ayrılmayan scoped handler; büyüyen heap.
06'dan sonraki yazılar bu uygulamaya kendi vakalarını ekliyor: Entity Framework Core N+1 sorgusu (07) ve checkout'ta uçtan uca bir performans vakası (09).
Overhead ve Güvenli Toplama
Tahmin sorusunun cevabı. Arama endpoint'ine 20 sanal kullanıcıyla 20 saniyelik k6 yükü verip üç tur ölçtüm: önce araçsız, sonra varsayılan profile'larla trace alırken, sonra gc-verbose ile:
docker exec -d profseries-api dotnet-trace collect -p 1 --duration 00:00:00:23 -o /tmp/r1-default.nettrace
docker exec -d profseries-api dotnet-trace collect -p 1 --profile gc-verbose --duration 00:00:00:23 -o /tmp/r1-gcv.nettrace
| Durum | İstek/sn (3 tur) | p95 (ms) | 23 sn'lik dosya |
|---|---|---|---|
| Araç yok | 1.518 / 1.431 / 1.480 | 18,6 / 19,9 / 18,8 | - |
Varsayılan (dotnet-common + dotnet-sampled-thread-time) | 1.320 / 1.467 / 1.460 | 22,9 / 19,3 / 18,5 | 20-22 MB |
gc-verbose | 1.267 / 1.432 / 1.514 | 23,4 / 21,1 / 18,2 | 197-234 MB |
İlk turda fark net: varsayılan profile throughput'u yüzde 13, gc-verbose yüzde 17 düşürdü ve p95 18,6 ms'den 23 ms'ye çıktı. İkinci ve üçüncü turda ise fark turlar arasındaki oynamanın (yüzde 5 civarı) içinde kaldı; üçüncü turda gc-verbose açıkken servis araçsız ölçümden bile hızlı çıktı. Yani bu endpoint'te trace'in maliyeti "bazen hissedilir, çoğu zaman ölçülemez" düzeyinde. Asıl fark dosyada: gc-verbose saniyede yaklaşık 9 MB yazdı, varsayılanın on katı. Overhead'in kesin bir yüzdesi yok; senin endpoint'inin ne kadar allocation yaptığına, kaç thread'in çalıştığına bağlı. Kendi servisinde aynı A/B (aynı yük, araçlı ve araçsız) ölçümünü bir kere yapmadan "trace production'ı yavaşlatmaz" diye söz verme.
Güvenli toplamanın kuralları bu sayılardan çıkıyor. Her kaydı süreyle sınırla (--duration); Enter'a basmayı unutup giden bir oturum diski dolduruyor. Dosyayı yazdığın yerin boş alanını önceden kontrol et; container'ın yazılabilir katmanı genelde küçük. Event üretimi diske yazma hızını aşarsa runtime'ın tamponu (varsayılan 256 MB, --buffersize) dolar ve event'ler düşer; trace eksik olur ama uygulama durmaz. Bu varsayılan davranışı değiştiren --buffering-mode Block seçeneği .NET 11 runtime'ı istiyor (son yazı). dotnet-gcdump ise ölçülü kullanılmalı: heap'i dolaşmak için tam bir gen2 GC tetikliyor ve büyük heap'te bu, bütün isteklerin durduğu uzun bir pause demek (05).
Üretimde Dikkat
Trace ve dump dosyaları zararsız log dosyaları değil. gcdump object grafiğini ve tip adlarını taşıyor, full dump ise bellekteki her şeyi: connection string, token, müşterinin e-posta adresi. Bu dosyaları kim alıyor, nereye kopyalanıyor, ne kadar süre saklanıyor; olay anında değil, önceden karar ver. Diagnostic port'a erişim, process'in bütün durumunu okuma ve davranışını değiştirme yetkisi demek; container'a exec yetkisi olan herkes bu yetkiye sahip. Kubernetes'te pods/exec RBAC (rol tabanlı yetkilendirme) iznini bu gözle değerlendir.
Production'da sıralama da önemli. dotnet-counters'ı istediğin kadar açık tut. dotnet-trace'i kısa süreli ve hedefli al: önce varsayılan profile, sorun allocation ise gc-verbose. gcdump ve dump'ı, istek trafiği load balancer'dan çekilmiş bir instance'ta almayı tercih et. Hiçbir aracı ilk kez olay anında deneme; komutları, yetkileri ve dosya yolunu sakin bir günde, staging'de bir kez çalıştırıp not al.
Sık Düşülen Tuzaklar
dotnet runile başlatılan uygulamayı izlemek:dotnet runönce SDK'yı ve build'i çalıştıran ayrı process'ler başlatıyor;pslistesinde yanlış PID'yi seçmek kolay. Ölçümüdotnet bin/Release/net10.0/Bookstore.Api.dllya da publish çıktısıyla al.- Debug build profillemek: optimizasyonlar kapalı, gördüğün sıcak noktalar Release'te hiç olmayabilir. Seri boyunca bütün ölçümler Release.
- Aracı farklı kullanıcıyla ya da farklı
TMPDIR'la çalıştırıp "process bulunamadı" hatasını aramak. - 32 bit uygulamaya 64 bit araçla bağlanmak (Windows'ta x86 uygulamalar için x86 araç gerekiyor).
- Trace'i
--durationvermeden başlatıp terminali kapatmak ya da dosyanın container'ın içinde kaldığını unutmak;docker cpile dışarı almadan container'ı silersen kayıt gider. - Uygulama unhandled exception ile çökerken trace almak: kayıt yarıda kesiliyor ve sondaki rundown bilgisi olmadığından stack'ler çözülemeyebiliyor. Çökme için doğru araç dump.
Küçük Bir Deneme
★☆☆ Lab'ı kendi makinende dotnet bin/Release/net10.0/Bookstore.Api.dll ile başlat ve dotnet-counters ps, dotnet-trace ps, dotnet-gcdump ps çıktılarını karşılaştır. Windows'taysan PowerShell'de Get-ChildItem \\.\pipe\ | Where-Object Name -like 'dotnet-diagnostic*' ile named pipe'ı bul ve adındaki PID'yi listedeki PID'yle eşleştir.
★★☆ Kendi servisine aynı yükü üç kez araçsız, üç kez dotnet-trace collect --duration 00:00:00:20 ile ver. Tur arası farkın, araçlı ve araçsız arasındaki farktan büyük mü küçük mü olduğuna bak; dosya boyutunu saniyeye böl ve diskindeki boş alanla karşılaştır.
★★★ Uygulamayı /tmp volume'ü olmadan başlatıp sidecar'dan bağlanmayı dene; sonra DOTNET_DiagnosticPorts ile uygulamanın sidecar'daki bir socket'e bağlanmasını sağla ve dotnet-counters collect --diagnostic-port ile karşıla. Hangi tarafın listen, hangi tarafın connect olduğunu not et.
Bir sonraki yazıda ilk satırdaki araçla başlıyoruz: dotnet-counters ile System.Runtime Meter'ını, ASP.NET Core ve Kestrel metric'lerini okuyacak, Bookstore'un sipariş akışına kendi Meter'ımızı ekleyip k6 yükü altında bir baseline kaydedeceğiz.