Exam Prep · Practice · Platform Architecture & Infrastructure

Practice — Platform Architecture & Infrastructure

Five hands-on tasks covering the exam’s Platform Architecture & Infrastructure domain, which is worth 15% of the paper. This is a smaller domain with an unusually high value per minute: the tasks are short, sharply defined and fast to verify, which makes them ideal “bank them early” material. Drill them cold — no notes, no tab of documentation open — and time-box each one to 5–7 minutes. Write the manifest, run the verification command, and only then open the worked solution. If you peeked before you attempted, you practised reading, not passing. These five are pulled from the full practice bank; the background lessons are platform architecture, the Kubernetes substrate, scaling & scheduling, multi-cluster and reference architecture.

☺ Explain it like I’m 10

These are the tasks about the shape of the playground itself — how much of it each team is allowed to use, where the equipment is allowed to be put, and how the playground quietly gets bigger when more kids turn up and smaller when they go home. You’re not building the swings here; you’re deciding the rules of the ground they stand on.

🦉Your host for this set: Professor Owl — she keeps one eye on the blueprint and one on the clock. Her advice: architecture answers are graded on proof, not intent. Every task below ends in a command that shows the guardrail actually biting.
▶ Try this

Do all five in one sitting with a 30-minute timer running. Attempt each task cold, stop at 7 minutes whether or not it works, and note which of the three failure kinds you hit: I didn’t know the API shape, I knew it but typed it slowly, or I couldn’t verify it. The first is a reading gap, the second is a command-reference drill, and the third is a troubleshooting-playbook gap — three completely different fixes.

Tenancy guardrails — capping what a namespace may consume

The first line of platform defence is arithmetic: a ceiling on the namespace and a sensible default on every container inside it. Both objects are quick to write and quick to prove, so this is the cheapest mark on the paper.

A1 · Cap a tenant with ResourceQuota and LimitRange, then prove enforcement

One tenant deployed a job with no resource requests, scheduled 400 pods, and starved two other teams off the nodes. You are adding tenant-level guardrails so a single namespace cannot do that again — and so pods without requests get sensible ones automatically.

Your task:

  1. Create a ResourceQuota in namespace tenant-a capping requests to 4 CPU / 8Gi, limits to 8 CPU / 16Gi, and pods to 20.
  2. Create a LimitRange giving containers a default request of 100m/128Mi and a maximum of 2 CPU / 4Gi.
  3. Prove both: show a pod inheriting the defaults, and show a request that exceeds the quota being rejected.

Done when: kubectl -n tenant-a describe quota shows non-zero used, a pod created with no resources stanza shows requests.cpu: 100m, and scaling a deployment past the 4 CPU ceiling is rejected with an exceeded quota error.

Show the worked solution
apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-a-quota
  namespace: tenant-a
spec:
  hard:
    requests.cpu: "4"
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 16Gi
    pods: "20"
    persistentvolumeclaims: "10"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: tenant-a-limits
  namespace: tenant-a
spec:
  limits:
    - type: Container
      default:                 # applied as limits when omitted
        cpu: 500m
        memory: 512Mi
      defaultRequest:          # applied as requests when omitted
        cpu: 100m
        memory: 128Mi
      max:
        cpu: "2"
        memory: 4Gi
kubectl apply -f quota.yaml -f limitrange.yaml
kubectl -n tenant-a run demo --image=nginx
kubectl -n tenant-a get pod demo -o jsonpath='{.spec.containers[0].resources}'
#   {"limits":{"cpu":"500m",...},"requests":{"cpu":"100m","memory":"128Mi"}}

kubectl -n tenant-a create deploy fat --image=nginx
kubectl -n tenant-a set resources deploy/fat --requests=cpu=1 --limits=cpu=1
kubectl -n tenant-a scale deploy/fat --replicas=10        # 10 CPU requested vs a 4 CPU ceiling
kubectl -n tenant-a describe rs -l app=fat | grep -i "exceeded quota"
#   Error creating: pods "fat-..." is forbidden: exceeded quota: tenant-a-quota,
#   requested: requests.cpu=1, used: requests.cpu=4, limited: requests.cpu=4
kubectl -n tenant-a describe quota tenant-a-quota

