Services: ClusterIP, NodePort, LoadBalancer
The idea in one sentence
Section titled “The idea in one sentence”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.
The problem: Pod IPs are not stable
Section titled “The problem: Pod IPs are not stable”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: v1kind: Servicemetadata: name: paymentsspec: selector: app: payments ports: - port: 80 targetPort: 8080# See which Pods this Service is currently routing tokubectl get endpoints paymentskubectl get endpointslices -l kubernetes.io/service-name=paymentsAny 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–32767range 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: v1kind: Servicemetadata: name: payments-publicspec: type: LoadBalancer selector: app: payments ports: - port: 80 targetPort: 8080 nodePort: 30080kubectl get svc payments-public# EXTERNAL-IP column is populated once the cloud provider finishes provisioningEndpointSlice, 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:
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: 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: v1kind: Servicemetadata: name: web-clusteripspec: selector: app: web-svc-demo sessionAffinity: ClientIP sessionAffinityConfig: clientIP: timeoutSeconds: 60 ports: - port: 80 targetPort: 80$ kubectl get endpoints web-clusteripWarning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSliceNAME ENDPOINTS AGEweb-clusterip 10.244.138.135:80,10.244.147.20:80 10s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-clusteripNAME ADDRESSTYPE PORTS ENDPOINTS AGEweb-clusterip-ftr9n IPv4 80 10.244.147.20,10.244.138.135 11sBoth 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-headlessNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb-headless ClusterIP None <none> 80/TCP 11s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-headlessNAME ADDRESSTYPE PORTS ENDPOINTS AGEweb-headless-wx6fm IPv4 80 10.244.147.20,10.244.138.135 11sA 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.
sessionAffinity: ClientIP
Section titled “sessionAffinity: ClientIP”$ 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: TCPLeft 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 rangeapiVersion: v1kind: Servicemetadata: name: web-badportspec: type: NodePort selector: app: web-svc-demo ports: - port: 80 targetPort: 80 nodePort: 1080$ kubectl apply -f nodeport-out-of-range.yamlThe 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-32767LoadBalancer stays <pending> on kind, and externalTrafficPolicy: Local
Section titled “LoadBalancer stays <pending> on kind, and externalTrafficPolicy: Local”apiVersion: v1kind: Servicemetadata: name: web-lbspec: type: LoadBalancer externalTrafficPolicy: Local selector: app: web-svc-demo ports: - port: 80 targetPort: 80$ kubectl get svc web-lbNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb-lb LoadBalancer 10.96.139.218 <pending> 80:31953/TCP 10s
$ kubectl describe svc web-lb...External Traffic Policy: LocalHealthCheck NodePort: 31312Endpoints: 10.244.138.135:80,10.244.147.20:80EXTERNAL-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: falseThis 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.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated Service manifests and the code that queries them, check for:
- Code that still calls the
EndpointsAPI assuming it’s the modern way to enumerate backends — proven above, both APIs work, but the server itself flagsEndpointsas deprecated in favor ofEndpointSlice. - A NodePort hardcoded outside
30000–32767assumed to “just work” — proven above, real, immediate rejection. - Assuming a
type: LoadBalancerService will get anEXTERNAL-IPon any cluster, including local kind — proven above, stays<pending>without MetalLB/cloud-provider-kind installed. externalTrafficPolicy: Localadded “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 realHealthCheck NodePortproven above.
🐛 Spot the bug
apiVersion: v1kind: ServiceAccountmetadata:name: endpoint-watcher---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: endpoint-watcherrules:- apiGroups: [""] resources: ["endpoints"] verbs: ["get", "list", "watch"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: endpoint-watchersubjects:- kind: ServiceAccount name: endpoint-watcherroleRef:kind: Rolename: endpoint-watcherapiGroup: rbac.authorization.k8s.ioShow 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).
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
sessionAffinity: ClientIP | Same client keeps hitting the same backend Pod, proven above with the real spec | Uneven load if one client sends disproportionate traffic |
externalTrafficPolicy: Local | Preserves real client source IP, no extra hop, proven above | Traffic only reaches nodes with a healthy local backend Pod at all — can be very uneven, and needs the real HealthCheck NodePort proven above |
| NodePort | Works without any cloud integration | Every node opens the port; no cloud LB in front, awkward at any real scale |
| LoadBalancer | Cleanest external entry point | Needs a cloud provider or something like MetalLB/cloud-provider-kind locally — proven above, kind alone leaves it <pending> forever |
Common Misconceptions
Section titled “Common Misconceptions”- “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-32767default 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.
kube-proxy makes the virtual IP real
Section titled “kube-proxy makes the virtual IP real”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"]