DEV Community

Cover image for Kubernetes Cookbook Part 5: Services and Built-In Service Discovery
FlatlinerAtheist | CLI-NERDS
FlatlinerAtheist | CLI-NERDS

Posted on Originally published at clinerds.com

Kubernetes Cookbook Part 5: Services and Built-In Service Discovery

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-demo Deployment from Part 4 running in the default namespace. Check with kubectl get deploy clinerds-demo and kubectl get pods -l app=clinerds-demo — you should see three Pods.
  • A terminal with kubectl configured 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
Enter fullscreen mode Exit fullscreen mode

Apply it and take a look:

kubectl apply -f service.yaml
kubectl get service clinerds-demo
Enter fullscreen mode Exit fullscreen mode
NAME            TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
clinerds-demo   ClusterIP   10.96.173.42   <none>        80/TCP    5s
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Inside the shell that opens:

curl clinerds-demo
Enter fullscreen mode Exit fullscreen mode

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:

  • curl hangs or refuses the connection — check that targetPort (5678) matches the port the container actually listens on, not the Service's own port (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
Enter fullscreen mode Exit fullscreen mode

Ask DNS who clinerds-demo is:

nslookup clinerds-demo
Enter fullscreen mode Exit fullscreen mode
Server:    10.96.0.10
Address:   10.96.0.10#53

Name:   clinerds-demo.default.svc.cluster.local
Address: 10.96.173.42
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To see why the short name works, look at the Pod's resolver config:

cat /etc/resolv.conf
Enter fullscreen mode Exit fullscreen mode
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
kubectl apply -f service.yaml
kubectl get service clinerds-demo
Enter fullscreen mode Exit fullscreen mode
NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)       AGE
clinerds-demo   NodePort   10.96.173.42   <none>        80:30080/TCP  12m
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-demo and it opens a browser with a working URL for you.
  • kind users who want real NodePort access: kind's extraPortMappings config 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
Enter fullscreen mode Exit fullscreen mode
kubectl apply -f service.yaml
kubectl get service clinerds-demo
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
NAME                  ADDRESSTYPE   PORTS   ENDPOINTS                    AGE
clinerds-demo-x7k2p   IPv4          5678    10.244.0.8,10.244.1.4,...    15m
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Service selector must match the Pod labels exactly. Second, read the full story:

kubectl describe service clinerds-demo
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)