Why: the two objects do different jobs and you need both. ResourceQuota is a namespace-wide ceiling; LimitRange sets per-container defaults and bounds. They interlock in a way that trips people: once a quota constrains requests.cpu, every pod in that namespace must specify a CPU request — the LimitRange is what supplies it automatically, so without the LimitRange, existing workloads suddenly fail to create. Also note that quota rejections surface on the ReplicaSet, not the Deployment, which is why describe rs is where you look.

Scaling & scheduling — how many, and where

Two questions the platform has to answer for every workload it hosts: how many replicas should exist right now, and which nodes are they allowed to sit on. Get the metric source right for the first and the taint/toleration split right for the second, and these are quick wins.

A2 · Autoscale on a custom metric

The ingest service is CPU-light but queue-bound: it sits at 15% CPU while a backlog of 40,000 messages builds up. A CPU-based HPA will never scale it. You have Prometheus metrics for the queue depth.

Your task:

  1. Create an HPA (autoscaling/v2) for ingest scaling on a Pods or External metric — target 500 messages per replica — between 2 and 30 replicas.
  2. Add a scale-down stabilisation window of 5 minutes so it doesn’t thrash.
  3. Alternatively, express the same thing as a KEDA ScaledObject.

Done when: kubectl -n platform get hpa ingest shows a real current value against the target (not <unknown>), and replicas rise as the backlog grows.

Show the worked solution
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: ingest
  namespace: platform
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ingest
  minReplicas: 2
  maxReplicas: 30
  metrics:
    - type: External
      external:
        metric:
          name: queue_messages_ready
          selector: { matchLabels: { queue: ingest } }
        target:
          type: AverageValue
          averageValue: "500"          # 500 messages per replica
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies: [{ type: Percent, value: 50, periodSeconds: 60 }]
    scaleUp:
      stabilizationWindowSeconds: 0
      policies: [{ type: Percent, value: 100, periodSeconds: 30 }]
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: ingest
  namespace: platform
spec:
  scaleTargetRef: { name: ingest }
  minReplicaCount: 2
  maxReplicaCount: 30
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:                          # KEDA passes this straight to the HPA it generates
        scaleDown:
          stabilizationWindowSeconds: 300
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus-operated.monitoring:9090
        query: sum(queue_messages_ready{queue="ingest"})
        threshold: "500"
kubectl -n platform get hpa ingest -w
kubectl -n platform describe hpa ingest   # ScalingActive / AbleToScale conditions explain <unknown>
kubectl get --raw /apis/external.metrics.k8s.io/v1beta1 | jq .   # is the adapter serving?

Why: autoscaling/v2 supports Resource, Pods, Object and External metrics — but the HPA controller does not talk to Prometheus, it reads the metrics APIs, so something must serve them (prometheus-adapter, or KEDA, which installs its own external metrics adapter). A target showing <unknown> almost always means that adapter chain is missing, not that your HPA is wrong. KEDA is usually the faster answer under time pressure and adds scale-to-zero, which plain HPA cannot do.

A3 · Place a platform component correctly: taints, spread and a PDB

The ingress controller keeps landing on cheap spot nodes and all three replicas once ended up in the same availability zone. During a node drain, all three went down together and the platform had an outage.

Your task:

  1. Taint the three dedicated system nodes and add the matching toleration plus a nodeSelector to the ingress controller.
  2. Add a topology spread constraint forcing at most one-replica skew across zones.
  3. Add a PodDisruptionBudget guaranteeing at least 2 replicas survive voluntary disruption, and prove a drain respects it.

Done when: kubectl get pods -o wide -l app=ingress shows the three pods on three different system nodes, kubectl get nodes -L topology.kubernetes.io/zone confirms those nodes are in three different zones, and kubectl drain on a system node blocks rather than evicting below the budget.

