Kubernetes Pods, Deployments, and Services in YAML
You will define a Deployment and a Service in YAML, apply them, then perform a rolling update and rollback. By the end you will manage workloads declaratively, the way real clusters are operated under GitOps.
In the Kubernetes Basics lab you created workloads imperatively with kubectl create. That works for learning, but it leaves no record of what the cluster is supposed to look like. In this lab you switch to the declarative model: you describe the desired state in manifest files, hand them to the API server with kubectl apply, and let the controllers converge the cluster toward that state. This is the workflow used in production, manifests live in Git, changes are reviewed as pull requests, and a CI/CD pipeline (or a GitOps controller such as Argo CD or Flux) applies them.
Concretely, you will deploy a 3-replica nginx workload, front it with a stable ClusterIP Service, push a new image version through a zero-downtime rolling update, and then roll the release back in a single command.
Learning Objectives
- Write Deployment and Service manifests.
- Perform a zero-downtime rolling update.
- Roll back a bad release in one command.
- Verify Service endpoints and understand how label selectors wire everything together.
- Time: ~3 hours · Difficulty: Intermediate · Prereqs: a running cluster from the basics lab.
Architecture Overview
Three object types cooperate here, and it is worth being precise about the division of labour:
- Deployment, the object you manage. It declares the desired replica count, the pod template, and the update strategy. It never runs containers itself.
- ReplicaSet, created and owned by the Deployment. Its only job is to keep N identical pods running. During a rolling update you briefly have two ReplicaSets: the old one scaling down and the new one scaling up.
- Pod, the actual running unit, one or more containers sharing a network namespace. Pods are ephemeral and get new IPs every time they are recreated, which is exactly why you never address them directly.
- Service, a stable virtual IP (ClusterIP) and DNS name that load-balances across every pod whose labels match its
selector. Because it matches on labels, pods can be killed and recreated freely and the Service keeps working.
The glue in the whole system is the label app: web: the Deployment selector matches pod template labels, and the Service selector matches the same labels on the running pods. If any of those three label sets drift apart, the workload either becomes unmanaged or loses its endpoints, this is the single most common mistake in this lab.
Notice what is deliberately absent from this design: you never create a bare Pod manifest for a real workload. A standalone pod is scheduled exactly once, if its node dies or the pod is evicted, nothing recreates it. The Deployment/ReplicaSet layering exists to make pods disposable: any pod can be deleted at any moment and the controller replaces it from the template, which is what the self-healing check in the validation section demonstrates.
On the traffic side, the Service object itself is only a control-plane record. The actual packet forwarding is done by kube-proxy on each node, which programs iptables/IPVS rules mapping the ClusterIP to the live endpoint IPs. That is why kubectl get endpoints is such a useful debugging view: it shows exactly which backends kube-proxy will pick from.
Environment Setup
Prerequisites
- A running cluster (kind or AKS) with
kubectlconfigured. - A container image you can version (any registry you can push to, or a public image with multiple tags).
- Assumed skills: YAML indentation and Kubernetes basics (pods, ReplicaSets).
You will need: a running cluster and kubectl context set.
Before you begin: keep manifests in a directory so you can version them in Git.
Confirm kubectl is talking to the cluster you expect before touching anything. Applying a manifest against the wrong context is a classic way to deploy a test workload into a shared cluster.
kubectl config current-context && kubectl cluster-infoConfirm at least one node is Ready to accept pods before you deploy anything:
If the cluster from the basics lab is gone (kind clusters do not survive a Docker restart), recreate it with kind create cluster --name lab and re-check the two commands above.
Create a working directory for the manifests and, if you plan to commit them, initialise it as a Git repository now rather than later, the whole point of this lab is that the YAML is the source of truth.
mkdir -p k8s-lab/manifests && cd k8s-lab/manifests && git initEverything in this lab runs in the default namespace to keep the commands short. On a real cluster you would create a dedicated namespace per app or team and scope your work with -n or kubectl config set-context --current --namespace=....
Step-by-Step Execution
The manifest below declares both objects in one file, separated by --- (a standard multi-document YAML separator). Save it as web.yaml. A Deployment manifest has four parts worth understanding line by line:
apiVersion: apps/v1andkind: Deployment, which API group and object type you are declaring. Deployments live in theappsgroup, not the corev1group.spec.replicas: 3, the desired state. The ReplicaSet controller will create pods until three are running, and recreate any that die.spec.selector.matchLabels, how the Deployment knows which pods belong to it. This must matchspec.template.metadata.labelsexactly, or the API server rejects the manifest.spec.template, the pod blueprint. Every pod the ReplicaSet creates is stamped from this template, including the pinned imagenginx:1.25.
# web.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: web }
spec:
replicas: 3
selector: { matchLabels: { app: web } }
template:
metadata: { labels: { app: web } }
spec:
containers:
- name: web
image: nginx:1.25
---
apiVersion: v1
kind: Service
metadata: { name: web }
spec:
selector: { app: web }
ports: [{ port: 80 }]
Expected output:
$ kubectl apply --dry-run=client -f web.yaml
deployment.apps/web created (dry run)
service/web created (dry run)
The Service is deliberately minimal: selector: { app: web } tells it to send traffic to every pod carrying that label, and port: 80 is the port the Service itself listens on. Because no type is given, it defaults to ClusterIP, reachable only from inside the cluster. No targetPort is set either, so traffic is forwarded to the same port number (80) on the pods, which is where nginx listens by default.
Common mistake: writing the Service selector as app: web-svc or the pod label as app: frontend "to be descriptive". The objects are wired together purely by these strings, they must match character for character. If you are ever unsure of a field, kubectl explain deployment.spec.template prints the API documentation for any field path.
Finally, validate the file before it ever touches the cluster. A client-side dry run catches schema errors (bad indentation, a wrong apiVersion) instantly, and once the objects exist, kubectl diff previews exactly what a future apply would change:
kubectl apply -f web.yaml --dry-run=clientWhat this teaches: The manifest is the application as data; Deployment plus Service is the smallest complete shippable unit.
kubectl apply is declarative: it diffs your file against the live state and makes only the changes needed. Re-running it with an unchanged file is a no-op, which is why it, not kubectl create, is the command you script and commit to CI. Follow it immediately with rollout status, which blocks until all replicas are updated and ready, so your terminal prompt returning means the deployment actually succeeded.
kubectl apply -f web.yaml && kubectl rollout status deploy/webNow verify what was actually created. You should see three running pods (each with a random suffix, the first chunk is the ReplicaSet hash, the second the pod hash) and a Service with a cluster-internal IP:
The endpoints output is the proof that the label wiring works: the Service's virtual IP 10.96.143.21 is backed by exactly the three pod IPs from the first command. If the endpoints list were <none>, the selector does not match any pod labels.
The Deployment's own status row is the compact summary operators watch:
UP-TO-DATE counts pods running the latest template revision; AVAILABLE counts pods passing readiness (with no probes defined, that simply means "container started"). During a rollout these two columns briefly disagree, that gap is the update in progress.
How to verify: READY 1/1 on all three pods, the rollout status line "successfully rolled out", and three IPs in the endpoints list. All three must be true before moving on.
Expected output:
$ kubectl apply -f web.yaml
deployment.apps/web created
service/web created
What this teaches: Apply is the only deployment verb you need; it creates or updates toward the declared state.
Now simulate shipping a new release: bump nginx from 1.25 to 1.27. kubectl set image patches the Deployment's pod template (the container name before the = must match the name: web in your manifest). Changing the template triggers the Deployment's default update strategy, RollingUpdate: it creates a new ReplicaSet for nginx:1.27, scales it up one pod at a time, and only scales an old pod down once a new one is Ready. With the defaults maxSurge: 25% and maxUnavailable: 25%, the Service always has healthy pods behind it, that is what makes the update zero-downtime for containers that report ready promptly. A readiness probe is what lets Kubernetes hold traffic until the new container actually serves, add one before relying on this in production.
To watch the swap happen in real time, open a second terminal before running the update and stream the pod list:
kubectl get pods -l app=web -wCatch the rollout mid-flight and the ReplicaSet view tells the whole story, the old ReplicaSet scaling down while the new one scales up:
Each ReplicaSet name embeds a hash of its pod template, which is how the Deployment tells revisions apart. When the rollout completes, the old ReplicaSet sits at 0 replicas but is kept around, it becomes your rollback target in step 04.
Every template change is recorded as a numbered revision. This history is what makes the next step possible:
Common mistake: the CHANGE-CAUSE column shows <none> because nothing recorded why the change happened. On real projects annotate each release so the history is self-documenting: kubectl annotate deploy/web kubernetes.io/change-cause="bump nginx 1.25 to 1.27". Also note the image tag is explicit (1.27, not latest), with latest the pod template hash does not change, so no rollout is triggered at all.
How to verify: kubectl get deploy web -o jsonpath='{.spec.template.spec.containers[0].image}' should print nginx:1.27, and kubectl get rs should show two ReplicaSets, the new one at 3 replicas, the old one scaled to 0 but retained for rollback.
Expected output:
$ kubectl set image deploy/web web=nginx:1.27
deployment.apps/web image updated
$ kubectl rollout status deploy/web
deployment "web" successfully rolled out
What this teaches: Rolling out a new image is changing one field and letting the controllers converge; watch the ReplicaSets trade places.
Pretend the 1.27 release introduced a regression and you need to revert now. Because the old ReplicaSet was scaled to zero rather than deleted, a rollback is just a pointer flip: the Deployment scales the previous ReplicaSet back up and the new one down. It is the same rolling mechanism in reverse, so it is also zero-downtime under the same readiness conditions.
kubectl rollout undo deploy/webThat reverts to the immediately previous revision. To jump to a specific older revision, pick its number from the history:
kubectl rollout undo deploy/web --to-revision=1One subtlety: a rollback does not erase history, it creates a new revision whose template is a copy of the one you reverted to. After the undo, kubectl rollout history shows revision 3 with the nginx:1.25 template, revisions only ever move forward.
Revision 1 disappeared from the list because its template now lives at revision 3, Kubernetes renumbers the newest copy rather than resurrecting the old slot. The change-cause annotation habit from step 03 is what keeps this list meaningful on a real project.
How to verify: the image is back to nginx:1.25, all pods show Running, and kubectl get endpoints web still lists three healthy IPs. In a real incident you would then curl the Service (next section) to confirm the app itself, not just the pods, is healthy.
Expected output:
$ kubectl rollout undo deploy/web
deployment.apps/web rolled back
What this teaches: Rollback scales the previous ReplicaSet back up; it is fast because the old pods' definition never left etcd.
Step Flow Map
Each box is one step in the lab; work them in order, since later steps build on earlier state.
Progress So Far
How It Works
A Deployment is a declaration of desired state for stateless pods: the image, the replica count, and the update strategy. The Deployment controller does not manage pods directly; it manages a ReplicaSet, which maintains the count. This indirection is what makes rolling updates possible: the new ReplicaSet scales up while the old scales down, and both exist simultaneously mid-rollout.
The rolling update strategy is governed by two knobs: maxUnavailable (how many pods may be down during the update) and maxSurge (how many extra pods may exist). The defaults (25 percent each) mean a rolling update never takes the service fully down and never more than doubles capacity. Changing these trades speed against safety, which is a deliberate operational decision, not a default to accept blindly.
Every rollout writes a new revision into the ReplicaSet history stored in etcd. Rollback is not undoing the YAML; it is scaling the previous ReplicaSet back up, which is why it is fast and reliable. The revision history is bounded (default 10), so old rollback targets eventually age out; annotate significant rollouts so future you knows what each revision was.
Testing & Validation
Passing rollout status proves Kubernetes reached the desired state, but it does not prove the application answers traffic. Validate end to end in three parts.
1. Confirm the object state. The history should show the revisions you created, and the Service should have a stable ClusterIP:
kubectl rollout history deploy/web && kubectl get svc webYou should see multiple revisions and a Service with a ClusterIP. If so, you can deploy, update, and undo declaratively.
2. Prove the Service actually routes traffic. A ClusterIP exists only inside the cluster network, so test it from a throwaway pod, not from your laptop:
Two things are being validated at once here: cluster DNS resolves the bare name web to the Service's ClusterIP, and kube-proxy routes that IP to one of the healthy pods. The --rm flag deletes the test pod automatically when the command finishes, so nothing is left behind. --command overrides the image entrypoint so the trailing curl invocation runs as the container command instead of being appended to it, and -- separates kubectl flags from that command.
3. Prove the Service follows scaling. Scale to five replicas and watch the endpoints list grow to match, this demonstrates that the Service tracks labels, not fixed pod IPs:
Scale back to 3 when done (kubectl scale deploy/web --replicas=3) so the live state matches the manifest in Git, drift between the file and the cluster is exactly what declarative management is supposed to prevent.
Validation is complete. Tear the lab workloads down last, nothing validates after this point by design:
kubectl delete -f web.yamlExpected output:
$ kubectl delete -f web.yaml
deployment.apps "web" deleted
service "web" deleted
Troubleshooting
Quick-reference for the failures you are most likely to hit in this lab.
| Symptom | Likely cause | Fix |
|---|---|---|
kubectl apply rejects the manifest | YAML indentation or a wrong API version | Validate with --dry-run=client; the error names the exact line. |
| Pods stuck in CrashLoopBackOff | The new image exits immediately | Read the logs of the previous container; the rollout is fine, the image is broken. |
| Rollout hangs mid-update | Readiness probe never passes on the new pods | Describe a new pod; the events show which probe fails and why. |
| Service sends no traffic | Selector does not match the pod labels | Compare the service selector against pod labels; one typo breaks it all. |
| Rollback went to the wrong revision | Revisions counted without checking | Read rollout history first; the numbers only mean something with the annotations. |
Troubleshooting
- Rollout stuck: a new pod is crashing; inspect with
kubectl describeandlogs. Runkubectl describe pod -l app=weband read theEventssection at the bottom, it names the failing container and reason. For application-level errors,kubectl logs deploy/web --tail=50. A rollout stuck inCrashLoopBackOffnever goes unhealthy on the old pods, which is safe but means the new release is not live; fix the image/config and re-apply, orkubectl rollout undo. - Service has no endpoints: the selector labels must match the pod labels exactly. Confirm with
kubectl get endpoints web, if it shows<none>, comparekubectl get svc web -o jsonpath='{.spec.selector}'againstkubectl get pods --show-labels. A single-character difference means zero matches. Fix the manifest and re-apply; do not patch pod labels by hand. - Image not updating: use a specific tag, not
latest, so changes are detected. Re-tagging an image underlatestchanges nothing in the pod template, so the Deployment sees no diff and rolls nothing. Pin a real version tag, or force a restart withkubectl rollout restart deploy/webwhen you genuinely want to re-pull the same tag. - curl to the ClusterIP times out from your laptop: that is expected, not a bug. ClusterIP addresses exist only on the cluster's internal virtual network. Test from a pod (as in the validation step) or bridge your laptop in temporarily with
kubectl port-forward svc/web 8080:80and curlhttp://localhost:8080. To expose the app properly, use a NodePort/LoadBalancer Service or an Ingress (covered in the networking lab). - Pods stuck in Pending: the scheduler cannot place them.
kubectl describe podshows an event likeInsufficient cpuor a taint/toleration mismatch. On a small local cluster, reducereplicasor add resource requests small enough to fit; the Deployment is fine, the cluster is just full.
Extension Ideas
- Expose externally and add ingress in Kubernetes Networking.
- Add readiness/liveness probes and resource limits. A
readinessProbeon port 80 would make the rolling update genuinely safe, today Kubernetes considers a pod ready the moment the container starts, so a slow-booting app could still drop requests mid-rollout. - Force a template change without touching the image: set an environment variable or annotation with
kubectl patch, watch the new rollout, then find it in the history. - Manage manifests with Kustomize or Helm under GitOps, put
web.yamlin a repo and let Argo CD reconcile it, then try editing the cluster by hand and watch GitOps revert your change.
Key Takeaways
- Declarative beats imperative: the YAML manifest is the source of truth,
kubectl applyconverges the cluster to it, and the file belongs in Git. - A Deployment never runs pods directly, it owns ReplicaSets, which own pods. Understanding that chain explains where rollout history and rollback capacity come from.
- Rolling updates are safe by default because of
maxSurge/maxUnavailable: new pods must become Ready before old ones are removed. - Services select pods by label, not by IP, so they survive pod churn, rollouts, and scaling without any reconfiguration.
rollout status,rollout history, androllout undoare the everyday lifecycle trio, know them before touching a production release.- ClusterIP is internal-only; reaching it from outside requires port-forward for debugging or a NodePort/LoadBalancer/Ingress for real exposure.
Key Results
- Deployed a 3-replica workload from declarative YAML.
- Performed a zero-downtime rolling update.
- Rolled back a release in a single command.
- Exposed pods through a stable Service ClusterIP.
- Verified routing end to end with an in-cluster curl and confirmed endpoints track scaling.
Practice Challenges
Challenge 1: Feel the Recreate strategy
Goal: Switch the strategy to Recreate, roll out, and observe the downtime window.
Hint: The outage is the lesson; this is why RollingUpdate is the default.
Challenge 2: Break the probes
Goal: Add readiness and liveness probes, then deploy an image that fails them, and watch the rollout stall.
Hint: The stalled rollout protecting the old version is the safety mechanism working.
Challenge 3: Manifest vs imperative scale
Goal: Scale with kubectl scale, then diff against the manifest to see the drift.
Hint: The manifest still says 2 while reality says 5; decide which one is the source of truth.