EduFlow

Section 4 · Kubernetes Core

Services: Stable Addresses for Moving Pods

20 min3 min readSpot the bug

Lesson at a glance

Give notes-api and its database stable names and addresses with Services, wire port, targetPort and containerPort correctly, and choose between ClusterIP, NodePort and LoadBalancer.

  1. 1A Service for notes-api
  2. 2Three ports, three meanings
  3. 3Finding Services by name
  4. 4Service types
  5. 5Reaching it from your laptop

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Run kubectl get pods -o wide twice, with a rollout in between, and every IP is different. Pods are replaced constantly: new versions, rescheduling, scaling. Anything that remembered a Pod’s IP is now pointing at nothing. A Service is the stable thing you point at instead.

A Service for notes-api

k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: notes-api
  labels:
    app.kubernetes.io/name: notes-api
spec:
  selector:
    app.kubernetes.io/name: notes-api
  ports:
    - name: http
      port: 80
      targetPort: http

This creates a virtual IP and a DNS name, notes-api, that last as long as the Service exists. Connections to it are spread across the Pods its selector matches, the same label query the Deployment uses. Kubernetes tracks the current set of matching ready Pods in EndpointSlices and updates them as Pods come and go. Clients never notice.

A ClusterIP isn’t a machine and no process listens on it. Each node’s networking layer (kube-proxy, or an eBPF-based replacement in many clusters) programs rules that rewrite packets sent to the Service’s IP and port so they go to one of the ready Pods. That’s why you can’t ping a Service IP and shouldn’t worry when it doesn’t answer: only the declared ports exist. It’s also why a Service costs almost nothing, so create one for every workload that receives traffic.

Three ports, three meanings

This is where most Service bugs live.

  • containerPort in the Pod spec is the port your process listens on: 3000. It’s informational, like EXPOSE, but naming it (name: http) lets other objects refer to it.
  • targetPort on the Service is where traffic is delivered on the Pod. It must match where the app actually listens. Here it uses the name http, which resolves to 3000.
  • port on the Service is what clients connect to: 80.
A client Pod calls notes-api on port 80. The Service forwards to targetPort http, which is containerPort 3000, on one of three ready notes-api Pods selected by label. client Pod notes-api:80 Service notes-api port: 80 targetPort: http pod :3000 pod :3000 pod :3000 selector: app.kubernetes.io/name=notes-api containerPort http = 3000
Clients use the Service port; the Service forwards to targetPort on a ready Pod

Finding Services by name

Cluster DNS gives every Service a name of the form <service>.<namespace>.svc.cluster.local. From a Pod in the same namespace, the short name notes-api is enough; from another namespace, use notes-api.notes or the full name.

notes-api needs a database, so give it one the same way. For practice in kind, run Postgres as a single-replica Deployment with a ClusterIP Service:

k8s/db.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: notes-db
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: notes-db
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notes-db
    spec:
      containers:
        - name: postgres
          image: postgres:17
          env:
            - name: POSTGRES_USER
              value: notes
            - name: POSTGRES_PASSWORD
              value: notes          # moved to a Secret in the next lesson
            - name: POSTGRES_DB
              value: notes
          ports:
            - name: postgres
              containerPort: 5432
---
apiVersion: v1
kind: Service
metadata:
  name: notes-db
spec:
  selector:
    app.kubernetes.io/name: notes-db
  ports:
    - name: postgres
      port: 5432
      targetPort: postgres

notes-api’s DATABASE_URL host is now notes-db, exactly as db was in Compose. The --- separates two objects in one file.

Service types

Type Reachable from Use it for
ClusterIP (default) Inside the cluster Service-to-service traffic, like notes-api → notes-db
NodePort Every node’s IP on a port in 30000–32767 Quick tests, or as a building block for external load balancers
LoadBalancer A cloud load balancer’s address Exposing one service publicly on a managed cluster

There’s also the headless Service, with clusterIP: None. It gets no virtual IP; instead its DNS name returns the IPs of the individual Pods. Databases and other clustered systems use it when each member needs to be addressed directly. You won’t need it for notes-api.

LoadBalancer builds on the other two: the cloud provider creates a load balancer that typically forwards to node ports, which forward to the Service (some controllers send traffic straight to Pod IPs instead). One per public service gets expensive and repetitive when you have many HTTP services; lesson 5.4 covers Gateway API, which puts many routes behind one entry point.

Reaching it from your laptop

Terminal
kubectl apply -f k8s/
kubectl get svc
kubectl get endpointslices -l kubernetes.io/service-name=notes-api
kubectl port-forward svc/notes-api 8080:80
curl localhost:8080/readyz

port-forward tunnels a local port to the Service through the API server. It’s for debugging, not for real traffic. If the EndpointSlice lists no addresses, the Service matches no ready Pods: compare the selector with kubectl get pods --show-labels, then check readiness.

Two problems are now visible in the YAML: a password sits in plain text, and configuration is mixed into the Deployment. The next lesson moves both out.

Key takeaways

  • A Service gives a changing set of Pods one stable virtual IP and DNS name, and spreads connections across the ready Pods its selector matches.
  • port is what clients connect to on the Service; targetPort is where traffic goes on the Pod and must match the container’s listening port.
  • Inside the cluster, Pods reach a Service by name, such as notes-db in the same namespace or notes-db.notes.svc.cluster.local from anywhere.
  • Use ClusterIP for internal traffic, LoadBalancer to expose one service through a cloud load balancer, and NodePort mainly for testing.
  • An empty EndpointSlice means the Service matches no ready Pods; check the selector and readiness first.

Further reading

Check your understanding

4 questions

Question 1 of 4

notes-api listens on 3000. The Service has port: 80 and targetPort: 3000. Which URL do other Pods in the same namespace use?

Question 2 of 4

kubectl get endpointslices -l kubernetes.io/service-name=notes-api shows no endpoints, but three notes-api Pods are Running. What do you check first?

Question 3 of 4

You need notes-api reachable from the internet on a managed cluster in AWS, and it’s the only public service. Which Service type fits?

Question 4 of 4

Which targetPort is correct for a container that declares ports: [{ name: http, containerPort: 3000 }]?

Practice

Spot the bug

Endpoints exist, requests fail

The Service below has three endpoints, the Pods are Running and Ready, yet curl notes-api from another Pod gets connection refused every time. Select the line that causes it.

Select the line(s) you think are wrong, then check.