Show the worked solution
kubectl taint nodes sys-1 sys-2 sys-3 dedicated=system:NoSchedule
kubectl label nodes sys-1 sys-2 sys-3 node-role.acme.io/system=true
# in the ingress controller's pod template
spec:
  nodeSelector:
    node-role.acme.io/system: "true"
  tolerations:
    - key: dedicated
      operator: Equal
      value: system
      effect: NoSchedule
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels: { app: ingress }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: ingress
  namespace: ingress-nginx
spec:
  minAvailable: 2               # or maxUnavailable: 1 - never set both
  selector:
    matchLabels: { app: ingress }
# zone labels live on NODES, not on pods - so read the placement in two steps
kubectl get pods -l app=ingress -o custom-columns='POD:.metadata.name,NODE:.spec.nodeName'
kubectl get nodes -L topology.kubernetes.io/zone,node-role.acme.io/system

kubectl get pdb ingress -n ingress-nginx    # ALLOWED DISRUPTIONS should be 1
kubectl drain sys-1 --ignore-daemonsets --delete-emptydir-data   # blocks at the budget

Why: the three mechanisms answer three different questions. A taint repels everything that hasn’t explicitly opted in, so it reserves nodes; a toleration alone doesn’t attract, which is why you also need the nodeSelector or affinity to pull the pod there. Topology spread handles “don’t put all the eggs in one zone.” A PDB constrains voluntary disruption only — node drains, cluster upgrades, descheduling — and does nothing about a node that simply catches fire. A PDB with minAvailable equal to the replica count blocks drains forever, which is a classic self-inflicted upgrade outage.

Mesh & multi-cluster — the shape of the estate

The last two tasks widen the frame: first securing traffic between workloads on one cluster, then treating several clusters as one platform from a single control plane.

A4 · Turn on strict mTLS and verify it

An audit found that service-to-service traffic inside the cluster is plaintext. The mesh is installed and sidecars are injected in payments, but the default permissive mode means nothing is actually enforced.

Your task:

  1. Enforce STRICT mTLS for the payments namespace.
  2. Prove that a plaintext request from a pod without a sidecar is now rejected.
  3. Prove that a request from a meshed pod still succeeds and is encrypted.

Done when: a curl from a non-mesh pod to payments-api:8080 fails with a connection reset, the same call from a meshed pod returns 200, and the mesh CLI reports the traffic as mTLS.

Show the worked solution
# Istio
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: payments
spec:
  mtls:
    mode: STRICT
---
# and lock down who may call it (authorization, not just encryption)
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: payments-api-allow
  namespace: payments
spec:
  selector:
    matchLabels: { app: payments-api }
  action: ALLOW
  rules:
    - from:
        - source:
            principals: ["cluster.local/ns/checkout/sa/checkout"]
# verify - Istio
istioctl x describe pod -n payments $(kubectl -n payments get pod -l app=payments-api -o name | head -1 | cut -d/ -f2)
kubectl -n default run plain --rm -it --image=curlimages/curl --restart=Never \
  -- curl -sS -m 5 http://payments-api.payments:8080/healthz    # connection reset

# Linkerd equivalent: strictness comes from the default inbound policy
linkerd viz edges deployment -n payments      # SECURED column shows √ for mTLS
kubectl annotate namespace payments \
  config.linkerd.io/default-inbound-policy=cluster-authenticated --overwrite

Why: a mesh in PERMISSIVE mode accepts both plaintext and mTLS — which is right for migration and worthless for compliance. STRICT is what an auditor is asking for, and the verification step is the part exams actually grade: showing the negative case (a non-mesh client is refused) proves enforcement in a way that a green dashboard does not. Note the split: PeerAuthentication is encryption and identity; AuthorizationPolicy is who may call whom. Strict mTLS with no authorization policy still lets every meshed pod reach every other one. More in networking.

