The KCSA Practice Cluster and Its Defaults
Security behaviour depends on what is installed, so this guide reads the lab off the machines rather than describing it. All four clusters were rebuilt from scratch on the day this was written, which makes every number here a genuine default and a baseline to measure later changes against.
Orientation Guide 3 of 42 Beginner
- Kubernetesapiserver v1.36.4, kubelet v1.36.3
- Runtimecontainerd 2.2.6
- CNICilium 1.18.1 - tunnel/VXLAN, with Hubble relay and UI
- Host OSUbuntu 26.04 LTS, kernel 7.0.0-29
- Built withkubeadm v1.36.3 - podSubnet 10.244.0.0/16, serviceSubnet 10.96.0.0/12
- TimeAbout 16 min
- Reviewed25 August 2026
Written against the versions above. A four-node kubeadm cluster rebuilt on 2026-08-25: apiserver v1.36.4, kubelets v1.36.3, containerd 2.2.6, Cilium 1.18.1 with Hubble. The kubelet is one patch BEHIND the apiserver, which is the supported direction; before the rebuild it was the reverse, which is not.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| CKA5001 | 192.168.0.41 | Ubuntu 26.04 LTS | Control Plane Node (tainted NoSchedule) | 2 Core | 4 GB | 50 GB |
| CKA5001-NODE01 | 192.168.0.42 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA5001-NODE02 | 192.168.0.43 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA5001-NODE03 | 192.168.0.44 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
Before you start
- guide 1 - the domains this cluster is for.
-
Four nodes, rebuilt today, and which of them can run anything
Node count is not the useful number. Taints are.
bash Example session kubectl get nodes -o custom-columns='NAME:.metadata.name,STATUS:.status.conditions[-1].type,KUBELET:.status.nodeInfo.kubeletVersion,TAINTS:.spec.taints[*].key,AGE:.metadata.creationTimestamp'NAME STATUS KUBELET TAINTS AGEcka5001 Ready v1.36.3 node-role.kubernetes.io/control-plane 2026-08-25T11:22:31Zcka5001-node01 Ready v1.36.3 <none> 2026-08-25T11:23:22Zcka5001-node02 Ready v1.36.3 <none> 2026-08-25T11:23:23Zcka5001-node03 Ready v1.36.3 <none> 2026-08-25T11:23:51ZExpected resultFour
Readynodes on kubeletv1.36.3, every one created 2026-08-25. Onlycka5001carriesnode-role.kubernetes.io/control-plane; the three workers show<none>.Success conditionYou know how much of this cluster can actually schedule a Pod.
-
A version skew, in the direction that is allowed
Compare the apiserver with a kubelet.
bash Example session echo "apiserver $(kubectl get --raw /version | python3 -c 'import json,sys;print(json.load(sys.stdin)["gitVersion"])') kubelet $(kubectl get nodes -o jsonpath='{.items[0].status.nodeInfo.kubeletVersion}')"; echo "--- kubelet is one patch BEHIND the apiserver, which is the supported direction"apiserver v1.36.4 kubelet v1.36.3--- kubelet is one patch BEHIND the apiserver, which is the supported directionExpected result
apiserver v1.36.4 kubelet v1.36.3- the kubelet one patch behind.Success conditionYou know which direction of skew is supported.
-
The CNI, which decides what a policy actually does
Every NetworkPolicy page on this path depends on this answer.
bash Example session kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium version 2>/dev/null | head -2Client: 1.18.1 e8a7070f 2025-08-13T14:47:02+00:00 go version go1.24.6 linux/amd64Daemon: 1.18.1 e8a7070f 2025-08-13T14:47:02+00:00 go version go1.24.6 linux/amd64kubectl api-resources --api-group=cilium.io --no-headers 2>/dev/null | awk '{printf " %-34s %s\n", $1, $2}' | head -5; echo "--- policy kinds that exist only because the CNI is Cilium" ciliumcidrgroups ccg ciliumclusterwidenetworkpolicies ccnp ciliumendpoints cep,ciliumep ciliumidentities ciliumid ciliuml2announcementpolicies l2announcement--- policy kinds that exist only because the CNI is CiliumExpected resultCilium 1.18.1, client and daemon matching. Then Cilium's own API group:
ciliumcidrgroups,ciliumclusterwidenetworkpolicies(ccnp),ciliumendpoints,ciliumidentities.Success conditionYou know what is enforcing policy here.
-
What a default kubeadm cluster actually contains
Five namespaces, and nothing you did not get from the installer.
bash Example session kubectl get ns --no-headers -o custom-columns='N:.metadata.name' | tr '\n' ' '; echo; echo "--- five namespaces. Nothing has been installed on top"cilium-secrets default kube-node-lease kube-public kube-system--- five namespaces. Nothing has been installed on topkubectl get pods -A --no-headers | wc -l; echo "pods, all of them cluster infrastructure:"; kubectl get pods -A --no-headers -o custom-columns='NS:.metadata.namespace,N:.metadata.name' | sed 's/-[a-z0-9]*-[a-z0-9]*$//' | sort -u | head -1221pods, all of them cluster infrastructure:kube-system ciliumkube-system cilium-69l44kube-system cilium-cgdjlkube-system cilium-fntj7kube-system cilium-j6wsjkube-system cilium-operatorkube-system corednskube-system etcd-cka5001kube-system hubble-relaykube-system hubble-uikube-system kubekube-system kube-controllerkubectl get validatingwebhookconfigurations --no-headers 2>&1 | tail -1; kubectl get mutatingwebhookconfigurations --no-headers 2>&1 | tail -1; echo "--- no admission webhooks at all, so nothing is validating or mutating what you submit"No resources foundNo resources found--- no admission webhooks at all, so nothing is validating or mutating what you submitExpected result
cilium-secrets default kube-node-lease kube-public kube-system. 21 pods, all of them cilium, coredns, etcd, hubble, kube-apiserver, kube-controller-manager, kube-proxy and kube-scheduler. AndNo resources foundfor both webhook kinds.Success conditionYou know exactly what is running before you add anything.
-
The posture it starts in, measured
Three questions any security review asks first. Nothing here is assumed.
bash Example session kubectl get ns -o custom-columns='NAME:.metadata.name,PSA-ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce' --no-headers | awk '$2=="<none>"{n++} END {print n" of 5 namespaces have NO Pod Security Admission enforce label"}'5 of 5 namespaces have NO Pod Security Admission enforce labelkubectl get networkpolicy -A 2>&1 | tail -1; kubectl get cnp,ccnp -A 2>&1 | tail -1; echo "--- no policy of either kind, so every Pod can reach every other Pod"No resources foundNo resources found--- no policy of either kind, so every Pod can reach every other Podkubectl get clusterrolebinding -o json | python3 -c "import json,sysd=json.load(sys.stdin)subs=[(b['metadata']['name'], s.get('kind'), s.get('name')) for b in d['items'] if b['roleRef']['name']=='cluster-admin' for s in (b.get('subjects') or [])]for b,k,n in subs: print(' %-28s %-8s %s' % (b,k,n))print('%d subject(s) hold cluster-admin - both of them kubeadm own' % len(subs))" cluster-admin Group system:masters kubeadm:cluster-admins Group kubeadm:cluster-admins2 subject(s) hold cluster-admin - both of them kubeadm ownExpected result5 of 5 namespaces have no PSA enforce label.
No resources foundfor both NetworkPolicy and CiliumNetworkPolicy. And 2 subjects hold cluster-admin:system:mastersandkubeadm:cluster-admins.Success conditionYou have a baseline to measure every later change against.
-
The supply chain, when nobody has added to it yet
Twelve images. Count how many are pinned.
bash Example session kubectl get pods -A -o go-template='{{range .items}}{{range .spec.containers}}{{.image}}{{"\n"}}{{end}}{{end}}' | sort -u | wc -l; echo "distinct images"; kubectl get pods -A -o go-template='{{range .items}}{{range .spec.containers}}{{.image}}{{"\n"}}{{end}}{{end}}' | grep -c '@sha256:'; echo "pinned by digest"; kubectl get pods -A -o go-template='{{range .items}}{{range .spec.containers}}{{.image}}{{"\n"}}{{end}}{{end}}' | grep -oE '^[^/]+' | sort | uniq -c | sort -rn12distinct images12pinned by digest 12 quay.io 10 registry.k8s.ioExpected result12 distinct images, 12 pinned by digest, from
quay.io(12 containers) andregistry.k8s.io(10).Success conditionYou know what a fully pinned cluster looks like, and why.
-
One Pod, and everything it is handed without asking
The defaults, made visible. Then cleaned up.
bash Example session kubectl run kcsa-probe --image=docker.io/library/busybox:1.37 --restart=Never --command -- sh -c 'sleep 600' >/dev/null && kubectl wait --for=condition=Ready pod/kcsa-probe --timeout=120s && kubectl get pod kcsa-probe -o custom-columns='NAME:.metadata.name,NODE:.spec.nodeName,SA:.spec.serviceAccountName,RUNASUSER:.spec.securityContext.runAsUser'pod/kcsa-probe condition metNAME NODE SA RUNASUSERkcsa-probe cka5001-node02 default <none>kubectl exec kcsa-probe -- id; kubectl exec kcsa-probe -- sh -c 'grep -E "^CapEff" /proc/self/status'; echo "--- root, and the capability set containerd hands every container"uid=0(root) gid=0(root) groups=0(root),10(wheel)CapEff: 00000000a80425fb--- root, and the capability set containerd hands every containerkubectl exec kcsa-probe -- ls /var/run/secrets/kubernetes.io/serviceaccount/; kubectl exec kcsa-probe -- sh -c 'touch /newfile && echo "root filesystem is WRITABLE"'ca.crtnamespacetokenroot filesystem is WRITABLEkubectl exec kcsa-probe -- sh -c 'wget -q -O- --no-check-certificate --timeout=5 --header="Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" https://kubernetes.default.svc/api/v1/namespaces/default/pods 2>&1 | head -c 180'; echo; echo "--- 403. The token is real and reaches the apiserver; RBAC is what refuses it"wget: server returned error: HTTP/1.1 403 Forbidden --- 403. The token is real and reaches the apiserver; RBAC is what refuses itkubectl auth can-i --list --as=system:serviceaccount:default:default --no-headers 2>/dev/null | wc -l; echo "rules the default ServiceAccount holds"23rules the default ServiceAccount holdskubectl delete pod kcsa-probe --wait=true 2>&1 | tail -1; kubectl get pod kcsa-probe 2>&1 | tail -1pod "kcsa-probe" deleted from default namespaceError from server (NotFound): pods "kcsa-probe" not foundExpected resultScheduled to
cka5001-node02, SAdefault,RUNASUSER <none>. Inside:uid=0(root),CapEff: 00000000a80425fb, the token mounted, androot filesystem is WRITABLE. The token reaches the apiserver and gets 403. The default SA holds 23 rules.Success conditionYou have seen what every Pod gets by default.
Troubleshooting
A NetworkPolicy has no effect.
Why: The CNI does not implement policy, or nothing selects the Pod.
Fix:Confirm the CNI first. Here it is Cilium 1.18.1, which does enforce it.
Pod Security Admission is not restricting anything.
Why: No namespace carries a
pod-security.kubernetes.io/enforcelabel.Fix:PSA is opt-in per namespace. Label it, or it does nothing.
A CiliumNetworkPolicy applied on another cluster does nothing.
Why: It is a Cilium kind. Another CNI accepts the CRD only if Cilium installed it.
Fix:Use the generic
NetworkPolicyfor anything portable.kubeadm upgraderefuses to proceed.Why: A component is ahead of the apiserver - as the kubelets are here.
Fix:
kubeadm upgrade plan. Control plane first, kubelets after. Never the reverse.Unexpected identities hold cluster-admin.
Why: Management planes and operators create their own bindings.
Fix:
kubectl get clusterrolebinding -o jsonfiltered on thecluster-adminroleRef.A workload can call the apiserver and you did not intend it to.
Why: The ServiceAccount token is mounted by default.
Fix:
automountServiceAccountToken: falseon the Pod or the ServiceAccount.