Capstone Part 2 — Workloads & Config
Part 1 gave you a running cluster and one empty namespace — a foundation with nothing standing on it yet. This part puts something on it: orbit-api, a small satellite-tracking HTTP service, deployed as a real Kubernetes Deployment with its configuration and its one secret split cleanly out of the container image, three probes watching whether it's alive and whether it's ready for traffic, and resource requests and limits that decide how the scheduler places it and how the kubelet treats it under pressure. By the end of this page you will have watched, with your own eyes, a Pod come back on its own after you kill it, and a bad rollout get caught and rolled back before it ever looked "done."
Imagine a toy factory that always keeps exactly two robots on the assembly line, no matter what. Each robot reads its job instructions off a shared clipboard (that's the ConfigMap) instead of having them welded into its body, and it keeps its one secret keycard in a locked pouch nobody can read over its shoulder (that's the Secret). A supervisor checks on each robot three ways: is it done warming up yet, is it currently able to do good work right now, and is it even still alive. A robot that's just tired gets pulled off the line for a breather without being scrapped. A robot that's actually broken gets swapped for a fresh one immediately — the factory never notices, because there are always exactly two working. And before any robot is even switched on, the factory decides exactly how much floor space and power it's allowed to use, so one greedy robot can never starve the other one next to it.
orbit-api's requests and limits before the scheduler is ever asked to place it. Both of them taught the concepts here first, in The Object Model and Workloads & Scheduling — today they're the ones actually running the manifests.Starting: the capstone Kind cluster and the empty capstone namespace from Part 1 — kubectl get ns capstone resolves, nothing is deployed into it yet. Leaving this page: orbit-api running as a two-replica Deployment in that namespace, its non-secret settings in a ConfigMap and its one API token in a Secret, three probes (startup, readiness, liveness) gating its traffic and its restarts, resource requests and limits set deliberately rather than guessed, and firsthand proof that killing a Pod triggers a real restart and that a broken image gets caught by a rollout instead of quietly going live. Part 3 picks up exactly here and gives these Pods a stable address other things in the cluster can actually reach.
What this part assumes, and what it produces
☺ Like you're 10: A cluster and an empty shelf from last time — nothing built on it yet.
This part assumes Part 1 left you with a local Kind cluster named capstone (kind create cluster --name capstone) and a capstone namespace already created inside it, with kubectl pointed at that context by default. Confirm both before you touch anything below:
kubectl config current-context # expect: kind-capstone
kubectl get ns capstone # expect: ActiveIf either comes back empty, go finish Capstone Part 1 — Cluster Foundation first — everything from here on assumes both already exist. Six parts share one running project, so it's worth naming the shape once, here, so nothing surprises you later:
| Thing | Name / value | Introduced |
|---|---|---|
| Cluster | Kind cluster capstone (context kind-capstone) | Part 1 |
| Namespace | capstone | Part 1 |
| The application | orbit-api — a small satellite-tracking HTTP service | Part 2 — this page |
| Config source | ConfigMap orbit-api-config | Part 2 |
| Secret source | Secret orbit-api-secrets (key API_TOKEN) | Part 2 |
| Workload object | Deployment orbit-api, 2 replicas, Burstable QoS | Part 2 |
| Network address | not yet named | Part 3 |
| Persistent state | not yet added | Part 4 |
| Access control | default service account, no RBAC narrowing yet | Part 5 |
Notice what's deliberately not here yet: no Service, no Ingress. This part only reaches orbit-api through kubectl port-forward — giving it a real, stable network identity other things in the cluster can find is Part 3's job, not this one's.
Building orbit-api and getting its image into the cluster
☺ Like you're 10: A tiny web service, baked into a container, then carried straight into the cluster's own toy box.
orbit-api is deliberately small — an Express service with a health surface, one list route, one write route guarded by a token, and nothing else. Small is the point: this exact app is what the rest of the capstone builds around. Create package.json and src/index.js:
{
"name": "orbit-api",
"version": "0.1.0",
"private": true,
"scripts": { "start": "node src/index.js" },
"dependencies": { "express": "^4.19.2" }
}// src/index.js
const express = require("express");
const { randomUUID } = require("node:crypto");
const app = express();
app.use(express.json());
const region = process.env.REGION || "unset";
const logLevel = process.env.LOG_LEVEL || "info";
const apiToken = process.env.API_TOKEN || "";
const satellites = new Map();
let ready = true; // flipped by /debug/ready to demo the readiness probe below
function log(...args) { if (logLevel === "debug") console.log(...args); }
app.get("/healthz/startup", (_req, res) => res.status(200).json({ status: "started", region }));
app.get("/healthz/live", (_req, res) => res.status(200).json({ status: "alive" }));
app.get("/healthz/ready", (_req, res) => {
if (!ready) return res.status(503).json({ status: "draining" });
res.status(200).json({ status: "ready", region });
});
app.post("/debug/ready", (req, res) => {
ready = req.body?.ready !== false;
log("readiness set to", ready);
res.status(200).json({ ready });
});
app.get("/satellites", (_req, res) => res.status(200).json([...satellites.values()]));
app.post("/satellites", (req, res) => {
if (req.get("x-api-token") !== apiToken) return res.status(401).json({ error: "bad or missing x-api-token" });
const { name, altitudeKm } = req.body || {};
if (!name || typeof altitudeKm !== "number") {
return res.status(400).json({ error: "name and altitudeKm are required" });
}
const id = randomUUID();
const sat = { id, name, altitudeKm, region };
satellites.set(id, sat);
res.status(201).json(sat);
});
module.exports = { app };
if (require.main === module) {
const port = process.env.PORT || 8080;
app.listen(port, () => console.log(`orbit-api listening on :${port}, region=${region}`));
}# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 8080
CMD ["node", "src/index.js"]Build the image, then get it into the cluster the way Kind requires — a plain docker build only populates your host's own Docker cache, never any node's containerd store, so a Pod that references this image with no registry behind it will sit in ImagePullBackOff until you push it in explicitly:
docker build -t orbit-api:v1 .
kind load docker-image orbit-api:v1 --name capstoneBecause orbit-api:v1 now lives only inside the cluster's own node images, not in any registry, the Deployment below sets imagePullPolicy: Never — leaving the default Always would have the kubelet try, and fail, to pull this exact tag from somewhere that's never heard of it.
Separating config from image: the ConfigMap
☺ Like you're 10: The instruction sheet lives on a shared clipboard, not welded into the robot.
Workloads & Scheduling already covers what a ConfigMap is for at the CKA level: non-secret settings that change per environment without ever touching the image. orbit-api has three — its log verbosity, which geographic region it reports itself as, and a soft cap it will use later. None of these belong baked into orbit-api:v1; the exact same image should be able to run in any region, at any log level, without a rebuild.
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: orbit-api-config
namespace: capstone
data:
LOG_LEVEL: "info"
REGION: "ap-south-1"
MAX_SATELLITES: "500"kubectl apply -f configmap.yaml
kubectl get configmap orbit-api-config -n capstone -o yamlKeeping the token out of the image: the Secret
☺ Like you're 10: The keycard goes in a locked pouch — never taped to the outside of the robot.
orbit-api guards POST /satellites behind a single bearer-style token, read from API_TOKEN. That value goes in a Secret, not the ConfigMap above and never as a literal in the Deployment spec — a Secret is base64-encoded at rest in etcd rather than plaintext, RBAC can restrict who's even allowed to get Secret objects independently of who can read ConfigMaps, and kubectl quietly redacts a Secret's data from a plain get the way it never does for a ConfigMap. Generate it imperatively rather than hand-typing base64, so the plaintext token never sits in a YAML file you might accidentally commit:
kubectl create secret generic orbit-api-secrets \
-n capstone \
--from-literal=API_TOKEN="$(openssl rand -hex 24)"kubectl get secret orbit-api-secrets -n capstone -o jsonpath='{.data.API_TOKEN}' | base64 -d
# save this value somewhere local — you'll need it in the milestones below to call POST /satellitesAnyone with get on this Secret object, or read access to the etcd data underneath it, can decode API_TOKEN in one base64 -d — the same command used above. That gap is exactly what Part 5 closes with RBAC narrowed to only the identities that actually need it, and Security: Defense in Depth covers encryption-at-rest for etcd itself if you want the mechanism behind the gap.
Wiring the Deployment: env, probes, and resource requests/limits
☺ Like you're 10: Two robots, always — reading the clipboard, guarding the keycard, and being checked three different ways before anyone trusts them.
This is the object that ties everything above together. envFrom pulls every key in orbit-api-config straight in as environment variables; API_TOKEN is pulled individually from the Secret by name, never inlined. Three probes reuse the exact routes orbit-api already exposes, in the same shape Workloads & Scheduling teaches at CKA depth. And the resource block is Sol's: a request the scheduler's bin-packing math can trust, and a limit the kernel will actually enforce.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: orbit-api
namespace: capstone
labels: { app: orbit-api, part-of: capstone }
spec:
replicas: 2
selector:
matchLabels: { app: orbit-api }
template:
metadata:
labels: { app: orbit-api, part-of: capstone }
spec:
containers:
- name: orbit-api
image: orbit-api:v1
imagePullPolicy: Never # image was kind-loaded, not pulled from a registry
ports:
- containerPort: 8080
envFrom:
- configMapRef: { name: orbit-api-config }
env:
- name: API_TOKEN
valueFrom:
secretKeyRef: { name: orbit-api-secrets, key: API_TOKEN }
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "250m"
memory: "256Mi"
startupProbe: # gates the other two until orbit-api is actually up
httpGet: { path: /healthz/startup, port: 8080 }
failureThreshold: 30
periodSeconds: 2 # up to 60s to start before liveness can kill it
readinessProbe:
httpGet: { path: /healthz/ready, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz/live, port: 8080 }
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3kubectl apply -f deployment.yaml
kubectl rollout status deployment/orbit-api -n capstone
kubectl get pods -n capstone -l app=orbit-api -o wideThree probes, three different consequences — this is the single most reliably tested distinction in the entire domain, and it's worth being precise about it once, here, before you rely on it. A failing readiness probe never kills anything; it just marks the Pod not-ready, which (once Part 3 adds a Service) means traffic stops arriving while the Pod keeps running and can recover on its own. A failing liveness probe gets the container killed and restarted by the kubelet, subject to restartPolicy (Always, the Deployment-relevant default). The startup probe exists so a legitimately slow boot can never be mistaken for a liveness failure and killed mid-start: while it's still failing, the other two aren't evaluated at all.
Proving it self-heals: killing a Pod on purpose
☺ Like you're 10: Knock a robot over and watch the factory swap it for a fresh one without missing a beat.
The Deployment already declares "two replicas, always" — that's a standing order to the controller, not a one-time action. Prove it by killing one directly, at the lowest level a Pod can die, and watching Kubernetes restore the declared state without you telling it to:
kubectl get pods -n capstone -l app=orbit-api
POD=$(kubectl get pods -n capstone -l app=orbit-api -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n capstone "$POD" -- kill 1 # kill the container's own pid 1
kubectl get pods -n capstone -l app=orbit-api -w # watch RESTARTS climb to 1, STATUS back to RunningThat restart came from the kubelet honoring restartPolicy: Always on this exact Pod — same identity, new process — because kill 1 only killed the container, not the Pod object. Now prove the other half: a readiness failure that never kills anything at all. Port-forward to reach orbit-api directly (no Service exists yet, by design — see the assumptions above), flip its readiness flag off, and watch the Pod stay Running the entire time while its readiness column goes to 0/1:
kubectl port-forward -n capstone deploy/orbit-api 8080:8080 &
curl -s -X POST localhost:8080/debug/ready -H 'content-type: application/json' -d '{"ready":false}'
kubectl get pods -n capstone -l app=orbit-api # STATUS stays Running, READY drops to 0/1
kubectl describe pod -n capstone "$POD" | grep -A3 Readiness
curl -s -X POST localhost:8080/debug/ready -H 'content-type: application/json' -d '{"ready":true}'
kubectl get pods -n capstone -l app=orbit-api # READY climbs back to 1/1 once the next probe firesNothing above involved a human deciding anything in the moment. The controller behind the Deployment is exactly the watch-diff-act reconciliation loop The Object Model covers: it doesn't know you ran kill 1 — it only ever compares desired replicas (2) against actual Ready Pods, and closes any gap it finds, on a loop, forever. Self-healing isn't a feature bolted onto Kubernetes. It's what reconciliation looks like from the outside.
Proving it catches a bad rollout: shipping a broken image on purpose
☺ Like you're 10: Send out a broken robot and watch the factory refuse to let it take over the whole line.
Build a second image where /healthz/ready is deliberately broken — comment out its handler, or make it always return res.status(500).end() — tag it :v2-broken, load it in, and roll it out the ordinary way:
docker build -t orbit-api:v2-broken .
kind load docker-image orbit-api:v2-broken --name capstone
kubectl set image deployment/orbit-api orbit-api=orbit-api:v2-broken -n capstone
kubectl rollout status deployment/orbit-api -n capstone --timeout=60s
# ...never reports success — new Pods can't pass readiness, so the rollout hangsBy default a Deployment's RollingUpdate strategy only replaces old, healthy Pods with new ones once the new ones report Ready — so a broken v2 Pod simply never gets counted, the rollout stalls with old Pods still serving, and nothing about your working service actually goes down. Confirm, then roll back to the last known-good revision:
kubectl get pods -n capstone -l app=orbit-api # some Pods still on v1, still Ready
kubectl rollout history deployment/orbit-api -n capstone
kubectl rollout undo deployment/orbit-api -n capstone
kubectl rollout status deployment/orbit-api -n capstoneThat's the readiness probe from earlier doing a second job: not just routing traffic away from a struggling Pod, but acting as the rollout's own safety gate, stopping a bad revision from ever reaching full replacement in the first place.
What your resource numbers actually bought: QoS class
☺ Like you're 10: How much floor space and power a robot is promised, versus how much it's ever allowed to grab.
orbit-api's request (100m CPU / 128Mi memory) is lower than its limit (250m CPU / 256Mi memory) — that gap places it in the Burstable QoS class, one of three Kubernetes assigns automatically from nothing but these two numbers, covered mechanically in Scheduling & Resource Management. Check it directly:
kubectl get pod -n capstone -l app=orbit-api -o jsonpath='{.items[0].status.qosClass}'
# BurstableCopy deployment.yaml, set requests.cpu/requests.memory equal to the limits above for every value, and re-apply. Re-run the jsonpath command — the class changes to Guaranteed with nothing else about the manifest touched. Then run kubectl describe node (the node under your control-plane container) and look at "Allocated resources": that section is the scheduler's own bin-packing math becoming visible, reading exactly the request numbers you just edited.
Benny the Beaver: Two replicas declared, two replicas running — even after I killed one on purpose. The controller didn't ask permission, it just closed the gap.
Gizmo the Gremlin: Cute. Faster fix though — skip the Secret, just hardcode API_TOKEN straight into src/index.js. One less YAML file, one less thing to apply. 🤑
Timmy the Turtle: Absolutely not. That token would sit in the image's layer history forever — anyone who ever pulls orbit-api:v1 reads it in plaintext, no cluster access required at all. The Secret exists so a credential can rotate without a rebuild, and so RBAC can gate it separately from the image.
Sol the Sloth: While we're being careful — I did the arithmetic on the request and limit before anyone applied anything. 100 millicores isn't a round guess, it's what orbit-api actually used under a light load test. Guessing low starves it; guessing high wastes the node.
Foxy: And the broken rollout? It just... sat there. No outage, no page.
Recon the Robot: Because I never counted v2-broken as available. Readiness said no, so I never touched the Pods that were still working. That's the whole rollout contract in one sentence.
What "done" looks like for Part 2, and where Part 3 picks up
☺ Like you're 10: Two robots, correctly configured, correctly guarded, correctly checked three ways — and no address yet for anyone else to find them by.
At the end of this part you have: orbit-api running as a two-replica Deployment in the capstone namespace, its settings pulled from a ConfigMap and its one token pulled from a Secret, three probes gating both traffic and restarts, deliberate resource requests and limits placing it in the Burstable QoS class, and firsthand proof — not a claim — that killing a Pod triggers a real restart and a broken rollout gets caught before it replaces anything healthy. Nothing here gets thrown away:
| Part | What it does with Part 2's artifacts |
|---|---|
| 3 — Networking & Ingress | Gives these exact Pods a stable Service address and an Ingress route, using the app: orbit-api label already on them |
| 4 — Storage & Stateful Apps | Adds a persistent store the in-memory satellites map above doesn't survive a restart without |
| 5 — Security & RBAC | Narrows who's allowed to read orbit-api-secrets and locks the Deployment's service account down to exactly what it needs |
This entire arc — declarative spec, controller reconciliation, probes, resource-driven scheduling — is also literally CKA Domain 2 muscle memory; see Workloads & Scheduling for the exam-scoped version of everything on this page, and the Platform Engineering on Kubernetes deep dive (or PE's own Kubernetes as substrate page) for how this same object model looks from underneath a golden path.
Milestones
☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."
Work these in order — each depends on the cluster and namespace state from Part 1 and the objects created earlier on this page. Progress saves in this browser.
kubectl config current-context and kubectl get ns capstone.kind-capstone and the namespace shows Active.orbit-api: health routes, /satellites, and the Dockerfilepackage.json, src/index.js, and the Dockerfile exactly as shown above.npm start boots locally and curl localhost:8080/healthz/live returns {"status":"alive"}.capstone Kind clusterdocker build -t orbit-api:v1 ., then kind load docker-image orbit-api:v1 --name capstone.kubectl apply -f configmap.yaml exactly as shown above.kubectl get configmap orbit-api-config -n capstone shows all three keys.kubectl create secret generic command above, then decode and record API_TOKEN.kubectl get secret orbit-api-secrets -n capstone exists and you have the plaintext token saved somewhere local (not in git).kubectl apply -f deployment.yaml exactly as shown above.kubectl rollout status deployment/orbit-api -n capstone reports success.kubectl port-forward -n capstone deploy/orbit-api 8080:8080, then a POST /satellites with a wrong token and with your real one.401 and the real one returns 201.kubectl exec -n capstone $POD -- kill 1, then watch with kubectl get pods -n capstone -l app=orbit-api -w.RESTARTS increments to 1 and STATUS returns to Running on the same Pod name./debug/ready and confirm the Pod stays Runningcurl -X POST localhost:8080/debug/ready calls shown above, checking kubectl get pods between them.READY drops to 0/1 without STATUS ever leaving Running, then recovers to 1/1.v2 image and watch the rollout stall, then roll backorbit-api:v2-broken, kubectl set image, confirm the stall, then kubectl rollout undo.v2-broken, and rollout undo returns the Deployment fully to v1 and Ready.jsonpath qosClass command, then do the Try It above (requests = limits, re-apply, re-check).Burstable and Guaranteed reported for the same Pod, from your own two manifests.orbit-api at 2/2 Ready on v1, ConfigMap and Secret both applied, no Service or Ingress created yet."People treat resources: as a formality — round the average up a bit, ship it, move on. I don't, because I've watched the exact opposite mistake happen on a real node: a Pod with no request at all, BestEffort, got evicted mid-incident while a neighboring Pod that had asked for almost nothing else on the node sailed through untouched. The number I write in requests.cpu isn't advice to the scheduler. It's the number the kernel itself keeps, and consults, long after I've moved on to writing the next manifest."
1. Why does the Deployment above use envFrom for the ConfigMap but a named secretKeyRef for the Secret instead of also using envFrom? 2. A liveness probe fails once, before failureThreshold is reached. What happens to the Pod, and what would have to be different for a readiness probe failing once to have the same effect? 3. Why couldn't orbit-api:v2-broken take over the Deployment even after kubectl set image ran successfully? 4. What specifically changes about a Pod's status.qosClass when its request equals its limit for every container, and why does that matter under real memory pressure? 5. Why is kubectl create secret generic --from-literal safer here than writing the token straight into a Secret YAML file and applying that?
Check your answers
- Nothing structurally forces this —
envFromworks for Secrets too — but namingAPI_TOKENexplicitly viasecretKeyRefmakes the one sensitive value visible by inspection in the manifest, rather than merged invisibly alongside every ConfigMap key under a singleenvFromblock. It's a readability and audit choice, not a functional requirement. - Nothing happens to the Pod on one failure — the kubelet only acts once
failureThresholdconsecutive failures accumulate (3, for this liveness probe). A readiness probe never has this "kill" effect regardless of threshold; failing readiness only ever marks the Pod not-ready, never restarts it. - Because the default
RollingUpdatestrategy only counts a new Pod as replacing an old one once the new Pod passes its readiness probe.orbit-api:v2-broken's/healthz/readyalways fails, so its Pods never register as Ready, the rollout can't advance past them, and the still-healthyv1Pods are never torn down. - Its QoS class becomes
Guaranteed. Under real memory pressure, the kubelet's eviction order and the kernel'soom_score_adjboth treat Guaranteed Pods as last in line — effectively protected unless nothing else is left to reclaim, versus Burstable or BestEffort Pods that get evicted first. - The plaintext token never has to exist in a file on disk at all — it's generated inline by
$(openssl rand -hex 24)and sent directly to the API server. A YAML file with the token embedded (even base64-encoded) is a file that can be accidentally committed, diffed into a pull request, or left in shell history as a saved script, all of which the imperative command sidesteps.
Part 2 turned an empty namespace into a real, self-healing workload — configured without a rebuild, guarded without a hardcoded secret, and checked three separate ways before anything trusts it. Continue to Capstone Part 3 — Networking & Ingress, where these exact Pods finally get an address. Or step back to Build a Cluster — Start Here to see how this capstone's five parts fit the rest of the hands-on labs, and revisit The Object Model and Workloads & Scheduling for the concepts behind what you just built.