Skip to content

Services: ClusterIP, NodePort, LoadBalancer

A Service gives a shifting set of Pods a single stable virtual IP and DNS name, load-balancing traffic across whichever matching Pods are currently healthy.

Pods are disposable. A Deployment reschedules a crashed Pod onto a different node, a rollout replaces every Pod with new ones, and the cluster autoscaler adds and removes nodes — every one of these events can give a Pod a brand new IP address. Anything that hardcodes a Pod IP breaks the moment that Pod is replaced.

A Service solves this by sitting in front of a group of Pods. It picks its members using a label selector, and it tracks which of those Pods are currently ready through an Endpoints object (or the newer, scalable EndpointSlice objects). As Pods come and go, the controller updates the Endpoints/EndpointSlice automatically — clients never need to know.

apiVersion: v1
kind: Service
metadata:
name: payments
spec:
selector:
app: payments
ports:
- port: 80
targetPort: 8080
Terminal window
# See which Pods this Service is currently routing to
kubectl get endpoints payments
kubectl get endpointslices -l kubernetes.io/service-name=payments

Any Pod whose labels match app: payments and that is passing its readiness probe is added to that list automatically. Delete the Pod and it drops out; a replacement Pod with the same labels is added back in — the Service’s IP and DNS name never change.

Three Service types, each building on the last

Section titled “Three Service types, each building on the last”

spec.type controls how the Service is reachable, and the three common types form a ladder — each one builds on the one before it.

  • ClusterIP (the default) — a virtual IP reachable only from inside the cluster. This is what most Pod-to-Pod communication uses.
  • NodePort — everything ClusterIP does, plus the same port is opened on every node in the cluster, in the 30000–32767 range by default. Hitting <any-node-ip>:<nodePort> reaches the Service from outside the cluster.
  • LoadBalancer — everything NodePort does, plus the cluster asks the cloud provider to provision an external load balancer that forwards to the NodePort.
apiVersion: v1
kind: Service
metadata:
name: payments-public
spec:
type: LoadBalancer
selector:
app: payments
ports:
- port: 80
targetPort: 8080
nodePort: 30080
Terminal window
kubectl get svc payments-public
# EXTERNAL-IP column is populated once the cloud provider finishes provisioning

EndpointSlice, real, versus the deprecated Endpoints API

Section titled “EndpointSlice, real, versus the deprecated Endpoints API”

Real proof of the deprecation itself — a plain kubectl get endpoints on Kubernetes 1.37 already prints a warning:

k8s/services-clusterip-nodeport-loadbalancer/svc-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web-svc-demo
template:
metadata:
labels:
app: web-svc-demo
spec:
containers:
- name: web
image: nginx:1.27-alpine
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: web-clusterip
spec:
selector:
app: web-svc-demo
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 60
ports:
- port: 80
targetPort: 80
$ kubectl get endpoints web-clusterip
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME ENDPOINTS AGE
web-clusterip 10.244.138.135:80,10.244.147.20:80 10s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-clusterip
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-clusterip-ftr9n IPv4 80 10.244.147.20,10.244.138.135 11s

Both APIs are still served and answer with the same backing Pod IPs, but the server itself tells you which one is deprecated. The real EndpointSlice object carries more than a flat IP list — real ready/serving/terminating conditions per address, and a nodeName, which the old Endpoints object never had:

$ kubectl get endpointslices -l kubernetes.io/service-name=web-clusterip -o yaml
...
endpoints:
- addresses: ["10.244.147.20"]
conditions: {ready: true, serving: true, terminating: false}
nodeName: storage-net-worker
targetRef: {kind: Pod, name: web-5b685b6556-c2b7l, namespace: default}
- addresses: ["10.244.138.135"]
conditions: {ready: true, serving: true, terminating: false}
nodeName: storage-net-worker2
targetRef: {kind: Pod, name: web-5b685b6556-n5rqk, namespace: default}

Headless Service: real endpointslice, no ClusterIP

Section titled “Headless Service: real endpointslice, no ClusterIP”
$ kubectl get svc web-headless
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-headless ClusterIP None <none> 80/TCP 11s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-headless
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-headless-wx6fm IPv4 80 10.244.147.20,10.244.138.135 11s

A headless Service (clusterIP: None) still gets a normal EndpointSlice tracking its Pods — the difference is entirely in what CoreDNS does with it (covered in the DNS lesson), not in how Endpoints/EndpointSlice work.

$ kubectl get svc web-clusterip -o jsonpath='{.spec.sessionAffinity}{" "}{.spec.sessionAffinityConfig}'
ClientIP {"clientIP":{"timeoutSeconds":60}}

With sessionAffinity: ClientIP set, kube-proxy’s programmed rules route a given client IP to the same backing Pod for the configured timeoutSeconds (60s here), instead of load-balancing every new connection independently.

NodePort range: real acceptance and real rejection

Section titled “NodePort range: real acceptance and real rejection”
$ kubectl expose deployment web --name=web-nodeport --type=NodePort --port=80 --target-port=80 --dry-run=server -o yaml | grep -A2 ports:
ports:
- nodePort: 32769
port: 80
protocol: TCP

Left unspecified, the API server auto-assigns a NodePort from the default range — 32769 here, safely inside 30000–32767. Forcing one outside that range is a real, immediate rejection:

# k8s/services-clusterip-nodeport-loadbalancer/nodeport-out-of-range.yaml — @expect-error nodePort 1080 is outside the default 30000-32767 range
apiVersion: v1
kind: Service
metadata:
name: web-badport
spec:
type: NodePort
selector:
app: web-svc-demo
ports:
- port: 80
targetPort: 80
nodePort: 1080
$ kubectl apply -f nodeport-out-of-range.yaml
The Service "web-badport" is invalid: spec.ports[0].nodePort: Invalid value: 1080: provided port is not in the valid range. The range of valid ports is 30000-32767

LoadBalancer stays <pending> on kind, and externalTrafficPolicy: Local

Section titled “LoadBalancer stays <pending> on kind, and externalTrafficPolicy: Local”
k8s/services-clusterip-nodeport-loadbalancer/svc-lb.yaml
apiVersion: v1
kind: Service
metadata:
name: web-lb
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: web-svc-demo
ports:
- port: 80
targetPort: 80
$ kubectl get svc web-lb
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-lb LoadBalancer 10.96.139.218 <pending> 80:31953/TCP 10s
$ kubectl describe svc web-lb
...
External Traffic Policy: Local
HealthCheck NodePort: 31312
Endpoints: 10.244.138.135:80,10.244.147.20:80

EXTERNAL-IP stays <pending> indefinitely on a plain kind cluster — real, and expected: nothing is watching LoadBalancer Services to provision anything. Two real ways to fix that locally: MetalLB (BGP or L2 mode, hands out real IPs from a configured pool to any LoadBalancer Service) or cloud-provider-kind (a Kubernetes SIG project that specifically simulates a cloud provider for kind, so LoadBalancer resolves the way it would on a real cloud). Separately, externalTrafficPolicy: Local (set above) is why HealthCheck NodePort: 31312 exists at all — that field only appears with Local, since Local forwards traffic only to Pods on the node that received it (preserving the real client source IP, no extra hop) rather than potentially forwarding cross-node like the default Cluster policy, so each node needs a way to report whether it actually has a healthy local backend.

kube-proxy mode: real, from this cluster’s own ConfigMap

Section titled “kube-proxy mode: real, from this cluster’s own ConfigMap”
$ kubectl -n kube-system get configmap kube-proxy -o yaml | grep -A2 "mode:"
mode: iptables
nftables:
masqueradeAll: false

This kind 0.33 / Kubernetes 1.37 cluster’s kube-proxy is genuinely running in iptables mode (still the default over ipvs/nftables here) — always check a specific cluster’s own ConfigMap rather than assuming a mode, since it’s a per-cluster configuration choice, not a fixed Kubernetes behavior.

When reviewing generated Service manifests and the code that queries them, check for:

  • Code that still calls the Endpoints API assuming it’s the modern way to enumerate backends — proven above, both APIs work, but the server itself flags Endpoints as deprecated in favor of EndpointSlice.
  • A NodePort hardcoded outside 30000–32767 assumed to “just work” — proven above, real, immediate rejection.
  • Assuming a type: LoadBalancer Service will get an EXTERNAL-IP on any cluster, including local kind — proven above, stays <pending> without MetalLB/cloud-provider-kind installed.
  • externalTrafficPolicy: Local added “for performance” without understanding it changes traffic distribution across nodes (only nodes with a healthy local backend get traffic at all) — not just client IP preservation, and it adds a real HealthCheck NodePort proven above.

🐛 Spot the bug

k8s/services-clusterip-nodeport-loadbalancer/legacy-endpoints-watch.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: endpoint-watcher
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: endpoint-watcher
rules:
- apiGroups: [""]
resources: ["endpoints"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: endpoint-watcher
subjects:
- kind: ServiceAccount
name: endpoint-watcher
roleRef:
kind: Role
name: endpoint-watcher
apiGroup: rbac.authorization.k8s.io
Show the answer

This RBAC grants get/list/watch only on the endpoints resource — the legacy, deprecated API, proven above with a real deprecation warning on Kubernetes 1.37. A controller or tool built against this Role literally cannot read EndpointSlice objects (a different resource, discovery.k8s.io/v1, not covered by this rule at all) — it will keep working today (both APIs are still served), but it is architecturally stuck watching the API the project itself is moving away from, and won’t see the richer per-address ready/serving/terminating conditions or per-address nodeName that only EndpointSlice carries. The fix: grant apiGroups: ["discovery.k8s.io"], resources: ["endpointslices"] instead (or in addition, during a migration window).

A Service is a stable address; EndpointSlice is the live, sharded membership list behind it; kube-proxy is the mechanism that makes the address real, in whichever mode (iptables proven above) a given cluster actually runs. The three Service types are a strict ladder (ClusterIP → NodePort → LoadBalancer), each adding one more layer of external reachability on top of the last, never replacing it — and every layer still depends on something external (a cloud provider, MetalLB, cloud-provider-kind) to become real outside the cluster, proven above by LoadBalancer sitting <pending> without one.

ChoiceBenefitCost
sessionAffinity: ClientIPSame client keeps hitting the same backend Pod, proven above with the real specUneven load if one client sends disproportionate traffic
externalTrafficPolicy: LocalPreserves real client source IP, no extra hop, proven aboveTraffic only reaches nodes with a healthy local backend Pod at all — can be very uneven, and needs the real HealthCheck NodePort proven above
NodePortWorks without any cloud integrationEvery node opens the port; no cloud LB in front, awkward at any real scale
LoadBalancerCleanest external entry pointNeeds a cloud provider or something like MetalLB/cloud-provider-kind locally — proven above, kind alone leaves it <pending> forever
  • “Endpoints and EndpointSlice are the same API, just renamed.” Both exist and are queryable on 1.37 (proven above), but Endpoints is deprecated with a real server-side warning — EndpointSlice is the scalable replacement (sharded, richer per-address conditions).
  • “A NodePort can be any port I like.” Proven above — the API server enforces the 30000-32767 default range and rejects outside it, with a real error.
  • “LoadBalancer Services always get an external IP eventually.” Proven above — on a plain kind cluster it stays <pending> indefinitely without MetalLB or cloud-provider-kind.

A Service’s ClusterIP is not backed by a real network interface anywhere — it is a virtual address. kube-proxy, running on every node, watches the API server for Services and Endpoints/EndpointSlices and programs local rules (typically iptables or IPVS, depending on the configured mode) so that any packet sent to the virtual IP gets transparently rewritten and delivered to one of the backing Pods. This is why a Service works identically no matter which node a client Pod happens to be on.

flowchart LR
  client["Client Pod"] --> vip["Service virtual IP\n(ClusterIP)"]
  vip --> kp["kube-proxy rules\n(iptables / IPVS)"]
  kp --> p1["Pod 1"]
  kp --> p2["Pod 2"]
  kp --> p3["Pod 3"]
A client reaching a Service's virtual IP, routed by kube-proxy to a backing Pod
Why does Kubernetes need a Service object at all, given that every Pod already has an IP address?
What is the practical difference between NodePort and ClusterIP?
How does a Service decide which Pods are currently backing it
What component actually implements a Service ClusterIP as reachable traffic on each node
This real warning appeared on a Kubernetes 1.37 cluster. What should this change in how new code queries Service backends?
$ kubectl get endpoints web-clusterip
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME            ENDPOINTS
web-clusterip   10.244.138.135:80,10.244.147.20:80
This is real output on a plain kind cluster. Why does EXTERNAL-IP stay <pending>?
$ kubectl get svc web-lb
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)
web-lb   LoadBalancer   10.96.139.218   <pending>     80:31953/TCP