In the previous lesson we updated bookings-api without interrupting the service, but to check that it responded we had to look up a specific pod's IP by hand and hope it still existed five seconds later. That is the hole we are left with: every rollout, every self-healing event and every scaling operation changes the pods' IPs, so no component can know another one by its address. The Service is Kubernetes' answer: a stable virtual address, with a name of its own, that spreads traffic across all the healthy replicas of a component and survives the pods behind it coming and going. In this lesson you will understand the problem in depth, you will see what a Service is exactly and how the endpoints controller keeps the list of destinations up to date, you will read the manifest field by field telling port apart from targetPort, you will create the four Rutas Norte Services —web-store, bookings-api, bookings-postgres and redis-cache—, you will verify them from inside the cluster with ephemeral pods and you will learn to diagnose the most frequent failure in all of Kubernetes: a selector that does not match the pods' labels.

Contents

  1. The problem: ephemeral IPs
  2. What a Service is and what it gives you
  3. Endpoints and EndpointSlices: who keeps the list
  4. Anatomy of the manifest: port, targetPort, protocol
  5. Why ClusterIP is the default type
  6. The Rutas Norte Services
  7. Verification from inside the cluster
  8. The DNS name and headless services
  9. The most common failure: the selector that does not match

  1. The problem: ephemeral IPs

Let's make the problem visible with a thirty-second experiment. Note down the current IPs of bookings-api:

kubectl get pods -l app=bookings-api -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP            NODE
bookings-api-c5a9b47d8-h7kdn   1/1   Running   0    22m   10.244.0.48   rutas-norte
bookings-api-c5a9b47d8-w3pqx   1/1   Running   0    22m   10.244.0.49   rutas-norte

Now trigger any rollout and look again:

kubectl rollout restart deployment/bookings-api
kubectl rollout status deployment/bookings-api
kubectl get pods -l app=bookings-api -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP            NODE
bookings-api-7f1d3e942-b8mrs   1/1   Running   0    18s   10.244.0.52   rutas-norte
bookings-api-7f1d3e942-n4jvt   1/1   Running   0    12s   10.244.0.53   rutas-norte

New names, new IPs. If web-store had the address 10.244.0.48 written into its configuration, it would just have lost its connection to the API. And the moments when this happens are constant:

Situation Effect on the IPs
Rollout of a new version Every pod is replaced: every IP changes
Self-healing after a crash The replacement pod has a new IP
Scaling up IPs appear that nobody knew about
Scaling down IPs disappear that somebody had noted down
Eviction for lack of memory The pod is reborn on another node with another IP

And there is a second problem, as important as the first: load spreading. Even if we froze the IPs, web-store would have to know the two, three or seven replicas of bookings-api and spread the traffic between them on its own, checking which ones are healthy. That means reinventing a load balancer inside every client.

Rutas Norte needs three things at once:

  1. An address that never changes, even if all the pods behind it do.
  2. Automatic traffic spreading across the available replicas.
  3. A name, not an IP, so addresses do not have to be configured anywhere.

  1. What a Service is and what it gives you

A Service is a Kubernetes object that defines a logical, stable access point to a set of pods, selected by labels.

flowchart LR
    TW["web-store pods"]
    SVC["<b>Service bookings-api</b><br/>ClusterIP 10.96.184.22<br/>DNS name: bookings-api"]
    P1["Pod bookings-api<br/>10.244.0.52"]
    P2["Pod bookings-api<br/>10.244.0.53"]
    P3["Pod bookings-api<br/>10.244.0.61<br/>(created after a scale-up)"]
    TW -->|"http://bookings-api:3000"| SVC
    SVC --> P1
    SVC --> P2
    SVC --> P3

What it brings, point by point:

  • A stable virtual IP, the ClusterIP. It is assigned when the Service is created and does not change while the Service exists, no matter how many pods are behind it, or whether there are none.
  • A DNS name, which is what you will actually use: bookings-api from the same namespace.
  • Load balancing across every healthy pod matching the selector, random by default.
  • Automatic updating of destinations: when a pod is born, it joins; when it dies or stops being ready, it leaves. With no human intervention.
  • A logical port that can differ from the container's real port.

One detail that throws people at first: the ClusterIP does not belong to any machine. There is no network interface configured with that address, it does not answer ping and you cannot ssh to it. It is a virtual address that exists solely as a forwarding rule installed on every node of the cluster. When a pod sends a packet to 10.96.184.22, that rule rewrites it on the fly towards the real IP of one of the destination pods.

