Mock Exam · Set 4
Another whole paper, sealed. Eighteen performance-based tasks weighted exactly to the CNPE blueprint, sat in one unbroken 120-minute block on a throwaway cluster with only the permitted documentation open. Every task on this page is new: none of them appears in Set 1, none repeats Set 2, and none is lifted from the five practice banks. Where a topic recurs — Argo CD sync behaviour, Kyverno, KEDA — the mechanism is deliberately different, because the exam does not ask you the same question twice either. Each task gives you a realistic platform situation, two to four numbered steps, an objective done when check you can run yourself, and a point value. The worked solutions are folded away so you can sit the paper honestly and mark it afterwards. Total the sheet at the end, compare it against the 64% bar, and let the misses choose your next fortnight.
You have already played two full matches, and you know roughly how good you are. This is the away game: same rules, same clock, but the other team plays differently — they pass where the last lot dribbled, so the moves you had memorised do not quite fit. That is the whole point. If you can only win when the game looks exactly like practice, you have not learned football, you have learned one match. Play this one, then look at which parts of your game only worked because you had seen the play before.
Before you start — exam conditions
☺ Like you’re 10: A practice match only teaches you something if you play it by the real rules — same clock, no help, no re-runs.
The comfortable mock is the useless mock. A pause to install something, a quick search for a field name, a peek at one solution when a manifest will not validate — each one is small, and together they produce a score that flatters you and teaches nothing. Set the conditions first; start the clock second.
One — build the cluster the night before. A throwaway kind or minikube cluster is enough, with Argo CD, Argo Rollouts, Tekton Pipelines and Tekton Triggers, the kube-prometheus-stack, a Jaeger instance with an OpenTelemetry Collector in front of it, KEDA, Crossplane with a provider installed, Kyverno and the External Secrets Operator. Karpenter needs a real cloud cluster; if you only have kind, do the Karpenter task on paper and mark it half at most. If a component turns out to be missing when you reach its task, skip the task and move on — never stop the clock to install things, because on the day nobody will let you.
Two — one timer, started once. 120 minutes, no pausing. If you lose eight minutes to your own laptop, that is data rather than an injustice: the real exam eats time in ways you did not plan for too. Sit it at roughly the hour your real exam is booked.
Three — only the permitted documentation, open before you start. The allowlist is narrower than most candidates expect: kubernetes.io/docs, kubernetes.io/blog, any task-specific documentation the exam links from its Quick Reference box, and the local documentation on the exam desktop — man pages and whatever ships under /usr/share. That is the entire list. The project sites for Argo CD, Flux, Tekton, Crossplane, Backstage, Prometheus, OpenTelemetry, Kyverno, Gatekeeper, Istio and Helm are not permitted, and neither is this site, your notes, a search engine or an AI assistant.
Four — no copy-paste from the solutions, and no AI assistance of any kind. If you stall, spend your budget, fail the task, and read the solution after the sitting. A task you pasted your way through is a task you will fail again on exam day, only more expensively.
That list reshapes how you prepare. For every tool whose own documentation is not allowed, the resource shape has to be in your head or reachable from what to know cold — you cannot look up an Application, a TriggerTemplate, a ScaledJob, a NodePool or an ExternalSecret mid-exam. What you can always do is interrogate the cluster: kubectl explain <kind> --recursive, kubectl api-resources and kubectl get <kind> <existing> -o yaml are not documentation, they are the live API, and they are your real reference. Separately: exam specifics move. Task counts, the 120-minute duration, the 64% pass mark, domain weights, the permitted-resource list and the Kubernetes version on the desktop have all changed before and will change again. Everything on this page is a study aid written at a point in time — the official CNCF certification pages and the Linux Foundation training site are the only authority. Confirm before you book.
Five — read the whole paper first, then choose your order. Six minutes spent reading all eighteen tasks is the highest-return six minutes of the sitting. You will spot the four you can finish in five minutes each, and the one that is going to eat twelve. Bank the cheap points first; sequential order is a trap in a weighted, partial-credit exam.
Your time budget
☺ Like you’re 10: Give every question its own small slice of the two hours. When a question eats more than its slice, put a sticky note on it and move on — you can always come back.
Eighteen tasks in 120 minutes is six and a half minutes each on average, but they are not equal and you should not treat them as though they are. Budget deliberately: six minutes to read everything, 108 minutes of task work, and six minutes at the end to re-run your done-when checks. That last block earns more points per minute than anything else on the paper, because half-finished work you never verified is exactly where points quietly die.
| Block | Tasks | Budget | Running total | Note |
|---|---|---|---|---|
| Read the whole paper | — | 6 min | 6 | Mark every task cheap / medium / expensive before you touch a terminal. |
| GitOps & Continuous Delivery | T1–T5 | 6 + 6 + 7 + 6 + 6 = 31 min | 37 | Biggest domain. T3 (Triggers) has the most objects to type — start it early or not at all. |
| Platform APIs & Self-Service | T6–T10 | 5 + 7 + 6 + 6 + 5 = 29 min | 66 | T6 and T10 are the two cheapest tasks on the paper. Do them first. |
| Observability & Operations | T11–T13 | 8 + 7 + 6 = 21 min | 87 | T11 is the single most expensive task. T13 is triage and may resolve in ninety seconds. |
| Platform Architecture & Infrastructure | T14–T16 | 6 + 5 + 5 = 16 min | 103 | Three self-contained tasks with excellent points-per-minute. |
| Security & Policy Enforcement | T17–T18 | 6 + 5 = 11 min | 114 | Two tasks worth 15 points between them — the highest value on the paper. Never skip. |
| Verify & sweep flagged tasks | — | 6 min | 120 | Re-run every done-when. Finish the nearly-done before you start anything new. |
The flag-and-move rule: when a task passes roughly six minutes with no passing done-when check, stop. Write one line about where you stalled, leave whatever you already applied in place — partial credit is real — and go to the next task. The urge to finish what you started is the single most expensive habit in a timed performance exam: it trades a guaranteed five points elsewhere for a possible five points here. Timmy’s version: you are not paid to finish tasks, you are paid to bank points.
Points, not tasks, are the unit of this exam. With a 64% bar you can fail four or five tasks outright and still pass comfortably — but only if you actually opened the cheap ones. Candidates who fail are rarely the ones who knew too little; they are the ones who spent twenty minutes on a Crossplane claim and never reached the two security tasks worth fifteen points at the bottom of the paper.
GitOps & Continuous Delivery — T1 to T5 (25 points)
☺ Like you’re 10: These five are about the robot that reads instructions from a folder: how it lays things down, what it should politely ignore, how a webhook wakes the build up, how to build a container without giving the builder the keys to the whole machine, and how to test a new version on the side before letting anyone use it.
The biggest slice of the blueprint and the one where speed pays most. Background reading lives in GitOps workflows, CI/CD & progressive delivery and release engineering, and the hands-on versions are the GitOps lab and the delivery lab — read and run those after the sitting, never during it.
T1 · Fix a sync that fails on annotation size, and let Argo CD own the namespace
The platform-addons Application will not sync. The controller reports metadata.annotations: Too long: must have at most 262144 bytes on one of the Prometheus Operator CRDs, and a second error says the destination namespace observability does not exist. A previous engineer “fixed” this by applying the CRDs by hand, which is why nobody trusts the sync status any more.
Your task:
- Switch the Application to server-side apply so the whole manifest is no longer stuffed into an annotation.
- Have Argo CD create the destination namespace itself, and stamp it with a Pod Security label and an owner annotation that Argo CD keeps reconciled.
- Sync, and prove Argo CD now owns the CRD through an
Applyentry inmanagedFieldsrather than through a last-applied annotation.
Done when: argocd app sync platform-addons completes without the Too long error, kubectl get ns observability -o jsonpath='{.metadata.labels}' shows the label you declared, and kubectl get crd prometheuses.monitoring.coreos.com -o jsonpath='{.metadata.managedFields[?(@.manager=="argocd-controller")].operation}' prints Apply — the ownership record now lives in managedFields, so Argo CD no longer has to write the manifest into an annotation at all. (5 points)
Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-addons
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/platform-config.git
targetRevision: main
path: infrastructure/observability
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true
managedNamespaceMetadata: # Argo CD owns these on the namespace it creates
labels:
pod-security.kubernetes.io/enforce: baseline
platform.acme.io/managed: "true"
annotations:
platform.acme.io/owner: platform-team
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
- ApplyOutOfSyncOnly=true# per-resource override, when only one object needs it:
metadata:
annotations:
argocd.argoproj.io/sync-options: ServerSideApply=truekubectl apply -f platform-addons-app.yaml
argocd app sync platform-addons
argocd app get platform-addons
kubectl get ns observability -o jsonpath='{.metadata.labels}{"\n"}'
kubectl get crd prometheuses.monitoring.coreos.com \
-o jsonpath='{.metadata.managedFields[*].manager}{"\n"}'
kubectl get crd prometheuses.monitoring.coreos.com \
-o jsonpath='{.metadata.managedFields[?(@.manager=="argocd-controller")].operation}{"\n"}' # ApplyWhy: client-side apply keeps a full copy of the manifest in the kubectl.kubernetes.io/last-applied-configuration annotation so it can compute a three-way merge — and the API server caps all annotations on an object at 256 KiB, which the big operator CRDs blow straight past. Server-side apply moves that bookkeeping into metadata.managedFields on the server, so Argo CD writes no annotation at all and several controllers can co-own different fields of the same object without fighting. (An object that was previously client-side applied by hand can keep its old, stale last-applied-configuration annotation until somebody strips it — that is cosmetic; what fixes the sync is that nothing writes a new one.) CreateNamespace=true makes Argo CD create the destination namespace instead of failing, and managedNamespaceMetadata lets it own that namespace’s labels and annotations without you shipping a separate Namespace manifest — which is how the Pod Security label stops being something a human remembers. More in Argo CD.
T2 · Stop a second controller from making an app permanently OutOfSync
The checkout Application shows OutOfSync forever, and two legitimate second owners are the reason. A HorizontalPodAutoscaler holds the Deployment at 11 replicas while Git says 4, so kube-controller-manager and Argo CD overwrite spec.replicas in turn; and cert-manager’s cainjector writes a caBundle into the ValidatingWebhookConfiguration the app ships, which Git cannot contain because the CA is generated in-cluster. Self-heal reverts both, the controllers write them back, and deploy frequency has halved because nobody can tell a real diff from this one.
Your task:
- Configure the
checkoutApplication to ignore whatever the autoscaler’s field manager owns on the Deployment. - Additionally ignore the injected
caBundleby matching the webhook entry by name rather than by array index, so the rule survives a reordering. - Make sure automated self-heal honours both exclusions, and show the app is Synced with the scaled replica count and the injected CA still in place.
Done when: kubectl -n argocd get app checkout -o jsonpath='{.status.sync.status}' prints Synced while kubectl -n checkout get deploy checkout -o jsonpath='{.spec.replicas}' still shows the autoscaler’s count rather than the number in Git, kubectl get validatingwebhookconfiguration checkout-validate -o jsonpath='{.webhooks[0].clientConfig.caBundle}' is non-empty, and argocd app diff checkout; echo $? prints 0. (5 points)
Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: checkout
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/platform-config.git
targetRevision: main
path: apps/checkout/overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: checkout
ignoreDifferences:
# 1. drop everything a named field manager owns — the right tool for a second controller
- group: apps
kind: Deployment
namespace: checkout
managedFieldsManagers:
- kube-controller-manager # the HPA writes spec.replicas through this manager
# 2. and match the injected array element by name, not by index
- group: admissionregistration.k8s.io
kind: ValidatingWebhookConfiguration
name: checkout-validate
jqPathExpressions:
- '.webhooks[] | select(.name == "validate.checkout.acme.io") | .clientConfig.caBundle'
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- RespectIgnoreDifferences=true # without this, self-heal reverts anywaykubectl apply -f checkout-app.yaml
argocd app get checkout
argocd app diff checkout; echo $? # 0 means no diff at all
kubectl -n argocd get app checkout -o jsonpath='{.status.sync.status}{"\n"}'
kubectl -n checkout get deploy checkout -o jsonpath='{.spec.replicas}{"\n"}' # the HPA's number
kubectl get validatingwebhookconfiguration checkout-validate \
-o jsonpath='{.webhooks[0].clientConfig.caBundle}{"\n"}' | head -c 40; echo
kubectl -n checkout get deploy checkout \
-o jsonpath='{range .metadata.managedFields[*]}{.manager}{" "}{.operation}{"\n"}{end}' # who owns whatWhy: an autoscaler is not drift — it is a second legitimate owner of part of the object, and the fix has to say so rather than paper over a diff. managedFieldsManagers tells Argo CD to subtract from the diff everything a named field manager owns, so you never enumerate the fields yourself and the rule keeps working when that controller changes them. Know its limit, because the exam likes it: this only works against a separate controller that writes the object in its own request. A mutating admission webhook’s patch is applied inside the original request, so the API server attributes it to the requester — Argo CD’s own argocd-controller — and there is no separate manager entry to name. For those, and for anything inside an array whose index you cannot predict, use jqPathExpressions with a select() on a stable key such as the webhook or container name. And exactly as with any ignore rule, ignoreDifferences alone only silences the diff — automated self-heal recomputes its own target and will still re-apply until you add the RespectIgnoreDifferences=true sync option. Keep every rule scoped to a kind, a namespace and where possible a name; a group-wide ignore hides real drift. More in Argo CD and cert-manager.
T3 · Start a build from a webhook with Tekton Triggers
Builds currently start when somebody remembers to run tkn pipeline start, which means the image in staging is routinely three commits behind. The team wants a push to main to start a PipelineRun automatically, with the commit SHA carried through as a real parameter rather than resolved later by a script.
Your task:
- Create the ServiceAccount and RoleBinding the EventListener needs to create PipelineRuns in
ci. - Create a
TriggerBindingthat extracts the commit SHA and clone URL from a GitHub push payload, and aTriggerTemplatethat turns them into a PipelineRun with a per-run workspace. - Create an
EventListenerwith a GitHub interceptor that verifies the HMAC signature and only acceptspushevents, then fire a test payload and prove a run appeared.
Done when: kubectl -n ci get eventlistener build-listener -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True, and after posting a signed sample payload, tkn pipelinerun list -n ci shows a new run whose revision param equals the SHA you sent. (5 points)
Show the worked solution
apiVersion: v1
kind: ServiceAccount
metadata:
name: tekton-triggers-sa
namespace: ci
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tekton-triggers-eventlistener
namespace: ci
subjects:
- kind: ServiceAccount
name: tekton-triggers-sa
namespace: ci
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: tekton-triggers-eventlistener-roles # shipped by the Triggers install
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding # ALSO required: interceptors are cluster-scoped
metadata:
name: tekton-triggers-eventlistener-clusterbinding
subjects:
- kind: ServiceAccount
name: tekton-triggers-sa
namespace: ci
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: tekton-triggers-eventlistener-clusterroles
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
name: github-push
namespace: ci
spec:
params:
- name: gitrevision
value: $(body.head_commit.id)
- name: gitrepositoryurl
value: $(body.repository.clone_url)
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
name: build-template
namespace: ci
spec:
params:
- name: gitrevision
- name: gitrepositoryurl
resourcetemplates:
- apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: build-
spec:
pipelineRef:
name: build-and-deploy
params:
- name: revision
value: $(tt.params.gitrevision)
- name: repo-url
value: $(tt.params.gitrepositoryurl)
workspaces:
- name: shared
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
---
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
name: build-listener
namespace: ci
spec:
serviceAccountName: tekton-triggers-sa
triggers:
- name: github-push
interceptors:
- ref:
name: github
params:
- name: secretRef
value:
secretName: github-webhook-secret
secretKey: secretToken
- name: eventTypes
value: ["push"]
bindings:
- ref: github-push
template:
ref: build-templatekubectl -n ci create secret generic github-webhook-secret \
--from-literal=secretToken="$WEBHOOK_TOKEN"
kubectl apply -f triggers.yaml
kubectl -n ci get eventlistener build-listener
kubectl -n ci get pods -l eventlistener=build-listener # the sink runs as a pod
kubectl -n ci port-forward svc/el-build-listener 8080:8080 &
BODY='{"head_commit":{"id":"a1b2c3d4e5f6"},"repository":{"clone_url":"https://github.com/acme/checkout.git"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_TOKEN" | awk '{print $2}')
curl -sS -X POST http://localhost:8080 \
-H 'Content-Type: application/json' \
-H 'X-GitHub-Event: push' \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"
tkn pipelinerun list -n ci
kubectl -n ci get pipelinerun --sort-by=.metadata.creationTimestamp \
-o jsonpath='{.items[-1:].spec.params[?(@.name=="revision")].value}{"\n"}'
kubectl -n ci logs -l eventlistener=build-listener --tail=40 # where silent failures show upWhy: Triggers deliberately splits webhook handling into three objects so each part is reusable. The EventListener is an HTTP sink that runs as a real pod behind a Service called el-<name>; TriggerBindings pull fields out of whatever JSON arrived; TriggerTemplates describe the resources to create, parameterised with $(tt.params.…). Two things fail silently and account for most lost time here: the listener runs under a ServiceAccount and without both bindings — a namespaced RoleBinding to create PipelineRuns and a ClusterRoleBinding so it can read the cluster-scoped ClusterInterceptor objects — it will happily return 202 Accepted and create nothing, and an interceptor that rejects the payload (wrong signature, wrong event type) also returns success while doing nothing — so read the listener’s own logs, not the HTTP status. Validating the HMAC in an interceptor rather than in a pipeline step is what stops anyone on the network starting builds. See Tekton.
T4 · Build an image in-cluster without a privileged container
Your build pipeline runs a Docker-in-Docker sidecar with privileged: true. Security has given you two weeks to remove it: a malicious Dockerfile in that pod owns the node’s kernel, and the same node runs other tenants’ workloads.
Your task:
- Write a Tekton Task that builds and pushes an image with Kaniko, taking source from a
sourceworkspace and registry credentials from adockerconfigworkspace. - Mount a
kubernetes.io/dockerconfigjsonSecret under the filename the Docker credential chain actually reads, and run the step unprivileged with no privilege escalation, dropping every capability and adding back only those the build genuinely needs. - Run it and emit the pushed digest as a Task result.
Done when: the TaskRun succeeds, kubectl -n ci get taskrun --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].status.results[?(@.name=="IMAGE_DIGEST")].value}' prints a sha256:… digest, and kubectl -n ci get pods -l tekton.dev/taskRun -o jsonpath='{.items[*].spec.containers[*].securityContext.privileged}' contains no true while the same query for .allowPrivilegeEscalation contains no true either. (5 points)
Show the worked solution
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: kaniko-build
namespace: ci
spec:
params:
- name: IMAGE
type: string
- name: DOCKERFILE
type: string
default: ./Dockerfile
- name: CONTEXT
type: string
default: ./
workspaces:
- name: source
- name: dockerconfig
optional: true
mountPath: /kaniko/.docker # where the credential chain looks
results:
- name: IMAGE_DIGEST
description: Digest of the image that was pushed
- name: IMAGE_URL
description: Fully qualified image reference
steps:
- name: build-and-push
image: gcr.io/kaniko-project/executor:v1.23.2
workingDir: $(workspaces.source.path)
env:
- name: DOCKER_CONFIG
value: /kaniko/.docker
args:
- --dockerfile=$(params.DOCKERFILE)
- --context=$(workspaces.source.path)/$(params.CONTEXT)
- --destination=$(params.IMAGE)
- --digest-file=$(results.IMAGE_DIGEST.path)
securityContext:
runAsUser: 0 # root INSIDE the container, not on the node
privileged: false
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
# least privilege, not zero privilege: unpacking a base image
# restores file ownership and modes, so these are still required
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: record-url
image: alpine:3.20
script: |
#!/bin/sh
printf '%s' "$(params.IMAGE)" > $(results.IMAGE_URL.path)apiVersion: tekton.dev/v1
kind: TaskRun
metadata:
generateName: kaniko-build-
namespace: ci
spec:
taskRef:
name: kaniko-build
params:
- name: IMAGE
value: ghcr.io/acme/checkout:2.7.0
workspaces:
- name: source
persistentVolumeClaim:
claimName: source-cache
- name: dockerconfig
secret:
secretName: ghcr-push # type kubernetes.io/dockerconfigjson
items:
- key: .dockerconfigjson # the key a docker-registry secret uses
path: config.json # the filename kaniko/docker expectskubectl -n ci create secret docker-registry ghcr-push \
--docker-server=ghcr.io --docker-username=acme-ci --docker-password="$GHCR_TOKEN"
kubectl create -f kaniko-taskrun.yaml
tkn taskrun logs -f -n ci --last
kubectl -n ci get taskrun --sort-by=.metadata.creationTimestamp \
-o jsonpath='{.items[-1:].status.results[?(@.name=="IMAGE_DIGEST")].value}{"\n"}'
kubectl -n ci get pods -l tekton.dev/taskRun \
-o jsonpath='{.items[*].spec.containers[*].securityContext.privileged}{"\n"}'
kubectl -n ci get pods -l tekton.dev/taskRun \
-o jsonpath='{.items[*].spec.containers[*].securityContext.allowPrivilegeEscalation}{"\n"}'Why: Docker-in-Docker needs a privileged container because the daemon wants to manage cgroups, namespaces and a storage driver on the host — which hands any Dockerfile in your pipeline the node’s kernel. Kaniko instead unpacks the base image and executes each Dockerfile instruction inside its own container filesystem in userspace: it still needs UID 0 and a handful of file-ownership capabilities within that container to restore a base image’s permissions, but it needs no privileged: true, no host mounts, no host PID or network and no access to the container runtime socket — which is the distinction the exam is testing. Dropping ALL and adding back only CHOWN, DAC_OVERRIDE, FOWNER, SETGID and SETUID is the honest version; dropping ALL with nothing added looks stricter on the page and fails the moment the base image has a file owned by anyone but root. The credential mapping is the other detail people lose time on: a docker-registry Secret stores its payload under .dockerconfigjson, but the credential chain reads a file called config.json, so the workspace has to remap the key to that path. Kaniko itself is no longer actively developed; Buildah and rootless BuildKit are the current equivalents and take the same shape, so learn the pattern rather than the flags.
T5 · Gate a blue-green promotion on analysis, at a fraction of the cost
The checkout service is already deployed blue-green, but two things are wrong. Running a full second copy of eight pods for every release doubles the cost of the deployment window, and “promotion” is a human clicking a button after glancing at a dashboard — which is how last month’s 8% error rate reached 100% of traffic.
Your task:
- Run the preview stack at a fraction of production scale while auto-promotion stays off.
- Add an
AnalysisTemplatethat queries Prometheus for the preview’s error ratio, and attach it asprePromotionAnalysiswith the service name passed in as an argument. - Trigger a bad revision and show the rollout degrades without the active Service ever pointing at it.
Done when: during the rollout kubectl argo rollouts get rollout checkout -n checkout shows the preview ReplicaSet at the reduced count while the active stays at 8; kubectl -n checkout get analysisrun -o jsonpath='{.items[-1:].status.phase}' prints Failed; and kubectl -n checkout get svc checkout-active -o jsonpath='{.spec.selector.rollouts-pod-template-hash}' is unchanged from the value you noted before triggering. (5 points)
Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: preview-error-rate
namespace: checkout
spec:
args:
- name: service
metrics:
- name: error-ratio
interval: 30s
count: 4 # four samples before a verdict
failureLimit: 1
successCondition: result[0] <= 0.01
provider:
prometheus:
address: http://prometheus-operated.monitoring.svc:9090
query: |
sum(rate(http_requests_total{service="{{args.service}}",code=~"5.."}[2m]))
/
sum(rate(http_requests_total{service="{{args.service}}"}[2m]))
---
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: checkout
namespace: checkout
spec:
replicas: 8
revisionHistoryLimit: 3
selector:
matchLabels:
app: checkout
template:
metadata:
labels:
app: checkout
spec:
containers:
- name: checkout
image: ghcr.io/acme/checkout:3.4.0
ports:
- containerPort: 8080
strategy:
blueGreen:
activeService: checkout-active
previewService: checkout-preview
previewReplicaCount: 2 # 25% of production while under test
autoPromotionEnabled: false
scaleDownDelaySeconds: 120 # old stack stays warm for instant rollback
abortScaleDownDelaySeconds: 30
prePromotionAnalysis:
templates:
- templateName: preview-error-rate
args:
- name: service
value: checkout-previewkubectl -n checkout get svc checkout-active \
-o jsonpath='{.spec.selector.rollouts-pod-template-hash}{"\n"}' # note this first
# the plugin, not `kubectl set image` — that only understands built-in workload kinds
kubectl argo rollouts set image checkout checkout=ghcr.io/acme/checkout:3.5.0 -n checkout
kubectl argo rollouts get rollout checkout -n checkout --watch
kubectl -n checkout get analysisrun
kubectl -n checkout get analysisrun -o jsonpath='{.items[-1:].status.phase}{"\n"}'
kubectl -n checkout get rollout checkout -o jsonpath='{.status.phase}{"\n"}' # Degraded
kubectl -n checkout get svc checkout-active \
-o jsonpath='{.spec.selector.rollouts-pod-template-hash}{"\n"}' # unchanged
kubectl argo rollouts abort checkout -n checkout
kubectl argo rollouts undo checkout -n checkoutWhy: blue-green’s cost is a whole second copy of the workload, and previewReplicaCount is the lever that makes the bill proportional to the test rather than to production — the controller scales the preview stack up to full size only once analysis passes and promotion begins, so you are never serving live traffic at reduced capacity. prePromotionAnalysis is what turns the human click into a measurement: the AnalysisRun samples the metric count times against successCondition, and a failure marks the Rollout Degraded while the active Service selector never moves — the bad version was real, deployed and observed, and no user ever saw it. Note that the arg indirection (args on the Rollout feeding {{args.service}} in the template) is what lets one AnalysisTemplate serve every service on the platform. Compare with canary weighting in Argo Rollouts and progressive delivery.
“Four of those five are invisible to me and I feel every one. Server-side apply means the sync status stopped lying, so I trust the dashboard during an incident again. The ignore rule means ‘OutOfSync’ means something. The webhook means my build starts before I have finished writing the PR description. And the analysis gate means the worst release I ever shipped was seen by two synthetic requests and nobody else. None of that came with a runbook — the platform just stopped having those failure modes.”
Platform APIs & Self-Service — T6 to T10 (25 points)
☺ Like you’re 10: These five are about the new kinds of object your platform invents: how to rescue one that refuses to be deleted, how to stop its caretaker robot spinning in circles, how to find out why a request for a database is stuck, how to turn a README into a form, and how to stop people asking for things they should not have.
The other 25% domain. Roughly half of it is mechanics you can drill to reflex; the rest is judgement about what to expose and what to keep behind the curtain. Background: Platform APIs & operators, self-service & golden paths and IaC & control planes, with the keyboard work in the Platform APIs lab.
T6 · Rescue objects stuck Terminating behind an orphaned finalizer
Someone uninstalled the Environment operator before deleting its custom resources. Three Environment objects have been Terminating for six days, the demo namespace that contains them is stuck Terminating too, and a colleague is thirty seconds away from running kubectl delete ns demo --force and calling it fixed. The cloud resources those environments guarded were already torn down by hand last week.
Your task:
- Show from evidence — not from a guess — exactly what is blocking each deletion.
- Unblock the objects without force-deleting the namespace and without touching the namespace’s own
finalizesubresource. - Confirm the namespace terminates on its own once the blocker clears.
Done when: kubectl get environments -A returns no resources, kubectl get ns demo returns NotFound, and you can state which finalizer string you removed and why it was safe. (5 points)
Show the worked solution
# 1. EVIDENCE — a Terminating object always tells you both halves
kubectl -n demo get environment demo \
-o jsonpath='{.metadata.deletionTimestamp}{"\n"}{.metadata.finalizers}{"\n"}'
# the namespace names its own blockers in status.conditions
kubectl get ns demo -o jsonpath='{range .status.conditions[*]}{.type}{": "}{.message}{"\n"}{end}'
# is the controller that owns that finalizer even running?
kubectl get crd environments.platform.acme.io
kubectl -n platform-system get deploy,pods
# what else is still alive in the namespace?
kubectl api-resources --verbs=list --namespaced -o name \
| xargs -n1 kubectl get -n demo --show-kind --ignore-not-found# 2. remove ONLY the orphaned finalizer, on the object that carries it
kubectl -n demo patch environment demo --type=json \
-p '[{"op":"remove","path":"/metadata/finalizers/0"}]'
# same thing for every remaining object, once you have confirmed each is safe
for e in $(kubectl get environments -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'); do
kubectl -n "${e%%/*}" patch environment "${e##*/}" --type=merge \
-p '{"metadata":{"finalizers":null}}'
done
# 3. the namespace finishes terminating by itself — no force, no /finalize
kubectl get environments -A
kubectl get ns demoWhy: a finalizer is nothing more than a string in metadata.finalizers, and its entire power is that the API server refuses to remove the object while the list is non-empty. kubectl delete on such an object does not delete it: it sets metadata.deletionTimestamp and returns, and the controller that put the string there is supposed to notice the timestamp, do its external cleanup, and then remove its own string — that removal is what actually deletes the object. Uninstall the controller first and nobody will ever remove it, so the object, and any namespace containing it, waits forever. Two traps: --force --grace-period=0 does not bypass a finalizer, and clearing the namespace’s spec.finalizers through the /finalize endpoint makes the namespace disappear while leaving its contents stranded in etcd — unreachable but still counted. Removing the specific object finalizer is the correct recovery, and it is only correct because you first confirmed the external resource it was guarding is genuinely gone. Read the contract again in Platform APIs & operators.
T7 · Stop a controller reconciling itself in a hot loop
The Environment controller pegs a full CPU core on a cluster with twelve Environment objects and nothing changing. controller_runtime_reconcile_total climbs by several thousand a minute, the API server audit log is a wall of identical PATCH requests, and kubectl wait --for=condition=Ready against an Environment sometimes returns immediately even when the object is mid-provision. The reconciler writes status on every pass and returns Requeue: true.
Your task:
- Make the reconcile idempotent: write status only when a condition actually changed, using
metav1.ConditionwithobservedGeneration. - Replace the unconditional requeue with a periodic resync, and make sure status is written through the
statussubresource so it cannot bumpmetadata.generation. - Add the CRD plumbing that makes
kubectl wait --for=condition=Readyand a Ready printer column work.
Done when: controller_runtime_reconcile_total for the controller grows by fewer than 20 over two minutes on an idle cluster, and kubectl wait --for=condition=Ready environment/demo -n demo --timeout=60s prints condition met while kubectl get environment demo -n demo shows a READY column. (5 points)
Show the worked solution
func (r *EnvironmentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var env platformv1.Environment
if err := r.Get(ctx, req.NamespacedName, &env); err != nil {
// gone: stop cleanly, never requeue a NotFound
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// ... do the actual work, which must itself be idempotent ...
ready := metav1.Condition{
Type: "Ready",
Status: metav1.ConditionTrue,
Reason: "Provisioned",
Message: "all child resources are healthy",
ObservedGeneration: env.Generation,
}
// SetStatusCondition reports whether it changed anything (apimachinery v0.27+)
if meta.SetStatusCondition(&env.Status.Conditions, ready) {
if err := r.Status().Update(ctx, &env); err != nil {
// a conflict is normal; controller-runtime requeues with backoff
return ctrl.Result{}, err
}
}
// periodic resync, not a busy loop
return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}
func (r *EnvironmentReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&platformv1.Environment{},
builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Complete(r)
}# the CRD side: a status subresource, a typed conditions array, a printer column
subresources:
status: {}
additionalPrinterColumns:
- name: Ready
type: string
jsonPath: .status.conditions[?(@.type=="Ready")].status
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
tier: { type: string, enum: [dev, staging, prod] }
status:
type: object
properties:
conditions:
type: array
x-kubernetes-list-type: map
x-kubernetes-list-map-keys: [type]
items:
type: object
required: [type, status, lastTransitionTime, reason]
properties:
type: { type: string }
status: { type: string, enum: ["True", "False", "Unknown"] }
reason: { type: string }
message: { type: string }
observedGeneration: { type: integer, format: int64 }
lastTransitionTime: { type: string, format: date-time }kubectl -n platform-system port-forward deploy/environment-controller-manager 8080:8080 &
curl -s localhost:8080/metrics | grep -E 'controller_runtime_reconcile_total|workqueue_depth'
sleep 120
curl -s localhost:8080/metrics | grep controller_runtime_reconcile_total # barely moved
kubectl get environment demo -n demo
kubectl wait --for=condition=Ready environment/demo -n demo --timeout=60s
kubectl get environment demo -n demo \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].observedGeneration}{"\n"}'Why: the hot loop is self-inflicted and needs both halves of the fix. Requeue: true re-enqueues immediately (rate-limited, but immediately), and a controller that also writes on every pass emits a watch event that re-triggers itself — so the loop feeds itself forever. Writing only on a real change breaks the feedback; writing through the status subresource means metadata.generation is untouched, so a GenerationChangedPredicate filters the event out entirely. RequeueAfter then gives you what you actually wanted: a slow safety net that catches drift the watch missed. observedGeneration on the condition is what lets a caller distinguish “Ready, and that verdict is about the current spec” from “Ready, about a spec from three edits ago” — without it, kubectl wait --for=condition=Ready is a coin flip immediately after an update. More in Kubebuilder.
T8 · Diagnose a Crossplane claim that never becomes Ready
A developer created a PostgresInstance claim forty minutes ago. kubectl describe on the claim says only that it is waiting for the composite resource to become ready. The composite says it is waiting for its composed resources. The developer’s instinct is to delete the claim and try again; the platform team’s standing rule is that you never do that before you know what failed.
Your task:
- Walk the chain claim → composite → managed resources and identify which object is actually failing.
- Read the two conditions that mean different things on the failing managed resource, and name the root cause from them.
- Fix it and bring the existing claim to Ready — without deleting or re-creating the claim.
Done when: kubectl -n team-a get postgresinstance db-1 -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True, and kubectl get managed shows every composed resource with SYNCED=True READY=True. (5 points)
Show the worked solution
# 1. FOLLOW THE CHAIN — the claim names its composite, the composite names its children
kubectl -n team-a get postgresinstance db-1 \
-o jsonpath='{.spec.resourceRef.kind}{" "}{.spec.resourceRef.name}{"\n"}'
XR=$(kubectl -n team-a get postgresinstance db-1 -o jsonpath='{.spec.resourceRef.name}')
kubectl get xpostgresinstance "$XR" -o yaml | sed -n '/status:/,$p'
kubectl describe xpostgresinstance "$XR" | sed -n '/Events/,$p' # composition errors land here
kubectl get managed # the SYNCED / READY columns point at the culprit
# 2. THE TWO CONDITIONS SAY DIFFERENT THINGS
kubectl get instance.rds.aws.upbound.io "$XR-db" \
-o jsonpath='{.status.conditions[?(@.type=="Synced")].message}{"\n"}'
kubectl get instance.rds.aws.upbound.io "$XR-db" \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].message}{"\n"}'
kubectl describe instance.rds.aws.upbound.io "$XR-db" | sed -n '/Events/,$p'
# the usual culprits behind Synced=False
kubectl get providers
kubectl get providerconfigs.aws.upbound.io
kubectl -n crossplane-system logs -l pkg.crossplane.io/provider --tail=50# ROOT CAUSE: the Composition points at a ProviderConfig name that does not exist,
# so every composed resource sits at Synced=False, "cannot get referenced ProviderConfig".
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default # the exact name the Composition references
spec:
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: aws-creds
key: creds
---
# and the base in the Composition must reference it explicitly
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
region: eu-west-1
engine: postgres
instanceClass: db.t3.micro
allocatedStorage: 20
providerConfigRef:
name: defaultkubectl apply -f providerconfig.yaml
kubectl apply -f composition.yaml # no need to touch the claim
kubectl get managed -w
kubectl -n team-a get postgresinstance db-1 \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'Why: Crossplane’s chain is claim → composite (XR) → managed resource → cloud API, and every layer politely reports “waiting for the layer below”, which makes the claim’s message the least informative one in the system. Skip to kubectl get managed and then read the two conditions, because they answer different questions: Synced means “Crossplane reconciled this spec with the provider API” — false points at credentials, a missing or misnamed ProviderConfig, or a field the provider rejected; Ready means “the external resource exists and is usable” — false with Synced=True usually just means an RDS instance is still creating and you should wait. Composition-level failures (a bad patch, a missing function) appear as events on the composite, not on the claim, which is why kubectl describe on the XR is the single highest-value command here. Resist deleting the claim: it destroys the evidence and, with the default deletionPolicy: Delete, can take a real database with it. See Crossplane.
T9 · Turn a README golden path into a Backstage software template
Your “golden path” is a README that says copy the checkout repo and change the names. In practice teams copy an eighteen-month-old repo, forget the catalog registration, and the service catalog is now about 40% stale — which means the ownership data your incident process depends on is wrong.
Your task:
- Write a
scaffolder.backstage.io/v1beta3Template with validated parameters: a name matching a DNS-label pattern, an owner picked from realGroupentities, and a repository location restricted to your GitHub organisation. - Add steps that render the skeleton, publish the repository, and register the produced
catalog-info.yaml, then expose the repo and the new catalog entity as output links. - Register the template itself in the catalog and prove both the template and a scaffolded component resolve through the catalog API.
Done when: curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=template,metadata.name=node-service' | jq -r '.[].metadata.name' returns node-service, and after running it, the same query with kind=component and the new name returns the new Component with the owner you selected. (5 points)
Show the worked solution
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: node-service
title: Node.js service (golden path)
description: Creates a Node.js service repo with CI and a registered catalog entry.
tags: [recommended, nodejs]
spec:
type: service
owner: group:default/platform-team
parameters:
- title: Service
required: [name, owner]
properties:
name:
title: Name
type: string
description: Lowercase DNS label; becomes the repo and component name.
pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'
maxLength: 30
ui:autofocus: true
owner:
title: Owner
type: string
description: The team that will be paged for this service.
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
- title: Repository
required: [repoUrl]
properties:
repoUrl:
title: Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts: [github.com]
allowedOwners: [acme]
steps:
- id: fetch
name: Render the skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
- id: publish
name: Publish to GitHub
action: publish:github
input:
repoUrl: ${{ parameters.repoUrl }}
description: ${{ parameters.name }}
defaultBranch: main
repoVisibility: private
- id: register
name: Register in the catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Open in catalog
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}# skeleton/catalog-info.yaml — rendered by fetch:template, then registered
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
annotations:
github.com/project-slug: acme/${{ values.name }}
backstage.io/techdocs-ref: dir:.
spec:
type: service
lifecycle: experimental
owner: ${{ values.owner }}
system: checkout# app-config.yaml — the template only exists once the catalog is told about it
catalog:
locations:
- type: url
target: https://github.com/acme/software-templates/blob/main/node-service/template.yaml
rules:
- allow: [Template]curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=template,metadata.name=node-service' \ | jq -r '.[].metadata.name' # after running the template from the UI: curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=component,metadata.name=orders-api' \ | jq -r '.[] | .metadata.name + " owned by " + .spec.owner'
Why: a golden path is only golden when the only work left is filling in a form, and that means the form has to make invalid answers impossible. The pattern keeps the name a legal DNS label so the same string can be a repo, a namespace and a Service; OwnerPicker with a catalogFilter forces the owner to be a Group that actually exists, which is what stops the catalog filling with dangling ownership references; RepoUrlPicker with allowedOwners keeps new services inside your organisation. The step that turns a repo generator into a platform is catalog:register — without it the component exists but the catalog never learns about it, which is precisely the 40% staleness you started with. One nuance worth holding: ${{ parameters.x }} and ${{ steps.y.output.z }} are evaluated by the scaffolder as it runs, while ${{ values.x }} inside the skeleton is rendered by the fetch:template action into the generated files. See Backstage and developer experience.
T10 · Guard your own API with a ValidatingAdmissionPolicy
Your Environment API lets any team request tier: prod. Three teams have quietly created prod environments in the sandbox cluster, one of which now holds real customer data. You want a cluster-side guardrail that needs no policy engine installed, cannot be bypassed by a mutating controller running later in the chain, and does not take the cluster down with it if a webhook pod dies.
Your task:
- Write a
ValidatingAdmissionPolicywhose CEL rejects anEnvironmentwithspec.tier: produnless its namespace carriesplatform.acme.io/prod-approved: "true". - Bind it with a
ValidatingAdmissionPolicyBindinglimited to namespaces labelled as tenants, withvalidationActionsthat both deny and audit. - Prove it denies in an unapproved namespace and admits in an approved one.
Done when: kubectl -n team-a apply -f prod-env.yaml is rejected with your own message, the identical file applies successfully in team-approved, and kubectl get validatingadmissionpolicy env-tier-guard -o jsonpath='{.status.typeChecking}' reports no CEL type errors. (5 points)
Show the worked solution
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: env-tier-guard
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["platform.acme.io"]
apiVersions: ["v1beta1"]
operations: ["CREATE", "UPDATE"]
resources: ["environments"]
matchConditions:
- name: only-prod-requests # cheap short-circuit: skip everything else
# tier is optional in the schema, so probe before you read it — a CEL error
# here is a request failure, and failurePolicy: Fail would deny it
expression: 'has(object.spec.tier) && object.spec.tier == "prod"'
variables:
- name: approved
expression: >-
has(namespaceObject.metadata.labels) &&
'platform.acme.io/prod-approved' in namespaceObject.metadata.labels &&
namespaceObject.metadata.labels['platform.acme.io/prod-approved'] == 'true'
validations:
- expression: variables.approved
reason: Forbidden
message: "tier=prod is only allowed in namespaces labelled platform.acme.io/prod-approved=true"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: env-tier-guard
spec:
policyName: env-tier-guard
validationActions: [Deny, Audit]
matchResources:
namespaceSelector:
matchExpressions:
- key: platform.acme.io/tenant
operator: Existskubectl apply -f env-tier-guard.yaml
kubectl get validatingadmissionpolicy env-tier-guard -o jsonpath='{.status.typeChecking}{"\n"}'
kubectl label ns team-a platform.acme.io/tenant=acme-a
kubectl label ns team-approved platform.acme.io/tenant=acme-b platform.acme.io/prod-approved=true
kubectl -n team-a apply -f prod-env.yaml # denied, with your message
kubectl -n team-approved apply -f prod-env.yaml # created
kubectl -n team-approved get environment prod-1Why: a ValidatingAdmissionPolicy is admission control inside the API server: CEL expressions, no webhook pod, no TLS certificate to rotate, and no failure mode where your policy engine being down takes the cluster with it. Two objects are always required and forgetting the second is the classic first mistake — the policy says what to check, the binding says where it applies and whether a violation denies, audits or merely warns; a policy with no binding is completely inert. matchConditions is a cheap pre-filter evaluated before the expensive validations, so the guard costs nothing on the 99% of requests it does not care about. And status.typeChecking is the feature that saves you on exam day: the API server type-checks your CEL against the matched resources when you create the policy, so a typo in a field path surfaces immediately instead of at the moment it wrongly rejects a real request. Compare the webhook-based approach in security & policy and Gatekeeper.
Before you sit the paper, warm up on pure speed rather than knowledge. From a cold terminal, and using only kubectl explain --recursive and an existing object as your reference, produce a valid skeleton for each of these in under three minutes each: an Argo CD Application with two sync options, a ValidatingAdmissionPolicy plus its binding, a KEDA ScaledJob, and a Karpenter NodePool with three requirements. Time each one. Any that takes over three minutes is your highest-value study target on the whole site — ahead of any concept, because on the day you will not have the project documentation to lean on.
Observability & Operations — T11 to T13 (20 points)
☺ Like you’re 10: These three are about noticing problems properly: doing the sums in advance so the dashboard is fast, following one single request to see where its time went, and working out why the measuring tape was never attached in the first place.
Twenty percent of the paper, and the domain that rewards a fixed method most. When a triage task starts, resist the urge to guess — walk the same evidence order every single time. Background: observability, reliability & incidents and the troubleshooting playbook, plus the observability lab and the troubleshooting lab.
T11 · Replace a threshold alert with tested burn-rate alerting
The availability dashboard takes forty seconds to load because every panel recomputes a ratio over raw counters. Meanwhile the single CheckoutErrorRateHigh alert — 1% errors over five minutes — pages on every deploy blip, and it missed a fortnight-long 0.4% error rate that quietly consumed the whole month’s error budget. The SLO is 99.9% over 30 days.
Your task:
- Add recording rules that pre-compute the error ratio at 5m, 30m, 1h and 6h.
- Replace the single alert with a multi-window multi-burn-rate pair: a critical page at 14.4× (1h and 5m windows) and a warning ticket at 6× (6h and 30m windows).
- Unit-test the rules with
promtoolso you can prove the page fires on a fast burn — then ship them as aPrometheusRule.
Done when: promtool check rules slo-rules.yaml prints SUCCESS, promtool test rules slo-tests.yaml reports all tests passed, and after applying, kubectl -n monitoring get prometheusrule checkout-slo -o jsonpath='{.spec.groups[*].name}' lists both groups while the rules appear under /api/v1/rules in Prometheus. (7 points)
Show the worked solution
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: checkout-slo
namespace: monitoring
labels:
release: kube-prometheus-stack # must match the Prometheus CR's ruleSelector
spec:
groups:
- name: checkout-slo-recording
interval: 30s
rules:
- record: job:slo_errors:ratio_rate5m
expr: |
sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="checkout"}[5m]))
- record: job:slo_errors:ratio_rate30m
expr: |
sum(rate(http_requests_total{job="checkout",code=~"5.."}[30m]))
/
sum(rate(http_requests_total{job="checkout"}[30m]))
- record: job:slo_errors:ratio_rate1h
expr: |
sum(rate(http_requests_total{job="checkout",code=~"5.."}[1h]))
/
sum(rate(http_requests_total{job="checkout"}[1h]))
- record: job:slo_errors:ratio_rate6h
expr: |
sum(rate(http_requests_total{job="checkout",code=~"5.."}[6h]))
/
sum(rate(http_requests_total{job="checkout"}[6h]))
- name: checkout-slo-alerting
rules:
- alert: CheckoutErrorBudgetBurnFast
# 14.4x burn on a 0.1% budget = the whole 30-day budget gone in ~2 days
expr: |
job:slo_errors:ratio_rate1h > (14.4 * 0.001)
and
job:slo_errors:ratio_rate5m > (14.4 * 0.001)
for: 2m
labels:
severity: critical
team: checkout
annotations:
summary: "Checkout is burning its error budget 14.4x too fast"
description: "At this rate the 30-day budget is gone in about 2 days."
- alert: CheckoutErrorBudgetBurnSlow
# 6x burn = budget gone in ~5 days. A ticket, not a page.
expr: |
job:slo_errors:ratio_rate6h > (6 * 0.001)
and
job:slo_errors:ratio_rate30m > (6 * 0.001)
for: 15m
labels:
severity: warning
team: checkout
annotations:
summary: "Checkout is burning its error budget 6x too fast"# slo-tests.yaml — promtool unit tests: 5% errors is a fast burn and must page
rule_files:
- slo-rules.yaml
evaluation_interval: 1m
tests:
- interval: 1m
input_series:
- series: 'http_requests_total{job="checkout",code="200"}'
values: '0+95x180'
- series: 'http_requests_total{job="checkout",code="500"}'
values: '0+5x180'
alert_rule_test:
- eval_time: 90m
alertname: CheckoutErrorBudgetBurnFast
exp_alerts:
- exp_labels:
severity: critical
team: checkout
exp_annotations:
summary: "Checkout is burning its error budget 14.4x too fast"
description: "At this rate the 30-day budget is gone in about 2 days."# promtool wants a plain rules file, so lift .spec out of the PrometheusRule
kubectl -n monitoring get prometheusrule checkout-slo -o jsonpath='{.spec}' | yq -P > slo-rules.yaml
promtool check rules slo-rules.yaml
promtool test rules slo-tests.yaml
kubectl apply -f checkout-slo.yaml
kubectl -n monitoring get prometheusrule checkout-slo -o jsonpath='{.spec.groups[*].name}{"\n"}'
curl -s localhost:9090/api/v1/rules | jq -r '.data.groups[].name'
curl -s --get localhost:9090/api/v1/query \
--data-urlencode 'query=job:slo_errors:ratio_rate1h' | jq '.data.result'Why: recording rules move expensive work from query time to evaluation time — the dashboard reads one cheap pre-computed series instead of re-aggregating raw counters per panel, and the alert expressions become short enough to be reviewable. The multi-window multi-burn-rate pattern then fixes both failure modes of a naive threshold at once: the long window (1h, 6h) supplies significance so a thirty-second blip cannot page, and the short window (5m, 30m) supplies fast reset so the alert clears when the burn stops rather than lingering for an hour. The two rates are chosen from the budget, not from taste: at 14.4× a 30-day budget is exhausted in roughly two days, which justifies waking someone; at 6× it takes about five days, which is a ticket. And promtool test rules is the part almost nobody does — an alert you have never seen fire is a hypothesis. One operational trap: the Prometheus Operator only loads a PrometheusRule whose labels match the Prometheus CR’s ruleSelector, so a perfectly correct rule file can sit in the cluster doing nothing. See Prometheus.
T12 · Find a latency regression that every dashboard says is fine
p99 latency on /checkout jumped from 180 ms to 2.4 s after Tuesday’s release. Every individual service’s dashboard looks healthy — each reports a normal median and a normal error rate — and the team has spent a day arguing about which service is at fault. Traces are in Jaeger and the collector is emitting span metrics.
Your task:
- Pull the slowest traces for the
checkoutservice’s/checkoutoperation from the Jaeger query API rather than clicking around the UI. - Determine whether the added time is one slow span or many repeated ones, and name the span responsible.
- State the fix, and add a platform-side guardrail that would have caught it — an alert on the fan-out ratio from span metrics.
Done when: a query with minDuration=2s returns traces before the fix and none after; the span-name histogram for one trace shows the repeated operation dropping from over 120 occurrences to fewer than 10; and the new alert rule loads in Prometheus. (7 points)
Show the worked solution
kubectl -n observability port-forward svc/jaeger-query 16686:16686 &
# 1. the slowest traces for one operation in the last hour
curl -s "http://localhost:16686/api/traces?service=checkout&operation=%2Fcheckout&lookback=1h&minDuration=2s&limit=20" \
| jq -r '.data[] | [.traceID, (.spans | length), ([.spans[].duration] | max / 1000)] | @tsv'
# 2a. ONE SLOW SPAN? sort the spans of a single trace by duration
TRACE=<traceID-from-above>
curl -s "http://localhost:16686/api/traces/$TRACE" \
| jq -r '.data[0].spans[] | [(.duration/1000|floor), .operationName] | @tsv' \
| sort -nr | head -20
# 2b. MANY REPEATED SPANS? count operation names in the same trace
curl -s "http://localhost:16686/api/traces/$TRACE" \
| jq -r '.data[0].spans[].operationName' | sort | uniq -c | sort -nr | head
# -> 124 db.query ... each ~15ms, none individually slow. An N+1.
# which service owns them:
curl -s "http://localhost:16686/api/traces/$TRACE" \
| jq -r '.data[0].spans[] | select(.operationName=="db.query")
| .tags[] | select(.key=="db.statement") | .value' | sort -u | head# 3. the guardrail: alert when the per-request fan-out ratio regresses.
# traces_span_metrics_calls_total comes from the collector's spanmetrics connector.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: checkout-fanout
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: checkout-fanout
rules:
- alert: CheckoutDatabaseFanoutRegression
expr: |
sum(rate(traces_span_metrics_calls_total{service_name="checkout",span_name="db.query"}[10m]))
/
sum(rate(traces_span_metrics_calls_total{service_name="checkout",span_name="/checkout"}[10m]))
> 10
for: 10m
labels:
severity: warning
team: checkout
annotations:
summary: "checkout issues more than 10 database calls per request"
description: "A fan-out regression: individual calls are fast, the count per request is not."# after the fix (batch the per-line-item query into one IN (...) query): curl -s "http://localhost:16686/api/traces?service=checkout&operation=%2Fcheckout&lookback=15m&minDuration=2s" \ | jq '.data | length' # 0 curl -s "http://localhost:16686/api/traces?service=checkout&operation=%2Fcheckout&lookback=15m&limit=1" \ | jq '[.data[0].spans[] | select(.operationName=="db.query")] | length' # single digits
Why: a trace answers the one question dashboards structurally cannot — “where did this particular request’s time go”. Per-service medians hid this regression completely because no individual database call got slower; only their count per request changed, and averaging over all requests dilutes that into invisibility. Two shapes are worth recognising instantly: one long span (a slow dependency, lock contention, a cold cache) versus many short identical spans (an N+1 fan-out, nearly always a loop over an ORM relation). Sorting spans by duration finds the first; counting operation names finds the second — and they need different fixes, so identify which one you have before proposing anything. minDuration is the fastest way to reach the tail without exporting everything. The guardrail matters as much as the fix: fan-out ratio is a property the platform can watch for every service, which turns “someone noticed p99 got worse” into an alert that names the cause. See Jaeger and OpenTelemetry.
T13 · A new service is never scraped, and nobody knows why
The payments team added a /metrics endpoint and a ServiceMonitor a week ago. Their Grafana panels are empty, their alerts have never fired, and they have concluded that “Prometheus is broken”. The ServiceMonitor object exists and the pods serve metrics correctly when you curl them directly.
Your task:
- Walk the scrape chain in a fixed order and determine, from evidence, at which link it breaks.
- Fix the smallest thing that is actually wrong.
- Prove the target is up and a metric from it is queryable.
Done when: curl -s localhost:9090/api/v1/targets | jq '[.data.activeTargets[] | select(.labels.job=="payments")] | length' is at least 1 with health: "up", and curl -s --get localhost:9090/api/v1/query --data-urlencode 'query=up{job="payments"}' | jq -r '.data.result[0].value[1]' returns 1. (6 points)
Show the worked solution
# THE CHAIN, IN ORDER. Do not skip a link; each one fails silently.
# 1. is the pod serving, and does the SERVICE name that port?
kubectl -n payments exec deploy/payments -- wget -qO- localhost:9102/metrics | head -3
kubectl -n payments get svc payments -o jsonpath='{.spec.ports[*].name}{"\n"}'
kubectl -n payments get svc payments -o jsonpath='{.metadata.labels}{"\n"}'
kubectl -n payments get endpointslices -l kubernetes.io/service-name=payments
# 2. does the ServiceMonitor select that SERVICE (not the pods) and name the PORT NAME?
kubectl -n payments get servicemonitor payments -o yaml
# 3. does the Prometheus CR select that ServiceMonitor, in that namespace?
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorSelector}{"\n"}'
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorNamespaceSelector}{"\n"}'
# 4. can Prometheus's ServiceAccount even list endpoints there?
kubectl auth can-i list endpoints -n payments \
--as=system:serviceaccount:monitoring:kube-prometheus-stack-prometheus
# 5. ground truth — what config did the operator actually generate?
kubectl -n monitoring get secret prometheus-kube-prometheus-stack-prometheus \
-o jsonpath='{.data.prometheus\.yaml\.gz}' | base64 -d | gunzip | grep -A6 payments# the fixed ServiceMonitor — three corrections, all of them common
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: payments
namespace: payments
labels:
release: kube-prometheus-stack # (a) matched by the Prometheus ruleSelector-equivalent
spec: # spec.serviceMonitorSelector
selector:
matchLabels:
app.kubernetes.io/name: payments # (b) must match the SERVICE's labels, not the pods'
namespaceSelector:
matchNames: [payments]
endpoints:
- port: metrics # (c) the Service PORT NAME, never a number
path: /metrics
interval: 30s# if the Prometheus CR was only watching its own namespace:
kubectl -n monitoring patch prometheus kube-prometheus-stack-prometheus --type=merge -p \
'{"spec":{"serviceMonitorNamespaceSelector":{},"serviceMonitorSelector":{"matchLabels":{"release":"kube-prometheus-stack"}}}}'
kubectl apply -f servicemonitor.yaml
kubectl -n monitoring port-forward svc/prometheus-operated 9090:9090 &
curl -s localhost:9090/api/v1/targets \
| jq -r '.data.activeTargets[] | select(.labels.job=="payments") | [.health, .scrapeUrl, .lastError] | @tsv'
curl -s --get localhost:9090/api/v1/query \
--data-urlencode 'query=up{job="payments"}' | jq -r '.data.result[0].value[1]'Why: four independent things must line up, and every one of them fails without an error message anywhere. The ServiceMonitor’s selector matches Service labels, not pod labels — the single most common mistake, and the one that produces exactly this “object exists, nothing happens” symptom. endpoints[].port is the Service port name, not a number (use targetPort only when the port is unnamed). The Prometheus CR then decides which ServiceMonitors it adopts at all: serviceMonitorSelector on a Helm-installed stack almost always requires a release: label, and for serviceMonitorNamespaceSelector an empty {} means “every namespace” while an absent one means “only my own”. Finally Prometheus needs RBAC to list services, endpoints and pods in the target namespace. When two of these look right and it still does not work, read the generated config out of the operator’s Secret — that is ground truth, and it ends the argument. The general method is in triaging delivery and the troubleshooting playbook.
Platform Architecture & Infrastructure — T14 to T16 (15 points)
☺ Like you’re 10: These three are about the room the work happens in: what kind of chairs you are allowed to buy and how many, how much of the table each team may take, and how to give every long job its own desk so nobody gets swept away mid-sentence.
Three self-contained tasks with excellent points-per-minute — do not leave them unattempted just because they sit late in the paper. Background: platform architecture, scaling & scheduling and FinOps, with the architecture lab for the hands-on build.
T14 · Constrain what Karpenter is allowed to buy
Karpenter is provisioning very large on-demand instances for a batch queue that tolerates interruption perfectly well, there is no ceiling on how much it may provision (a bad replicas value cost four figures last month), and consolidation keeps repacking nodes at 09:15 on weekday mornings, restarting platform components in the middle of standup.
Your task:
- Write a
NodePoolthat provisions only spot capacity, from a restricted set of instance families and sizes, with hard CPU and memory limits and weekly node expiry. - Taint the pool so only batch workloads land on it, and add the matching toleration and node selector to the batch Deployment.
- Add disruption budgets so consolidation never touches more than 10% of nodes, and nothing at all on weekday working hours.
Done when: kubectl get nodepool batch -o jsonpath='{.spec.limits}' shows the caps and {.spec.disruption.budgets} shows both budgets, and after scaling the batch Deployment, kubectl get nodes -L karpenter.sh/capacity-type,node.kubernetes.io/instance-type shows a new node with capacity-type=spot and an instance type from your allowlist, hosting the batch pods. (5 points)
Show the worked solution
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: batch
spec:
template:
metadata:
labels:
workload-class: batch
spec:
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
expireAfter: 168h # replace nodes weekly: this is your patching story
taints:
- key: workload-class
value: batch
effect: NoSchedule
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["c6i", "c6g", "m6i", "m6g"]
- key: karpenter.k8s.aws/instance-size
operator: NotIn
values: ["nano", "micro", "small"]
limits: # the hard ceiling for the whole pool
cpu: "200"
memory: 800Gi
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m
budgets:
- nodes: "10%" # never churn more than a tenth at once
- nodes: "0" # and nothing during weekday working hours
schedule: "0 9 * * mon-fri"
duration: 9h# the workload has to opt in: the taint keeps everything else off the pool
apiVersion: apps/v1
kind: Deployment
metadata:
name: cruncher
namespace: batch
spec:
replicas: 30
selector:
matchLabels: { app: cruncher }
template:
metadata:
labels: { app: cruncher }
spec:
nodeSelector:
workload-class: batch
tolerations:
- key: workload-class
operator: Equal
value: batch
effect: NoSchedule
containers:
- name: cruncher
image: ghcr.io/acme/cruncher:2.2.0
resources:
requests: { cpu: "2", memory: 4Gi }kubectl apply -f nodepool.yaml
kubectl get nodepool batch -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'
kubectl get nodepool batch -o jsonpath='{.spec.limits}{"\n"}{.spec.disruption.budgets}{"\n"}'
kubectl apply -f cruncher.yaml
kubectl get nodeclaims -w
kubectl get nodes -L karpenter.sh/capacity-type,node.kubernetes.io/instance-type,workload-class
kubectl -n kube-system logs -l app.kubernetes.io/name=karpenter --tail=50 | grep -i launchWhy: a NodePool is a constraint set, not a node group — Karpenter intersects the pending pods’ requirements with the pool’s and then buys the cheapest instance that satisfies both, which means the pool’s real job is to bound that search. capacity-type: spot says this work is interruptible; an instance-family allowlist keeps the bill predictable and the AMI story simple; limits is the hard ceiling that turns a runaway replicas: 5000 from an invoice into a batch of Pending pods. The taint is not optional decoration — without it, anything unschedulable will happily land on your interruptible spot nodes, and you will discover this when a spot reclamation takes the ingress controller with it. Disruption budgets are the piece people meet during an incident rather than in design: consolidation is continuously trying to repack the cluster, and a nodes: "0" window is how you say “not during business hours”. expireAfter is how nodes stay patched without a maintenance project. See Karpenter.
T15 · Carve a batch sub-budget out of a tenant’s quota
Team A filled the cluster with best-effort batch pods and then could not scale their own production Deployment. Their pods request 10 m of CPU and set a 4-core limit, so the scheduler believes the node is empty right up until it saturates. They have also filed a ticket saying pods are now rejected with “must specify limits.cpu” and they do not know why.
Your task:
- Cap the tenant with a
ResourceQuotafor total compute and object counts, plus a second, much smaller quota scoped to thebatch-backgroundPriorityClass. - Add a
LimitRangesupplying defaults (which fixes their ticket) and amaxLimitRequestRatioso a limit can never exceed 4× its request. - Prove a bare pod is admitted with injected defaults, an over-committed pod is rejected, and the batch quota is enforced independently of the main one.
Done when: kubectl -n team-a describe quota shows both quotas with used/hard, a pod created with no resources block comes back with the LimitRange defaults in kubectl -n team-a get pod defaults -o jsonpath='{.spec.containers[0].resources}', and applying a pod requesting 100m with a limit of 2 is rejected with a maxLimitRequestRatio message. (5 points)
Show the worked solution
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-a-compute
namespace: team-a
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.cpu: "40"
limits.memory: 80Gi
count/deployments.apps: "30"
persistentvolumeclaims: "10"
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-a-batch
namespace: team-a
spec:
hard:
pods: "20"
requests.cpu: "4"
requests.memory: 8Gi
scopeSelector: # only pods with this PriorityClass count here
matchExpressions:
- scopeName: PriorityClass
operator: In
values: ["batch-background"]
---
apiVersion: v1
kind: LimitRange
metadata:
name: team-a-defaults
namespace: team-a
spec:
limits:
- type: Container
default: # becomes limits when the pod omits them
cpu: 500m
memory: 512Mi
defaultRequest: # becomes requests when the pod omits them
cpu: 100m
memory: 128Mi
min:
cpu: 10m
memory: 32Mi
max:
cpu: "4"
memory: 8Gi
maxLimitRequestRatio: # a limit may not exceed 4x its request
cpu: "4"
memory: "4"# wide-pod.yaml — deliberately over-committed: ratio 2000m / 100m = 20
apiVersion: v1
kind: Pod
metadata:
name: wide
namespace: team-a
spec:
containers:
- name: app
image: nginx:1.27-alpine
resources:
requests: { cpu: 100m }
limits: { cpu: "2" }kubectl apply -f tenant-limits.yaml
kubectl -n team-a describe quota
kubectl -n team-a describe limitrange team-a-defaults
# defaults are injected, so the ticket goes away
kubectl -n team-a run defaults --image=nginx:1.27-alpine --restart=Never
kubectl -n team-a get pod defaults -o jsonpath='{.spec.containers[0].resources}{"\n"}'
# the over-committed pod is refused at admission
kubectl -n team-a apply -f wide-pod.yaml
# Error from server (Forbidden): ... cpu max limit to request ratio per Container is 4,
# but provided ratio is 20.000000
kubectl -n team-a get quota team-a-batch -o jsonpath='{.status.used}{"\n"}'Why: quota and LimitRange are two different tools that only work properly as a pair, and the ticket in the scenario is the proof. The moment a ResourceQuota names requests.cpu or limits.cpu, every pod in that namespace must state that value or be rejected outright — the LimitRange is what supplies the missing numbers so developers never have to care. scopeSelector on PriorityClass is the piece that solves the actual incident: it carves a small, separate budget for interruptible work out of the tenant’s total, so batch can exhaust its own four cores while production headroom is untouched. maxLimitRequestRatio fixes the scheduling lie — the scheduler places pods on requests, so a 10 m request with a 4-core limit makes a node look empty and then saturates it; capping the ratio forces requests to resemble reality. Two properties to remember: quota is enforced at admission and is never retroactive, so existing over-budget pods keep running, and a LimitRange only mutates pods created after it exists. More in governance & compliance.
T16 · Give every long unit of work its own Job with KEDA
The video transcode worker is a Deployment autoscaled by a KEDA ScaledObject. Each item takes about twenty minutes, and scale-in regularly terminates a worker halfway through one — the message is redelivered, the item is transcoded twice, and the bill is roughly 30% higher than it should be. Between batches the queue is empty for hours.
Your task:
- Replace the ScaledObject with a KEDA
ScaledJobwhose pod processes exactly one item and exits. - Keep the broker connection string out of the CR with a
TriggerAuthentication, cap concurrency, and choose a scaling strategy that accounts for jobs already running. - Publish work and show one Job per item appearing, each completing, with nothing killed mid-flight.
Done when: kubectl -n media get scaledjob transcode -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True; publishing five messages produces at most five Jobs (kubectl -n media get jobs) that all reach Complete; and kubectl -n media get pods shows no pod terminated by a scale-in. (5 points)
Show the worked solution
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: rabbitmq-auth
namespace: media
spec:
secretTargetRef:
- parameter: host # the parameter the rabbitmq scaler expects
name: rabbitmq-conn # an existing Secret; never inline the URL
key: host
---
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: transcode
namespace: media
spec:
jobTargetRef:
parallelism: 1
completions: 1
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: ghcr.io/acme/transcode:1.8.0
args: ["--once"] # process exactly one item, then exit
resources:
requests: { cpu: "1", memory: 2Gi }
pollingInterval: 15
maxReplicaCount: 20 # concurrency cap
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 5
rollout:
strategy: gradual # spec changes do not nuke running Jobs
scalingStrategy:
strategy: accurate # subtract in-flight Jobs from the queue length
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: transcode
mode: QueueLength
value: "1" # one Job per queued message
authenticationRef:
name: rabbitmq-authkubectl -n media delete scaledobject transcode --ignore-not-found
kubectl apply -f scaledjob.yaml
kubectl -n media get scaledjob transcode
kubectl -n media get scaledjob transcode \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'
# publish five messages, then watch
kubectl -n media get jobs -w
kubectl -n media get jobs -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.succeeded}{"\n"}{end}'
kubectl -n media get pods
kubectl -n media describe scaledjob transcode | sed -n '/Events/,$p'Why: the two KEDA objects answer different questions and the exam likes the distinction. A ScaledObject scales a long-lived workload whose pods are interchangeable and disposable — which is precisely wrong for a twenty-minute unit of work, because scaling in means terminating a pod that is halfway through one. A ScaledJob creates one Job per unit of work: KEDA never terminates a running Job, so “scale in” simply means creating fewer new ones, and the redelivery problem disappears without any change to the worker. scalingStrategy: accurate subtracts the Jobs already running from the reported queue length, which stops KEDA double-counting messages that are in flight but not yet acknowledged and over-provisioning on every poll. TriggerAuthentication keeps the broker credential in a Secret so the ScaledJob itself is safe to commit to Git — the same instinct as everywhere else in GitOps. Compare the autoscaling axes in KEDA and scaling & scheduling.
Security & Policy Enforcement — T17 to T18 (15 points)
☺ Like you’re 10: These two are about house rules and house keys: stop keeping the front-door key on a sticky note, and make sure every new room automatically comes with a lock and a smoke alarm rather than waiting for someone to remember.
Only two tasks, but fifteen points — the highest value per task on the paper. Never leave these unattempted. Background: security & policy enforcement, secrets management and governance & compliance, then the security lab.
T17 · Make a production Secret a projection instead of an artefact
The payments Deployment reads its database password from a Secret a since-departed engineer created by hand fourteen months ago. The value in Vault was rotated last week, so production is now running on a stale credential; a copy of the plaintext is sitting in a Slack thread; and nobody knows which other namespaces have their own copy.
Your task:
- Create a
ClusterSecretStorefor the Vault KV v2 mount, authenticating with Kubernetes ServiceAccount auth. - Create an
ExternalSecretinpaymentsthat materialises the Secret on a refresh interval, composing two Vault keys into a singleDATABASE_URLwith a template. - Prove the Secret is owned by the ExternalSecret, is recreated if deleted, and picks up a rotated value.
Done when: kubectl -n payments get externalsecret payments-db -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True; kubectl -n payments get secret payments-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d shows the composed URL; kubectl -n payments delete secret payments-db is followed by the Secret reappearing; and after rotating the value in Vault a forced sync returns the new value. (8 points)
Show the worked solution
apiVersion: external-secrets.io/v1 # external-secrets.io/v1beta1 on older installs
kind: ClusterSecretStore
metadata:
name: vault-kv
spec:
provider:
vault:
server: https://vault.platform.svc:8200
path: kv # the KV mount point
version: v2
auth:
kubernetes:
mountPath: kubernetes # the Vault auth mount
role: external-secrets # a Vault role scoped to payments/* only
serviceAccountRef:
name: external-secrets
namespace: external-secrets
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: payments-db
namespace: payments
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-kv
target:
name: payments-db
creationPolicy: Owner # ESO owns it: delete it and it comes back
deletionPolicy: Retain
template:
engineVersion: v2
type: Opaque
data:
DATABASE_URL: "postgres://{{ .username }}:{{ .password }}@pg.payments.svc:5432/payments?sslmode=require"
data:
- secretKey: username
remoteRef:
key: payments/db
property: username
- secretKey: password
remoteRef:
key: payments/db
property: passwordkubectl apply -f vault-store.yaml
kubectl get clustersecretstore vault-kv \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'
kubectl -n payments get externalsecret payments-db
kubectl -n payments get secret payments-db -o jsonpath='{.metadata.ownerReferences[0].kind}{"\n"}'
kubectl -n payments get secret payments-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo
# self-healing: ESO recreates what it owns
kubectl -n payments delete secret payments-db
kubectl -n payments get secret payments-db
# force an immediate resync instead of waiting out refreshInterval
kubectl -n payments annotate externalsecret payments-db force-sync="$(date +%s)" --overwrite
kubectl -n payments get secret payments-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo
kubectl -n external-secrets logs deploy/external-secrets --tail=30 # auth failures land hereWhy: the fix is not “rotate the Secret”, it is “stop the Secret being a hand-made artefact”. The External Secrets Operator turns the Kubernetes Secret into a projection of a value that lives in a real secret manager: creationPolicy: Owner puts an owner reference on it so deleting it is self-healing rather than an outage, and refreshInterval means rotation propagates without a human in the loop. The template block does more work than it looks: applications usually want a composed connection string, and composing it inside ESO means the individual credentials never exist as separate keys for someone to copy into Slack. ClusterSecretStore versus namespaced SecretStore is the real multi-tenancy decision — a cluster-scoped store lets any namespace that can create an ExternalSecret read anything the store’s Vault role can reach, so scope the Vault role, not just the store. One last operational fact worth knowing: a pod that read the Secret through env will not see the new value until it restarts, whereas a Secret mounted as a volume is refreshed by the kubelet within about a minute — pair rotation with a checksum annotation or a reloader. See External Secrets and secrets management.
T18 · Ship the tenant baseline instead of describing it
Every new tenant namespace is supposed to receive the registry pull Secret and the standard platform ConfigMap that live in platform-templates. Today someone copies them by hand and forgets roughly one namespace in five. Separately, about half of tenant pods carry no resource requests at all, so the scheduler over-packs nodes and the platform team spends Mondays rebalancing.
Your task:
- Write a Kyverno
ClusterPolicyrule that generates copies of everything labelledplatform.acme.io/clone=trueinplatform-templatesinto every namespace labelled as a tenant, kept continuously in sync. - Add a second rule that mutates Pods in those namespaces to carry default resource requests — without ever overriding a team that already set them.
- Create a tenant namespace and a bare pod, prove both rules fired, then change the source ConfigMap and show the clone follows.
Done when: kubectl -n tenant-y get secret ghcr-pull and kubectl -n tenant-y get cm platform-defaults both exist; kubectl -n tenant-y get pod bare -o jsonpath='{.spec.containers[0].resources.requests}' shows the injected requests while a pod that declared its own requests is unchanged; and editing the source ConfigMap changes the clone within a minute. (7 points)
Show the worked solution
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: tenant-bootstrap
spec:
background: true # also applies to namespaces that already exist
rules:
- name: clone-platform-templates
match:
any:
- resources:
kinds: [Namespace]
selector:
matchLabels:
platform.acme.io/tenant: "?*"
generate:
namespace: "{{request.object.metadata.name}}"
synchronize: true # updates to the source flow to every clone
cloneList:
namespace: platform-templates
kinds:
- v1/Secret
- v1/ConfigMap
selector:
matchLabels:
platform.acme.io/clone: "true"
- name: default-resource-requests
match:
any:
- resources:
kinds: [Pod]
namespaceSelector:
matchLabels:
platform.acme.io/tenant: "?*"
mutate:
patchStrategicMerge:
spec:
containers:
- (name): "*" # conditional anchor: every container
resources:
requests:
+(cpu): "100m" # add anchor: only when the key is absent
+(memory): "128Mi"# the single source of truth, maintained in one place
kubectl create ns platform-templates
kubectl -n platform-templates create secret docker-registry ghcr-pull \
--docker-server=ghcr.io --docker-username=acme-ci --docker-password="$GHCR_TOKEN"
kubectl -n platform-templates create cm platform-defaults --from-literal=REGION=eu-west-1
kubectl -n platform-templates label secret ghcr-pull platform.acme.io/clone=true
kubectl -n platform-templates label cm platform-defaults platform.acme.io/clone=true
kubectl apply -f tenant-bootstrap.yaml
kubectl get clusterpolicy tenant-bootstrap # READY True before you test anything
kubectl create ns tenant-y
kubectl label ns tenant-y platform.acme.io/tenant=acme-y
kubectl -n tenant-y get secret,cm
kubectl -n tenant-y run bare --image=nginx:1.27-alpine --restart=Never
kubectl -n tenant-y get pod bare -o jsonpath='{.spec.containers[0].resources.requests}{"\n"}'
# the source is live, not a snapshot
kubectl -n platform-templates patch cm platform-defaults -p '{"data":{"REGION":"eu-central-1"}}'
sleep 30 && kubectl -n tenant-y get cm platform-defaults -o jsonpath='{.data.REGION}{"\n"}'
kubectl get policyreport -A # what fired, and whereWhy: Kyverno’s three rule types answer three different questions — validate rejects bad input, mutate corrects it on the way in, and generate creates things the user never asked for — and only the last two turn policy from a gate into a golden path. cloneList is the difference between describing a baseline and shipping it: you maintain one source object in one namespace, Kyverno propagates it to every tenant, and synchronize: true makes those copies both self-healing and live, so updating the source updates every clone. The mutate rule is the other half: rather than rejecting a pod with no requests and making a developer read a policy error, you fill in a sane default and let them ship. The anchors are what make that safe — (name): "*" is a conditional anchor that matches every container in the list, and +(cpu) adds the key only when it is absent, so a team that deliberately set its own requests is never silently overridden. Two things to check when a generate rule appears to do nothing at all: Kyverno’s background controller needs RBAC for every kind it clones (Secrets are commonly missing), and the source namespace must not be caught by the installation’s namespace exclusion list. See Kyverno.
First, rolling out an enforcing policy without measuring first. Both tasks above have a measure step available — a --dry-run=server apply, or Kyverno’s Audit validation action and its policy reports — and skipping it is how a policy rollout becomes an incident at 09:00. Second, testing a policy as yourself. On your practice cluster you are almost certainly cluster-admin, and admin bypasses more than you expect: Kyverno installations routinely exempt system namespaces and service accounts, and a ValidatingAdmissionPolicy with no binding does nothing at all while looking perfectly healthy. Always verify from the identity that will actually be affected — kubectl auth can-i --as for RBAC, a real tenant namespace for admission.
Score yourself
☺ Like you’re 10: Add up your points, turn it into a percentage, and then do the more useful bit — look at which questions you lost points on and go back to those.
Mark after a break. Marking your own work while still adrenalised produces generous nonsense. Award full points only when the done-when check actually passed on the cluster, half when the resource exists and is broadly right but the check did not pass, and zero when you did not attempt it. There is no credit for “I knew how to do that one.”
| Task | Domain | Points | Your score |
|---|---|---|---|
| T1 · Server-side apply and a managed namespace | GitOps | 5 | |
| T2 · Ignore a second controller’s owned fields | GitOps | 5 | |
| T3 · Tekton Triggers from a signed webhook | GitOps | 5 | |
| T4 · Kaniko build with no privileged container | GitOps | 5 | |
| T5 · Blue-green preview at reduced scale, analysis-gated | GitOps | 5 | |
| T6 · Recover objects stuck behind an orphaned finalizer | Platform APIs | 5 | |
| T7 · Stop a controller reconcile hot loop | Platform APIs | 5 | |
| T8 · Crossplane claim triage down the chain | Platform APIs | 5 | |
| T9 · Backstage template with validated parameters | Platform APIs | 5 | |
| T10 · ValidatingAdmissionPolicy on your own API | Platform APIs | 5 | |
| T11 · Recording rules and tested burn-rate alerts | Observability | 7 | |
| T12 · Jaeger trace analysis of an N+1 regression | Observability | 7 | |
| T13 · Triage a ServiceMonitor that never scrapes | Observability | 6 | |
| T14 · Karpenter NodePool constraints and budgets | Architecture | 5 | |
| T15 · Priority-scoped quota plus LimitRange ratio | Architecture | 5 | |
| T16 · KEDA ScaledJob for long units of work | Architecture | 5 | |
| T17 · External Secrets with templating and rotation | Security | 8 | |
| T18 · Kyverno cloneList generate plus safe mutate | Security | 7 | |
| Total | All five domains | 100 |
Computing your result. The points total 100, so your raw score is your percentage, and the bar is 64%. That leaves 36 points spendable — you can drop six of the five-point tasks outright and still clear the bar with six to spare. Then do the more useful arithmetic and total each domain separately: a 70 built from five even domains and a 70 built from four strong domains plus a zero are completely different results, and the second one fails a real exam the first day the task draw is unkind.
| Domain | Available | Yours | If you scored under two-thirds, go here |
|---|---|---|---|
| GitOps & Continuous Delivery | 25 | GitOps workflows, CI/CD & progressive delivery, then re-drill the GitOps bank. | |
| Platform APIs & Self-Service | 25 | Platform APIs & operators, self-service, then the Platform APIs bank. | |
| Observability & Operations | 20 | Observability, reliability & incidents, then the Observability bank. | |
| Platform Architecture & Infrastructure | 15 | Platform architecture, scaling & scheduling, then the Architecture bank. | |
| Security & Policy Enforcement | 15 | Security & policy, secrets management, then the Security bank. |
What to do with each individual miss. Sort your zeros and halves into three piles, because they need three different treatments. A task you did not recognise at all is a knowledge gap: read the lesson linked in its solution, then redo the task cold two days later. A task where you knew the shape but burned the clock hunting field names is a speed problem, and the fix is the command reference plus kubectl explain drills, not more reading. A task that broke in a way you could not diagnose is a triage gap: work the troubleshooting playbook and triaging workloads until the evidence order is automatic.
What your score means
☺ Like you’re 10: One number cannot tell you whether you will pass — but the shape of it tells you what to practise next, which is more useful anyway.
Treat the number as a diagnostic, never as a prediction. A mock is harsher in some ways (no Quick Reference box tailored to each task, tasks written by someone who cannot see your cluster) and gentler in others (your own cluster, your own aliases, no exam-day nerves and no proctor watching your desk). Read the band, not the decimal.
| Score | Read it as | Next move |
|---|---|---|
| 80–100 | Comfortably ready, with margin for a bad task draw. | Stop grinding tasks. Work the final checklist, skim the field notes for exam-day logistics, and book it. |
| 64–79 | A pass on this paper, but with no margin if two tasks go sideways. | Re-drill only your weakest domain, then sit another timed paper. Speed work, not new topics. |
| 50–63 | Close. Usually a speed problem plus one weak domain rather than a knowledge problem. | Two weeks of timed drills from the bank, plus what to know cold for every shape you had to look up. |
| Under 50 | Genuine gaps across domains. Booking now wastes the sitting. | Back to the lessons for your two lowest domains, then the lab track to rebuild hands-on fluency before another mock. |
One pattern is worth checking independently of the total: how many tasks did you leave completely unattempted? More than two and your problem is pacing rather than knowledge — and pacing is the cheapest thing on this entire list to fix. Sit the paper again in a fortnight with one hard rule: you touch every task once before you return to any of them.
Dot: 66. I passed! Barely, but I passed.
Timmy: Which tasks were the zeros?
Dot: T3, T4 and T11. The Triggers one had four objects and I ran out of patience, Kaniko I have genuinely never done, and the burn-rate maths made my eyes slide off the page.
Benny: Those are three different problems, Dot, and only one of them is “study harder”. T3 is typing speed under a clock. T4 is a gap. T11 is arithmetic you can memorise in ten minutes — 14.4 pages, 6 tickets, done.
Gizmo: Or just skip the analysis template! Promote straight to prod, watch the graph, revert if it goes red. Same outcome, half the YAML. 🤑
Professor Owl: “Watch the graph” is a person, Gizmo. The whole point of prePromotionAnalysis is that the measurement happens even at 03:00 when nobody is watching anything.
Nutty: Wait — I got T4 in four minutes because I had built a Kaniko Task at work last year. And I scored zero on T8 because I have never touched Crossplane. It is the same paper and we are strong in opposite places.
Timmy: Which is exactly why you both re-sit in a fortnight. Dot, start at T11. Nutty, start at T8. Same paper, different order, and neither of you will run out of clock on the domain you are worst at.
1. Which documentation may you have open during the real exam, and name three popular projects whose own docs are not permitted. 2. Why does a large CRD fail to sync with client-side apply, and which sync option fixes it? 3. An autoscaler and a CA injector keep an app permanently OutOfSync — which two Argo CD ignore mechanisms fit which case, and which sync option must accompany them? 4. Your custom resource has been Terminating for six days. What is actually blocking it, and why is --force no help? 5. In a burn-rate alert pair, what does the long window buy you and what does the short window buy you? 6. Name three independent reasons a correct-looking ServiceMonitor produces no targets. 7. When should you reach for a KEDA ScaledJob instead of a ScaledObject?
Check your answers
- kubernetes.io/docs, kubernetes.io/blog, task-specific documentation linked from the exam’s Quick Reference box, and the local
man//usr/sharedocumentation on the exam desktop. Not permitted: Argo CD, Flux, Tekton, Crossplane, Backstage, Prometheus, OpenTelemetry, Kyverno, Gatekeeper, Istio and Helm docs (any three). Your substitute is the live API —kubectl explain --recursive,kubectl api-resources, and reading an existing object. - Client-side apply stores the entire manifest in the
kubectl.kubernetes.io/last-applied-configurationannotation, and all annotations on an object are capped at 256 KiB.ServerSideApply=truemoves ownership tracking intometadata.managedFieldson the server, so no annotation is written. ignoreDifferences.managedFieldsManagersfor the autoscaler — it drops everythingkube-controller-managerowns without enumerating a single field.jqPathExpressionsfor the CA bundle — it reaches an array element selected by name rather than by index. Note the limit of the first one: a mutating webhook’s patch is applied inside the original request and is attributed to the requester, so it never gets a manager entry of its own. Either rule must be paired with theRespectIgnoreDifferences=truesync option, or automated self-heal re-applies anyway.- A string left in
metadata.finalizersby a controller that no longer exists. Deletion only setsdeletionTimestamp; the API server will not remove the object while the list is non-empty, and--force --grace-period=0does not bypass finalizers. Remove the specific finalizer entry once you have confirmed the external resource it guarded is gone. - The long window (1h, 6h) gives significance, so a brief blip cannot page you. The short window (5m, 30m) gives fast reset, so the alert clears promptly once the burn stops instead of lingering for the length of the long window.
- Any three of: the
selectormatches pod labels instead of Service labels;endpoints[].portuses a number instead of the Service port name; the Prometheus CR’sserviceMonitorSelectordoes not match the ServiceMonitor’s labels;serviceMonitorNamespaceSelectoris absent so only the Prometheus’s own namespace is watched; Prometheus’s ServiceAccount lacks RBAC to list endpoints in that namespace. - When each unit of work is long-running and must not be interrupted. A
ScaledObjectscales a Deployment whose pods are disposable, so scale-in kills work in progress; aScaledJobcreates one Job per item and KEDA never terminates a running Job — scaling in just means creating fewer new ones.
That is the whole paper. Score it, write the domain breakdown somewhere you will actually see it, and let your two weakest numbers choose the next fortnight. When those numbers stop moving, the remaining work is logistical rather than technical: the exam guide covers booking, the environment and the rules; the field notes collect what candidates reported from the day itself; the final checklist is the last thing to read before you sit down; and the command reference, what to know cold and the glossary are the three pages worth a last skim on the morning. Go again with Set 1 for pacing or Set 2 for another cold read, drill a single domain in the bank, or head back to the course home to pick a lesson.