Hands-On · Architecture, Scaling & Cost Labs

Architecture, Scaling & Cost Labs

This is the bedrock and the bill. Every clever platform API, every beautiful portal, every canary rollout eventually lands on a scheduler that has to decide which pod goes where, on a quota that has to say no, and on an invoice somebody has to explain. Twelve labs, each 5–15 minutes on a throwaway local cluster, take you through the part of the CNPE that can’t be bluffed: carve a cluster into tenants that can’t hurt each other, watch Kubernetes choose who lives and who gets evicted, make workloads grow and shrink on their own — all the way to zero — give them real storage, then put a number on what all of it costs and go delete the waste. Tick them off as you go; your progress saves in this browser.

☺ Explain it like I’m 10

Imagine a big shared kitchen. First you give each team their own counter and a rule about how many pans they may use, so nobody hogs everything. Then you put up a little wall so teams don’t reach into each other’s bowls. Then you decide who has to leave first if the kitchen gets too crowded — and who is so important they never get bumped. Then you teach the kitchen to hire extra cooks when the orders pile up and send them home when it’s quiet. Finally you read the electricity bill, find the oven nobody has used in a month, and switch it off.

🦉🦥Your hosts for this track: Professor Owl — who explains why the scheduler made the choice it made — and Sol the Sloth, who moves slowly, spends slowly, and asks the one question nobody else asks: “what is this costing us?” Benny, Ellie, Pip, Timmy and a very unhelpful Gizmo drop in along the way.
⚠ Before you start

These are local, throwaway clusters — nothing here touches production or costs real money. You want Docker (or Podman), kind or minikube, plus kubectl, helm and jq. Some labs need a multi-node cluster and one needs a CNI that actually enforces NetworkPolicy — both are called out where they matter. Chart names, flags and API versions drift: whenever a command here disagrees with a project’s current quickstart, the quickstart wins. When you’re done, kind delete cluster --name arch and nothing lingers.

Build one cluster now and stay on it for all twelve labs. Labs 4–6 need several nodes, and Labs 11–12 put a price on the namespaces you create in Lab 1 — so if you switch clusters half-way through, the later labs have nothing to measure. Save the kind-arch.yaml block further down this page and run kind create cluster --name arch --config kind-arch.yaml before Lab 1; that also sets your kubectl context to kind-arch. If you need enforced NetworkPolicy for Lab 2, add networking: {disableDefaultCNI: true} to that same config and install a CNI first.

⚖ CNPA vs CNPE — Every lab on this page is CNPE-only hands-on practice: CNPE is performance-based, so you build and fix these exact things live on a real cluster. CNPA is a fully closed-book multiple-choice exam with no lab component at all — you will never run a kubectl command in it — but the concepts underneath (quotas, QoS, preemption, autoscaling shapes, cost accountability) still matter for CNPA's closed-book recall, just tested as a question instead of a task.

Work them in order if you can — Labs 1–2 build the tenants that Labs 11–12 later put a price on. If you only have one evening, do 1, 4, 6, 7 and 9: quota rejection, preemption, a blocked drain, an HPA under load and a scale-to-zero. Those five are the shapes that show up most often when an exam task says “make this workload behave.” Deep-dive the theory behind any lab through its linked lesson, and keep the command reference open in a second tab.

The twelve labs

0 / 12 labs complete
# tenants.yaml — apply once, then again with tenant-a → tenant-b
apiVersion: v1
kind: Namespace
metadata:
  name: tenant-a
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-quota
  namespace: tenant-a
spec:
  hard:
    requests.cpu: "1"
    requests.memory: 1Gi
    limits.cpu: "2"
    limits.memory: 2Gi
    pods: "10"
    persistentvolumeclaims: "2"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: tenant-defaults
  namespace: tenant-a
spec:
  limits:
    - type: Container
      defaultRequest: { cpu: 100m, memory: 128Mi }   # injected if you omit requests
      default:        { cpu: 200m, memory: 256Mi }   # injected if you omit limits
      max:            { cpu: "1",  memory: 1Gi }     # a single container may not exceed this