Who installs those rules and how (iptables, IPVS, eBPF) is the subject of Cluster Networking. For this lesson the mental model is enough: a Service is a named forwarding rule, not a machine.

A word about the balancing, because it causes confusion: the spreading happens per connection, not per request. If a client opens a persistent HTTP (keep-alive) or gRPC connection to the ClusterIP, every request on that connection ends up on the same pod. It is normal and predictable behaviour, but it is worth knowing before being surprised that the traffic "is not being spread".

  1. Endpoints and EndpointSlices: who keeps the list

The Service on its own knows nothing about pods. It only declares a selector. The one doing the dirty work is another kube-controller-manager controller: the endpoints controller.

Its reconciliation loop, which by now is familiar to you:

  1. It reads the Service's selector.
  2. It looks for the namespace's pods that match those labels.
  3. Of those, it picks the ones that are ready (Ready).
  4. It writes the list of their IPs and ports into an EndpointSlice object associated with the Service.
flowchart TD
    SVC["Service bookings-api<br/>selector: app=bookings-api, environment=dev"]
    EC["Endpoints controller<br/>(kube-controller-manager)"]
    ES["EndpointSlice bookings-api-xk4p2<br/>10.244.0.52:3000 · ready<br/>10.244.0.53:3000 · ready"]
    KP["kube-proxy on every node<br/>turns it into forwarding rules"]
    SVC --> EC
    EC -->|"watches pods with those labels"| EC
    EC --> ES
    ES --> KP

The crucial point, and the reason probes exist: only ready pods make it into the list. A pod that is starting up, one that is terminating or one whose readinessProbe is failing stays out and receives no traffic. That is the exact mechanism that makes the previous lesson's downtime-free rollouts possible, and also the piece we are still missing until Health Checks and Probes.

Historically this list lived in an Endpoints object (one per Service, with all the addresses inside). In large clusters, a Service with thousands of pods produced an enormous object that was resent in full to every node whenever a single IP changed. That is why since Kubernetes 1.21 the real mechanism is EndpointSlices: fragments of up to 100 addresses each.

Aspect Endpoints EndpointSlice
Objects per Service 1 As many as needed (100 addresses per slice)
Scalability Poor with thousands of pods Designed to scale
Status Kept for compatibility The real mechanism since 1.21
Command kubectl get endpoints kubectl get endpointslices

In practice you will still use kubectl get endpoints for diagnosis because its output is more compact and readable, and Kubernetes keeps it in sync. Just remember that underneath they are EndpointSlices.

  1. Anatomy of the manifest: port, targetPort, protocol

The project's first Service, the one for bookings-api:

# k8s/base/bookings-api-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: bookings-api
  namespace: rutas-norte-dev
  labels:
    app: bookings-api
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  type: ClusterIP
  selector:
    app: bookings-api
    environment: dev
  ports:
    - name: http
      port: 3000
      targetPort: http
      protocol: TCP

Field by field:

  • apiVersion: v1: the Service belongs to the core group, like the Pod. It carries no apps/.
  • metadata.name: bookings-api: hugely important, because the Service's name is the DNS name. http://bookings-api:3000 will work thanks to this line.
  • spec.type: ClusterIP: this is the default value; we write it explicitly for clarity. The other types are in Service Types.
  • spec.selector: which pods are behind it. There is no matchLabels here: a Service's selector is a flat equality map, simpler than a Deployment's. And a warning we will develop in section 9: these labels must match those of the pods, that is, the Deployment's spec.template.metadata.labels, not the Deployment's own.
  • ports[].name: http: mandatory if there is more than one port, advisable always.
  • ports[].port: 3000: the Service's port. It is the one clients connect to: bookings-api:3000.
  • ports[].targetPort: http: the pod's port traffic is forwarded to. Here we use the name of the port declared in the container.
  • ports[].protocol: TCP: TCP by default. It also accepts UDP and SCTP.

port versus targetPort

This is confusion number one with Services. The rule is simple:

port is where traffic enters the Service. targetPort is where it leaves towards the pod.

flowchart LR
    C["Client<br/>web-store pod"] -->|"http://bookings-api:3000"| S["Service bookings-api<br/>port: 3000"]
    S -->|"forwards to targetPort"| P["Pod bookings-api<br/>containerPort: 3000"]

They do not have to be equal, and often they are not. A very common case at Rutas Norte:

ports:
  - name: http
    port: 80          # clients use the standard port
    targetPort: 3000  # but the Node application listens on 3000

That way web-store can call http://bookings-api without specifying a port, while the application keeps listening wherever suits it.

targetPort by name

Compare the two ways of pointing at the pod:

# By number
targetPort: 3000
# By name, referencing the container's ports[].name
targetPort: http

The second is clearly superior, and it is the one the project adopts. Advantages:

  • It decouples the Service from the container. If tomorrow bookings-api starts listening on 8080, it is enough to change the containerPort in the Deployment; the Service stays valid without touching a line.
  • It allows one Service to cover heterogeneous pods. Different pods can expose the http port on different numbers and the same Service serves them all.
  • It documents itself. targetPort: metrics says more than targetPort: 9090.

The requirement for using it: the container must declare the port with a name. Our Deployment already does:

ports:
  - name: http
    containerPort: 3000

Watch out for a confusing asymmetry: port does not accept a name, only a number. Names are valid only for targetPort.

  1. Why ClusterIP is the default type

Kubernetes offers four types of Service. This lesson focuses on ClusterIP; the rest are covered in detail in Service Types.

Type Reach Typical use
ClusterIP (default) Inside the cluster only Communication between components: the vast majority of Services
NodePort A port on every node Testing and environments with no load balancer
LoadBalancer The cloud provider's external load balancer Public exposure, one per service
ExternalName A DNS alias to an external host Pointing at a service outside the cluster

ClusterIP being the default is no accident: it is a design decision aligned with the principle of least privilege. A component should not be reachable from outside unless somebody decides so explicitly.

Applied to the six Rutas Norte components:

Component Service type Why
web-store ClusterIP It will be exposed to the outside through an Ingress, not with a public Service
bookings-api ClusterIP Same: the Ingress will route api.rutasnorte.example to it
bookings-postgres ClusterIP It must never be reachable from the internet: it holds personal data
redis-cache ClusterIP Only bookings-api consumes it
notifications-worker None Nobody talks to it; it goes out to talk to others
occupancy-reports None A scheduled task with no inbound traffic

Notice that even the public components use ClusterIP. Exposure to the outside will be handled by a single Ingress Controller that routes by domain to these internal Services: one front door, one place to terminate TLS and apply rules.

And notice too that two components carry no Service at all. A Service is only needed if somebody has to initiate connections towards the component. notifications-worker consumes a queue and talks to PostgreSQL, but nobody calls it.

  1. The Rutas Norte Services

Let's give the platform a stable address. We start with the one we have already written:

kubectl apply -f k8s/base/bookings-api-service.yaml
kubectl get svc bookings-api
service/bookings-api created

NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
bookings-api   ClusterIP   10.96.184.22    <none>        3000/TCP   4s

There is the virtual IP: 10.96.184.22. It will never change while the Service exists.

web-store

# k8s/base/web-store-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: web-store
  namespace: rutas-norte-dev
  labels:
    app: web-store
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  type: ClusterIP
  selector:
    app: web-store
    environment: dev
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP

bookings-postgres

We have not deployed PostgreSQL yet —it needs persistent storage and that arrives in module 5— but we can set up a provisional single-replica Deployment to practise connectivity. The Service is what interests us:

# k8s/base/bookings-postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: bookings-postgres
  namespace: rutas-norte-dev
  labels:
    app: bookings-postgres
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  type: ClusterIP
  selector:
    app: bookings-postgres
    environment: dev
  ports:
    - name: postgres
      port: 5432
      targetPort: postgres
      protocol: TCP

And the provisional Deployment, with a password that for now is in plain text. It is a deliberate, temporary bad practice: it is fixed in Secrets.

# k8s/base/bookings-postgres-deployment.yaml (PROVISIONAL: no persistence)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings-postgres
  namespace: rutas-norte-dev
  labels:
    app: bookings-postgres
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: bookings-postgres
      environment: dev
  template:
    metadata:
      labels:
        app: bookings-postgres
        app.kubernetes.io/part-of: rutas-norte
        environment: dev
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - name: postgres
              containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: "bookings"
            - name: POSTGRES_USER
              value: "rutasnorte"
            - name: POSTGRES_PASSWORD
              value: "change-me-in-module-3"   # provisional: see lesson 03-02
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

redis-cache

