In Part 4 you got a Deployment running three replicas of a tiny web app. Here's the uncomfortable truth about those Pods: they are disposable. Every time one restarts, it gets a brand-new IP address, so any other app that hardcoded an IP is already broken. This part cooks the fix: a Service, which gives a changing set of Pods one stable name and one stable virtual IP, plus the cluster DNS (Domain Name System) that lets everything find everything else with zero configuration.
What you need
- A local cluster from Part 1 (kind or minikube both work).
- The 3-replica
clinerds-demoDeployment from Part 4 running in thedefaultnamespace. Check withkubectl get deploy clinerds-demoandkubectl get pods -l app=clinerds-demo— you should see three Pods. - A terminal with
kubectlconfigured to talk to that cluster. - No new files up front; you'll create one small YAML file per recipe.
Recipe 1: Put one stable address in front of your Pods
Problem: Your three Pods each have their own IP, and those IPs change whenever a Pod is replaced. You need one address that always reaches a healthy Pod.
Create a file called service.yaml:
apiVersion: v1 kind: Service metadata: name: clinerds-demo spec: type: ClusterIP selector: app: clinerds-demo ports: - port: 80 targetPort: 5678 Apply it and take a look:
kubectl apply -f service.yaml kubectl get service clinerds-demo NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE clinerds-demo ClusterIP 10.96.173.42 <none> 80/TCP 5s That CLUSTER-IP is only reachable from inside the cluster, so test it from a throwaway client Pod:
kubectl run netshoot --image=nicolaka/netshoot -it --rm -- bash Inside the shell that opens:
curl clinerds-demo You should get back hello from clinerds-demo (or whatever text you passed in Part 4). Run it a few times — different Pods answer each time. Type exit to leave; the --rm flag deletes the client Pod automatically.
How it works: A Service is three things glued together. The selector (app: clinerds-demo) continuously finds all Pods with that label, and Kubernetes records their current IPs in objects called EndpointSlices — think of them as the live guest list. The type: ClusterIP field allocates a virtual IP that exists only in the cluster's networking rules; no machine actually owns it. A small program called kube-proxy, running on every node, writes rules so traffic sent to that virtual IP gets forwarded to one of the Pod IPs from the EndpointSlice. Pod dies? It drops off the list. New Pod? It joins. The Service IP and name never change.
When it goes wrong:
-
curlhangs or refuses the connection — check thattargetPort(5678) matches the port the container actually listens on, not the Service's ownport(80). - No Pods behind the Service at all — see Recipe 5.
Recipe 2: Find Services by name with cluster DNS
Problem: You curled clinerds-demo by name in Recipe 1, but nobody told you where that name comes from or what its rules are.
Every cluster runs CoreDNS, a DNS server that watches the Kubernetes API and creates a DNS record for every Service automatically. Spin up the client Pod again:
kubectl run netshoot --image=nicolaka/netshoot -it --rm -- bash Ask DNS who clinerds-demo is:
nslookup clinerds-demo Server: 10.96.0.10 Address: 10.96.0.10#53 Name: clinerds-demo.default.svc.cluster.local Address: 10.96.173.42 Notice the full name in the answer. All three of these work from inside the Pod:
curl clinerds-demo # short name, same namespace curl clinerds-demo.default # name.namespace curl clinerds-demo.default.svc.cluster.local # the full FQDN To see why the short name works, look at the Pod's resolver config:
cat /etc/resolv.conf search default.svc.cluster.local svc.cluster.local cluster.local nameserver 10.96.0.10 options ndots:5 How it works: Kubernetes injects that resolv.conf into every Pod, pointing it at CoreDNS (10.96.0.10 is the DNS Service's own ClusterIP). The search line is the trick: when you ask for a bare name like clinerds-demo, the resolver tries it with each search suffix appended, and clinerds-demo.default.svc.cluster.local matches the record CoreDNS registered. The FQDN (fully qualified domain name) pattern is always <service>.<namespace>.svc.cluster.local. The short name only works from inside the same namespace; from another namespace you need at least name.namespace. The DNS record resolves to the Service's stable ClusterIP, so even when every Pod is replaced, the name never breaks.
Variations:
- Need a Pod's own IP instead of the Service? Headless Services (
clusterIP: None) make DNS return the Pod IPs directly. You'll meet them in Part 9.
Recipe 3: Reach the app from your laptop with NodePort
Problem: ClusterIP is cluster-internal only. You want to open the app in your own browser.
Change the Service type. Edit service.yaml so the spec looks like this:
apiVersion: v1 kind: Service metadata: name: clinerds-demo spec: type: NodePort selector: app: clinerds-demo ports: - port: 80 targetPort: 5678 nodePort: 30080 kubectl apply -f service.yaml kubectl get service clinerds-demo NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE clinerds-demo NodePort 10.96.173.42 <none> 80:30080/TCP 12m NodePort opens port 30080 on every node's IP and forwards it to the Service. On minikube or a cloud cluster you could hit a node IP directly — but on kind, the "nodes" are Docker containers with no route from your laptop. The simple, always-works option is kubectl port-forward:
kubectl port-forward service/clinerds-demo 8080:80 Leave that running and open http://localhost:8080 in your browser. Hello from your cluster.
How it works: NodePort is not a different Service; it's ClusterIP plus one extra rule. The Service keeps its virtual IP and DNS name, and kube-proxy additionally listens on nodePort (30000–32767 by default) on every node, forwarding to the same EndpointSlice Pods. kubectl port-forward is a different mechanism entirely: your local kubectl opens a tunnel through the Kubernetes API server straight to the Service, bypassing node networking. That's why it works even on kind.
Variations:
- minikube users: run
minikube service clinerds-demoand it opens a browser with a working URL for you. - kind users who want real NodePort access: kind's
extraPortMappingsconfig maps node ports to your laptop, but it must be set when the cluster is created. Port-forward is the zero-setup answer.
Recipe 4: Understand what LoadBalancer really does
Problem: Every cloud tutorial says "use type: LoadBalancer." You want to know what it actually buys you before you need it.
Flip the type one more time:
apiVersion: v1 kind: Service metadata: name: clinerds-demo spec: type: LoadBalancer selector: app: clinerds-demo ports: - port: 80 targetPort: 5678 kubectl apply -f service.yaml kubectl get service clinerds-demo On kind, you'll see this, forever:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE clinerds-demo LoadBalancer 10.96.173.42 <pending> 80:30080/TCP 1m How it works: LoadBalancer is NodePort plus a phone call. When the Service is created, the cloud provider's controller (built into Kubernetes on AWS, Google Cloud, Azure, and friends) provisions a real cloud load balancer, points it at your nodes' NodePorts, and writes its public address into EXTERNAL-IP. On a cloud cluster that takes a minute or two and then just works. On kind there's no cloud to call, so nothing ever fills in the address — <pending> is the expected result, not a bug. The Service still functions as ClusterIP and NodePort underneath.
Variations:
- Homelab or bare metal? MetalLB is the standard add-on that plays the role of cloud provider and hands out IPs from a pool you define.
- Before switching back for the next recipe, note you rarely need one LoadBalancer per app — Part 6 shows Ingress, which puts many apps behind one entry point.
Recipe 5: Debug a Service that answers with nothing
Problem: curl from the client Pod times out or refuses to connect. The Service exists, so what's missing?
Work through the checklist in order. First, does the Service have any backends?
kubectl get endpointslices -l kubernetes.io/service-name=clinerds-demo NAME ADDRESSTYPE PORTS ENDPOINTS AGE clinerds-demo-x7k2p IPv4 5678 10.244.0.8,10.244.1.4,... 15m If that lists zero EndpointSlices or the ENDPOINTS column is empty, the selector found no Pods. Compare both sides:
kubectl get service clinerds-demo -o jsonpath='{.spec.selector}' kubectl get pods --show-labels The Service selector must match the Pod labels exactly. Second, read the full story:
kubectl describe service clinerds-demo Check the Port and TargetPort lines against what your app actually listens on (5678 for http-echo). Third, confirm you're in the right namespace — Services only select Pods in their own namespace, and clinerds-demo from a Pod in namespace web resolves to clinerds-demo.web..., a different name entirely.
How it works: These are the three classic mistakes, and they cover almost every broken Service you'll meet: (1) wrong selector, so the EndpointSlice is empty; (2) wrong port/targetPort, so traffic arrives at a port nothing listens on; (3) wrong namespace, so you're querying a Service that has no Pods — or doesn't exist. endpointslices tells you what the Service sees; describe tells you what you told it to see. The diff between the two is your bug.
Where to go next
Services give every app a stable name inside the cluster — but real sites need one public entry point, hostnames, and TLS. That's Part 6: Ingress. For the full reference on everything a Service can do, read the official Kubernetes Service documentation, or head back to the series index.