Lab 1Two tenants, one cluster — 🦉 Professor Owl
Carve the cluster into two namespaces that cannot starve each other, then prove the cap is real by walking into it.
  1. Save the manifest above as tenants.yaml and apply it: kubectl apply -f tenants.yaml. Then sed 's/tenant-a/tenant-b/' tenants.yaml | kubectl apply -f -.
  2. Deploy something that sets no resources at all: kubectl -n tenant-a create deploy web --image=nginx:alpine. Read what the LimitRange injected: kubectl -n tenant-a get pod -l app=web -o jsonpath='{.items[0].spec.containers[0].resources}'.
  3. Watch the meter: kubectl -n tenant-a describe quota tenant-quota — note Used vs Hard.
  4. Now break it on purpose — and size the ask so the quota is what says no. Create it parked at zero so exactly one ReplicaSet exists: kubectl -n tenant-a create deploy hog --image=nginx:alpine --replicas=0, then kubectl -n tenant-a set resources deploy/hog --requests=cpu=1,memory=1Gi --limits=cpu=1,memory=1Gi, then kubectl -n tenant-a scale deploy/hog --replicas=1. (Set both requests and limits. Set only requests and the LimitRange injects its 200m default limit, which is now below your request — the API server then rejects the pod for “requests must be less than or equal to limits”, which is a true error but not the one this lab is about.)
  5. The Deployment is accepted but no pod appears. Find out why where the error actually lives: kubectl -n tenant-a describe rs -l app=hog and kubectl -n tenant-a get events --sort-by=.lastTimestamp | tail. web already reserved 100m, so another 1 CPU cannot fit under a hard cap of 1.
  6. Now trip the other guard: kubectl -n tenant-a set resources deploy/hog --requests=cpu=4,memory=4Gi --limits=cpu=4,memory=4Gi. The message changes to maximum cpu usage per Container is 1 — that is the LimitRange, not the quota. Two admission plugins, two rejections, two very different fixes.
Done when: kubectl -n tenant-a describe rs -l app=hog shows a FailedCreate event containing exceeded quota: tenant-quota (and, after the last step, one containing maximum cpu usage per Container), while kubectl -n tenant-a describe quota tenant-quota still lists the web pod’s injected requests under Used. You can say in one sentence why the Deployment succeeded and the pod did not.
# isolation.yaml — deny everything into tenant-a, then re-allow same-namespace traffic
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: tenant-a
spec:
  podSelector: {}            # every pod in the namespace
  policyTypes: ["Ingress"]   # no ingress rules below = deny all ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-same-namespace
  namespace: tenant-a
spec:
  podSelector: {}
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector: {}    # any pod in THIS namespace
Lab 2Build the wall between tenants — 🐢 Timmy the Turtle
Namespaces are a billing and RBAC boundary, not a network one — by default every pod can reach every other pod. Fix that.
  1. Check your CNI first. kind’s default networking does not enforce NetworkPolicy, so a policy will appear to “work” while traffic still flows. Either recreate kind with --config setting networking.disableDefaultCNI: true and install Cilium or Calico, or use minikube start --cni=calico.
  2. Expose the Lab 1 app: kubectl -n tenant-a expose deploy web --port=80.
  3. Prove the “before”: kubectl -n tenant-b run probe --rm -it --restart=Never --image=curlimages/curl --command -- curl -sS --max-time 5 -o /dev/null -w '%{http_code}\n' http://web.tenant-a.svc.cluster.local200. (--command matters: it overrides the image’s curl entrypoint instead of appending your words to it.)
  4. Apply the policies above: kubectl apply -f isolation.yaml, and confirm both landed: kubectl -n tenant-a get netpol.
  5. Re-run the identical probe from tenant-b. It hangs for five seconds and fails; check the code with echo $?28 (curl’s “operation timed out”).
  6. Run the same probe from inside the walled namespace: kubectl -n tenant-a run probe --rm -it --restart=Never --image=curlimages/curl --command -- curl -sS --max-time 5 -o /dev/null -w '%{http_code}\n' http://web.tenant-a.svc.cluster.local → still 200, because the second policy re-allows same-namespace traffic.
Done when: the probe from tenant-b exits 28 (timeout) while the identical probe run in tenant-a prints 200 — and kubectl -n tenant-a get netpol lists both default-deny-ingress and allow-same-namespace.
# qos.yaml — the three classes, side by side.
# busybox, not pause: you need a shell and /bin/cat inside to read the OOM score.
apiVersion: v1
kind: Pod
metadata: { name: qos-guaranteed, namespace: default }
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sleep", "3600"]
      resources:
        requests: { cpu: 100m, memory: 128Mi }
        limits:   { cpu: 100m, memory: 128Mi }   # limits == requests, for EVERY resource
---
apiVersion: v1
kind: Pod
metadata: { name: qos-burstable, namespace: default }
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sleep", "3600"]
      resources:
        requests: { cpu: 100m, memory: 128Mi }
        limits:   { cpu: 500m, memory: 512Mi }   # limits > requests