# k8s/base/redis-cache-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: redis-cache
  namespace: rutas-norte-dev
  labels:
    app: redis-cache
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  type: ClusterIP
  selector:
    app: redis-cache
    environment: dev
  ports:
    - name: redis
      port: 6379
      targetPort: redis
      protocol: TCP

Apply it all and take in the result:

kubectl apply -f k8s/base/
kubectl get svc
NAME                TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
bookings-api        ClusterIP   10.96.184.22    <none>        3000/TCP   3m
bookings-postgres   ClusterIP   10.96.201.140   <none>        5432/TCP   8s
redis-cache         ClusterIP   10.96.77.33     <none>        6379/TCP   8s
web-store           ClusterIP   10.96.12.209    <none>        80/TCP     8s

Four stable addresses for the Rutas Norte platform. From now on, no component ever knows a pod IP again.

  1. Verification from inside the cluster

Checking the endpoints

Before testing traffic, always verify that the Service has found pods:

kubectl get endpoints
NAME                ENDPOINTS                                       AGE
bookings-api        10.244.0.52:3000,10.244.0.53:3000              5m
bookings-postgres   10.244.0.58:5432                                2m
redis-cache         10.244.0.55:6379                                2m
web-store           10.244.0.44:80,10.244.0.45:80,10.244.0.46:80    2m

That ENDPOINTS column is the most valuable piece of data when diagnosing Services: those are the real IPs traffic will be forwarded to. Three web-store pods, two bookings-api ones, one of each database. Exactly as expected.

The modern view, in EndpointSlices:

kubectl get endpointslices -l kubernetes.io/service-name=bookings-api
NAME                 ADDRESSTYPE   PORTS   ENDPOINTS                 AGE
bookings-api-4x7km   IPv4          3000    10.244.0.52,10.244.0.53   5m

Testing traffic with an ephemeral pod

We use the technique from the Pods lesson. Note that now we do not need to find out any IP:

kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  curl -s http://bookings-api:3000/availability
{"service":"bookings-api","version":"2.4.0","pod":"bookings-api-7f1d3e942-b8mrs"}
pod "test" deleted

We have called the API by name. That http://bookings-api:3000 is exactly the connection string that will go into web-store's configuration, and it will never change.

Demonstrating load balancing

This is where including the pod name in the API's response pays off:

kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  sh -c 'for i in $(seq 1 8); do curl -s http://bookings-api:3000/ | grep -o "bookings-api-[a-z0-9-]*"; done'
bookings-api-7f1d3e942-b8mrs
bookings-api-7f1d3e942-n4jvt
bookings-api-7f1d3e942-n4jvt
bookings-api-7f1d3e942-b8mrs
bookings-api-7f1d3e942-b8mrs
bookings-api-7f1d3e942-n4jvt
bookings-api-7f1d3e942-b8mrs
bookings-api-7f1d3e942-n4jvt

Eight requests spread across the two replicas. The spreading is random, not strictly alternating: do not expect a perfect distribution over a handful of requests.

Checking that the address persists

The definitive test. Scale, redeploy and call again:

kubectl scale deployment bookings-api --replicas=4
kubectl rollout restart deployment/bookings-api
kubectl rollout status deployment/bookings-api
kubectl get svc bookings-api
kubectl get endpoints bookings-api
NAME           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
bookings-api   ClusterIP   10.96.184.22   <none>        3000/TCP   12m

NAME           ENDPOINTS
bookings-api   10.244.0.64:3000,10.244.0.65:3000,10.244.0.66:3000,10.244.0.67:3000

The ClusterIP is still 10.96.184.22. The four endpoints are completely new IPs. That is the Service's contract in one line: the address on the outside never changes, the list on the inside updates itself.

Testing the databases

kubectl run test-redis --rm -it --image=redis:7.2-alpine --restart=Never -- \
  redis-cli -h redis-cache -p 6379 ping
PONG
pod "test-redis" deleted
kubectl run test-pg --rm -it --image=postgres:16 --restart=Never --env="PGPASSWORD=change-me-in-module-3" -- \
  psql -h bookings-postgres -U rutasnorte -d bookings -c "SELECT 'connection ok' AS status;"
     status
---------------
 connection ok
(1 row)

pod "test-pg" deleted

All four components are reachable by name from anywhere in the cluster. The platform now has a circulatory system.

Go back to 2 replicas before moving on:

kubectl scale deployment bookings-api --replicas=2

  1. The DNS name and headless services

The full name

Every Service automatically gets a DNS record of this form:

<service>.<namespace>.svc.cluster.local

For our API: bookings-api.rutas-norte-dev.svc.cluster.local. And because the cluster DNS configures search suffixes in every pod, from rutas-norte-dev you can use any of these forms:

Form Works from When to use it
bookings-api The same namespace The usual case: configuration of Rutas Norte components
bookings-api.rutas-norte-dev Any namespace When environments have to be crossed
bookings-api.rutas-norte-dev.svc.cluster.local Any namespace The full name, with no ambiguity

Check it:

kubectl run test-dns --rm -it --image=busybox:1.36 --restart=Never -- \
  nslookup bookings-api
Server:    10.96.0.10
Address:   10.96.0.10:53

Name:      bookings-api.rutas-norte-dev.svc.cluster.local
Address:   10.96.184.22

pod "test-dns" deleted

The name resolves to the ClusterIP, not to the pods' IPs. The inner workings of CoreDNS, the search suffixes, the SRV records and cross-namespace resolution are studied in depth in Internal DNS and Service Discovery.

Headless services

There is a variant worth knowing about even though we are not using it yet: a Service with clusterIP: None, called headless.

spec:
  clusterIP: None       # headless service
  selector:
    app: bookings-postgres

Its behaviour is different: it has no virtual IP and balances nothing. Instead, DNS returns the IPs of all the pods matching the selector directly.

Aspect Normal Service Headless Service
ClusterIP Yes, a virtual IP None
What DNS returns The ClusterIP Every pod IP
Balancing Yes No: the client chooses
Typical use Stateless workloads Stateful workloads, clients that need to talk to a specific replica

What it is for: when a client needs to address a specific replica rather than "any of them". That is the case of a PostgreSQL cluster where you have to write to the primary and read from the replicas, or of Kafka, or of any quorum-based system. That is why headless services almost always go hand in hand with StatefulSets: the combination gives each replica its own stable DNS name, such as bookings-postgres-0.bookings-postgres.rutas-norte-dev.svc.cluster.local.

When in module 6 we turn bookings-postgres into a StatefulSet, its Service will become headless. The details are in Internal DNS and StatefulSets.

  1. The most common failure: the selector that does not match

If you had to memorise a single thing from this lesson, let it be this. The number one Service fault in Kubernetes is a selector that does not match the pods' labels. And it is especially treacherous because it produces no error at all: the Service is created without complaint, gets its ClusterIP, shows up in kubectl get svc looking perfectly healthy... and forwards traffic nowhere.

Causing the fault

# /tmp/broken-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: bookings-api-broken
  namespace: rutas-norte-dev
spec:
  type: ClusterIP
  selector:
    app: booking-api        # the "s" is missing
    environment: dev
  ports:
    - name: http
      port: 3000
      targetPort: 3000
kubectl apply -f /tmp/broken-service.yaml
kubectl get svc bookings-api-broken
service/bookings-api-broken created

NAME                  TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
bookings-api-broken   ClusterIP   10.96.155.71   <none>        3000/TCP   3s

Looks immaculate. But:

kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  curl -s --max-time 5 http://bookings-api-broken:3000/
curl: (7) Failed to connect to bookings-api-broken port 3000 after 2 ms: Could not connect to server

The diagnostic procedure

Four steps, always in this order.

Step 1: does it have endpoints? This is the question that resolves 80 % of cases.

kubectl get endpoints bookings-api-broken
NAME                  ENDPOINTS   AGE
bookings-api-broken   <none>      2m

<none>. The Service has not found a single pod. Confirmed: the problem is in the selector or in the labels, not in the network or the application.

Step 2: what selector does the Service have?

kubectl describe svc bookings-api-broken | grep -i selector
Selector:  app=booking-api,environment=dev

Step 3: what labels do the pods have?

kubectl get pods -l app=bookings-api --show-labels
NAME                           READY   STATUS    LABELS
bookings-api-7f1d3e942-b8mrs   1/1     Running   app=bookings-api,app.kubernetes.io/part-of=rutas-norte,environment=dev,pod-template-hash=7f1d3e942

Step 4: compare. app=booking-api against app=bookings-api. There is the missing s.

The definitive test is to use the Service's selector as a kubectl filter: if it returns no pods, the Service will not find them either.

kubectl get pods -l app=booking-api,environment=dev
No resources found in rutas-norte-dev namespace.
kubectl delete -f /tmp/broken-service.yaml

