IP Adresi, Subnet, Port ve NAT: localhost, 0.0.0.0 ve Container'larda Bağlantı
IP adresi bir arayüzü, port o makinedeki bir socket'i bulur; 127.0.0.1'e bind olan bir sunucuya yalnızca o makinenin içinden ulaşılır. RFC 1918 özel adresleri, CIDR ile subnet, NAT, ephemeral port'lar, address already in use ve container'da localhost'a bind olan Kestrel'in neden Empty reply verdiği gerçek çıktılarla.
Sürüm notu: Bu yazıdaki kodlar ve komutlar .NET 10 (ASP.NET Core 10 Kestrel), Docker ve mcr.microsoft.com/dotnet/aspnet:10.0 image'ı ile macOS üzerinde denenmiştir; testler xUnit v3 4.0.2 ile koşulmuştur.
Önce Tahmin Et
Bookstore API'yi (Application Programming Interface) bir Docker container'ına koydun. Container'ın logu her şeyin yolunda olduğunu söylüyor:
Now listening on: http://127.0.0.1:5491
Container'ı docker run -p 127.0.0.1:5594:5491 ... ile başlattın, yani host'taki 5594 port'unu container'daki 5491'e yönlendirdin. Şimdi host'ta curl http://127.0.0.1:5594/api/books çalıştırıyorsun. Ne olur? Kitap listesi mi gelir, "connection refused" mı, timeout mu, yoksa başka bir şey mi? Uygulama container'ın içinde çalışıyor, port yönlendirmesi doğru, log temiz. Sorun nerede olabilir?
Bu yazı, bu sorunun cevabını verecek kadar adres ve port bilgisini topluyor: IP (Internet Protocol) adresleri, subnet'ler, routing, NAT (Network Address Translation), port'lar ve bir sunucunun bir adrese "bind" olmasının gerçekte ne demek olduğu.
IP Adresi: IPv4, IPv6 ve Özel Adresler
IP adresi, bir ağ arayüzünün ağdaki kimliği. IPv4 adresleri 32 bit ve 192.168.8.9 gibi dört sayıyla yazılır; yaklaşık 4,3 milyar adres eder ve bu sayı çoktan yetmez oldu. IPv6 adresleri 128 bit ve fe80::1 gibi onaltılık gruplarla yazılır. Bu yazıyı yazdığım makinenin Wi-Fi arayüzü ve loopback arayüzü:
$ ifconfig en0 | grep "inet "
inet 192.168.8.9 netmask 0xffffff00 broadcast 192.168.8.255
$ ifconfig lo0 | grep inet
inet 127.0.0.1 netmask 0xff000000
inet6 ::1 prefixlen 128
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
Her adres aynı türden değil. Bazı adres blokları özel amaçlara ayrılmış ve kodda bunları ayırt etmek çoğu zaman gerekiyor: bir rate limiter iç ağdan gelen istekleri muaf tutmak, bir webhook doğrulaması özel adreslere giden istekleri reddetmek isteyebilir. .NET 8 ile gelen System.Net.IPNetwork ile küçük bir sınıflandırıcı:
public static class AddressTools
{
// RFC 1918 özel IPv4 blokları
private static readonly IPNetwork[] PrivateV4 =
[
IPNetwork.Parse("10.0.0.0/8"),
IPNetwork.Parse("172.16.0.0/12"),
IPNetwork.Parse("192.168.0.0/16"),
];
public static string Classify(IPAddress address) => address switch
{
_ when IPAddress.IsLoopback(address) => "loopback",
{ AddressFamily: AddressFamily.InterNetwork } when PrivateV4.Any(n => n.Contains(address)) => "private (RFC 1918)",
{ AddressFamily: AddressFamily.InterNetwork } when IPNetwork.Parse("169.254.0.0/16").Contains(address) => "link-local",
{ AddressFamily: AddressFamily.InterNetworkV6, IsIPv6LinkLocal: true } => "link-local",
{ AddressFamily: AddressFamily.InterNetworkV6 } when IPNetwork.Parse("fc00::/7").Contains(address) => "unique local (IPv6)",
_ when address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) => "unspecified (all interfaces)",
_ => "public",
};
}
$ dotnet Bookstore.Net.Lab.dll classify 127.0.0.1 ::1 192.168.8.23 172.17.0.2 10.0.0.5 0.0.0.0 203.0.113.10 fe80::1 169.254.10.1
127.0.0.1 loopback
::1 loopback
192.168.8.23 private (RFC 1918)
172.17.0.2 private (RFC 1918)
10.0.0.5 private (RFC 1918)
0.0.0.0 unspecified (all interfaces)
203.0.113.10 public
fe80::1 link-local
169.254.10.1 link-local
RFC (Request for Comments) 1918 üç IPv4 bloğunu özel kullanıma ayırır: 10.0.0.0/8, 172.16.0.0/12 ve 192.168.0.0/16. Bu adresler internette yönlendirilmez; evdeki router'ın, ofis ağının ve Docker'ın (172.17.0.2 Docker'ın varsayılan bridge ağından tipik bir container adresi) iç adresleridir. Loopback (127.0.0.0/8 ve IPv6'da ::1) paketleri makineden hiç çıkarmaz. Link-local adresler (169.254.0.0/16, fe80::/10) yalnızca aynı fiziksel segmentte geçerlidir. 203.0.113.10'u sınıflandırıcım "public" dedi; aslında RFC 5737'nin dokümantasyon için ayırdığı bir blok, bu yüzden bu serinin örneklerinde gerçek bir adres yerine onu kullanıyorum. Bu testler ve 172.31.255.1 / 172.32.0.1 sınırı gibi kenar durumlar bir xUnit testinde doğrulanıyor.
Subnet ve CIDR: /24 Ne Demek
ifconfig çıktısındaki netmask 0xffffff00, adresin ilk 24 bitinin ağı, son 8 bitinin o ağdaki cihazı belirttiğini söylüyor. Bunu CIDR (Classless Inter-Domain Routing) notasyonuyla 192.168.8.0/24 diye yazarız: "ilk 24 bit sabit". 8 bit host kısmı 256 adres eder; ağ adresi (.0) ve broadcast adresi (.255) çıkınca 254 kullanılabilir adres kalır.
$ dotnet Bookstore.Net.Lab.dll subnet 192.168.8.0/24 192.168.8.23 192.168.9.23
network 192.168.8.0/24, usable hosts 254
192.168.8.23 inside: True
192.168.9.23 inside: False
Subnet bilgisi, makinenin bir paketi nereye göndereceğine karar vermesini sağlar. Hedef aynı subnet'teyse paket doğrudan o cihaza gider. Değilse paket default gateway'e, yani router'a verilir:
$ route -n get default
gateway: 192.168.8.1
interface: en0
Router da kendi routing tablosuna bakıp paketi bir sonraki router'a verir; internet, her biri yalnızca bir sonraki adımı bilen router'lar zinciridir. Bu zinciri traceroute (Windows'ta tracert) ile görebilirsin: komut, paketlerin TTL (Time To Live) alanını 1'den başlatıp her adımda artırır ve her router'ın "süresi doldu" cevabından yolun bir sonraki halkasını öğrenir. Kendi ev ağının ve servis sağlayıcının adreslerini ifşa ettiği için çıktısını burada paylaşmıyorum; kendi makinende traceroute -m 5 1.1.1.1 ile ilk beş adımı görebilirsin.
Production'da CIDR'ı en çok cloud ağ tasarımında ve güvenlik kurallarında yazarsın: bir VNet veya VPC (Virtual Private Cloud) için 10.0.0.0/16, uygulama subnet'i için 10.0.1.0/24, bir firewall kuralında "yalnızca 10.0.0.0/8'den gelen trafik". Subnet'leri baştan küçük seçmek, Kubernetes gibi her pod'a bir IP veren ortamlarda kısa sürede adres tükenmesine yol açar.
NAT: Evdeki Makinene Neden Dışarıdan Ulaşılamaz
Makinemin adresi 192.168.8.9 ve bu adres internette yönlendirilmez. Peki dışarıya nasıl çıkıyorum? Router'ım NAT yapıyor: içeriden çıkan her paketin kaynak adresini ve port'unu kendi public adresi ve kendi seçtiği bir port'la değiştiriyor, bu eşlemeyi bir tabloda tutuyor ve cevap geldiğinde ters çeviriyor. Dışarıdaki sunucu benim makinemi değil, router'ın public adresini görüyor.
Bu tablonun bir sonucu var: eşleme yalnızca içeriden başlatılan bağlantılar için oluşur. Dışarıdan biri router'ın public adresine bir bağlantı açmaya çalışırsa, router o paketin içerideki hangi makineye gideceğini bilmez ve paketi düşürür. Evdeki makinende çalışan bir Kestrel'e internetten erişilememesinin sebebi bu. Bunu aşmak için ya router'da port forwarding (sabit bir dış port'u içerideki bir adrese ve port'a bağlamak) yaparsın ya da bir tünel servisi kullanırsın. Birçok mobil operatör ve servis sağlayıcı ayrıca kendi ağında ikinci bir NAT katmanı (CGNAT, Carrier-Grade NAT) çalıştırır; o durumda port forwarding bile işe yaramaz.
Docker'ın -p seçeneği aynı fikrin yerel bir versiyonu. Container'lar kendi özel ağlarında yaşar; -p 127.0.0.1:5594:5491 host'un 5594 port'una gelen bağlantıları container'ın 5491 port'una çeviren bir kural ekler. Bu kural, tahmin bölümündeki sorunun anahtarı.
Port'lar: Well-Known, Ephemeral ve "Address Already in Use"
IP adresi bir makineyi, port ise o makinedeki bir uygulamayı (daha doğrusu bir socket'i) bulur. TCP (Transmission Control Protocol) ve UDP (User Datagram Protocol) bağlantıları dört değerle tanımlanır: kaynak IP, kaynak port, hedef IP, hedef port. IANA (Internet Assigned Numbers Authority) port aralığını üçe böler: 0–1023 well-known port'lar (80 HTTP (Hypertext Transfer Protocol), 443 HTTPS (HTTP Secure), 22 SSH (Secure Shell), 53 DNS (Domain Name System)), 1024–49151 registered port'lar ve 49152–65535 dynamic, yani ephemeral port'lar. İstemci bir bağlantı açtığında kaynak port'u işletim sistemi ephemeral aralıktan seçer. macOS'ta bu aralık:
$ sysctl net.inet.ip.portrange.first net.inet.ip.portrange.last
net.inet.ip.portrange.first: 49152
net.inet.ip.portrange.last: 65535
Önceki yazıdaki curl çıktısında gördüğün from ::1 port 62043 tam olarak bu aralıktan seçilmiş bir kaynak port'u. Linux'ta varsayılan aralık 32768–60999'dur (/proc/sys/net/ipv4/ip_local_port_range); bu aralığın tükenmesi bir sonraki yazının konusu olan socket exhaustion'ın ta kendisi.
Sunucu tarafında bir port'u aynı adreste aynı anda yalnızca bir socket dinleyebilir. Kestrel'i çalışırken ikinci bir kopyasını başlatınca:
$ lsof -nP -iTCP:5491 -sTCP:LISTEN
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
dotnet 93444 mertozen 195u IPv4 0xb4b57746e21ed2e 0t0 TCP 127.0.0.1:5491 (LISTEN)
$ dotnet Bookstore.Net.Server.dll
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
System.IO.IOException: Failed to bind to address http://127.0.0.1:5491: address already in use.
---> Microsoft.AspNetCore.Connections.AddressInUseException: Address already in use
---> System.Net.Sockets.SocketException (48): Address already in use
"Address already in use" gördüğünde ilk iş, port'u kimin tuttuğunu bulmak. macOS ve Linux'ta lsof -nP -iTCP:5491 -sTCP:LISTEN, Linux'ta ayrıca ss -tlnp, Windows'ta netstat -ano | findstr :5491 ve sonra PID'den (process ID) process'i bulmak. Çoğu zaman suçlu, kapanmamış eski bir debug oturumu ya da arka planda kalmış bir dotnet watch.
localhost, 127.0.0.1, ::1 ve 0.0.0.0
Bu dört ifade sık sık birbirinin yerine kullanılır ama aynı şey değiller. localhost bir isim; çözümlemesi işletim sistemine bağlı. 127.0.0.1 ve ::1 loopback adresleri. 0.0.0.0 ise bir adres değil, bind sırasında "bütün arayüzler" anlamına gelen özel bir değer (IPv6'daki karşılığı ::). .NET'te localhost'un nasıl çözüldüğüne bakalım:
$ dotnet Bookstore.Net.Lab.dll resolve localhost
localhost -> ::1, 127.0.0.1
İlk sırada IPv6 var. Sunucum yalnızca 127.0.0.1'de dinliyorken HttpClient ile localhost'a istek attım:
$ dotnet Bookstore.Net.Lab.dll connect http://localhost:5491/conn
OK in 20 ms: {"id":"0HNPTFV2DKJ2E","remotePort":62345,"protocol":"HTTP/1.1"}
$ dotnet Bookstore.Net.Lab.dll connect http://localhost:5599/conn
FAILED in 16 ms: Connection refused (localhost:5599) (ConnectionRefused)
İstek başarılı. Loopback'te ::1 denemesi anında "connection refused" ile döndüğü için istemci hemen bir sonraki adrese, 127.0.0.1'e geçti; önceki yazıdaki curl çıktısında bu denemeyi satır satır görmüştük. Ama aynı durum gerçek bir ağda farklı biter. Bir alan adının hem A (IPv4 adres kaydı) hem AAAA (IPv6 adres kaydı) varsa ve IPv6 yolu bir firewall tarafından sessizce düşürülüyorsa, ilk deneme reddedilmez; istemci işletim sisteminin connect timeout'u dolana kadar bekler ve bu onlarca saniye sürebilir. curl ve tarayıcılar gibi bazı istemciler Happy Eyeballs (RFC 8305) ile iki adres ailesini kısa bir gecikmeyle paralel dener; her istemci bunu yapmaz. Kendi istemcinin davranışını varsayma, ölç.
Sunucu tarafında ise bind adresi, hangi arayüzden gelen bağlantıların kabul edileceğini belirler. Kestrel'i 127.0.0.1'e bind etmek "yalnızca bu makinenin içinden gelenler"; 0.0.0.0'a bind etmek "bu makinenin bütün arayüzlerinden gelenler" demek. Geliştirme makinende 127.0.0.1 güvenli bir varsayılan: kahve dükkânındaki Wi-Fi ağı API'ni göremez. Container'da ise tam tersi.
Docker'da "Çalışıyor Ama Erişilemiyor"
Tahmin bölümündeki senaryoyu gerçekten çalıştırdım. Container'daki Kestrel'i 127.0.0.1'e bind ettim ve host'tan istek attım:
$ docker run -d --name network-kestrel-lo -p 127.0.0.1:5594:5491 -e BIND_ADDRESS=127.0.0.1 ... mcr.microsoft.com/dotnet/aspnet:10.0 dotnet /app/Bookstore.Net.Server.dll
$ docker logs network-kestrel-lo
Overriding address(es) 'http://*:8080'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
Now listening on: http://127.0.0.1:5491
$ curl -sS -m 5 http://127.0.0.1:5594/api/books
curl: (52) Empty reply from server
Ne "connection refused" ne timeout: bağlantı kuruluyor ama hemen kapanıyor. Host tarafında Docker'ın port yönlendiricisi 5594'ü dinliyor ve bağlantıyı kabul ediyor; sonra container'ın ağ arayüzündeki 5491'e iletmeye çalışıyor. Ama container'ın içindeki 127.0.0.1, container'ın kendi loopback'i. Kestrel yalnızca orada dinliyor ve container'ın dışarıya bakan arayüzüne gelen bağlantıyı kimse kabul etmiyor. Container'ın socket tablosu bunu doğruluyor:
$ docker exec network-kestrel-lo sh -c 'cat /proc/net/tcp | head -3'
sl local_address rem_address st ...
0: 0100007F:1575 00000000:0000 0A ...
1: 0100007F:1574 00000000:0000 0A ...
0100007F, byte sırası ters yazılmış 127.0.0.1; 1574 ve 1575 onaltılık 5492 ve 5493; 0A durumu LISTEN. Çözüm, container'da 0.0.0.0'a bind etmek. Aynı image'ı BIND_ADDRESS=0.0.0.0 ile çalıştırınca önceki yazıdaki bütün curl istekleri başarılı oldu.
Logdaki ilk satır da önemli bir ipucu. Microsoft'un .NET container image'larında ASPNETCORE_HTTP_PORTS environment variable'ı tanımlı:
$ docker run --rm mcr.microsoft.com/dotnet/aspnet:10.0 env | grep -E "ASPNETCORE|DOTNET_RUNNING"
ASPNETCORE_HTTP_PORTS=8080
DOTNET_RUNNING_IN_CONTAINER=true
.NET 8'den beri image'lar varsayılan olarak 8080'de, bütün arayüzlerde (http://*:8080) dinler; eskiden 80'di. Ben endpoint'leri kodda Listen ile verdiğim için Kestrel bu ayarı ezdi ve uyarı yazdı. Kodda bir şey belirtmezsen uygulaman container'da 8080'de, doğru arayüzde dinler ve -p 5594:8080 yeterli olur. En sık görülen hata, kodda veya launchSettings.json'dan gelen bir localhost URL'ini (Uniform Resource Locator) container'a taşımak.
Üretimde Dikkat
Bind adresini environment'a göre ver. Geliştirmede 127.0.0.1, container'da 0.0.0.0 veya http://+:8080. Bunu kodda sabitlemek yerine ASPNETCORE_URLS, ASPNETCORE_HTTP_PORTS veya configuration'dan okumak, aynı image'ın her ortamda doğru davranmasını sağlar. Seçeneklerin tamamı Microsoft Learn'deki "Configure endpoints for the ASP.NET Core Kestrel web server" sayfasında.
Gerçek istemci IP'sini doğru yerden oku. NAT, load balancer ve reverse proxy arkasında HttpContext.Connection.RemoteIpAddress istemciyi değil son hop'u gösterir; gerçek adres X-Forwarded-For header'ında gelir ve ASP.NET Core'da Forwarded Headers middleware'i ile, yalnızca güvendiğin proxy'lerden kabul edilecek şekilde okunur. Güvenmediğin bir kaynaktan gelen X-Forwarded-For'a inanmak, IP tabanlı kısıtlamaları atlatmanın klasik yolu.
Özel adreslere giden isteklere dikkat et. Kullanıcının verdiği bir URL'e sunucu tarafından istek atan her özellik (webhook, "URL'den resim yükle") 10.x, 169.254.169.254 gibi iç adreslere yönlendirilebilir; bu SSRF (Server-Side Request Forgery) zafiyeti. Yukarıdaki gibi bir sınıflandırıcı, çözümlenen adresi isteği atmadan önce kontrol etmenin ilk adımı; ayrıntıları Web Güvenliği serisinde.
Sık Düşülen Tuzaklar
Container'da localhost'a bind etmek. Log temiz görünür, container ayakta durur, dışarıdan hiçbir istek gelmez; curl'de "Empty reply from server" veya "connection reset".
localhost'u yalnızca IPv4 sanmak. İsim önce ::1'e çözülebilir; sunucu yalnızca IPv4'te dinliyorsa her bağlantı bir başarısız denemeyle başlar, kötü durumda bir timeout bekler.
0.0.0.0'ı bir hedef adres gibi kullanmak. 0.0.0.0 bind için "bütün arayüzler" demek; istemci tarafında hedef olarak kullanılması tanımsız davranıştır.
Subnet'i küçük seçmek. Bir /24 254 adres demek; her pod'a adres veren bir cluster'da bu birkaç node'da biter.
"Address already in use"'da port'u değiştirip geçmek. Port'u tutan eski process çoğu zaman hâlâ çalışan ve yanlış sürüme cevap veren bir sunucu; onu bul ve kapat.
Küçük Bir Deneme
★☆☆ Kendi makinende ifconfig (Windows'ta ipconfig) ile adresini ve netmask'ini bul ve CIDR notasyonuyla yaz. Ağında kaç kullanılabilir adres var?
★★☆ AddressTools.Classify'a RFC 6598'in CGNAT için ayırdığı 100.64.0.0/10 bloğunu ve RFC 5737'nin dokümantasyon bloklarını ekle; xUnit testlerini genişlet.
★★★ Bir ASP.NET Core uygulamasını container'da hiçbir endpoint vermeden çalıştır ve docker exec ile /proc/net/tcp6'ya bak. Kestrel hangi adreste ve port'ta dinliyor? ASPNETCORE_HTTP_PORTS=5000 verince ne değişiyor?
Adresi ve port'u bulduk. Sırada o adrese giden bağlantının kendisi var. Bir sonraki yazıda TCP'nin 3-way handshake'ine, congestion control'e ve HttpClient'ı yanlış kullanınca ephemeral port'ların nasıl tükendiğine bakıyoruz.