Pods, Deployments & Services
Now the workhorses. This lesson is the core object model you'll use every day: Pods wrapped by ReplicaSets wrapped by Deployments; Services (ClusterIP / NodePort / LoadBalancer) that give a stable address to a moving set of Pods; ConfigMaps and Secrets for configuration; and labels & selectors — the glue that connects them all. You'll write your first real Deployment + Service and drive it with kubectl apply / get / describe / logs.
Learning objectives
- Explain the Pod → ReplicaSet → Deployment ownership chain and why you deploy via Deployments.
- Write a Deployment and expose it with a Service, connected by labels & selectors.
- Choose between ClusterIP, NodePort, and LoadBalancer Service types.
- Inject configuration with a ConfigMap and sensitive data with a Secret.
- Use namespaces to isolate environments, and drive it all with core
kubectlverbs.
1 · The ownership chain: Pod ← ReplicaSet ← Deployment
You rarely touch the lower two directly. A Deployment is the object you write; it creates and manages a ReplicaSet, which in turn keeps the right number of Pods alive. The Deployment adds what a bare ReplicaSet lacks: rollouts and rollbacks. When you change the image, the Deployment spins up a new ReplicaSet and shifts Pods over gradually.
2 · Your first Deployment
Here is a real Deployment: 3 replicas of a web image, with a selector that must match the Pod template's labels. That label match is not decoration — it's how the Deployment knows which Pods it owns.
deployment.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web # which Pods this Deployment manages
template: # the Pod template it stamps out
metadata:
labels:
app: web # MUST match the selector above
spec:
containers:
- name: app
image: ghcr.io/example/web:1.2.0 # pin a version
ports:
- containerPort: 8000
apply.shkubectl apply -f deployment.yaml
kubectl get deploy web # READY 3/3 when settled
kubectl get pods -l app=web # list the 3 Pods by label
kubectl rollout status deploy/web # block until the rollout is done
kubectl describe deploy web # events, replica counts, strategy
NAME READY UP-TO-DATE AVAILABLE AGE
web 3/3 3 3 40s
selector.matchLabels doesn't match the Pod template's labels, the Deployment either errors or manages zero Pods. This is the #1 first-Deployment bug. And the selector is immutable after creation — verify the current rules against the Kubernetes docs.3 · Services — a stable address for moving Pods
Pods are cattle: they're created, killed, and rescheduled with new IPs constantly. Clients can't chase those IPs. A Service gives a single stable virtual IP and DNS name that load-balances across whichever Pods currently match its selector. Three types, from internal to external:
| Type | Reachable from | Typical use |
|---|---|---|
| ClusterIP (default) | Inside the cluster only | Service-to-service calls; the default |
| NodePort | Any node's IP on a high port | Dev/testing, or behind an external LB |
| LoadBalancer | The public internet (cloud LB) | Exposing a service externally on a cloud |
service.yamlapiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web # sends traffic to Pods with label app=web
ports:
- port: 80 # the Service's port
targetPort: 8000 # the container's port
# type: ClusterIP is the default (internal). Use LoadBalancer to expose externally.
svc.shkubectl apply -f service.yaml
kubectl get svc web # note the CLUSTER-IP
# Every Service gets in-cluster DNS: <name>.<namespace>.svc.cluster.local
kubectl run tmp --rm -it --image=curlimages/curl -- \
curl -s http://web.default.svc.cluster.local/healthz
NAME TYPE CLUSTER-IP PORT(S) AGE
web ClusterIP 10.96.140.21 80/TCP 8s
selector is matched continuously against Pod labels; the set of matching Pod IPs is tracked as EndpointSlices. Add or kill a Pod and the endpoints update automatically — that's why clients hit one address while Pods churn underneath.4 · ConfigMaps and Secrets
Never bake config or credentials into the image. A ConfigMap holds non-sensitive settings; a Secret holds sensitive ones. Both are injected at runtime as environment variables or mounted files, so the same image runs in dev and prod with different config.
config.yamlapiVersion: v1
kind: ConfigMap
metadata:
name: web-config
data:
LOG_LEVEL: "info"
FEATURE_FLAGS: "search,export"
---
apiVersion: v1
kind: Secret
metadata:
name: web-secret
type: Opaque
stringData: # stringData: plain text, encoded for you on apply
API_KEY: "replace-me-in-a-real-secret-store"
---
# ...in the Deployment's container spec:
# envFrom:
# - configMapRef: { name: web-config }
# - secretRef: { name: web-secret }
5 · Namespaces, labels & selectors
Namespaces partition a cluster into virtual sub-clusters — a common pattern is one per environment (dev, staging, prod) or per team. Objects in different namespaces can share names and are isolated by default. Labels are arbitrary key/value tags on objects; selectors query them. Together they're how nearly everything in k8s finds everything else.
ns.shkubectl create namespace staging
kubectl apply -f deployment.yaml -n staging # deploy into a namespace
kubectl get pods -A # -A = across all namespaces
# Labels drive selection everywhere:
kubectl get pods -l 'app=web,tier!=batch' # boolean label selectors
kubectl label pod web-abc123 canary=true # add a label on the fly
kubectl get pods -l canary=true # now query by it
app, tier, env, version) — it pays off across every tool.✓ Checkpoint — you can move on when you can…
- Draw the Deployment → ReplicaSet → Pod chain and say what each layer adds.
- Write a Service whose selector matches a Deployment's Pod labels, mapping port 80 → 8000.
- State when you'd use ClusterIP vs NodePort vs LoadBalancer.
- Explain why a Secret is not encrypted by default and what to do about it.
- Use
kubectl get pods -lwith a label selector to filter Pods.
You applied a Deployment and a Service, but curl to the Service returns nothing and kubectl get endpoints web shows no addresses. The Pods are Running. What is the most likely cause?
Show answer
selector does not match the Pods' labels. A Service populates its endpoints from Pods whose labels satisfy its selector; if they don't match, the endpoint list is empty and there's nowhere to route, so requests hang or fail even though the Pods are perfectly healthy. Fix it by making the Service selector (e.g. app: web) exactly match the labels on the Deployment's Pod template. Confirm with kubectl describe svc web and kubectl get pods --show-labels.A teammate wants to expose an internal-only backend API to other services in the cluster and reaches for a LoadBalancer Service. Why is that wrong, and what should they use?
Show answer
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The Deployment+Service pair is the atom of almost every k8s app; writing it from memory is the first real milestone.
Your task: Write a Deployment of 2 replicas of a web image on container port 8080, and a ClusterIP Service that maps port 80 to it.
Requirements:
- The Deployment's selector must match its Pod template labels
- The Service's selector must match the same labels
- Map Service port 80 → targetPort 8080
- Pin the image to a version, never
:latest
💡 Hint: One label (e.g. app: api) is the join key across all three objects.
Show solution
The label app: api ties the Deployment, its Pods, and the Service together:
apiVersion: apps/v1
kind: Deployment
metadata: { name: api, labels: { app: api } }
spec:
replicas: 2
selector: { matchLabels: { app: api } }
template:
metadata: { labels: { app: api } }
spec:
containers:
- name: app
image: ghcr.io/example/api:1.0.0
ports: [ { containerPort: 8080 } ]
---
apiVersion: v1
kind: Service
metadata: { name: api }
spec:
selector: { app: api }
ports: [ { port: 80, targetPort: 8080 } ]Apply with kubectl apply -f, then kubectl get deploy,svc,pods -l app=api. The Service will show endpoints once the 2 Pods are Ready.
Context: Externalizing config is what lets one image run in every environment.
Your task: Add a ConfigMap and a Secret to the app and consume both as environment variables via envFrom.
Requirements:
- ConfigMap holds LOG_LEVEL and a feature flag
- Secret holds an API_KEY using
stringData - The container consumes both with
envFrom - Note why the Secret value must not be committed to git
💡 Hint: envFrom pulls every key in a ConfigMap/Secret in as an env var at once.
Show solution
apiVersion: v1
kind: ConfigMap
metadata: { name: api-config }
data: { LOG_LEVEL: "info", FEATURE_SEARCH: "true" }
---
apiVersion: v1
kind: Secret
metadata: { name: api-secret }
type: Opaque
stringData: { API_KEY: "set-via-CI-or-a-secret-store" }Consume both in the container spec:
envFrom:
- configMapRef: { name: api-config }
- secretRef: { name: api-secret }Why not commit the value: a Secret is only base64-encoded in etcd, not encrypted, so committing the manifest with a real API_KEY leaks it in git history in effectively plaintext. Inject the real value from CI or an external secret store, and keep only a placeholder in the repo.
Context: The most common broken-Service symptom is empty endpoints; diagnosing it fast is a core operating skill.
Your task: A Service returns nothing though its Pods are Running. Give the exact kubectl sequence to diagnose it and the two most likely root causes.
Requirements:
- Check the Service's endpoints
- Compare the Service selector to the Pod labels
- Also consider a port mismatch (targetPort vs containerPort)
- State the fix for each cause
💡 Hint: No endpoints = no Pods matched the selector; wrong port = matched but nothing listening.
Show solution
Diagnose:
kubectl get endpoints web # empty? no Pods matched the selector
kubectl get pods --show-labels # what labels do the Pods actually have?
kubectl describe svc web # selector + target port
kubectl get svc web -o yaml # confirm targetPort matches containerPortCause 1 — selector/label mismatch (empty endpoints). The Service selector doesn't match the Pod labels, so no endpoints are populated. Fix: align the selector with the Pod template labels.
Cause 2 — port mismatch (endpoints present, still fails). Endpoints exist but targetPort points at a port the container isn't listening on. Fix: set targetPort to the real containerPort.
Rule of thumb: no endpoints → label problem; endpoints but connection refused → port problem.
Context: Understanding a rollout as two ReplicaSets scaling in opposite directions demystifies both deploys and rollbacks.
Your task: Explain what objects exist and how their replica counts move when you change a Deployment's image from v1 to v2 with the default rolling strategy, and how rollback works.
Requirements:
- Describe the old and new ReplicaSets during the rollout
- Explain what
maxSurgeandmaxUnavailablecontrol - Explain what
kubectl rollout undoactually does
💡 Hint: A Deployment never edits Pods in place — it shifts them between ReplicaSets.
Show solution
During the rollout, the Deployment holds two ReplicaSets: the old (v1) and a new (v2). It scales the new RS up and the old RS down in steps until v2 = desired and v1 = 0. Pods are never mutated in place — old Pods are deleted, new ones created.
The two knobs bound the disruption: maxSurge = how many extra Pods above desired may exist mid-rollout (speed), maxUnavailable = how many below desired may be missing (safety). With maxUnavailable: 0 you never drop below full capacity — at the cost of needing surge headroom.
Rollback is cheap because the old ReplicaSet still exists (scaled to 0). kubectl rollout undo deploy/web just scales the previous RS back up and the current one down — reversing the arithmetic. That's why rollback is seconds, not a rebuild.
Context: A labeling and namespace convention adopted early is what keeps a growing cluster legible.
Your task: Design a namespace layout and a label schema for a company running several apps across dev/staging/prod, so Services, monitoring, and cost allocation all work off it.
Requirements:
- Decide the namespace boundary (by env, by team, or both)
- Define a minimal required label set every workload must carry
- Show one query each label enables (ops, cost, canary)
- Note one thing namespaces do NOT isolate by default
💡 Hint: Recommended labels cluster around app, component, version, part-of, managed-by.
Show solution
Namespaces: partition by environment × team — e.g. payments-prod, payments-staging, search-prod. This keeps prod blast radius contained and lets you apply per-namespace quotas and RBAC.
Required labels (aligned to the common recommended set): app.kubernetes.io/name, app.kubernetes.io/component, app.kubernetes.io/version, app.kubernetes.io/part-of, app.kubernetes.io/managed-by, plus a team label like owner: payments.
What each unlocks: -l app.kubernetes.io/name=web selects a service for ops; owner=payments drives cost allocation and dashboards; a track=canary label lets a Service or rollout target only canary Pods.
The gotcha: namespaces isolate names and are an RBAC/quota boundary, but they do not isolate network traffic by default — any Pod can reach a Service in another namespace unless you add NetworkPolicies (K4). Don't mistake a namespace for a security wall.
Context: Representative scenario: you're handed a frontend and a backend container and asked to run them together in a staging namespace, with the frontend reachable and the backend internal-only.
Your task: Produce the full manifest set: two Deployments, two Services (one internal, one external-ish), config, and the commands to deploy and verify in a namespace.
Requirements:
- Backend: ClusterIP Service, consumed by the frontend via in-cluster DNS
- Frontend: a Service the outside can reach (LoadBalancer or NodePort for now)
- Config/secret injected, image versions pinned
- Give the deploy + verify commands scoped to the namespace
💡 Hint: The frontend calls the backend at backend.staging.svc.cluster.local.
Show solution
Backend (internal ClusterIP):
apiVersion: apps/v1
kind: Deployment
metadata: { name: backend, labels: { app: backend } }
spec:
replicas: 2
selector: { matchLabels: { app: backend } }
template:
metadata: { labels: { app: backend } }
spec:
containers:
- name: app
image: ghcr.io/example/backend:2.1.0
ports: [ { containerPort: 8000 } ]
envFrom: [ { configMapRef: { name: backend-config } } ]
---
apiVersion: v1
kind: Service
metadata: { name: backend }
spec:
selector: { app: backend }
ports: [ { port: 80, targetPort: 8000 } ] # ClusterIP (default)Frontend reaches the backend by DNS at http://backend.staging.svc.cluster.local and is itself exposed:
apiVersion: apps/v1
kind: Deployment
metadata: { name: frontend, labels: { app: frontend } }
spec:
replicas: 2
selector: { matchLabels: { app: frontend } }
template:
metadata: { labels: { app: frontend } }
spec:
containers:
- name: app
image: ghcr.io/example/frontend:2.1.0
ports: [ { containerPort: 3000 } ]
env:
- name: BACKEND_URL
value: "http://backend.staging.svc.cluster.local"
---
apiVersion: v1
kind: Service
metadata: { name: frontend }
spec:
type: LoadBalancer # externally reachable (or NodePort locally)
selector: { app: frontend }
ports: [ { port: 80, targetPort: 3000 } ]Deploy & verify in the namespace:
kubectl create namespace staging
kubectl apply -n staging -f backend.yaml -f frontend.yaml
kubectl get all -n staging
kubectl get svc frontend -n staging # note EXTERNAL-IP once assignedThe backend stays internal (ClusterIP, no external IP); the frontend gets an external address and reaches the backend over cluster DNS. In K4 you'd replace the frontend LoadBalancer with an Ingress for TLS and host-based routing.