A5 · Register and target a second cluster from one control plane

Acme now runs prod-eu and prod-us. Argo CD lives in the management cluster and currently only deploys in-cluster. The same platform add-ons must land on both, without maintaining two copies of anything.

Your task:

  1. Register both remote clusters with Argo CD and confirm they appear with the right labels.
  2. Create an ApplicationSet using the cluster generator so each registered cluster gets its own Application.
  3. Restrict it to clusters labelled env=prod.

Done when: argocd cluster list shows both remotes Successful, and kubectl -n argocd get applications shows one generated app per prod cluster, all Synced.

Show the worked solution
kubectl config get-contexts
argocd cluster add prod-eu --name prod-eu --label env=prod --label region=eu
argocd cluster add prod-us --name prod-us --label env=prod --label region=us
argocd cluster list
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: platform-addons
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - clusters:
        selector:
          matchLabels:
            env: prod          # excludes the in-cluster/management destination
  template:
    metadata:
      name: 'addons-{{.name}}'
    spec:
      project: platform
      source:
        repoURL: https://github.com/acme/platform-config.git
        targetRevision: main
        path: 'infrastructure/{{index .metadata.labels "region"}}'
      destination:
        server: '{{.server}}'
        namespace: platform-system
      syncPolicy:
        automated: { prune: true, selfHeal: true }
        syncOptions: [ CreateNamespace=true, ServerSideApply=true ]

Why: argocd cluster add creates a ServiceAccount in the target cluster and stores its credentials as a labelled Secret in argocd — those labels are exactly what the cluster generator selects on, which is why labelling at registration time saves you a rewrite later. This is the hub-and-spoke pattern: one management control plane, many workload clusters, one repo. Its failure mode is that the hub becomes a single point of failure, so the alternative — an Argo CD per cluster, all reading the same repo — is worth knowing too. See multi-cluster.

Where to go next

When all five land inside the time-box without notes, this 15% is banked. Go back to the full practice bank for the other four domains, or take the weak spots back into the lessons: scaling & scheduling for A2 and A3, networking for A4, multi-cluster and GitOps workflows for A5. Keep the command reference next to you while you build speed, and use the troubleshooting playbook whenever the verification step is what defeated you rather than the manifest. The domain weightings and the full blueprint live in the exam guide.

🐢 Timmy’s checkpoint

1. Once a ResourceQuota constrains requests.cpu, what happens to a pod submitted with no resources stanza and no LimitRange in the namespace? 2. Where does a quota rejection actually appear — on the Deployment, the ReplicaSet, or the Pod? 3. Your HPA target reads <unknown>. What is almost always missing? 4. Why isn’t a toleration on its own enough to keep the ingress controller on the system nodes? 5. What kind of disruption does a PodDisruptionBudget protect against — and what does it not touch? 6. In Istio, which object gives you encryption and identity, and which one decides who may call whom?

Check your answers
  1. It is rejected — a quota on requests.cpu makes a CPU request mandatory for every pod in the namespace, so without a LimitRange to supply a default, previously-working workloads suddenly fail to create.
  2. On the ReplicaSet — which is why kubectl describe rs, not describe deploy, is where you find the exceeded quota message.
  3. The metrics adapter chain. The HPA controller reads the metrics APIs, not Prometheus, so something (prometheus-adapter, or KEDA’s own external metrics adapter) has to serve external.metrics.k8s.io.
  4. A toleration only says the pod may sit on a tainted node; it doesn’t attract it there. You need a nodeSelector (or node affinity) to actually pull the pod onto those nodes.
  5. It protects against voluntary disruption only — drains, cluster upgrades, descheduling. It does nothing about an involuntary failure such as a node dying. And minAvailable set equal to the replica count blocks drains forever.
  6. PeerAuthentication (with mode: STRICT) gives encryption and identity; AuthorizationPolicy decides who may call whom. Strict mTLS alone still lets every meshed pod reach every other one.