The full catalogue of causes

When ENDPOINTS is empty or incomplete, the cause is always in this table:

Cause How to detect it Fix
A misspelled label in the selector kubectl get pods -l <selector> returns nothing Correct the selector
The selector points at the Deployment's labels, not the pod's The metadata and template.metadata labels differ The selector must match spec.template.metadata.labels
Service and pods in different namespaces kubectl get pods -n <ns> -l <selector> in the Service's namespace A Service only selects pods in its own namespace
The pods exist but are not Ready kubectl get pods shows 0/1 Fix the pod: look at describe and logs
targetPort with a name the container does not declare There are endpoints, but with the wrong port or no port Declare ports[].name in the container
The container does not actually listen on that port There are endpoints, but the connection is refused kubectl exec and check the real port

Of all of them, the second is the subtlest and the biggest time waster. Look at this Deployment:

metadata:
  name: bookings-api
  labels:
    app: bookings-api-deploy     # the DEPLOYMENT's label
spec:
  template:
    metadata:
      labels:
        app: bookings-api        # the PODS' label

A Service with selector: {app: bookings-api-deploy} will find nothing, because that label is carried by the Deployment, which is not a pod. A Service selects pods, always. The Rutas Norte convention —using the same three labels on the Deployment and on the template— prevents this mistake by construction.

When there are endpoints and it still fails

If ENDPOINTS has addresses but the connection does not work, the problem is further down:

# Does the pod respond directly, bypassing the Service?
kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  curl -s --max-time 5 http://10.244.0.52:3000/

# Is the process listening on the port it claims?
kubectl exec deploy/bookings-api -- netstat -tlnp 2>/dev/null || \
kubectl exec deploy/bookings-api -- wget -qO- http://localhost:3000/

If the pod responds by direct IP but not through the Service, look at the targetPort. If it does not respond even by direct IP, the problem is the application, not Kubernetes.

And one last warning that saves hours: if your application listens on 127.0.0.1 instead of 0.0.0.0, it only answers itself. The Service will have correct endpoints and every connection will still fail. It is an application configuration fault that looks like a Kubernetes fault.

Common Mistakes and Tips

  • A selector that does not match the pods' labels. Failure number one. The Service is created with no error and serves nothing. Diagnosis: kubectl get endpoints.
  • Pointing the selector at the Deployment's labels. Services select pods: the relevant labels are those in spec.template.metadata.labels.
  • Confusing port with targetPort. port is where the Service listens; targetPort, where the container listens.
  • Using a name in port. Only targetPort accepts names. port is always numeric.
  • Expecting a Service to reach pods in another namespace. It does not: it only selects in its own. To cross over, the full DNS name is used, as we will see in Namespaces.
  • Having the application listen on 127.0.0.1. It must listen on 0.0.0.0 to be reachable from outside the container.
  • Creating a Service for notifications-worker. It receives no inbound connections; a Service with no clients is noise and unnecessary attack surface.
  • Expecting perfectly alternating spreading. Balancing is random per connection, not per request. With keep-alive, every request on a connection goes to the same pod.
  • Running ping against a ClusterIP. It does not answer: it is a forwarding rule, not an interface. Use curl or nc against the Service port.
  • Tip: Service diagnosis always starts with kubectl get endpoints <name>. Empty means a selector or label problem; with addresses, a port or application problem.
  • Tip: always use targetPort by name. It decouples the Service from the container and saves you a change every time the application moves its port.
  • Tip: for a quick test without writing manifests, kubectl expose deployment bookings-api --port=3000 --target-port=http --dry-run=client -o yaml generates a correct Service you can review and save.

Exercises

Exercise 1: Create and verify the web-store Service

  1. Apply this lesson's web-store Service and check that it has 3 endpoints.
  2. From an ephemeral pod, make 6 requests to http://web-store/ and verify that nginx responds.
  3. Note down the ClusterIP, scale the Deployment to 5 replicas, redeploy it with rollout restart and prove with two commands that the ClusterIP has not changed and that the endpoints have.
  4. Explain why the Service uses port: 80 and targetPort: http instead of targetPort: 80.

Exercise 2: Diagnose three broken Services

A colleague has applied these three Services in rutas-norte-dev and none of them works. For each one, identify the exact cause, state the command that reveals it and write the fix.

# Service A
apiVersion: v1
kind: Service
metadata:
  name: redis-cache-a
  namespace: rutas-norte-dev