---
apiVersion: v1
kind: Pod
metadata: { name: qos-besteffort, namespace: default }
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sleep", "3600"]                 # no requests, no limits at all
Lab 3Who gets thrown overboard first — 🦉 Professor Owl
You never choose a QoS class directly; Kubernetes derives it from what you wrote in resources. That derived class decides the order of eviction under node pressure.
  1. Apply the three pods: kubectl apply -f qos.yaml. (Use default, not tenant-a — the LimitRange there would inject requests and quietly turn BestEffort into Burstable. Try it in tenant-a afterwards to see exactly that happen.)
  2. Read the classes back: kubectl get pod -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass.
  3. See the mechanism, not just the label. For each pod: kubectl exec qos-guaranteed -- cat /proc/1/oom_score_adj, then the same for qos-burstable and qos-besteffort. Guaranteed sits at -997, BestEffort at 1000, and Burstable somewhere between 2 and 999 — scaled by how much memory it asked for, so on a roomy node it lands just under 1000. The higher the score, the earlier the kernel kills it.
  4. Read the kubelet’s own thresholds: kubectl describe node | grep -A6 Conditions and look for MemoryPressure.
  5. Optional, if your node has little headroom: run a memory hog with no limits and watch for evictions with kubectl get events -A --field-selector reason=Evicted. On a roomy laptop this may never fire — the ordering below is the durable lesson.
Done when: the custom-columns output shows exactly one Guaranteed, one Burstable and one BestEffort, and you can recite the eviction order (BestEffort → Burstable over its request → Guaranteed) with the oom_score_adj values as your evidence.
# priority.yaml — two lanes: cheap batch, and never-touch-this
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: { name: batch-low }
value: 100
globalDefault: false
description: "Best-effort batch work — the first thing preempted when the node fills up."
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: { name: platform-critical }
value: 1000000
globalDefault: false
preemptionPolicy: PreemptLowerPriority
description: "Platform components that must schedule even on a full node."
Lab 4Preemption: bumping batch for something critical — 🦉 Professor Owl
Fill a node with low-priority work, then land a high-priority pod on it and watch the scheduler make room.
  1. kubectl apply -f priority.yaml, then check kubectl get priorityclass (you’ll also see the two built-in system-* classes).
  2. Don’t try to fill a whole cluster — fill one node, and do the arithmetic first. Read its allocatable CPU: kubectl get node arch-worker -o jsonpath='{.status.allocatable.cpu}{"\n"}'. Call that number A (a kind node reports every core your Docker VM has, so A is often 8). Each filler will request a quarter of A — with A=8 that is 2.
  3. Fill that one node with five such pods, pinned there and marked cheap: kubectl create deploy filler --image=registry.k8s.io/pause:3.9 --replicas=5, then kubectl set resources deploy/filler --requests=cpu=2 (substitute your A/4), then kubectl patch deploy filler -p '{"spec":{"template":{"spec":{"priorityClassName":"batch-low","nodeSelector":{"kubernetes.io/hostname":"arch-worker"}}}}}'.
  4. Confirm the node is genuinely full: kubectl get pods -l app=filler -o wide shows three (at most four) Running on arch-worker and the rest Pending — four quarters plus the kubelet’s own daemons don’t fit in one whole.
  5. Now land the VIP on that same full node: kubectl run vip --image=registry.k8s.io/pause:3.9 --overrides='{"apiVersion":"v1","spec":{"priorityClassName":"platform-critical","nodeSelector":{"kubernetes.io/hostname":"arch-worker"},"containers":[{"name":"vip","image":"registry.k8s.io/pause:3.9","resources":{"requests":{"cpu":"2"}}}]}}'. (--overrides needs that apiVersion or kubectl refuses to merge the fragment.)
  6. Watch the eviction happen: kubectl get events -A --field-selector reason=Preempted, and while vip is still waiting, kubectl get pod vip -o wide — the NOMINATED NODE column is the scheduler saying “I have already reserved this seat.”
Done when: kubectl get pod vip shows Running on arch-worker, kubectl get events -A --field-selector reason=Preempted names one of the filler pods, and kubectl get pods -l app=filler shows one more pod in Pending than before. Priority bought the VIP a seat by taking someone else’s.
# kind-arch.yaml — a four-node cluster so scheduling actually has choices
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
  - role: worker
  - role: worker
