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
- The problem: ephemeral IPs
- What a Service is and what it gives you
- Endpoints and EndpointSlices: who keeps the list
- Anatomy of the manifest:
port,targetPort,protocol - Why
ClusterIPis the default type - The Rutas Norte Services
- Verification from inside the cluster
- The DNS name and headless services
- The most common failure: the selector that does not match
- The problem: ephemeral IPs
Let's make the problem visible with a thirty-second experiment. Note down the current IPs of bookings-api:
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-norteNow 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 wideNAME 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-norteNew 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:
- An address that never changes, even if all the pods behind it do.
- Automatic traffic spreading across the available replicas.
- A name, not an IP, so addresses do not have to be configured anywhere.
- 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-apifrom 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".
- 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:
- It reads the Service's
selector. - It looks for the namespace's pods that match those labels.
- Of those, it picks the ones that are ready (
Ready). - 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.
- Anatomy of the manifest:
port, targetPort, protocol
port, targetPort, protocolThe 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: TCPField by field:
apiVersion: v1: the Service belongs to the core group, like the Pod. It carries noapps/.metadata.name: bookings-api: hugely important, because the Service's name is the DNS name.http://bookings-api:3000will 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 nomatchLabelshere: 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'sspec.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 acceptsUDPandSCTP.
port versus targetPort
This is confusion number one with Services. The rule is simple:
portis where traffic enters the Service.targetPortis 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 3000That 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:
The second is clearly superior, and it is the one the project adopts. Advantages:
- It decouples the Service from the container. If tomorrow
bookings-apistarts listening on 8080, it is enough to change thecontainerPortin the Deployment; the Service stays valid without touching a line. - It allows one Service to cover heterogeneous pods. Different pods can expose the
httpport on different numbers and the same Service serves them all. - It documents itself.
targetPort: metricssays more thantargetPort: 9090.
The requirement for using it: the container must declare the port with a name. Our Deployment already does:
Watch out for a confusing asymmetry: port does not accept a name, only a number. Names are valid only for targetPort.
- Why
ClusterIP is the default type
ClusterIP is the default typeKubernetes 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.
- The Rutas Norte Services
Let's give the platform a stable address. We start with the one we have already written:
service/bookings-api created
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
bookings-api ClusterIP 10.96.184.22 <none> 3000/TCP 4sThere 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: TCPbookings-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: TCPAnd 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: TCPApply it all and take in the result:
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 8sFour stable addresses for the Rutas Norte platform. From now on, no component ever knows a pod IP again.
- Verification from inside the cluster
Checking the endpoints
Before testing traffic, always verify that the Service has found pods:
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 2mThat 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:
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" deletedWe 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-n4jvtEight 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-apiNAME 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:3000The 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 pingkubectl 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;"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:
- The DNS name and headless services
The full name
Every Service automatically gets a DNS record of this form:
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:
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" deletedThe 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.
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.
- 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: 3000service/bookings-api-broken created
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
bookings-api-broken ClusterIP 10.96.155.71 <none> 3000/TCP 3sLooks 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 serverThe diagnostic procedure
Four steps, always in this order.
Step 1: does it have endpoints? This is the question that resolves 80 % of cases.
<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?
Step 3: what labels do the pods have?
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=7f1d3e942Step 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.
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' labelA 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
portwithtargetPort.portis where the Service listens;targetPort, where the container listens. - Using a name in
port. OnlytargetPortaccepts names.portis 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 on0.0.0.0to 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
pingagainst aClusterIP. It does not answer: it is a forwarding rule, not an interface. Usecurlorncagainst 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
targetPortby 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 yamlgenerates a correct Service you can review and save.
Exercises
Exercise 1: Create and verify the web-store Service
- Apply this lesson's
web-storeService and check that it has 3 endpoints. - From an ephemeral pod, make 6 requests to
http://web-store/and verify that nginx responds. - Note down the
ClusterIP, scale the Deployment to 5 replicas, redeploy it withrollout restartand prove with two commands that theClusterIPhas not changed and that the endpoints have. - Explain why the Service uses
port: 80andtargetPort: httpinstead oftargetPort: 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: httpExercise 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.
- Check that the three Services have endpoints.
- From an ephemeral
redis:7.2-alpinepod, write a departure's availability into the cache: the keyseats:BIL-SAN:2026-08-14with value37. Then retrieve it from another, different ephemeral pod. - From an ephemeral
postgres:16pod, create thebookingstable with the columnsid,customeranddeparture, insert two fictional bookings and query them. - Write the environment-variable fragment that the
bookings-apiDeployment would carry to connect to both by name, and explain why that configuration is identical inrutas-norte-dev,rutas-norte-preandrutas-norte-pro.
Solutions
Solution 1
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 5skubectl 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'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-storeNAME 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:80The ClusterIP is still 10.96.12.209; the five endpoints are new IPs.
targetPort: httpreferences the name of the port declared in the container (ports[].name: http) rather than its number. That way, if tomorrowweb-storechanges its nginx to listen on 8080, it is enough to update thecontainerPortin 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.
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 B: there are pods but the port is a name that does not exist
kubectl describe svc bookings-api-b | grep -E "TargetPort|Endpoints"# Checking C
kubectl get endpoints web-store-c -n default
kubectl get pods -n default -l app=web-storeSolution 3
# 1. The three Services with endpoints
kubectl get endpoints bookings-api redis-cache bookings-postgresNAME 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 37kubectl run redis-read --rm -it --image=redis:7.2-alpine --restart=Never -- \
redis-cli -h redis-cache GET seats:BIL-SAN:2026-08-14Two 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" deletedbookings-apiconfiguration 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-02That 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