spec:
  selector:
    app: redis
    environment: dev
  ports:
    - port: 6379
      targetPort: redis
# Service B
apiVersion: v1
kind: Service
metadata:
  name: bookings-api-b
  namespace: rutas-norte-dev
spec:
  selector:
    app: bookings-api
    environment: dev
  ports:
    - port: 3000
      targetPort: api      # the container declares the port as "http"
# Service C
apiVersion: v1
kind: Service
metadata:
  name: web-store-c
  namespace: default        # careful
spec:
  selector:
    app: web-store
    environment: dev
  ports:
    - port: 80
      targetPort: http

Exercise 3: Wire the platform end to end

Goal: get bookings-api talking to redis-cache and to bookings-postgres by name only, with no IPs at all.

  1. Check that the three Services have endpoints.
  2. From an ephemeral redis:7.2-alpine pod, write a departure's availability into the cache: the key seats:BIL-SAN:2026-08-14 with value 37. Then retrieve it from another, different ephemeral pod.
  3. From an ephemeral postgres:16 pod, create the bookings table with the columns id, customer and departure, insert two fictional bookings and query them.
  4. Write the environment-variable fragment that the bookings-api Deployment would carry to connect to both by name, and explain why that configuration is identical in rutas-norte-dev, rutas-norte-pre and rutas-norte-pro.

Solutions

Solution 1

kubectl apply -f k8s/base/web-store-service.yaml
kubectl get svc,endpoints web-store
service/web-store created

NAME                TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/web-store   ClusterIP   10.96.12.209   <none>        80/TCP    5s

NAME                  ENDPOINTS                                      AGE
endpoints/web-store   10.244.0.44:80,10.244.0.45:80,10.244.0.46:80   5s
kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  sh -c 'for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code} " http://web-store/; done; echo'
200 200 200 200 200 200
pod "test" deleted
kubectl scale deployment web-store --replicas=5
kubectl rollout restart deployment/web-store
kubectl rollout status deployment/web-store
kubectl get svc web-store
kubectl get endpoints web-store
NAME        TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
web-store   ClusterIP   10.96.12.209   <none>        80/TCP    4m

NAME        ENDPOINTS
web-store   10.244.0.71:80,10.244.0.72:80,10.244.0.73:80,10.244.0.74:80,10.244.0.75:80

The ClusterIP is still 10.96.12.209; the five endpoints are new IPs.

  1. targetPort: http references the name of the port declared in the container (ports[].name: http) rather than its number. That way, if tomorrow web-store changes its nginx to listen on 8080, it is enough to update the containerPort in the Deployment: the Service remains correct without being touched. It is a decoupling between the service definition and the container's implementation detail, and it also makes the manifest self-explanatory.
kubectl scale deployment web-store --replicas=3

Solution 2

Service Cause Command that reveals it Fix
A The selector uses app: redis, but the pods carry app: redis-cache kubectl get endpoints redis-cache-a<none>, and kubectl get pods -l app=redis returns nothing app: redis-cache
B targetPort: api does not exist: the container declares the port as http There are endpoints, but the connection fails; kubectl describe svc bookings-api-b shows TargetPort: api/TCP and kubectl get pod -o jsonpath='{.spec.containers[0].ports}' shows name: http targetPort: http
C The Service is in the default namespace and the pods are in rutas-norte-dev. A Service only selects pods in its own namespace kubectl get endpoints web-store-c -n default<none>, while kubectl get pods -n default -l app=web-store returns nothing namespace: rutas-norte-dev
# Checking A
kubectl get endpoints redis-cache-a
kubectl get pods -l app=redis
NAME            ENDPOINTS   AGE
redis-cache-a   <none>      1m

No resources found in rutas-norte-dev namespace.
# Checking B: there are pods but the port is a name that does not exist
kubectl describe svc bookings-api-b | grep -E "TargetPort|Endpoints"
TargetPort:  api/TCP
Endpoints:   <none>
# Checking C
kubectl get endpoints web-store-c -n default
kubectl get pods -n default -l app=web-store
NAME          ENDPOINTS   AGE
web-store-c   <none>      1m

No resources found in default namespace.

Solution 3

# 1. The three Services with endpoints
kubectl get endpoints bookings-api redis-cache bookings-postgres
NAME                ENDPOINTS                            AGE
bookings-api        10.244.0.52:3000,10.244.0.53:3000    25m
redis-cache         10.244.0.55:6379                     20m
bookings-postgres   10.244.0.58:5432                     20m
# 2. Write to the cache from one pod and read from another
kubectl run redis-write --rm -it --image=redis:7.2-alpine --restart=Never -- \
  redis-cli -h redis-cache SET seats:BIL-SAN:2026-08-14 37
