Pods and Services
The three resources you will write every day.
Pods, Services, and Deployments
A Pod has an IP address. That address changes every time the Pod is recreated, and Pods are recreated often: on every deployment, on every crash, and whenever the scheduler decides to move one. No application can therefore call another by its address. This is the problem this lesson solves, and it runs deeper than it seems.
The three resources that follow form a chain in which every link has a precise reason to exist. The Pod runs. The Deployment guarantees that the right number of Pods of the right version exist, and knows how to move from one version to another without downtime. The Service provides a stable name and a stable address in front of a set of Pods whose identity constantly changes.
What ties these three objects together is not a direct reference, and this is the point that confuses people at first: they don’t know each other by name, but by labels. A Service doesn’t know which Pods it exposes; it declares a selection criterion, and the system continuously associates with it every Pod that matches. This indirect coupling is what allows a Pod to disappear and its replacement to be served immediately, with no reconfiguration at all.
Understanding this label mechanism means understanding 80% of Kubernetes. A badly written selector produces the most opaque beginner symptom of all: a Service that responds, but with a connection error, because it points at nothing.
The Deployment Rather Than the Pod
You don’t create a Pod by hand outside of a throwaway test. A standalone Pod has no resilience: if it dies, it stays dead. Instead, you declare a Deployment, which takes on that responsibility.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web # must match the labels of the template below
template: # the mold from which Pods are made
metadata:
labels:
app: web # the labels the created Pods will carry
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 80
There are two pitfalls in this file, both tied to the same spot.
The first: spec.selector.matchLabels and spec.template.metadata.labels must match. The selector says “the Pods I am responsible for have this label,” and the template says “the Pods I create carry this label.” If they diverge, the API server rejects the manifest, which is good news: the error is caught at write time.
The second: spec.selector is immutable after creation. To change it, you have to delete and recreate the Deployment. So choose your labels with some care.
kubectl apply -f deployment.yaml
kubectl get pods -l app=web -w # watch the scale-up live
kubectl get deployment web # ready replicas out of desired replicas
The ReplicaSet Intermediary
A Deployment doesn’t manage Pods directly. It creates a ReplicaSet, and it is this ReplicaSet that maintains the Pod count. This layer looks like needless overhead until you understand what it’s for:
kubectl get replicasets
# NAME DESIRED CURRENT READY AGE
# web-7d4b8c9f5 3 3 3 2m
Each version of the template produces a distinct ReplicaSet. During an update, the Deployment scales the old ReplicaSet down and the new one up. The old one is kept at zero replicas — this is exactly what makes rollback possible and instantaneous: the previous definition is still there, and all it takes is to scale it back up.
Check the hierarchy for yourself: delete a Pod by hand, and the ReplicaSet recreates it within seconds, having noticed that the count no longer matches.
kubectl delete pod web-7d4b8c9f5-xk2mp
kubectl get pods -l app=web # a new Pod, with a new suffix
Labels and Selectors
A label is a free-form key-value pair attached to any object. It isn’t documentation: it is the system’s binding mechanism.
metadata:
labels:
app: web
tier: frontend
version: v1
environnement: production
kubectl can query on these labels, which is a much better option than reading through lists by hand:
kubectl get pods -l app=web # equality
kubectl get pods -l app=web,tier=frontend # conjunction
kubectl get pods -l 'app!=web' # negation
kubectl get pods -l 'environnement in (staging,production)' # set
kubectl get pods -l version # key presence
kubectl get pods --show-labels # display labels
The tip that prevents the most mess: reserve the selector labels for the application’s identity (app, tier), and put everything that changes often — a version number, a commit identifier — in non-selector labels or in annotations. A selector that contains version: v1 breaks the Service’s continuity on every release.
The Service: An Address That Doesn’t Move
A Service creates a stable virtual IP address and DNS name, and distributes traffic across all the Pods that match its selector.
ClusterIP, the Default Case
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: ClusterIP # default value: reachable from inside the cluster
selector:
app: web # every Pod carrying app=web becomes a target
ports:
- name: http
port: 80 # Service port
targetPort: 80 # port the container listens on
protocol: TCP
The port / targetPort distinction comes up constantly and is worth getting straight: port is what clients call, and targetPort is what the container listens on. Nothing requires them to be equal, and in practice they rarely are.
The Service is reachable by name from any Pod in the cluster, thanks to internal DNS:
web # same namespace
web.atelier # explicit namespace
web.atelier.svc.cluster.local # fully qualified name
Verify it from an ephemeral Pod; this is the test that clears up doubt fastest:
kubectl run debug --image=busybox:1.36 --rm -it --restart=Never -- \
wget -qO- http://web:80
The Essential Diagnostic: Endpoints
A Service that finds no Pod doesn’t return an explicit error. It exists, it has an address, and it simply refuses connections. The cause is almost always a selector that matches nothing.
kubectl get endpoints web # the Pods actually behind the Service
# Empty list? The selector matches no Pod. Compare the two:
kubectl get service web -o jsonpath='{.spec.selector}'
kubectl get pods --show-labels
Remember this reflex: connection refused on a Service, look at the endpoints before anything else. It turns half an hour of fumbling into thirty seconds.
NodePort
NodePort opens the same port on every node in the cluster, in the 30000-32767 range.
apiVersion: v1
kind: Service
metadata:
name: web-nodeport
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 80
nodePort: 30080 # optional; otherwise Kubernetes assigns one
# minikube gives you the directly reachable URL
minikube service web-nodeport --url
This is convenient in development and best avoided in production: the port is nonstandard, there is no TLS or domain name, and the exposure covers every node at once. For real HTTP traffic, the right answer is an Ingress, covered in lesson 6.
LoadBalancer and Its Dependence on the Provider
apiVersion: v1
kind: Service
metadata:
name: web-lb
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 80
Let’s be precise, because this point trips up a lot of people: Kubernetes cannot create a load balancer. It issues a request and waits for an external controller to fulfill it. On AWS, Google Cloud, or Azure, that controller exists and provisions a billed resource. On a local cluster or an installation without cloud integration, nobody answers, and the Service stays indefinitely with an external address of <pending>.
kubectl get service web-lb
# EXTERNAL-IP stays <pending> without a controller: this is not a bug
# With minikube, a command simulates the behavior (leave it running)
minikube tunnel
| Type | Scope | Realistic use |
|---|---|---|
ClusterIP | internal to the cluster | communication between services: the common case |
NodePort | port opened on every node | development, troubleshooting access |
LoadBalancer | external address provided by the cloud | public entry point, depends on the provider |
Rolling Updates
This is the service Kubernetes performs best, and it is almost free. Change a Deployment’s image: the Pods are replaced in batches, keeping the service available throughout the transition.
# Fragment to insert in the Deployment's spec, next to replicas.
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # at most 1 Pod unavailable during the switchover
maxSurge: 1 # at most 1 Pod above the desired 4
With these values, there are always between 3 and 5 Pods during the transition, at least 3 of them available. maxUnavailable: 0 guarantees zero capacity loss, at the cost of a slower rollout since it has to create before it destroys.
kubectl apply -f deployment.yaml # after changing the image in the manifest
# Imperative variant, useful for an urgent fix
kubectl set image deployment/web nginx=nginx:1.27.1-alpine
kubectl rollout status deployment/web # exits when the switchover is complete
Rolling Back
Since the old ReplicaSets are kept, going back requires no rebuild:
kubectl rollout history deployment/web # revision history
kubectl rollout undo deployment/web # go back to the previous one
kubectl rollout undo deployment/web --to-revision=2 # go back to a specific revision
# Pause then resume a rollout in progress, if errors increase
kubectl rollout pause deployment/web
kubectl rollout resume deployment/web
An honest note about the history: by default it doesn’t show what changed between two revisions, only their number. To make the history readable, add an annotation with each release:
metadata:
name: web
annotations:
kubernetes.io/change-cause: "Upgrade to nginx 1.27.1 — CVE fix"
Scaling
kubectl scale deployment/web --replicas=6 # imperative
kubectl apply -f deployment.yaml # declarative, after editing the file
Prefer the declarative approach. A kubectl scale is overwritten the next time the manifest is applied, which produces a silent regression that is hard to explain.
Probes: Liveness and Readiness
Without a probe, Kubernetes considers a container healthy as soon as its main process is running. That is a crude approximation: an application may have started its HTTP server without being able to respond — database connection not established, cache not loaded. The Service will then send traffic to a Pod that answers with errors.
Two probes answer two distinct questions, and confusing them has serious consequences.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: mon-api:1.0.0
ports:
- containerPort: 8080
# "Can this Pod receive traffic right now?"
# On failure: removed from the Service's endpoints, but NOT restarted.
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
# "Is this process permanently stuck?"
# On failure: the container is KILLED and restarted.
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
# "Has startup finished?"
# As long as it fails, the livenessProbe is suspended.
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 30
The classic mistakes, worth knowing before you make them:
- Pointing the liveness probe at an endpoint that checks the database. If the database slows down, every Pod fails the probe, they are all killed at the same time, and a temporary slowdown becomes a total outage. Liveness should only test the process itself; external dependencies belong to readiness.
- An
initialDelaySecondsthat is too short on liveness. The application is killed before it has finished starting, in a loop: this is the inexplicableCrashLoopBackOff. ThestartupProbeis the clean answer for slow startups. - Declaring no readiness probe at all. Traffic reaches a Pod that isn’t ready, and every deployment causes a wave of 502 errors.
Three forms of probes exist, depending on what the application exposes:
# HTTP: any code from 200 to 399 is a success
readinessProbe:
httpGet: { path: /ready, port: 8080 }
---
# TCP: does the connection open?
readinessProbe:
tcpSocket: { port: 5432 }
---
# Command: exit code 0 = success
livenessProbe:
exec: { command: ["sh", "-c", "test -f /tmp/sain"] }
Diagnosing a Pod
The commands to know by heart, in the order you use them:
# 1. Overview: STATUS and RESTARTS tell most of the story
kubectl get pods -o wide
# 2. The Events section at the end of the output gives the real cause
kubectl describe pod web-7d4b8c9f5-xk2mp
# 3. Application logs
kubectl logs web-7d4b8c9f5-xk2mp
kubectl logs -f deployment/web # live follow
kubectl logs web-7d4b8c9f5-xk2mp --previous # logs from the container BEFORE its crash
# 4. Get inside the container
kubectl exec -it web-7d4b8c9f5-xk2mp -- sh
# 5. Reach the Pod from your workstation, without exposing it
kubectl port-forward pod/web-7d4b8c9f5-xk2mp 8080:80
The --previous option is the one people discover too late. On a Pod in CrashLoopBackOff, kubectl logs shows the current container, which is often empty because it has just restarted. --previous shows the instance that actually crashed, and therefore the error message.
The most common statuses and what they mean:
| Status | Usual cause |
|---|---|
Pending | the scheduler can’t find a node: insufficient resources or an unbound volume |
ImagePullBackOff | image not found, wrong tag, or private registry without credentials |
CrashLoopBackOff | the container starts, then stops, in a loop: see logs --previous |
Running but 0/1 READY | the readinessProbe is failing |
OOMKilled | memory limit exceeded (see lesson 6) |
Key Takeaways
- Declare a Deployment, never a Pod by hand. It creates one ReplicaSet per version of the template, and it’s the retention of old ReplicaSets that makes
kubectl rollout undoinstantaneous. - Objects are linked by labels, not by names. Keep the selector labels stable (
app,tier) and move anything that changes often — versions, commits — out of the selector, which is immutable anyway. - An unreachable Service is diagnosed with
kubectl get endpoints: an empty list means a selector that matches no Pod. ClusterIPfor internal traffic,NodePortfor development,LoadBalanceronly where a cloud controller exists — otherwise the external address stays<pending>indefinitely.- readiness removes a Pod from traffic without restarting it, liveness kills and restarts it. Never put an external dependency in a liveness probe: a slow database would turn into a general outage.
Tip: run
kubectl get pods -win one terminal, then trigger an update in another. Watching Pods appear and disappear in batches cements the mechanism better than any explanation.
Comments
Comments are powered by GitHub Discussions
Sign in with GitHub to join the discussion