# kind create cluster --name arch --config kind-arch.yaml
# placement.yaml — "zones a and b only, spread evenly, keep off the GPU node"
apiVersion: apps/v1
kind: Deployment
metadata: { name: spread, namespace: default }
spec:
  replicas: 4
  selector: { matchLabels: { app: spread } }
  template:
    metadata: { labels: { app: spread } }
    spec:
      containers:
        - name: app
          image: registry.k8s.io/pause:3.9
          resources: { requests: { cpu: 50m, memory: 32Mi } }
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: topology.kubernetes.io/zone
                    operator: In
                    values: ["a", "b"]
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector: { matchLabels: { app: spread } }
Lab 5Placement: affinity, taints and spread — 🦫 Benny the Beaver
Three controls decide where a pod lands: the pod asking (affinity), the node refusing (taints), and the platform balancing (topology spread). Use all three on one cluster.
  1. You should already be on the four-node arch cluster from the setup step above; check with kubectl get nodes. If you have been working on a single-node cluster, build it now — kind create cluster --name arch --config kind-arch.yaml — and re-run Lab 1’s tenants.yaml against it, because Labs 11–12 put a price on those namespaces.
  2. Invent zones: kubectl label node arch-worker topology.kubernetes.io/zone=a, arch-worker2zone=b, arch-worker3zone=c.
  3. Make one node hostile: kubectl taint node arch-worker3 tier=gpu:NoSchedule.
  4. kubectl apply -f placement.yaml, then kubectl get pods -l app=spread -o wide — expect 2 on zone a, 2 on zone b, none on arch-worker3.
  5. Now push the constraint until it actually breaks — and note that scaling alone will not do it. With two eligible zones, maxSkew: 1 happily allows 3/2, 3/3, 4/3 and so on; skew is the gap between domains, not an evenness rule. You have to take a domain away: kubectl cordon arch-worker2 (zone b stops accepting new pods; the two already running there stay), then kubectl scale deploy/spread --replicas=6.
  6. One new pod lands in zone a — 3 against b’s 2, a skew of exactly 1, still legal. The next one cannot: zone a would hold 4 against 2, a skew of 2, and DoNotSchedule forbids it, while zone b’s only node is cordoned and zone c is both outside the affinity and tainted. Read the reason: kubectl describe pod -l app=spread | grep -A5 Eventsdidn't match pod topology spread constraints.
  7. Fix it honestly, and name which lever you pulled — kubectl uncordon arch-worker2 (give the domain back), or relax whenUnsatisfiable to ScheduleAnyway and re-apply, or add "c" to the affinity and a toleration for tier=gpu. Confirm all six schedule.
Done when: kubectl get pods -l app=spread -o wide shows a balanced spread across zones a and b with nothing on the tainted node, you produced a Pending pod whose event text contains didn't match pod topology spread constraints, and your fix got all six Running without blindly deleting the constraint.
# pdb.yaml — start deliberately too strict, then relax it
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: spread-pdb, namespace: default }
spec:
  minAvailable: 4                       # == replicas, so NO voluntary eviction is allowed
  selector: { matchLabels: { app: spread } }
Lab 6A drain that has to wait its turn — 🐢 Timmy the Turtle
A PodDisruptionBudget is the platform’s promise to a workload during voluntary disruption — upgrades, drains, autoscaler scale-downs. Feel it block, then feel it allow.
  1. Reset Lab 5 to a clean, drainable state: kubectl uncordon arch-worker2, kubectl scale deploy/spread --replicas=4, and let an evicted pod be replaced anywhere — kubectl patch deploy spread --type=json -p='[{"op":"replace","path":"/spec/template/spec/topologySpreadConstraints/0/whenUnsatisfiable","value":"ScheduleAnyway"}]' — then kubectl rollout status deploy/spread. (Leave it on DoNotSchedule and the drain below can never finish: draining cordons the node, the replacement pod then has nowhere legal to go, the budget never recovers, and kubectl drain retries for ever. Worth doing once on purpose — it is a real production deadlock.)
  2. Confirm at least one spread pod actually sits on the node you are about to drain: kubectl get pods -l app=spread -o wide.
  3. Apply the PDB: kubectl apply -f pdb.yaml, then kubectl get pdb spread-pdb (watch ALLOWED DISRUPTIONS — it should be 0).
  4. Try to drain a node holding one of the pods: kubectl drain arch-worker2 --ignore-daemonsets --delete-emptydir-data.
  5. Read the refusal — Cannot evict pod as it would violate the disruption budget — and leave the drain retrying in another terminal, or Ctrl-C it.
  6. Relax the promise: kubectl patch pdb spread-pdb -p '{"spec":{"minAvailable":3}}'. Confirm ALLOWED DISRUPTIONS is now 1.
  7. Re-run the drain. It completes. Check the pod moved: kubectl get pods -l app=spread -o wide. Then kubectl uncordon arch-worker2.
  8. Note the asymmetry out loud: a PDB does not protect you from a node dying — only from a human or controller politely asking.
Done when: the first kubectl drain is refused with the disruption-budget error and the second, after patching minAvailable, drains the node while kubectl get pods -l app=spread never drops below three Running pods.
# metrics-server: the prerequisite for kubectl top, HPA and VPA.
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

# kind/minikube use self-signed kubelet certs, so metrics-server needs one extra flag:
kubectl -n kube-system patch deploy metrics-server --type=json \
  -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'

kubectl -n kube-system rollout status deploy/metrics-server
kubectl top nodes        # if this prints numbers, HPA has something to read
Lab 7Horizontal autoscaling, under real load — 🐦 Pip the Hummingbird
An HPA is a control loop over a ratio: current metric ÷ target metric × current replicas. Make it move.
  1. Install metrics-server with the block above and confirm kubectl top nodes returns numbers (give it ~60s).
  2. Deploy a CPU-hungry app: kubectl create deploy php-apache --image=registry.k8s.io/hpa-example, then kubectl set resources deploy/php-apache --requests=cpu=200man HPA on CPU percentage is meaningless without a request — and kubectl expose deploy php-apache --port=80.
  3. Create the autoscaler: kubectl autoscale deploy php-apache --cpu-percent=50 --min=1 --max=10. Confirm with kubectl get hpa php-apache (targets should read <unknown> briefly, then a percentage).
  4. Apply load in a second terminal: kubectl run -it --rm load --image=busybox:1.36 --restart=Never -- /bin/sh -c "while sleep 0.01; do wget -q -O- http://php-apache; done".
  5. Watch it climb: kubectl get hpa php-apache -w. Then kubectl describe hpa php-apache and read the ScalingActive / SuccessfulRescale events.
  6. Kill the load and wait. Scale-down is deliberately sluggish (a five-minute stabilisation window by default) — that lag is a feature, not a bug. Say why.
Done when: kubectl get hpa php-apache shows TARGETS well above 50% and REPLICAS greater than 1 under load, kubectl describe hpa logs a SuccessfulRescale event, and replicas return toward 1 a few minutes after the load stops.
# vpa-recommend.yaml — advice only. updateMode "Off" never touches a running pod.
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata: { name: php-apache, namespace: default }
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache
  updatePolicy:
    updateMode: "Off"          # "Auto" would evict and recreate pods to resize them
  resourcePolicy:
    containerPolicies:
      - containerName: "*"
        controlledResources: ["cpu", "memory"]
Lab 8Right-size from a VPA recommendation — 🦥 Sol the Sloth
Horizontal autoscaling answers “how many?”; vertical answers “how big?” Run the VPA in recommendation mode and let it tell you how much you over-asked for.
  1. Install the Vertical Pod Autoscaler: git clone --depth=1 https://github.com/kubernetes/autoscaler.git, then ./autoscaler/vertical-pod-autoscaler/hack/vpa-up.sh. Confirm three pods in kubectl -n kube-system get pods | grep vpa.
  2. kubectl apply -f vpa-recommend.yaml.
  3. Give it something to observe — re-run the Lab 7 load generator for 3–5 minutes so the recommender sees real usage rather than an idle pod.
  4. Read the advice: kubectl describe vpa php-apache, and machine-readably: kubectl get vpa php-apache -o jsonpath='{.status.recommendation.containerRecommendations[0].target}'.
  5. Compare target against the 200m you requested in Lab 7, then act on it — substituting the CPU value it just printed for CPU: kubectl set resources deploy/php-apache --requests=cpu=CPU (so if it recommended {"cpu":"587m",…}, that is --requests=cpu=587m).
  6. Understand the trap before you ever reach for updateMode: "Auto": VPA in Auto mode evicts pods to resize them, and it fights an HPA that scales on the same resource. Never point both at CPU for the same workload.
Done when: the jsonpath query returns a non-empty {"cpu":"...","memory":"..."} recommendation and kubectl get deploy php-apache -o jsonpath='{..resources.requests}' shows requests you changed because of that number — and you can state why VPA-Auto plus HPA-on-CPU is an anti-pattern.
# scaledobject.yaml — scale a worker on queue depth, all the way down to zero
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: worker, namespace: default }
spec:
  scaleTargetRef:
    name: worker
  minReplicaCount: 0          # the headline: no work, no pods, no cost
  maxReplicaCount: 10
  pollingInterval: 5          # seconds between metric checks
  cooldownPeriod: 30          # seconds of idle before scaling back to zero
  triggers:
    - type: redis
      metadata:
        address: redis.default.svc.cluster.local:6379
        listName: jobs
        listLength: "5"       # aim for ~5 queued items per replica
        enableTLS: "false"
Lab 9Scale on a queue — and all the way to zero — 🐦 Pip the Hummingbird
An HPA can’t scale to zero and can’t see a queue. KEDA can do both, by feeding an external metric into a Deployment it owns.
  1. Install KEDA: helm repo add kedacore https://kedacore.github.io/charts && helm repo update, then helm install keda kedacore/keda -n keda --create-namespace. Wait for kubectl -n keda get pods to be Running.
  2. Stand up a queue: kubectl create deploy redis --image=redis:7-alpine and kubectl expose deploy redis --port=6379.
  3. Create the consumer (it doesn’t need to do real work for this lab): kubectl create deploy worker --image=busybox:1.36 -- /bin/sh -c "while true; do sleep 5; done".
  4. kubectl apply -f scaledobject.yaml, then kubectl get scaledobject workerREADY should be True, ACTIVE False. Within ~30s, kubectl get deploy worker reads 0/0.
  5. Post work: kubectl run -it --rm cli --image=redis:7-alpine --restart=Never -- redis-cli -h redis LPUSH jobs a b c d e f g h i j k l m n o p q r s t.
  6. Watch it wake and grow: kubectl get deploy worker -w and kubectl get hpa — note KEDA created an HPA named keda-hpa-worker for you.
  7. Drain the queue: kubectl run -it --rm cli --image=redis:7-alpine --restart=Never -- redis-cli -h redis DEL jobs, then watch replicas fall back to 0 after the cooldown.
Done when: kubectl get deploy worker reads 0/0 with an empty queue, climbs above 1 replica after the LPUSH, and returns to 0/0 within the cooldown after DEL jobs — and kubectl get hpa keda-hpa-worker shows the HPA KEDA manages on your behalf.
# stateful.yaml — a standalone PVC that will sit Pending, plus a StatefulSet that binds
apiVersion: v1
kind: Service                           # headless: gives db-0 and db-1 stable DNS names
metadata: { name: db, namespace: default }
spec:
  clusterIP: None
  selector: { app: db }
  ports:
    - { name: db, port: 5432 }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: lonely, namespace: default }
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: standard            # kind's default: rancher.io/local-path
  resources: { requests: { storage: 1Gi } }
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db, namespace: default }
spec:
  serviceName: db
  replicas: 2
  selector: { matchLabels: { app: db } }
  template:
    metadata: { labels: { app: db } }
    spec:
      containers:
        - name: app
          image: busybox:1.36
          command: ["/bin/sh","-c","echo hello-from-$HOSTNAME >> /data/log; sleep 3600"]
          volumeMounts:
            - { name: data, mountPath: /data }
  volumeClaimTemplates:                 # one PVC per ordinal, named data-db-0, data-db-1
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: standard
        resources: { requests: { storage: 1Gi } }
Lab 10Storage that sticks to its pod — 🐘 Ellie the Elephant
Watch the whole binding dance: StorageClass → PVC → PV → mounted volume, and the identity a StatefulSet gives each replica.
  1. Inspect what you already have: kubectl get storageclass and kubectl describe sc standard. Note VolumeBindingMode: WaitForFirstConsumer — that is kind’s local-path provisioner. (On minikube the default standard class binds Immediate, so the lonely PVC below binds at once and step 2 has nothing to show. Run this one on kind.)
  2. kubectl apply -f stateful.yaml, then immediately kubectl get pvc. The lonely PVC sits Pending — that is the binding mode doing its job, not a bug. Confirm: kubectl describe pvc lonelywaiting for first consumer.
  3. The StatefulSet’s PVCs, by contrast, bind — because pods consume them: kubectl get pvc,pv shows data-db-0 and data-db-1 Bound.
  4. Prove the identity is stable: kubectl exec db-0 -- cat /data/log, then kubectl delete pod db-0, wait for it to come back, and kubectl exec db-0 -- cat /data/log again — the old line is still there, because the same PVC re-attached.
  5. Prove the lifecycle is deliberately conservative: kubectl scale statefulset db --replicas=1, then kubectl get pvc. data-db-1 survives. Kubernetes will not throw away your data just because a replica went away — which is exactly the waste you’ll hunt in Lab 12.
Done when: kubectl get pvc shows lonely still Pending while data-db-0/data-db-1 are Bound, the /data/log line survives deleting db-0, and data-db-1 still exists after scaling down to one replica.
# A Prometheus for OpenCost to read from, then OpenCost itself.
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add opencost https://opencost.github.io/opencost-helm-chart
helm repo update

helm install prometheus prometheus-community/prometheus \
  -n prometheus --create-namespace \
  --set alertmanager.enabled=false --set prometheus-pushgateway.enabled=false

helm install opencost opencost/opencost -n opencost --create-namespace \
  --set opencost.prometheus.internal.serviceName=prometheus-server \
  --set opencost.prometheus.internal.namespaceName=prometheus \
  --set opencost.prometheus.internal.port=80

