The 4C Cloud Native Security Model
The 4Cs are usually four nested rings on a slide, which is why people can recite them without using them. This guide asks each layer a question the cluster can answer: what is under the nodes, who may call the API, what the runtime handed a Pod, and what is actually inside the images.
Cloud Native Security Guide 4 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 18 min
- Reviewed25 August 2026
Written against the versions above. A four-node kubeadm cluster on bare metal - `providerID` is empty, which is why the Cloud layer here is the physical network rather than a provider API. On a managed cluster that layer is much larger and the page says what changes.
| 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 3 - what is installed and enforcing.
-
Cloud: what is underneath, and what a Pod can reach of it
The outermost C. On this cluster it is smaller than usual, and the reason matters.
bash Example session kubectl get nodes -o custom-columns='NAME:.metadata.name,PROVIDER:.spec.providerID,INTERNAL-IP:.status.addresses[?(@.type=="InternalIP")].address'; echo "--- no providerID: bare metal, so there is no cloud metadata service to protect"NAME PROVIDER INTERNAL-IPcka5001 <none> 192.168.0.41cka5001-node01 <none> 192.168.0.42cka5001-node02 <none> 192.168.0.43cka5001-node03 <none> 192.168.0.44--- no providerID: bare metal, so there is no cloud metadata service to protectkubectl run kcsa-4c --image=docker.io/library/busybox:1.37 --restart=Never --command -- sh -c 'sleep 600' >/dev/null && kubectl wait --for=condition=Ready pod/kcsa-4c --timeout=120s >/dev/null && kubectl exec kcsa-4c -- sh -c 'wget -q -T 3 -O- http://169.254.169.254/latest/meta-data/ 2>&1 | head -2'; echo "--- 169.254.169.254 is the cloud metadata address. On a cloud node an unprotected Pod reads instance credentials from it"wget: download timed out--- 169.254.169.254 is the cloud metadata address. On a cloud node an unprotected Pod reads instance credentials from itExpected result
PROVIDER <none>on all four nodes, internal IPs on 192.168.0.0/24. Thenwget: download timed outagainst 169.254.169.254.Success conditionYou know what the Cloud layer is on this cluster and what it would be elsewhere.
-
Cluster: the API boundary, and who you are when you cross it
The second C. Two questions: is the API reachable, and as whom.
bash Example session kubectl get --raw '/readyz?verbose' 2>/dev/null | head -6; echo "--- unquoted, the ? is a shell glob and zsh refuses the command before kubectl sees it"[+]ping ok[+]log ok[+]etcd ok[+]etcd-readiness ok[+]informer-sync ok[+]poststarthook/start-apiserver-admission-initializer ok--- unquoted, the ? is a shell glob and zsh refuses the command before kubectl sees itkubectl get --raw '/livez?verbose' 2>/dev/null | tail -2; echo "---"; kubectl get --raw /api/v1/namespaces/default/pods 2>&1 | head -c 110; echo[+]poststarthook/apiservice-openapiv3-controller oklivez check passed---{"kind":"PodList","apiVersion":"v1","metadata":{"resourceVersion":"2680"},"items":[{"metadata":{"name":"kcsa-4Expected result
[+]ping ok,[+]log ok,[+]etcd ok,[+]etcd-readiness ok,[+]informer-sync okand the poststart hooks; thenlivez check passedand the version payload.Success conditionYou can read the apiserver's own health without credentials.
-
Cluster: and the credential in your own kubeconfig
Before auditing anyone else, find out what you are.
bash Example session kubectl get --raw '/livez?verbose' 2>/dev/null | tail -2; echo "---"; kubectl get --raw /api/v1/namespaces/default/pods 2>&1 | head -c 110; echo[+]poststarthook/apiservice-openapiv3-controller oklivez check passed---{"kind":"PodList","apiVersion":"v1","metadata":{"resourceVersion":"2680"},"items":[{"metadata":{"name":"kcsa-4kubectl auth whoami 2>/dev/null; echo "--- who the kubeconfig actually is"ATTRIBUTE VALUEUsername kubernetes-adminGroups [kubeadm:cluster-admins system:authenticated]Extra: authentication.kubernetes.io/credential-id [X509SHA256=da024493843d4cf0b1166a0d2defc96e9ea89084fc6d817e1a2bb955b1a28503]--- who the kubeconfig actually isExpected resultA full
PodListreturned, thenUsername kubernetes-adminwith groups includingkubeadm:cluster-adminsand an X509 credential id.Success conditionYou know how privileged your own access is.
-
Container: what the runtime handed the Pod
The third C. Nothing here was requested - it is all default.
bash Example session kubectl get pod kcsa-4c -o jsonpath='{.spec.containers[0].securityContext}'; echo " <- container securityContext"; kubectl get pod kcsa-4c -o jsonpath='{.spec.securityContext}'; echo " <- pod securityContext"; echo "--- both empty, so every default below is in force" <- container securityContext{} <- pod securityContext--- both empty, so every default below is in forcekubectl exec kcsa-4c -- sh -c 'ls -l /proc/self/attr/ 2>/dev/null | head -3'; echo "--- AppArmor is the mandatory access control layer on an Ubuntu node"total 0dr-xr-xr-x 2 root root 0 Aug 25 11:33 apparmor-rw-rw-rw- 1 root root 0 Aug 25 11:33 current--- AppArmor is the mandatory access control layer on an Ubuntu nodekubectl exec kcsa-4c -- sh -c 'touch /newfile && echo "root filesystem is WRITABLE"'; echo "--- readOnlyRootFilesystem is not set, so the image is mutable at runtime"root filesystem is WRITABLE--- readOnlyRootFilesystem is not set, so the image is mutable at runtimeExpected resultBoth securityContexts empty, an
apparmordirectory under/proc/self/attr/, androot filesystem is WRITABLE.Success conditionYou can state exactly what a Pod gets when nobody restricts it.
-
Code: the image, the digest, and what is in it
The innermost C, and the one you control most directly.
bash Example session kubectl get pod kcsa-4c -o jsonpath='{.status.containerStatuses[0].image}{"\n"}{.status.containerStatuses[0].imageID}'; echo; echo "--- the tag you asked for, and the DIGEST you actually got"docker.io/library/busybox:1.37docker.io/library/busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0--- the tag you asked for, and the DIGEST you actually gotkubectl get pod kcsa-4c -o jsonpath='{.spec.containers[0].imagePullPolicy}'; echo " <- imagePullPolicy, defaulted because the tag is not :latest"IfNotPresent <- imagePullPolicy, defaulted because the tag is not :latestkubectl exec kcsa-4c -- sh -c 'ls /bin | wc -l; ls /bin/sh /bin/wget /bin/nc 2>/dev/null | tr "\n" " "'; echo; echo "--- busybox ships a shell, wget and netcat. Every one is a tool an attacker inherits"407/bin/nc /bin/sh /bin/wget--- busybox ships a shell, wget and netcat. Every one is a tool an attacker inheritsExpected resultTag
docker.io/library/busybox:1.37, digestbusybox@sha256:9db7b599...,imagePullPolicy IfNotPresent, and 407 entries in/binincludingsh,wgetandnc.Success conditionYou know the difference between what you asked for and what you got.
Troubleshooting
kubectl get --raw /readyz?verbosefails with a shell error.Why: The
?is a glob. The shell refuses before kubectl runs.Fix:Quote it:
kubectl get --raw '/readyz?verbose'.A Pod can read cloud instance metadata.
Why: The metadata service is reachable from the Pod network.
Fix:NetworkPolicy denying egress to 169.254.169.254, and IMDSv2 on AWS.
Empty securityContext assumed to be safe.
Why: Empty means defaults, and the defaults include root and 14 capabilities.
Fix:Set
runAsNonRoot, dropALLcapabilities, add back only what is needed.Two nodes run different code under the same tag.
Why: A moved tag plus
imagePullPolicy: IfNotPresent.Fix:Pin by digest, or use immutable tags and
Alwayswhere the pull cost is fine.kubectl execinto a container fails with no such file.Why: The image has no shell - distroless or scratch.
Fix:Expected and desirable. Use an ephemeral debug container instead.