K8s Basics #5: Service — ClusterIP / NodePort / LoadBalancer
#4 Deployment and ReplicaSet made it clear that Pod IPs change every time. This post covers the abstraction that fixes that — Service. We’ll pin down the stable virtual IP and DNS name, the backend group built from a selector, and the three exposure types: ClusterIP / NodePort / LoadBalancer.
This series is K8s Basics, 7 posts.
- #1 What is Kubernetes — why do we need a container orchestrator?
- #2 Local environments — minikube / kind / Docker Desktop k8s
- #3 kubectl and your first Pod
- #4 Deployment and ReplicaSet — declarative deploys and rolling updates
- #5 Service — ClusterIP / NodePort / LoadBalancer ← this post
- #6 ConfigMap / Secret
- #7 Namespaces and labels
By the end of this post you’ll have the first manifest that puts a stable entry point in front of Pods. The shape that lets Pods inside the cluster reach each other by name, the shape that lets your laptop’s browser hit a node port directly, and the shape that auto-attaches an external LB on a cloud — all branch off the same manifest by a single line.
The limit of Pod IPs — why Service exists #
If you followed #4 all the way through, the picture in your head looks like this — three nginx Pods labeled app: web are up, each holding cluster-internal IPs like 10.244.0.5, 10.244.0.6, 10.244.0.7. From here, you’d want to do one more thing — send HTTP requests to those three from another Pod in the same cluster, or open them in your laptop’s browser.
Try that and four problems hit at once:
- Pod IPs are ephemeral. A Pod that’s recreated gets a new IP. The
10.244.0.5you wrote down yesterday may not exist today. Hard-coding a Pod IP into client code is closed off from the start. - No load balancing across the three Pods. Pick one Pod IP and only that Pod works while the other two sit idle. Something has to spread traffic evenly across N Pods.
- No service discovery. From the client Pod’s view, “what’s the current IP of the
webservice?” — the place to ask isn’t obvious. We need to call by name, not by IP. - No external entry point. Cluster-internal IPs aren’t visible from the laptop’s browser. We need a separate door for outside traffic to flow in.
The abstraction that solves all four at once is Service. One manifest gives you a stable virtual IP that load-balances traffic across the Pods grouped by selector, plus a DNS record that other Pods in the cluster can reach by name.
Service — stable IP + selector + DNS #
A Service manifest produces three results, broken out:
- A stable virtual IP (ClusterIP) — an IP that doesn’t change for the cluster’s lifetime. Stays the same regardless of Pods dying or coming up.
- A Pod group built from selector — Pods matching the labels in
spec.selectorbecome the Service’s backends. New Pods join automatically when their labels match; dying Pods are removed automatically. - A DNS name — an FQDN of the form
<svc>.<ns>.svc.cluster.localis created automatically. Inside the same namespace you can call by the short<svc>form alone.
Mental picture:
┌──────────────────────────────┐
│ Service: web │ selector: app=web
│ ClusterIP: 10.96.x.x │ DNS: web.default.svc.cluster.local
└──────────────┬───────────────┘
│ traffic distribution
┌──────────┼──────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ Pod-1 │ │ Pod-2 │ │ Pod-3 │ app=web
│.0.5 │ │.0.6 │ │.0.7 │ (Pod IPs are ephemeral)
└────────┘ └────────┘ └────────┘The key takeaway from that picture — clients only have to look at the Service IP / name in the middle, and K8s keeps the Pods underneath updated as they die and come back. The Service IP is stable; the Pod IPs underneath are ephemeral. That separation is what makes zero-downtime operation possible.
Endpoints / EndpointSlice — the result of selector #
The list of IPs and ports for the Pods matching the Service’s selector is kept in a separate object — Endpoints (or EndpointSlice, recommended from 1.21+). You almost never create one by hand; K8s fills it in automatically when you create a Service.
kubectl get endpoints webNAME ENDPOINTS AGE
web 10.244.0.5:80,10.244.0.6:80,10.244.0.7:80 30sThe ENDPOINTS column shows the Pod IPs directly. When a Pod dies it falls off this list shortly after, and a new Pod with matching labels joins automatically.
From 1.21+, EndpointSlice is the recommended object. The motivation is to avoid one giant Endpoints object as a Service’s backend count grows. The difference is small from a user’s perspective — both show up in kubectl get.
kubectl get endpointslices -l kubernetes.io/service-name=webNAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-abc12 IPv4 80 10.244.0.5,10.244.0.6,10.244.0.7 30sThis object is the first weapon for debugging. “Traffic to the Service doesn’t seem to be going through” — this is the first place to check.
kubectl get endpoints webNAME ENDPOINTS AGE
web <none> 1mIf ENDPOINTS is empty, the Service’s selector isn’t matching any Pod. One of two things — a typo in the selector labels, or no Pods with matching labels in that namespace. kubectl get pods --show-labels shows the actual labels on Pods so you can compare against the selector.
ClusterIP — cluster-internal only #
We start with the default type used most often. If you don’t set spec.type on a Service, it’s ClusterIP automatically. It takes a virtual IP reachable from inside the cluster.
Assume the app: web Deployment from #4 is still up, and we put a Service in front of it. Save the file as web-svc.yaml.
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: ClusterIP
selector:
app: web
ports:
- port: 80
targetPort: 80The manifest spine is the same four fields from #3 — apiVersion / kind / metadata / spec. The only thing to remember is that Service is in the core group v1, not apps/v1. People mix it up with Deployment all the time.
Three new things inside spec:
type— one ofClusterIP/NodePort/LoadBalancer/ExternalName. Default isClusterIPif omitted.selector— which Pod labels to pick up as backends. Above,app: web— matched to the Deployment template label from #4.ports— list of port mappings. One Service can expose multiple ports at once, or you can write a single line as above.
port vs targetPort #
Two fields under ports, one line each:
port— the port the Service listens on. What clients hit. With this manifest, you reach it asweb:80.targetPort— the port the backend Pod’s container listens on. The nginx container listens on 80, so 80.
They have the same number, which is confusing, but they’re separate for a reason. If your container listens on 8080 but you want the Service to expose the standard 80, write port: 80, targetPort: 8080. That separation makes Service double as a lightweight port-mapping layer.
apply and check #
Push the manifest to the cluster.
kubectl apply -f web-svc.yamlservice/web createdkubectl get svcNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 2d
web ClusterIP 10.96.142.31 <none> 80/TCP 10sThe columns — NAME / TYPE / CLUSTER-IP / EXTERNAL-IP / PORT(S) / AGE. The kubernetes row is the Service the cluster keeps for its own API server, ignore it. The new row is web. CLUSTER-IP 10.96.142.31 is taken, and EXTERNAL-IP is <none> — only reachable from inside the cluster.
(The 10.96.0.0/12 range is kubeadm’s default service CIDR. It varies by environment. minikube and kind use similar ones; managed services like EKS / GKE have their own defaults.)
Calling it from inside the cluster #
The core test for ClusterIP is can another Pod call this Service? Spin up a temporary debug Pod and curl from inside.
kubectl run tmp --rm -it --image=curlimages/curl -- sh--rm cleans up the Pod when you exit; -it is interactive + TTY. Inside, try three forms:
/ $ curl -s http://web | head -1
<!DOCTYPE html>
/ $ curl -s http://web.default.svc.cluster.local | head -1
<!DOCTYPE html>
/ $ curl -s http://10.96.142.31 | head -1
<!DOCTYPE html>All three reach the same place.
- Short name
web— inside the same namespace (default), the Service name alone reaches it. The most common form. - FQDN
web.default.svc.cluster.local— the canonical name when calling a Service in another namespace, or when you want to be unambiguous. - ClusterIP
10.96.142.31— you can hit the virtual IP directly, but you almost never memorize one. Calling by DNS is the canonical path.
Hit the same command repeatedly and the response is always nginx’s welcome page, but K8s is actually picking one of the 3 backend Pods per request and routing to it. Load balancing is the default — no setup required. To see which Pod actually responded, open the nginx access logs — you’ll see requests landing evenly across all three Pods.
exit from the temp Pod and --rm cleans it up automatically. In real-world ops, intra-cluster traffic is almost always this ClusterIP shape — backend ↔ DB, backend ↔ Redis, microservice ↔ microservice. All ClusterIP.
NodePort — exposed on a specific port across nodes #
ClusterIP only reaches inside. The simplest way to make it reachable from outside is NodePort. Open the same port (default range 30000–32767) on every node in the cluster, and traffic to that port flows into the same Service.
The manifest is two extra lines on top of ClusterIP.
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 80
nodePort: 30080type: NodePort and an added nodePort: 30080 under ports. If you don’t specify nodePort, K8s picks one from the 30000–32767 range automatically. When you do specify it, the value has to be in that range.
kubectl apply -f web-svc.yamlservice/web configuredkubectl get svc webNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web NodePort 10.96.142.31 <none> 80:30080/TCP 5mTwo things changed — TYPE is now NodePort, and PORT(S) is 80:30080/TCP. The leading 80 is the Service port (what you hit from inside the cluster), and the trailing 30080 is the NodePort. Inside the cluster you still reach it as web:80; from outside, as <NodeIP>:30080.
curl http://<NodeIP>:30080Replace <NodeIP> with the worker node’s external IP. The shape varies a bit by local environment.
- kind — the node is a Docker container, so the host can’t reach it directly. Either use
extraPortMappingsto expose 30080 to the host when creating the cluster, or work around it withkubectl port-forward. - minikube —
minikube service web --urlgives you a reachable URL. - Docker Desktop k8s — the node is the host, so
localhost:30080works directly.
NodePort is rarely exposed directly to clients in production. Port numbers in the 30000s feel awkward, and external clients have to track the moving list of node IPs as nodes are added and removed. The usual shape is a LoadBalancer or Ingress sitting on top, with NodePort underneath. NodePort itself is useful for quick external access in local development, or for opening a debug port for a short while.
LoadBalancer — integrated with cloud LBs #
The most common shape for production external exposure is LoadBalancer. One line of type: LoadBalancer and K8s asks the cloud provider (AWS ELB, GCP LB, Azure LB, etc.) to provision an external LB. The new LB’s external IP fills in the Service’s EXTERNAL-IP column.
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 80kubectl apply -f web-svc.yamlOn a cloud #
On managed clusters like EKS / GKE / AKS, the manifest above usually provisions an external LB in 1–2 minutes.
kubectl get svc webNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web LoadBalancer 10.96.142.31 <pending> 80:31523/TCP 20sNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web LoadBalancer 10.96.142.31 a1b2c3d4.elb.. 80:31523/TCP 2mEXTERNAL-IP flips from <pending> to a real IP / DNS name. That address is your external entry point. AWS gives an ELB DNS name, GCP gives an IP — exact form differs by environment. Notice that PORT(S) also shows a NodePort 31523 — under the hood, LoadBalancer takes a NodePort and the cloud LB routes traffic to that NodePort. So LoadBalancer is essentially a layer above NodePort.
In local / on-prem environments #
On kind, plain minikube, or any bare-metal cluster without a cloud controller, the manifest above leaves EXTERNAL-IP stuck at <pending> forever — there’s nobody to provision an external LB.
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web LoadBalancer 10.96.142.31 <pending> 80:31523/TCP 5mThe tools that fill that gap are MetalLB (for bare metal) and cloud-provider-kind (for kind specifically). Install one and it acts like a cloud controller, filling in the EXTERNAL-IP. Just naming them here — full installs are out of scope for this post.
The takeaway in one line — production external entry points are almost always LoadBalancer or Ingress on top. Ingress is a higher-level abstraction that puts multiple Services behind one LoadBalancer with host/path-based routing. It’s outside the scope of K8s Basics, so it gets its own post in K8s Intermediate. This post stops at LoadBalancer.
Service types in one table #
The three we’ve covered plus the two you’ll meet often, in one table.
| Type | External exposure | Primary use |
|---|---|---|
ClusterIP (default) | None (cluster-internal) | backend ↔ DB, microservice ↔ microservice |
NodePort | <NodeIP>:<30000–32767> | Local dev, debug-only external access, the layer underneath an LB |
LoadBalancer | External IP / DNS of a cloud LB | Production external entry point. Requires cloud or MetalLB |
ExternalName | None (DNS CNAME only) | Alias an internal cluster name to an external domain |
Headless (clusterIP: None) | None (no virtual IP) | When you need per-Pod IPs, e.g., StatefulSet |
The bottom two, one line each:
ExternalName— settype: ExternalName+externalName: db.example.comand DNS lookups for<svc>.<ns>.svc.cluster.localreturn a CNAME pointing to the external domain. No selector, no backend Pods — a special shape. Used when you want to call an external system by an internal cluster name.- Headless Service — set
spec.clusterIP: Noneand the Service skips the virtual IP entirely. DNS lookups return Pod IPs directly. The fit for cases where clients need to reach Pods individually, e.g., StatefulSet. Rarely useful for plain web services.
kube-proxy — so who’s actually moving the traffic? #
If you’ve followed this far, one thing nags — the Service ClusterIP 10.96.142.31 is an IP that doesn’t actually live on any node. Run ip addr on any node and you won’t find it. And yet, when a Pod sends a packet to that IP, it lands somewhere. Who’s routing it?
The answer is the system component kube-proxy running on each node. It didn’t show up in the control plane diagram in #1, but it’s a daemon that runs one-per-worker-node.
Pod ─▶ 10.96.142.31:80 (virtual IP)
│
▼ DNAT via iptables / IPVS rules
│
one of three Pod IPs ─▶ 10.244.0.5:80
10.244.0.6:80
10.244.0.7:80kube-proxy watches Endpoints/EndpointSlice and installs the node’s iptables (or IPVS) rules automatically. The rules say “DNAT packets to 10.96.142.31:80 to one of 10.244.0.5:80, .0.6:80, .0.7:80.” A packet sent by a Pod to the Service IP gets caught by these rules before it leaves the node and rewritten to a real Pod IP.
So a Service isn’t a load balancer on one node — it’s a virtual LB distributed across every node. The same rules sit on every node, so any Pod on any node reaches the same ClusterIP equally well. kube-proxy’s mode is usually iptables (default) or ipvs; the deeper details and eBPF-based alternatives (Cilium, etc.) are a K8s Intermediate networking topic.
DNS — CoreDNS and service names #
How does a short name like web resolve to a ClusterIP? In one paragraph: in the cluster’s kube-system namespace there’s a DNS server called CoreDNS (usually two Pods). CoreDNS automatically creates an A record for every Service.
The default domain is cluster.local, with FQDNs of the form <svc>.<ns>.svc.cluster.local. Inside the same namespace, the short <svc> resolves correctly because the search domain auto-completes it.
nslookup web
# Server: 10.96.0.10
# Address: 10.96.0.10#53
#
# Name: web.default.svc.cluster.local
# Address: 10.96.142.31The key is that the response IP matches the ClusterIP we saw. K8s populates each Pod’s /etc/resolv.conf with nameserver pointing to CoreDNS’s ClusterIP (something like 10.96.0.10) and search set to <ns>.svc.cluster.local svc.cluster.local cluster.local. That’s how short names auto-expand to canonical names.
The default domain cluster.local is configurable (at install time). Almost every environment leaves the default in place, so when writing manifests or code, you can safely assume cluster.local.
Clean up #
Tear down today’s Service along with the Deployment that was up from #4.
kubectl delete -f web-svc.yamlservice "web" deletedkubectl delete deploy webdeployment.apps "web" deletedkubectl get svc,deploy,pods showing emptiness puts you back at the start. The single kubernetes Service still being there is normal — the cluster owns that one and it’s not ours to delete.
Summary #
What this post pinned down:
- Pod IPs are ephemeral. The abstraction that provides a stable IP, DNS, and load balancing across N Pods with the same labels in one shot is Service.
- The Service manifest spine is
apiVersion: v1/kind: Service/spec.type/spec.selector/spec.ports. The selector must match the Deployment template’s labels from #4. - The selector’s result lives in Endpoints / EndpointSlice automatically. An empty
kubectl get endpoints <svc>is the first suspect when selector / labels are off. - Three types — ClusterIP (default, cluster-internal), NodePort (
<NodeIP>:30000–32767for external access), LoadBalancer (auto-provisions a cloud LB). PlusExternalNameand Headless (clusterIP: None) on the side. - A Service’s virtual IP doesn’t live on any node.
kube-proxyon each node DNATs through iptables / IPVS rules to a real Pod IP — a distributed virtual LB. - Short names
<svc>resolve thanks to CoreDNS inkube-system, which creates an A record per Service. The FQDN is<svc>.<ns>.svc.cluster.local.
Next — ConfigMap / Secret #
Even now, one thing in the manifest is awkward — image tags, ports, domain names, all written directly into the manifest. The next topic is pulling values that should differ across environments (dev / staging / prod), and values like passwords that shouldn’t be in plain text in a manifest, out of the manifest body.
#6 ConfigMap / Secret covers (1) collecting environment config in a ConfigMap and injecting it into Pods as env vars or volumes, (2) what makes Secret different from ConfigMap (and the one-line caveat that base64 isn’t encryption), and (3) one cycle of pulling a bundle of config values out of the web Deployment from this post into an external object.