kubectl -n opencost rollout status deploy/opencost
kubectl -n opencost port-forward svc/opencost 9003:9003
Lab 11Put a number on each tenant — 🦥 Sol the Sloth
Cost stops being an argument the moment it becomes a query. Install OpenCost and get a per-namespace figure for the tenants from Lab 1.
  1. Run the install block above. Chart values move between releases — if a flag is rejected, check helm show values opencost/opencost and the project’s current install page rather than guessing.
  2. With the port-forward running, ask for the last hour by namespace: curl -s 'http://localhost:9003/allocation/compute?window=1h&aggregate=namespace' | jq '.data[0] | to_entries[] | {ns: .key, cpu: .value.cpuCost, ram: .value.ramCost, pv: .value.pvCost}'.
  3. Find your tenants in the output. Give tenant-a more work (kubectl -n tenant-a scale deploy/web --replicas=4), wait a few minutes, and re-run the query — its share should visibly rise.
  4. Look at the idle gap: OpenCost reports cost against requests as well as usage. A pod requesting 1 CPU and using 50m is billed for the CPU it reserved and nobody else could have. That gap is the entire FinOps argument in one number.
  5. Optional: browse the built-in UI with kubectl -n opencost port-forward svc/opencost 9090:9090.
Done when: the curl … | jq command prints a cost line for tenant-a and tenant-b, and scaling one tenant up changes its number on the next query. (On kind the underlying prices are default on-prem estimates — the numbers are fictional, the shape of the answer is exactly what a real cluster gives you.)
# 1 — PVCs that no running pod mounts: pure, silent, recurring cost.
kubectl get pvc -A -o json \
  | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name)"' | sort > /tmp/all-pvc
kubectl get pods -A -o json \
  | jq -r '.items[] as $p | $p.spec.volumes[]? | select(.persistentVolumeClaim)
           | "\($p.metadata.namespace)/\(.persistentVolumeClaim.claimName)"' | sort -u > /tmp/used-pvc
comm -23 /tmp/all-pvc /tmp/used-pvc          # <- orphans

# 2 — Requested vs actually used (needs metrics-server from Lab 7).
kubectl top pods -A --containers --no-headers | sort -k4 -h | head -20
kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,CPU_REQ:.spec.containers[*].resources.requests.cpu,MEM_REQ:.spec.containers[*].resources.requests.memory'

# 3 — Objects that exist but do nothing.
kubectl get pods -A --field-selector status.phase=Succeeded
kubectl get deploy -A -o json | jq -r '.items[] | select(.spec.replicas==0) | "\(.metadata.namespace)/\(.metadata.name)"'
Lab 12The waste hunt — 🦥 Sol the Sloth & 👺 Gizmo
Every cluster is quietly paying for something nobody uses. Find three kinds of it, kill it, and prove the bill moved.
  1. Run block 1. The lonely PVC from Lab 10 shows up straight away; so does data-db-1 after you scaled the StatefulSet down. Both are billed storage doing nothing.
  2. Before deleting anything, ask Timmy’s question: is it orphaned, or is it a scaled-down StatefulSet member that must come back? Delete only the genuinely dead one: kubectl delete pvc lonely. (On kind that PVC was still Pending and cost nothing — on a cloud StorageClass that binds Immediate, the same object is a real provisioned disk billed by the hour. Same manifest, very different invoice.)
  3. Run block 2 and find the biggest gap between request and usage. The Lab 4 filler pods reserve a quarter of a node’s CPU each and use essentially nothing — the textbook over-request.
  4. Right-size or remove it: kubectl set resources deploy/filler --requests=cpu=10m, or delete it outright with kubectl delete deploy filler and kubectl delete pod vip (vip is a bare Pod, not a Deployment — the two need separate commands). Then kubectl describe node arch-worker | grep -A6 'Allocated resources' and watch reserved CPU drop.
  5. Run block 3 and clear the leftovers: completed pods and zero-replica Deployments.
  6. Close the loop: re-run the Lab 11 OpenCost query and compare the numbers to what you wrote down before the hunt. Write one sentence naming what you removed and what it saved.
Done when: comm -23 /tmp/all-pvc /tmp/used-pvc returns a shorter list than it did at the start, kubectl describe node arch-worker | grep -A6 'Allocated resources' shows a lower cpu Requests figure than the one you noted before the hunt, and you have a written before/after pair of OpenCost figures for at least one namespace.
🦆 Dot’s-eye view

“Notice what I never had to learn in any of this. I don’t write ResourceQuotas, I don’t reason about maxSkew, and I have genuinely never typed the word preemption. The platform team encodes all twelve of these in the template I scaffold from — sensible requests, a PDB, an HPA, the right priority class. When it’s done well, the whole page you just worked through is invisible to me. That’s the job.”

What you’ll have built

☺ Like you’re 10: A shared kitchen with fair rules, walls between the teams, a queue of cooks that hires and fires itself, cupboards that keep their contents, and a bill you can actually read.

Finish all twelve and your laptop holds a small but honest multi-tenant platform: two isolated tenants with enforced quotas, defaults and network boundaries; a scheduler you have bent to your will with affinity, taints and topology spread; workloads that survive a node drain because a PodDisruptionBudget made a promise; three flavours of autoscaling — HPA horizontally, VPA vertically, KEDA event-driven and down to zero; stateful workloads with stable identity and sticky volumes; and an OpenCost query that turns all of it into money, plus a waste hunt that made the number go down.

That maps almost one-to-one onto the exam’s architecture, infrastructure and operations territory, and it is precisely the material that a performance-based test can hand you as a broken cluster. Take the theory deeper in Architecture & Infrastructure and Scaling & Scheduling; then pressure-test yourself against the clock with the practice tasks, the architecture drills and the troubleshooting playbook. When you want the rest of the platform — GitOps, pipelines, portals, policy — go back to the main lab track.

◆ Key idea

Every lab here is the same move in a different costume: declare an intent, then let the platform enforce it without a human in the loop. A quota that rejects. A policy that drops a packet. A budget that refuses a drain. An autoscaler that adds a replica at 2 a.m. If your platform needs a person to notice, it isn’t a platform yet — it’s a rota.

🦥 Sol’s challenge · going further

Ready for more? Chain the labs into one story. Put every manifest on this page into a Git repo and let Argo CD reconcile the tenants, so onboarding a tenant is a folder. Add a Kyverno policy that rejects any pod without CPU and memory requests — Lab 12’s waste becomes structurally impossible. Wire the Lab 7 HPA to a Prometheus metric (requests-per-second) instead of CPU. Back up the Lab 10 StatefulSet with Velero, delete the namespace, and restore it. And read Karpenter for the layer this local cluster can’t show you: when even preemption can’t help, a real cluster answers by provisioning a node — right-sized, spot-priced, and consolidated away again when the work drains.

🎬 At the Platform Guild
🦊

Foxy: The batch job kept getting killed, so I removed its limits and its requests. It hasn’t died since. Problem solved.

🦉

Owl: You didn’t solve it, Foxy — you moved it. No requests means BestEffort, which means it is now first in line to be evicted the moment that node feels pressure. You made it luckier, not safer.

👺

Gizmo: Easy fix! Give everything priorityClassName: platform-critical. Then nothing ever gets preempted. 😈

🦉

Owl: If everything is critical, nothing is. Priority is a ranking, Gizmo. Flatten it and you’ve simply deleted the scheduler’s ability to make a good decision on your behalf.

🦥

Sol: …and I’ve been reading the bill while you two argued. Four terabytes of PersistentVolumeClaims. Nothing has mounted them since March.

🐢

Timmy: Don’t delete those in a hurry. Check whether they belong to a scaled-down StatefulSet first — Kubernetes keeps them on purpose. Slowly, Sol. Correctly.

🦥

Sol: Slowly is the only speed I have.

🐢 Timmy’s checkpoint

1. In Lab 1 the Deployment applied cleanly but no pod ever appeared — which object carries the error, and which command surfaces it? 2. You wrote a pod with requests: {cpu: 100m} and limits: {cpu: 500m} — what QoS class is it, and where does it sit in the eviction order? 3. A kubectl drain hangs forever. Name the object most likely refusing, and the field you’d check first. 4. Why is an HPA targeting cpu-percent useless on a container with no CPU request? 5. Give the one thing KEDA does in Lab 9 that a plain HPA cannot. 6. You find a Bound PVC that no pod mounts. Before deleting it, what must you check?

Check your answers
  1. The ReplicaSet. Admission of the Deployment succeeds; the quota/LimitRange rejection happens when the ReplicaSet controller tries to create a pod. Surface it with kubectl describe rs -l app=hog (or kubectl get events) — not kubectl get pods, which shows nothing at all.
  2. Burstable — limits exceed requests, and not every resource is pinned. Eviction order is BestEffort first, then Burstable pods exceeding their requests, then Guaranteed last. Its /proc/1/oom_score_adj sits between the two extremes.
  3. A PodDisruptionBudget. Check kubectl get pdb and specifically ALLOWED DISRUPTIONS; if it’s 0, minAvailable (or maxUnavailable) is set so tightly that no voluntary eviction is legal. Remember it only governs voluntary disruption — a node crashing ignores it entirely.
  4. Because the percentage is computed against the request. With no request there is no denominator, so the HPA reports <unknown> targets and never scales. This is the single most common “my HPA does nothing” cause.
  5. Scale to zero (minReplicaCount: 0) driven by an external event source such as queue depth. A plain HPA has a floor of one replica and only reads metrics the metrics API exposes. KEDA still creates an HPA underneath for the 1→N range — it owns the 0↔1 transition itself.
  6. Whether it belongs to a scaled-down StatefulSet (a volumeClaimTemplates PVC such as data-db-1), which Kubernetes retains deliberately so the replica keeps its data when it returns — and whether the data has been backed up. Genuinely orphaned PVCs are free money; a StatefulSet’s retained PVC is someone’s database.