OK
pod "redis-write" deleted
kubectl run redis-read --rm -it --image=redis:7.2-alpine --restart=Never -- \
  redis-cli -h redis-cache GET seats:BIL-SAN:2026-08-14
"37"
pod "redis-read" deleted

Two different ephemeral pods, created and destroyed, have shared state through redis-cache without knowing a single IP.

# 3. PostgreSQL
kubectl run pg-cli --rm -it --image=postgres:16 --restart=Never \
  --env="PGPASSWORD=change-me-in-module-3" -- \
  psql -h bookings-postgres -U rutasnorte -d bookings -c "
    CREATE TABLE IF NOT EXISTS bookings (
      id SERIAL PRIMARY KEY,
      customer TEXT NOT NULL,
      departure TEXT NOT NULL
    );
    INSERT INTO bookings (customer, departure) VALUES
      ('Marta Ibarra', 'BIL-SAN 2026-08-14 08:30'),
      ('Xabier Aroca', 'BIL-SAN 2026-08-14 08:30');
    SELECT * FROM bookings;"
 id |   customer   |        departure
----+--------------+--------------------------
  1 | Marta Ibarra | BIL-SAN 2026-08-14 08:30
  2 | Xabier Aroca | BIL-SAN 2026-08-14 08:30
(2 rows)

pod "pg-cli" deleted
  1. bookings-api configuration by name:
env:
  - name: REDIS_HOST
    value: "redis-cache"
  - name: REDIS_PORT
    value: "6379"
  - name: DATABASE_HOST
    value: "bookings-postgres"
  - name: DATABASE_PORT
    value: "5432"
  - name: DATABASE_NAME
    value: "bookings"
  - name: DATABASE_USER
    value: "rutasnorte"
  # DATABASE_PASSWORD will come from a Secret in lesson 03-02

That configuration is identical in all three environments because the short name redis-cache resolves within the namespace the pod runs in. A bookings-api pod in rutas-norte-pro will resolve redis-cache as redis-cache.rutas-norte-pro.svc.cluster.local, while the same pod in rutas-norte-dev will resolve it as redis-cache.rutas-norte-dev.svc.cluster.local. The same manifest works for the three environments without changing a single line, and each one talks to its own database and its own cache. It is exactly the mechanism the next lesson, Namespaces, exploits.

Conclusion

The Rutas Norte platform now has a circulatory system. You have seen first-hand why pod IPs are useless as a connection point —they change on every rollout, every self-healing event and every scaling operation— and how the Service solves the problem with a virtual ClusterIP that never changes, a DNS name and automatic balancing across the healthy replicas. You know that IP is not a machine but a forwarding rule, and that the list of real destinations is kept up to date by the endpoints controller, which writes only the addresses of ready pods into EndpointSlices.

You read a Service manifest without hesitation: port is where traffic enters and targetPort where it leaves towards the container, preferably by name to decouple the Service from the implementation. You understand why ClusterIP is the default type and why at Rutas Norte all the Services are of that type, including those of the public components, which will be exposed through a single front door. And you have given a stable address to four components: web-store, bookings-api, bookings-postgres and redis-cache, while notifications-worker and occupancy-reports carry no Service because nobody initiates connections towards them. You have verified it without shortcuts: ephemeral pods calling by name, eight requests spread across two replicas, and the definitive test of scaling and redeploying while watching the ClusterIP hold as every endpoint is renewed.

Finally, you take away the most profitable diagnostic procedure in all of Kubernetes: when a Service does not respond, the first thing is kubectl get endpoints. Empty means the selector does not match the pods' labels, and from there it is one step to comparing describe svc with get pods --show-labels. With addresses, the problem is in the ports or in the application.

You will have noticed that throughout the lesson we have written rutas-norte-dev over and over, and that bookings-api's configuration mentions the environment nowhere because DNS resolves it on its own. That is no accident: it is the property that makes it possible to deploy the same platform three times without touching the manifests. In the next lesson, Namespaces, we will set up Rutas Norte's three environments —dev, pre and pro—, we will deploy the same manifest to several of them, we will see what a namespace really isolates and, most importantly, what it does not isolate at all.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved