Mert Özen Her Satırda Daha İleri
DevOps Kubernetes — #05

Kubernetes Service: ClusterIP, NodePort, LoadBalancer, DNS ve EndpointSlice

Mert Özen 8 Kas 2026 10 dk 5 görüntülenme
Kubernetes Service: ClusterIP, NodePort, LoadBalancer, DNS ve EndpointSlice

Service, değişen pod IP'lerinin önüne sabit bir sanal IP ve api.bookstore.svc.cluster.local gibi bir DNS adı koyar; adresleri EndpointSlice'tan, yönlendirmeyi kube-proxy kurallarından alır. Named targetPort'u, bağlantı başına load balancing'in HttpClient'a etkisini, readiness'ın bütün pod'ları trafikten çekmesini, NodePort, LoadBalancer ve headless farkını, externalIPs'in 1.36'daki deprecation'ını ve boş endpoint tuzaklarını gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, kubectl 1.37, .NET 10, Npgsql 10.0 ve PostgreSQL 18 ile denenmiştir.

Önce Tahmin Et

Bookstore API'sinin (Application Programming Interface) üç pod'u bir Service'in arkasında çalışıyor ve readiness probe'ları PostgreSQL bağlantısını kontrol ediyor. PostgreSQL'i sıfır replica'ya indiriyorsun. Cluster içindeki bir client http://api/api/info adresine istek attığında ne alır: 503 mü, bir zaman aşımı mı, yoksa "connection refused" mı? İkinci soru: Service'in targetPort'unu yanlışlıkla 80 yazdın. kubectl get endpointslices boş mu görünür, dolu mu?

Pod IP'leri Neden Yetmez?

Her pod kendi IP (Internet Protocol) adresini alır ve cluster'daki diğer pod'lar bu adrese doğrudan ulaşabilir. Ama önceki yazılarda gördüğümüz gibi bu adresler kalıcı değil: rolling update'te, node arızasında ya da basit bir yeniden başlatmada pod yeni bir adresle geri gelir. Angular uygulamasını sunan nginx'in ya da bir worker'ın API'ye hangi adresle ulaşacağını bilmesi gerekir. Service bu sorunu çözer: değişen pod kümesinin önüne sabit bir sanal IP, sabit bir DNS (Domain Name System) adı ve bir load balancing katmanı koyar.

ClusterIP Service ve Named targetPort

apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: bookstore
  labels:
    app.kubernetes.io/name: api
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: api
  ports:
    - name: http
      port: 80
      targetPort: http

Service de Deployment gibi pod'ları label selector ile bulur. port Service'in dinlediği port, targetPort trafiğin pod'da gideceği port. Burada targetPort bir sayı değil, bir ad: http. Bu ad Deployment'taki container port tanımına bağlanıyor (name: http, containerPort: 8080). Yarın uygulamanın portu değişirse yalnız Deployment'ı değiştirirsin; Service ve probe'lar adı kullandığı için dokunulmaz. API pod'ları bu yazıda PostgreSQL'e bağlanıyor; connection string'i bir Secret'tan ConnectionStrings__Default environment variable'ı olarak alıyorlar (ayrıntısı yedinci yazıda).

$ kubectl get svc api
NAME   TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
api    ClusterIP   10.96.24.23   <none>        80/TCP    59s
$ kubectl get pods -l app.kubernetes.io/name=api -o wide
NAME                  READY   IP            NODE
api-75dcdbd96-2dzgt   1/1     10.244.2.21   k8swork1-1-worker
api-75dcdbd96-cv2g4   1/1     10.244.1.17   k8swork1-1-worker2
api-75dcdbd96-np2nj   1/1     10.244.2.22   k8swork1-1-worker
$ kubectl get endpointslices -l kubernetes.io/service-name=api
NAME        ADDRESSTYPE   PORTS   ENDPOINTS                             AGE
api-456cl   IPv4          8080    10.244.2.22,10.244.2.21,10.244.1.17   22s

10.96.24.23 hiçbir network interface'te yaşamayan sanal bir adres; yalnız cluster içinden erişilebilir. Service'in arkasındaki gerçek adresler ayrı bir object'te duruyor: EndpointSlice. Adı api-456cl, içinde üç pod IP'si ve port 8080 var; named port'un sayıya çözülmüş hâli.

Cluster DNS: api.bookstore.svc.cluster.local

Cluster içinden denemek için bir busybox pod'u açtım:

$ kubectl run client --image=busybox:1.37 --restart=Never --command -- sleep 3600
$ kubectl exec client -- nslookup api.bookstore.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53

Name:	api.bookstore.svc.cluster.local
Address: 10.96.24.23
$ kubectl exec client -- cat /etc/resolv.conf
search bookstore.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

Her Service <service>.<namespace>.svc.cluster.local adını alır ve bu ad ClusterIP'ye çözülür. DNS sunucusu 10.96.0.10, yani kube-system'deki CoreDNS. search satırı sayesinde aynı namespace'teki bir pod yalnız api, başka namespace'teki bir pod api.bookstore yazabilir. Altı istek attım:

$ for i in 1 2 3 4 5 6; do kubectl exec client -- wget -qO- http://api/api/info; echo; done
{"pod":"api-75dcdbd96-2dzgt","version":"1.0.0","environment":"Production","storeName":"Bookstore","discountPercent":0,"database":"postgres"}
{"pod":"api-75dcdbd96-np2nj","version":"1.0.0","environment":"Production","storeName":"Bookstore","discountPercent":0,"database":"postgres"}
{"pod":"api-75dcdbd96-cv2g4","version":"1.0.0","environment":"Production","storeName":"Bookstore","discountPercent":0,"database":"postgres"}
{"pod":"api-75dcdbd96-np2nj","version":"1.0.0","environment":"Production","storeName":"Bookstore","discountPercent":0,"database":"postgres"}
{"pod":"api-75dcdbd96-np2nj","version":"1.0.0","environment":"Production","storeName":"Bookstore","discountPercent":0,"database":"postgres"}
{"pod":"api-75dcdbd96-2dzgt","version":"1.0.0","environment":"Production","storeName":"Bookstore","discountPercent":0,"database":"postgres"}
$ kubectl exec client -- wget -qO- http://api.bookstore.svc.cluster.local/api/books
[{"id":1,"title":"Clean Architecture","author":"Robert C. Martin","price":42.50},{"id":2,"title":"Designing Data-Intensive Applications","author":"Martin Kleppmann","price":55.00},{"id":3,"title":"Kubernetes in Action","author":"Marko Luksa","price":49.90}]

Dağılım round robin değil: np2nj üç, 2dzgt iki, cv2g4 bir istek aldı. kube-proxy'nin iptables modu her yeni bağlantı için endpoint'i rastgele bir olasılıkla seçer; yeterince istekte eşitlenir ama kısa ölçümde dengesiz görünür. Daha önemli bir ayrıntı: seçim bağlantı başına yapılır, istek başına değil. Bir .NET servisi HttpClient ile API'ye bağlanıp bağlantıyı açık tutarsa (keep-alive), bütün istekleri aynı pod'a gider. Yeni pod'lar ölçeklendiğinde de trafik almazlar. SocketsHttpHandler.PooledConnectionLifetime'ı birkaç dakikayla sınırlamak bağlantıların zaman zaman yenilenip yeni pod'lara dağılmasını sağlar; HTTP (Hypertext Transfer Protocol) 2 ve gRPC (gRPC Remote Procedure Calls) gibi tek bağlantı üzerinde çoğullayan protokollerde bu etki daha da belirgindir.

API'nin veritabanı bağlantısı da aynı mekanizmayı kullanıyor. Connection string'deki Host=postgres, aynı namespace'teki postgres Service'ine çözülür; /api/books yanıtı bu yoldan geldi. Uygulama hiçbir pod IP'si bilmiyor.

ndots:5 ayarının bir yan etkisi var. İçinde beşten az nokta olan bir adı (örneğin api.payments.example.com) çözerken resolver önce search listesindeki son ekleri dener: api.payments.example.com.bookstore.svc.cluster.local ve diğerleri. Dış servislere çok istek atan bir uygulamada bu, her çözümleme için birkaç boşa DNS sorgusu demek. Adın sonuna nokta koymak (tam nitelikli ad) ya da pod'un dnsConfig'inde ndots'u düşürmek bunu önler.

EndpointSlice ve Readiness

EndpointSlice'ın içine bakalım:

$ kubectl get endpointslice -l kubernetes.io/service-name=api -o yaml
- addressType: IPv4
  apiVersion: discovery.k8s.io/v1
  endpoints:
  - addresses:
    - 10.244.2.22
    conditions:
      ready: true
      serving: true
      terminating: false
    nodeName: k8swork1-1-worker
    targetRef:
      kind: Pod
      name: api-75dcdbd96-np2nj
      namespace: bookstore
...

Her endpoint'in üç koşulu var: ready, serving ve terminating. EndpointSlice controller'ı Service'in selector'ına uyan pod'ları ve onların readiness durumunu izler; readiness probe'u başarısız olan pod listede kalır ama ready: false olur ve kube-proxy ona trafik göndermez. serving ve terminating ise kapanmakta olan pod'ları ayırt etmek için var; onuncu yazıda graceful shutdown'da bunlara döneceğiz.

05-service-endpointslice-tr.png

Şimdi baştaki ilk soru. PostgreSQL'i sıfıra indirdim:

$ kubectl scale deploy/postgres --replicas=0
deployment.apps/postgres scaled
$ kubectl get pods -l app.kubernetes.io/name=api
NAME                  READY   STATUS    RESTARTS   AGE
api-75dcdbd96-2dzgt   0/1     Running   0          67s
api-75dcdbd96-cv2g4   0/1     Running   0          67s
api-75dcdbd96-np2nj   0/1     Running   0          67s
$ kubectl get endpointslice -l kubernetes.io/service-name=api -o jsonpath='{range .items[*].endpoints[*]}{.targetRef.name} ready={.conditions.ready}{"\n"}{end}'
api-75dcdbd96-np2nj ready=false
api-75dcdbd96-2dzgt ready=false
api-75dcdbd96-cv2g4 ready=false
$ kubectl exec client -- wget -T 3 -qO- http://api/api/info
wget: can't connect to remote host (10.96.24.23): Connection refused
command terminated with exit code 1

Pod'lar çalışıyor (Running, restart yok) ama hiçbiri ready değil, EndpointSlice'taki üç endpoint de ready=false. Hazır endpoint'i olmayan bir Service'e gelen bağlantıyı kube-proxy'nin kuralı reddediyor; client "connection refused" alıyor. Dikkat et: /api/info veritabanına hiç dokunmayan bir endpoint, yine de erişilemez oldu. Readiness probe'una veritabanı kontrolü koymak "veritabanı yoksa bütün API'yi trafikten çek" demek. Bazen istediğin tam olarak bu, bazen değil; sekizinci yazıda bu kararı ayrıntılı tartışacağız. PostgreSQL'i geri açtığımda üç pod 12 saniye içinde yeniden 1/1 oldu.

NodePort, LoadBalancer ve Headless Service

TürKimden erişilirNe zaman
ClusterIPYalnız cluster içindenServisler arası trafik; varsayılan ve en yaygın
NodePortHer node'un IP'sinde 30000–32767 arası bir portÖnüne kendi load balancer'ını koyduğun ya da lokal denemeler
LoadBalancerBulut sağlayıcısının açtığı dış IPManaged cluster'da tek bir servisi dışarı açmak
Headless (clusterIP: None)DNS doğrudan pod IP'lerini dönerStatefulSet'ler, client tarafı load balancing

Üçünü aynı anda denedim:

$ kubectl get svc
NAME                TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
api                 ClusterIP      10.96.24.23    <none>        80/TCP         2m31s
api-lb              LoadBalancer   10.96.162.54   <pending>     80:32695/TCP   5s
api-nodeport        NodePort       10.96.166.39   <none>        80:30080/TCP   5s
postgres            ClusterIP      10.96.28.37    <none>        5432/TCP       22m
postgres-headless   ClusterIP      None           <none>        5432/TCP       5s
$ curl -s localhost:18780/api/info
{"pod":"api-75dcdbd96-2dzgt","version":"1.0.0","environment":"Production","storeName":"Bookstore","discountPercent":0,"database":"postgres"}
$ kubectl exec client -- nslookup postgres-headless.bookstore.svc.cluster.local
Address: 10.244.1.18
$ kubectl exec client -- nslookup postgres.bookstore.svc.cluster.local
Address: 10.96.28.37

NodePort Service'ine nodePort: 30080 verdim; ikinci yazıdaki kind config'i bu portu makinenin 18780'ine bağladığı için curl localhost:18780 doğrudan API'ye ulaştı. LoadBalancer Service'inin EXTERNAL-IP'si <pending>'de kaldı: dış IP'yi bir bulut sağlayıcısının controller'ı ya da MetalLB gibi bir eklenti verir, kind'da ikisi de yok. Ayrıca her LoadBalancer Service'i de bir NodePort (32695) açar. Headless Service'in DNS kaydı ise ClusterIP yerine doğrudan pod'un IP'sini (10.244.1.18) döndü.

Production'da HTTP trafiğini her servis için ayrı bir LoadBalancer'la dışarı açmak pahalı ve dağınık olur: her biri ayrı bir bulut load balancer'ı ve ayrı bir IP demek. Dış HTTP trafiği için doğru araç bir sonraki yazının konusu olan Gateway API.

kube-proxy'nin Rolü ve externalIPs'in Sonu

Service'in sanal IP'sini gerçek pod'lara çeviren, her node'daki kube-proxy'nin kurduğu kurallar. Bizim cluster'da mod iptables:

$ kubectl logs -n kube-system kube-proxy-7sz4f | grep -i proxier
I1108 03:20:11.217309       1 server_linux.go:144] "Using iptables Proxier"

kube-proxy paketin yolunda durmaz; yalnız kernel'e kural yazar ve paketleri kernel yönlendirir. iptables dışında ipvs ve 1.33'te stable olan nftables modları var; Cilium gibi bazı CNI (Container Network Interface) eklentileri kube-proxy'yi tamamen devre dışı bırakıp aynı işi eBPF (extended Berkeley Packet Filter) ile yapar. Uygulama geliştirici olarak hangisinin kullanıldığını çoğu zaman bilmen gerekmez; ama "Service'e erişemiyorum" sorununu ayıklarken trafiği bir proxy'nin değil kernel kurallarının taşıdığını bilmek işe yarar.

Eski manifest'lerde göreceğin bir field artık emeklilik yolunda: spec.externalIPs. Bir Service'e keyfi bir dış IP atamaya izin veren bu field, CVE (Common Vulnerabilities and Exposures) 2020-8554 ile belgelenen bir güvenlik sorunu yüzünden 1.21'den beri kapatılması öneriliyordu; Kubernetes 1.36'da resmen deprecated oldu. Kullanınca API server uyarı veriyor:

$ kubectl apply --dry-run=server -f extip.yaml
Warning: spec.externalIPs is deprecated and may no longer be implemented in some clusters
service/api-externalip created (server dry run)

Kubernetes blog'undaki takvime göre kube-proxy desteği en erken 1.40'ta varsayılan olarak kapanacak (geçiş için tekrar açılabilecek), en erken 1.43'te tamamen kalkacak. Önerilen alternatifler LoadBalancer Service'i (bulutta ya da MetalLB ile) ve Gateway API.

Üretimde Dikkat

Readiness'ın trafik üzerindeki etkisini tasarla. Service yalnız ready pod'lara trafik gönderir; readiness'a koyduğun her bağımlılık, o bağımlılık gittiğinde bütün replica'ların aynı anda trafikten çekilmesi anlamına gelebilir.

Uzun ömürlü bağlantıları hesaba kat. Service L4 (Layer 4, TCP (Transmission Control Protocol) bağlantısı düzeyinde) load balancing yapar; istekleri değil bağlantıları dağıtır. Servisler arası .NET HttpClient trafiğinde bağlantı ömrünü sınırla; gRPC için client tarafı load balancing ya da bir L7 (Layer 7, HTTP düzeyinde) proxy düşün.

Service adlarını configuration'da kısa adla değil, gerekiyorsa namespace'iyle yaz (postgres.bookstore). Aynı manifest başka bir namespace'e uygulandığında kısa ad o namespace'teki, belki de olmayan, Service'e çözülür.

Sık Düşülen Tuzaklar

Selector ve label uyuşmazlığı. Pod'lar app.kubernetes.io/name: api taşırken Service app: api seçerse Service hiçbir hata vermez, yalnız boş kalır:

$ kubectl get endpointslices -l 'kubernetes.io/service-name in (api-wrong-selector,api-wrong-port)'
NAME                       ADDRESSTYPE   PORTS     ENDPOINTS                             AGE
api-wrong-port-wxnrm       IPv4          80        10.244.1.17,10.244.2.22,10.244.2.21   3s
api-wrong-selector-vbpl2   IPv4          <unset>   <unset>                               3s
$ kubectl describe svc api-wrong-selector
Selector:                 app=api
Endpoints:
$ kubectl exec client -- wget -T 3 -qO- http://api-wrong-selector/api/info
wget: can't connect to remote host (10.96.88.40): Connection refused
$ kubectl exec client -- wget -T 3 -qO- http://api-wrong-port/api/info
wget: can't connect to remote host (10.96.163.162): Connection refused

Boş Endpoints: satırı ilk bakılacak yer: selector'ı kubectl get pods --show-labels çıktısıyla karşılaştır.

Port 80 ile 8080'i karıştırmak. Baştaki ikinci sorunun cevabı yukarıda: targetPort: 80 yazılan Service'in EndpointSlice'ı dolu ve her şey sağlıklı görünüyor, çünkü pod'lar ready. Ama pod'larda 80'i dinleyen bir process yok, bağlantı reddediliyor. .NET 8 ve sonrası image'lar 8080'i dinler; named port kullanmak bu hatayı kökten önler.

Yalnız Service'e bakmak. "Service çalışmıyor" şikâyetinin çoğu Service'te değil, pod'un readiness'ında ya da port'unda. Sıra: kubectl get endpointslices, sonra pod'ların READY sütunu, sonra pod'a doğrudan istek.

Küçük Bir Deneme

★☆☆ client pod'undan wget -qO- http://api/api/info'yu 30 kez çalıştır ve hangi pod'un kaç istek aldığını say. Sonra API'yi 5 replica'ya çıkar ve ölçümü tekrarla.

★★☆ Bir API pod'unu kubectl label pod <ad> app.kubernetes.io/name- ile selector'dan çıkar. EndpointSlice'tan düştüğünü, Deployment'ın yerine yeni bir pod yarattığını ve etiketsiz pod'un çalışmaya devam ettiğini gözle. Etiketsiz pod'u kim silecek?

★★★ api Service'ine sessionAffinity: ClientIP ekle ve dağılımı yeniden ölç. Ardından bir .NET konsol uygulamasından tek bir HttpClient ile 100 istek at (cluster içinde bir pod olarak) ve PooledConnectionLifetime ayarlı ve ayarsız durumda kaç farklı pod adı gördüğünü karşılaştır.

API'ye cluster içinden sabit bir adla ulaşabiliyoruz. Ama Bookstore'un kullanıcıları cluster'ın dışında. Bir sonraki yazıda dış trafiği içeri alıyoruz: Ingress'in neden dondurulduğunu, ingress-nginx'in emekliliğini ve Gateway API ile /api isteklerini .NET'e, geri kalanını Angular'a yönlendirmeyi göreceğiz.