The CKS (Certified Kubernetes Security Specialist) is the third and most demanding of the CNCF's Kubernetes certifications. It certifies that you know how to harden a cluster, detect what is happening inside it and respond when something goes wrong. It is the certification for the profile that, at Rutas Norte, makes sure bookings-postgres —which holds travellers' personal data— is encrypted, isolated, audited and monitored.
It has one peculiarity that sets it apart from the other two exams: to sit it you need a valid CKA. That is not a recommendation, it is an administrative requirement. And there is a reason: the CKS assumes you already know how to administer the cluster and devotes itself exclusively to securing it, with external tools that do not appear in the other exams.
Everything in this lesson takes a strictly defensive angle: harden, detect and respond. No offensive techniques are taught or described.
Essential notice. Price, duration, number of tasks, pass mark, Kubernetes version assessed and domain weighting change over time. What follows is indicative. Always check the current official curriculum on the Linux Foundation / CNCF website (
training.linuxfoundation.org,cncf.io/certification/cks) before you enrol.Security warning. The configurations shown here are study material aimed at an exam. Before applying any security policy to a real environment, it must be reviewed by a security professional who knows the context, the threat model and the organisation's regulatory requirements.
Contents
- What the CKS certifies and the valid-CKA requirement
- The exam format and why it is the most demanding
- The curriculum domains and their indicative weights
- Full map: every CKS objective and its lesson in this course
- The tools the CKS requires you to handle
- Procedures you must execute without hesitating
- Study plan and exam traps
- Eight CKS-style tasks solved
- What the CKS certifies and the valid-CKA requirement
1.1 The profile
The CKS attests to competence in securing container-based applications and platforms throughout their whole lifecycle: build, deployment and runtime. In Rutas Norte terms:
| Phase | What is certified | Example |
|---|---|---|
| Build | That the images entering the cluster are clean and verifiable | Scanning rutas-norte/bookings-api:2.4 with Trivy and signing it before publishing |
| Deployment | That whatever is admitted into the cluster meets a policy | Preventing a privileged pod from reaching rutas-norte-pro |
| Runtime | That anomalies are detected and responded to | Detecting a shell opened inside a production container and isolating the pod |
| Platform | That the cluster itself is hardened | Encrypting Secrets in etcd, auditing the API, restricting RBAC |
1.2 The entry requirement
You must hold a valid (not expired) CKA at the time you sit the CKS. Practical consequences:
- You cannot take the CKS first. The order is compulsory: CKA → CKS.
- If your CKA expires (validity is around two years) before you take the CKS exam, you will not be able to sit it until you renew.
- The CKS certification, once obtained, has its own independent validity.
- Check the official handbook for how this requirement is verified and how far in advance.
Planning tip: do not let too much time pass between the CKA and the CKS. Beyond the expiry risk, the CKS reuses a lot from the CKA (RBAC, kubeadm, static pods, diagnosis) and it is a shame to lose that form.
1.3 How it differs from the CKA and the CKAD
| Aspect | CKA | CKAD | CKS |
|---|---|---|---|
| Central question | Is the cluster working? | Is my application working? | Is any of this secure? |
| External tools | None | None | kube-bench, Trivy, Falco, AppArmor, seccomp, Kyverno/OPA, gVisor |
| Documentation allowed | kubernetes.io | kubernetes.io | kubernetes.io + the Trivy, Falco and AppArmor docs |
| Prerequisite | None | None | Valid CKA |
| Indicative no. of tasks | 15-20 | 15-20 | 15-16 (fewer, but longer) |
| Perceived difficulty | Medium-high | Medium | High |
- The exam format and why it is the most demanding
2.1 Characteristics
| Aspect | Indicative description |
|---|---|
| Type | 100 % hands-on, browser-based terminal, proctored |
| Duration | Around two hours |
| Number of tasks | Approximately 15-16 |
| Pass mark | Around 67 % |
| Clusters | Several, with a context switch per task |
| Scoring | Per task, with partial marks |
| Documentation allowed | kubernetes.io/docs, plus the official Trivy, Falco and AppArmor websites |
| Prerequisite | Valid CKA |
| Validity | Around two years |
Indicative. Verify it on the official website, including the exact list of allowed documentation domains, which has varied between programme revisions.
2.2 Why it is considered the hardest
Four concrete reasons:
- Tools outside Kubernetes. You have to know how to invoke
kube-bench, read its output and fix what it flags; runtrivy imageand interpret severities; write a Falco rule and know where its configuration file lives. None of that is inkubectl. - Long tasks. With ~15 tasks in 120 minutes that is ~8 minutes on average, but a single task may ask you to edit the apiserver manifest, restart the control plane and wait for it to come back. Dead time counts.
- Work on the node, not just against the API. AppArmor, seccomp,
audit-policy, etcd encryption andkube-benchare configured withsshandsudoover system files. - Failure is catastrophic if you get it wrong. A mistake in
/etc/kubernetes/manifests/kube-apiserver.yamlleaves the API down and blocks the remaining tasks on that cluster. You have to know how to recover.
2.3 The rule that saves the exam: back up before you touch
Before editing any critical control plane file:
If the API does not come back after the change, you restore and try again:
Be careful where you keep the backup. Never inside /etc/kubernetes/manifests/: the kubelet would try to start the .bak file as an extra static pod and you would have two apiservers fighting over the port. Keep it in /root/ or /tmp/.
2.4 How to check that the apiserver is back
# The control plane takes between 20 and 60 seconds to recover
sudo crictl ps | grep kube-apiserver
kubectl get nodesIf kubectl answers connection refused, look at the container logs:
- The curriculum domains and their indicative weights
Split published at the time of writing; verify it on the official website.
| Domain | Indicative weight | What it covers |
|---|---|---|
| Cluster Setup | ~15 % | NetworkPolicies, CIS benchmark, Ingress with TLS, node access, binary verification |
| Cluster Hardening | ~15 % | Minimal RBAC, ServiceAccounts, restricting API access, frequent upgrades |
| System Hardening | ~10 % | Minimising the OS surface, IAM, restricting network access, AppArmor, seccomp |
| Minimise microservice vulnerabilities | ~20 % | Security contexts, OS-level security domains, Secrets, sandboxing (gVisor), mTLS |
| Supply Chain security | ~20 % | Minimal base images, allowed registry lists, signing and validation, static analysis, scanning |
| Monitoring, logging and runtime security | ~20 % | Behavioural analysis, threat detection, immutability, audit logs |
The three heaviest domains (microservices, supply chain and runtime, ~60 % between them) are precisely the ones you worked through in lessons 08-02, 08-05 and 08-06.
- Full map: every CKS objective and its lesson in this course
4.1 Cluster Setup (~15 %)
| Official objective | Course lesson |
|---|---|
| Use network-level security (NetworkPolicies) | 04-06-network-policies, 08-04-network-security |
| Use the CIS benchmark to review component configuration | 08-06-auditing-scanning-and-vulnerabilities |
| Configure Ingress objects correctly with TLS security | 04-04-ingress-controllers, 04-05-tls-and-certificates-with-cert-manager |
| Protect node metadata and endpoints | 08-04-network-security |
| Minimise the use of and access to GUI elements | 08-01-role-based-access-control |
| Verify platform binaries before deployment | 08-05-image-security |
4.2 Cluster Hardening (~15 %)
| Official objective | Course lesson |
|---|---|
| Restrict access to the Kubernetes API | 08-01-role-based-access-control, 03-06-serviceaccounts-and-api-access |
| Use RBAC to minimise exposure | 08-01-role-based-access-control |
| Be careful with ServiceAccounts (disable the default one, tokens) | 03-06-serviceaccounts-and-api-access |
| Upgrade Kubernetes frequently | 10-02-kubeadm |
4.3 System Hardening (~10 %)
| Official objective | Course lesson |
|---|---|
| Minimise the host operating system footprint | 08-02-security-contexts-and-hardening |
| Minimise IAM roles | 08-01-role-based-access-control, 10-06-managed-kubernetes-eks-aks-gke |
| Minimise external network access | 04-06-network-policies, 08-04-network-security |
| Use kernel hardening tools: AppArmor, seccomp | 08-02-security-contexts-and-hardening, 08-03-pod-security-policies-and-standards |
4.4 Minimise microservice vulnerabilities (~20 %)
| Official objective | Course lesson |
|---|---|
| Apply OS-level security domains (PSA, securityContext) | 08-02-security-contexts-and-hardening, 08-03-pod-security-policies-and-standards |
| Manage Kubernetes Secrets | 03-02-secrets, 08-06-auditing-scanning-and-vulnerabilities |
| Use sandboxed runtimes (gVisor, kata) | 08-02-security-contexts-and-hardening |
| Implement pod-to-pod encryption with mTLS | 08-04-network-security |
4.5 Supply Chain security (~20 %)
| Official objective | Course lesson |
|---|---|
| Minimise the base image footprint | 08-05-image-security |
| Restrict the allowed registries | 08-05-image-security, 08-03-pod-security-policies-and-standards |
| Sign and validate images (Cosign) | 08-05-image-security |
| Use static analysis of workloads | 08-06-auditing-scanning-and-vulnerabilities, 11-03-cicd-with-kubernetes |
| Scan images for known vulnerabilities | 08-06-auditing-scanning-and-vulnerabilities |
4.6 Monitoring, logging and runtime security (~20 %)
| Official objective | Course lesson |
|---|---|
| Behavioural analysis at syscall and process level | 08-06-auditing-scanning-and-vulnerabilities (Falco) |
| Detect threats in infrastructure, apps, networks and storage | 08-06-auditing-scanning-and-vulnerabilities, 07-04-visualization-and-alerting-with-grafana |
| Detect every phase of an attack, wherever it occurs | 08-06-auditing-scanning-and-vulnerabilities, 11-06-production-operations |
| Perform deep forensic analysis of suspicious activity | 07-06-debugging-and-cluster-events, 08-06-auditing-scanning-and-vulnerabilities |
| Ensure the immutability of running containers | 08-02-security-contexts-and-hardening |
| Use audit logs to monitor access | 08-06-auditing-scanning-and-vulnerabilities |
- The tools the CKS requires you to handle
These nine pieces appeared in module 8. Here we revisit them exam-oriented: the exact command, the exact file and the exact check.
5.1 kube-bench: the CIS standard
kube-bench checks the cluster configuration against the CIS Kubernetes Benchmark and tells you what is wrong and how to fix it.
# Run it against the control node
kube-bench run --targets=master
# Against a worker node
kube-bench run --targets=node
# A single specific control
kube-bench run --targets=master --check=1.2.20Typical output:
[INFO] 1 Control Plane Security Configuration
[INFO] 1.2 API Server
[FAIL] 1.2.20 Ensure that the --profiling argument is set to false (Automated)
[PASS] 1.2.21 Ensure that the --audit-log-path argument is set (Automated)
== Remediations master ==
1.2.20 Edit the API server pod specification file
/etc/kubernetes/manifests/kube-apiserver.yaml
on the control plane node and set the below parameter.
--profiling=falseWhat you have to know how to do: read the [FAIL], go to the == Remediations == section, apply exactly what it says in the file it names, and verify by running kube-bench again with --check.
The most common FAILs and their fixes:
| Control | Fix in /etc/kubernetes/manifests/kube-apiserver.yaml |
|---|---|
--profiling |
- --profiling=false |
--anonymous-auth |
- --anonymous-auth=false |
--authorization-mode |
- --authorization-mode=Node,RBAC (never AlwaysAllow) |
--audit-log-path |
- --audit-log-path=/var/log/kubernetes/audit.log |
--kubelet-certificate-authority |
Point it at the cluster CA |
--insecure-port |
No longer exists in modern versions; if it shows up, remove it |
And on the kubelet (/var/lib/kubelet/config.yaml):
authentication:
anonymous:
enabled: false # never true
webhook:
enabled: true
authorization:
mode: Webhook # never AlwaysAllow
readOnlyPort: 0 # close port 10255
protectKernelDefaults: trueAfter editing it: sudo systemctl restart kubelet.
5.2 Trivy: image scanning
# Scan an image
trivy image rutas-norte/bookings-api:2.4
# Only critical and high vulnerabilities
trivy image --severity CRITICAL,HIGH rutas-norte/bookings-api:2.4
# Only the ones with a patch available
trivy image --ignore-unfixed --severity CRITICAL nginx:1.27-alpine
# Compact output, ideal for the exam
trivy image --severity CRITICAL --format table --quiet postgres:16
# Scan Kubernetes manifests (static analysis)
trivy config /path/to/manifests/
# Scan a whole cluster
trivy k8s --report summary clusterOutput:
rutas-norte/bookings-api:2.4 (alpine 3.20)
==========================================
Total: 3 (CRITICAL: 3)
┌────────────┬────────────────┬──────────┬───────────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │
├────────────┼────────────────┼──────────┼───────────────────┼───────────────┤
│ openssl │ CVE-2024-XXXXX │ CRITICAL │ 3.1.4-r5 │ 3.1.6-r0 │
└────────────┴────────────────┴──────────┴───────────────────┴───────────────┘Typical exam task: "out of these four pods, delete the ones using images with CRITICAL vulnerabilities". Procedure:
# 1. Pull out the images of the pods in the namespace
kubectl get pods -n rutas-norte-pro \
-o custom-columns='POD:.metadata.name,IMAGE:.spec.containers[*].image'
# 2. Scan each one
for img in nginx:1.27-alpine postgres:16 redis:7-alpine node:20-alpine; do
echo "=== $img ==="
trivy image --severity CRITICAL --quiet "$img" | grep -c CVE || true
done
# 3. Delete the ones that apply
kubectl delete pod <name> -n rutas-norte-pro5.3 Falco: runtime detection
Falco watches system calls and fires alerts when behaviour matches a rule.
| File | Contents |
|---|---|
/etc/falco/falco.yaml |
General configuration: outputs, format, rule files loaded |
/etc/falco/falco_rules.yaml |
Default rules (do not edit) |
/etc/falco/falco_rules.local.yaml |
Your rules and overrides |
/etc/falco/rules.d/ |
Directory for additional rules |
# Status and logs
sudo systemctl status falco
sudo journalctl -u falco -f
sudo journalctl -u falco --since "10 minutes ago" | grep Warning
# Reload after changing rules
sudo systemctl restart falco
# Validate rule syntax without starting
falco --validate /etc/falco/falco_rules.local.yamlA typical output:
09:14:22.113 Notice A shell was spawned in a container with an attached terminal
(user=root container_id=a1b2c3d4 container_name=bookings-api
shell=sh parent=runc cmdline=sh)Changing the output format of an existing rule (a very frequent task): you override it in falco_rules.local.yaml.
# /etc/falco/falco_rules.local.yaml
- rule: Terminal shell in container
output: >
%evt.time,%user.name,%container.id,%container.name,%proc.name
override:
output: replaceWriting a new rule:
- rule: Suspicious write to /etc inside a container
desc: Detects writes to /etc inside a container
condition: >
container and
evt.type in (open, openat) and
evt.is_open_write=true and
fd.name startswith /etc
output: >
Write to /etc detected (user=%user.name container=%container.name
file=%fd.name command=%proc.cmdline)
priority: WARNING
tags: [filesystem, container]And saving the output wherever the statement asks:
5.4 AppArmor applied to a pod
The full flow, which you have to know by heart:
# 1. On the NODE where the pod will run: create the profile
sudo tee /etc/apparmor.d/k8s-rutas-norte-deny-write <<'EOF'
#include <tunables/global>
profile k8s-rutas-norte-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
file,
deny /** w, # deny all writes
}
EOF
# 2. Load it in enforce mode
sudo apparmor_parser -q /etc/apparmor.d/k8s-rutas-norte-deny-write
# 3. Check that it is loaded
sudo aa-status | grep rutas-norte4. Apply it to the pod. Since Kubernetes 1.30 there is a native field in securityContext (before that, only the annotation existed):
apiVersion: v1
kind: Pod
metadata:
name: notifications-worker
namespace: rutas-norte-pro
spec:
containers:
- name: worker
image: busybox:1.36
command: ["sleep", "3600"]
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-rutas-norte-deny-writeThe old form, still accepted and the one that appears in many statements:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/worker: localhost/k8s-rutas-norte-deny-writeCheck:
kubectl exec notifications-worker -n rutas-norte-pro -- touch /tmp/test
# touch: /tmp/test: Permission deniedTraps: the profile must be loaded on the node where the pod is scheduled (use nodeName or labels if the cluster has several); the name in localhostProfile is the profile name, not the file path; type: RuntimeDefault applies the runtime's default profile and type: Unconfined disables AppArmor.
5.5 seccomp applied to a pod
apiVersion: v1
kind: Pod
metadata:
name: bookings-api-seccomp
namespace: rutas-norte-pro
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # the runtime's default profile: the most common one
containers:
- name: api
image: nginx:1.27-alpineWith a custom profile, the file must live in the node's /var/lib/kubelet/seccomp/:
sudo mkdir -p /var/lib/kubelet/seccomp/profiles
sudo tee /var/lib/kubelet/seccomp/profiles/audit.json <<'EOF'
{
"defaultAction": "SCMP_ACT_LOG"
}
EOF securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/audit.json # path RELATIVE to /var/lib/kubelet/seccomp/type |
Meaning |
|---|---|
RuntimeDefault |
The runtime's default profile (blocks dangerous syscalls). The right answer 80 % of the time. |
Localhost |
Custom profile at /var/lib/kubelet/seccomp/<localhostProfile> |
Unconfined |
No restriction. Never the answer they are looking for. |
Classic trap: putting the absolute path in localhostProfile. It is relative to the kubelet's seccomp directory.
5.6 The apiserver audit policy
This is the long task par excellence. Two parts: writing the policy and wiring it into the apiserver.
Part 1 — the policy, in /etc/kubernetes/audit/policy.yaml:
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- RequestReceived
rules:
# Do not log noisy read-only requests
- level: None
verbs: ["get", "list", "watch"]
resources:
- group: ""
resources: ["events"]
# Secrets and ConfigMaps: metadata only, never the content
- level: Metadata
resources:
- group: ""
resources: ["secrets", "configmaps"]
# Pods in the critical namespace: full request and response bodies
- level: RequestResponse
namespaces: ["rutas-norte-pro"]
resources:
- group: ""
resources: ["pods"]
# Everything else, at metadata level
- level: MetadataThe four levels, which you have to tell apart:
| Level | What gets logged |
|---|---|
None |
Nothing |
Metadata |
Who, what, when, on which resource. No bodies. |
Request |
Metadata + request body |
RequestResponse |
Metadata + request and response bodies |
Part 2 — wiring it into the apiserver, in /etc/kubernetes/manifests/kube-apiserver.yaml:
spec:
containers:
- command:
- kube-apiserver
- --audit-policy-file=/etc/kubernetes/audit/policy.yaml
- --audit-log-path=/var/log/kubernetes/audit/audit.log
- --audit-log-maxage=30
- --audit-log-maxbackup=10
- --audit-log-maxsize=100
volumeMounts:
- mountPath: /etc/kubernetes/audit
name: audit-policy
readOnly: true
- mountPath: /var/log/kubernetes/audit
name: audit-log
readOnly: false
volumes:
- name: audit-policy
hostPath:
path: /etc/kubernetes/audit
type: DirectoryOrCreate
- name: audit-log
hostPath:
path: /var/log/kubernetes/audit
type: DirectoryOrCreateThe trap that fails this task: adding the arguments and forgetting the volumes. The apiserver runs in a container: if you do not mount the host directories, it can neither see the policy nor write the log, and it goes into a restart loop.
Check:
5.7 Pod Security Admission
The built-in admission controller that replaced PodSecurityPolicies. It is switched on by labelling the namespace.
| Level | What it allows |
|---|---|
privileged |
Everything. No restrictions. |
baseline |
Blocks the obviously dangerous: privileged, hostNetwork, hostPID, dangerous added capabilities |
restricted |
Strong hardening: runAsNonRoot, allowPrivilegeEscalation: false, drop: ALL, seccomp RuntimeDefault |
| Mode | Effect |
|---|---|
enforce |
Rejects non-compliant pods |
audit |
Allows them, but records it in the audit log |
warn |
Allows them and warns the user in the terminal |
kubectl label namespace rutas-norte-pro \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.30 \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted \
--overwriteCheck (a non-compliant pod must be rejected):
Error from server (Forbidden): pods "insecure" is forbidden:
violates PodSecurity "restricted:v1.30": privileged (container "insecure"
must not set securityContext.privileged=true), allowPrivilegeEscalation != false,
unrestricted capabilities, runAsNonRoot != true, seccompProfileTrap: if the namespace already has non-compliant pods, enforce does not evict them; it only blocks new ones. Use warn and audit to spot the existing ones.
5.8 Kyverno or OPA Gatekeeper
When the policy they ask for goes beyond what PSA covers (for example, "only images from the corporate registry"), you use a policy engine.
Kyverno (YAML syntax, more direct):
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: corporate-registry-only
spec:
validationFailureAction: Enforce
background: false
rules:
- name: check-registry
match:
any:
- resources:
kinds:
- Pod
namespaces:
- rutas-norte-pro
validate:
message: "Only images from registry.rutasnorte.es are allowed"
pattern:
spec:
containers:
- image: "registry.rutasnorte.es/*"# Require mandatory labels
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-owner-label
spec:
validationFailureAction: Enforce
rules:
- name: check-owner
match:
any:
- resources:
kinds: ["Deployment", "StatefulSet"]
validate:
message: "The 'owner' label is missing"
pattern:
metadata:
labels:
owner: "?*"OPA Gatekeeper (two objects: the template with Rego, and the constraint):
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8sallowedrepos
spec:
crd:
spec:
names:
kind: K8sAllowedRepos
validation:
openAPIV3Schema:
type: object
properties:
repos:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sallowedrepos
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
satisfied := [good | repo := input.parameters.repos[_]
good := startswith(container.image, repo)]
not any(satisfied)
msg := sprintf("image not allowed: %v", [container.image])
}
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
name: allowed-registries
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces: ["rutas-norte-pro"]
parameters:
repos:
- "registry.rutasnorte.es/"Check in both cases:
kubectl run test --image=docker.io/nginx -n rutas-norte-pro
# Error from server: admission webhook denied the request: ...5.9 gVisor via RuntimeClass
gVisor (the runsc runtime) intercepts system calls in user space, isolating the container from the host kernel. It is used when the workload is not fully trusted.
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc # must match the handler configured in containerdapiVersion: v1
kind: Pod
metadata:
name: untrusted-workload
namespace: rutas-norte-pre
spec:
runtimeClassName: gvisor
containers:
- name: app
image: nginx:1.27-alpineCheck (the kernel seen from inside is gVisor's, not the host's):
Trap: the RuntimeClass installs nothing. The runsc handler has to be configured already in the node's /etc/containerd/config.toml. In the exam it usually already is, and all you need is to create the RuntimeClass and assign it.
5.10 Default-deny NetworkPolicies
The pattern you have to be able to write from memory:
# 1. Deny EVERYTHING in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all
namespace: rutas-norte-pro
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress# 2. Allow DNS (essential, and always forgotten)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: rutas-norte-pro
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53# 3. Allow only what is needed
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-to-postgres
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: bookings-api
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: bookings-postgres
ports:
- protocol: TCP
port: 5432Fatal trap: applying the total deny and forgetting DNS. The whole namespace stops resolving names and the symptoms look like something else entirely.
- Procedures you must execute without hesitating
Six choreographies that are almost certain to come up. Practise them until they come out without documentation.
6.1 ServiceAccount with minimal RBAC
# 1. The ServiceAccount
kubectl create serviceaccount bookings-app -n rutas-norte-pro
# 2. The Role with the bare minimum
kubectl create role bookings-app-role \
--verb=get,list \
--resource=configmaps \
--resource-name=api-config \
-n rutas-norte-pro
# 3. The binding
kubectl create rolebinding bookings-app-binding \
--role=bookings-app-role \
--serviceaccount=rutas-norte-pro:bookings-app \
-n rutas-norte-pro
# 4. Assign it to the Deployment
kubectl set serviceaccount deployment/bookings-api bookings-app -n rutas-norte-proCheck:
kubectl auth can-i get configmap/api-config \
--as=system:serviceaccount:rutas-norte-pro:bookings-app -n rutas-norte-pro # yes
kubectl auth can-i get configmaps \
--as=system:serviceaccount:rutas-norte-pro:bookings-app -n rutas-norte-pro # no (all of them)
kubectl auth can-i create pods \
--as=system:serviceaccount:rutas-norte-pro:bookings-app -n rutas-norte-pro # noUsing --resource-name is the key to "least privilege" and a highly valued answer.
6.2 Disabling automatic token mounting
Three levels, and you have to know which one the statement is asking for:
# A) On the ServiceAccount: affects every pod that uses it
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookings-app
namespace: rutas-norte-pro
automountServiceAccountToken: false# B) On the Pod: wins over the ServiceAccount
apiVersion: v1
kind: Pod
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
serviceAccountName: bookings-app
automountServiceAccountToken: false
containers:
- name: api
image: rutas-norte/bookings-api:2.4# C) Patch the namespace's "default" ServiceAccount (a very typical task)
kubectl patch serviceaccount default -n rutas-norte-pro \
-p '{"automountServiceAccountToken": false}'Check:
kubectl exec bookings-api -n rutas-norte-pro -- ls /var/run/secrets/kubernetes.io/serviceaccount
# ls: ...: No such file or directory6.3 Encrypting Secrets at rest in etcd
Step 1 — the configuration file, in /etc/kubernetes/enc/enc.yaml:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <32_BYTE_BASE64_KEY>
- identity: {}The key is generated like this:
Provider order (this is what gets asked):
- The first in the list is the one used to encrypt.
- All of them are used to decrypt, in order.
identity: {}means "unencrypted". If it goes first, everything is written in the clear.
Step 2 — wire it into the apiserver:
- --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
volumeMounts:
- name: enc
mountPath: /etc/kubernetes/enc
readOnly: true
volumes:
- name: enc
hostPath:
path: /etc/kubernetes/enc
type: DirectoryOrCreateStep 3 — re-encrypt the Secrets that already exist (they do not encrypt themselves):
Check (the value in etcd is no longer in the clear):
sudo ETCDCTL_API=3 etcdctl get /registry/secrets/rutas-norte-pro/bookings-postgres-credentials \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key | hexdump -C | head -300000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000020 6b 38 73 3a 65 6e 63 3a 61 65 73 63 62 63 3a 76 |k8s:enc:aescbc:v|The k8s:enc:aescbc:v1: marker confirms it is encrypted. Without encryption you would see the value in readable form.
6.4 Hardening an insecure pod handed to you
This is the quintessential CKS task: they give you a manifest and ask you to make it compliant. The mental checklist:
| What to look for | What to put |
|---|---|
privileged: true |
privileged: false (or remove it) |
hostNetwork, hostPID, hostIPC set to true |
Remove them |
hostPath pointing at system paths |
Replace with emptyDir or remove |
Missing runAsNonRoot |
runAsNonRoot: true + runAsUser: <non-0> |
Missing allowPrivilegeEscalation |
allowPrivilegeEscalation: false |
Added capabilities (SYS_ADMIN, NET_ADMIN...) |
capabilities: {drop: ["ALL"]} |
| Writable filesystem | readOnlyRootFilesystem: true |
| No seccomp profile | seccompProfile: {type: RuntimeDefault} |
| Token mounted unnecessarily | automountServiceAccountToken: false |
| Privileged ports with no need for them | Review |
A typical result, compliant with the restricted level:
apiVersion: v1
kind: Pod
metadata:
name: bookings-api-hardened
namespace: rutas-norte-pro
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: registry.rutasnorte.es/bookings-api:2.4
securityContext:
privileged: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}Practical note: with readOnlyRootFilesystem: true, almost any application needs an emptyDir at /tmp to work.
6.5 Isolating a suspicious pod and freezing the analysis
The response procedure for a pod behaving anomalously. Defensive approach: contain, preserve evidence, restore.
# 1. Isolate it from the Service (by removing the label that selects it)
kubectl label pod bookings-api-abc123 -n rutas-norte-pro app-
# 2. Cut its network off completely with a targeted NetworkPolicy
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: quarantine-api-abc123
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
quarantine: "true"
policyTypes:
- Ingress
- Egress
EOF
kubectl label pod bookings-api-abc123 -n rutas-norte-pro quarantine=trueWith no ingress or egress rules, the pod is completely isolated but still alive for analysis. That is the point: do not delete it yet.
# 3. Preserve the evidence
kubectl logs bookings-api-abc123 -n rutas-norte-pro > /opt/evidence/logs.txt
kubectl describe pod bookings-api-abc123 -n rutas-norte-pro > /opt/evidence/describe.txt
kubectl get events -n rutas-norte-pro --sort-by=.lastTimestamp > /opt/evidence/events.txt
sudo journalctl -u falco --since "1 hour ago" > /opt/evidence/falco.txt
# 4. Once the analysis is finished: delete it
kubectl delete pod bookings-api-abc123 -n rutas-norte-pro --force --grace-period=0The Deployment will recreate a clean pod. If the image was the cause, it has to be fixed first.
6.6 Finding who did what in the audit log
# Look for a specific user's actions
sudo grep '"username":"support"' /var/log/kubernetes/audit/audit.log | tail -5
# Look for access to Secrets
sudo cat /var/log/kubernetes/audit/audit.log | \
jq 'select(.objectRef.resource=="secrets") | {user:.user.username, verb:.verb, ns:.objectRef.namespace, name:.objectRef.name, t:.requestReceivedTimestamp}'
# Look for deletions in a namespace
sudo cat /var/log/kubernetes/audit/audit.log | \
jq 'select(.verb=="delete" and .objectRef.namespace=="rutas-norte-pro") | {user:.user.username, res:.objectRef.resource, name:.objectRef.name}'
# With no jq available
sudo grep '"verb":"delete"' /var/log/kubernetes/audit/audit.log | \
grep 'rutas-norte-pro' | tail -3Expected output:
{"user":"support","verb":"get","ns":"rutas-norte-pro","name":"bookings-postgres-credentials","t":"2026-08-06T09:12:44Z"}The audit log fields you have to know:
| Field | Contents |
|---|---|
user.username |
Who |
verb |
Which action (get, list, create, delete, patch) |
objectRef.resource |
On which kind of resource |
objectRef.namespace / .name |
On which exact object |
sourceIPs |
From where |
responseStatus.code |
Whether it succeeded (200/201) or was denied (403) |
requestReceivedTimestamp |
When |
level |
The level it was logged at |
- Study plan and exam traps
7.1 A four-week plan (starting from a passed CKA)
| Week | Focus | What to do |
|---|---|---|
| 1 | Cluster and system hardening | Revise 08-01, 08-02, 08-03. Run kube-bench and fix every FAIL. Practise AppArmor and seccomp ten times each. |
| 2 | Microservices and networking | Revise 04-06, 08-04. Write from memory: deny-all, allow-DNS, allow-selective. Pod Security Admission at all three levels. Kyverno with three different policies. |
| 3 | Supply chain | Revise 08-05, 08-06, 11-03. Scan ten different images with Trivy. Sign and verify with Cosign. Allowed-registry policy. Static analysis of manifests. |
| 4 | Runtime, auditing and mock exams | Revise 08-06, 07-06. Write three Falco rules. Configure auditing from scratch five times. Encrypt etcd from scratch three times. Timed mock exams. |
Key rule: the "edit the apiserver" tasks (auditing, encryption, kube-bench) have to be repeated until the full cycle (edit → wait → verify → recover if it fails) drops below 8 minutes.
7.2 The exam traps
| Trap | How to avoid it |
|---|---|
| Editing the apiserver and leaving it broken without noticing | Backup in /root/ beforehand, and kubectl get nodes afterwards |
Keeping the backup inside /etc/kubernetes/manifests/ |
It starts as an extra static pod. Keep it outside. |
| Configuring auditing without mounting the volumes | The apiserver will not start. Arguments and volumes, always. |
| Encrypting etcd and not re-encrypting the existing Secrets | The old ones stay in the clear. kubectl get secrets -A -o json | kubectl replace -f - |
identity: {} first in the provider list |
It encrypts nothing. The encryption provider goes first. |
| Denying all traffic and forgetting DNS | The entire namespace stops working |
| AppArmor profile loaded on the wrong node | Pin the node with nodeName or load it on all of them |
Absolute path in seccomp's localhostProfile |
It is relative to /var/lib/kubelet/seccomp/ |
Applying PSA restricted and expecting it to evict existing pods |
It only blocks new ones |
Editing falco_rules.yaml instead of falco_rules.local.yaml |
It is lost on the next update; and the exam usually asks for the local one |
| Not restarting the service after changing configuration | systemctl restart falco / restart kubelet |
| Wasting time installing tools | In the exam they are already installed. Find them with which trivy, which kube-bench. |
7.3 Official resources
| Resource | What for |
|---|---|
| Official CKS page (Linux Foundation) | Current curriculum, requirements, price, allowed documentation domains |
cncf/curriculum on GitHub |
The PDF with the exact objectives |
kubernetes.io/docs/concepts/security/ |
The complete security section |
falco.org/docs/ |
Allowed in the exam: rule syntax and fields |
aquasecurity.github.io/trivy/ |
Allowed: flags and output formats |
apparmor.net / the AppArmor manual |
Allowed: profile syntax |
| Simulator included with enrolment | Practice in an equivalent environment |
- Eight CKS-style tasks solved
Rutas Norte scenarios. Strictly defensive angle.
Task 1 — Harden an insecure pod (weight ~7 %, target: 6 min)
Context:
rutas-norte-pro. The Podnotifications-workerruns privileged, withhostPIDand with added capabilities. Modify it so it meets therestrictedstandard: no privileges, no escalation, no capabilities, read-only root, non-root user and the default seccomp profile.
apiVersion: v1
kind: Pod
metadata:
name: notifications-worker
namespace: rutas-norte-pro
spec:
# hostPID: true ← REMOVED
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: worker
image: busybox:1.36
command: ["sleep", "3600"]
securityContext:
privileged: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}Check:
kubectl get pod notifications-worker -n rutas-norte-pro
kubectl exec notifications-worker -n rutas-norte-pro -- id
kubectl exec notifications-worker -n rutas-norte-pro -- touch /rootfileThe trap: privileged, capabilities and readOnlyRootFilesystem go in the container's securityContext; runAsNonRoot and seccompProfile can go in the pod's. And you have to delete and recreate the pod: almost all of securityContext is immutable.
Task 2 — Audit policy (weight ~9 %, target: 12 min)
Context:
rutas-norte-pro. Configure apiserver auditing to log atMetadatalevel every access to Secrets, atRequestResponselevel every change to Pods inrutas-norte-pro, and nothing for read requests on events. The log must go to/var/log/kubernetes/audit/audit.logand be kept for 15 days.
sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/apiserver.bak
sudo mkdir -p /etc/kubernetes/audit /var/log/kubernetes/audit
sudo tee /etc/kubernetes/audit/policy.yaml <<'EOF'
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- RequestReceived
rules:
- level: None
verbs: ["get", "list", "watch"]
resources:
- group: ""
resources: ["events"]
- level: Metadata
resources:
- group: ""
resources: ["secrets"]
- level: RequestResponse
namespaces: ["rutas-norte-pro"]
resources:
- group: ""
resources: ["pods"]
- level: Metadata
EOF
sudo vim /etc/kubernetes/manifests/kube-apiserver.yaml - --audit-policy-file=/etc/kubernetes/audit/policy.yaml
- --audit-log-path=/var/log/kubernetes/audit/audit.log
- --audit-log-maxage=15
volumeMounts:
- mountPath: /etc/kubernetes/audit
name: audit-policy
readOnly: true
- mountPath: /var/log/kubernetes/audit
name: audit-log
volumes:
- name: audit-policy
hostPath:
path: /etc/kubernetes/audit
type: DirectoryOrCreate
- name: audit-log
hostPath:
path: /var/log/kubernetes/audit
type: DirectoryOrCreateCheck:
sudo crictl ps | grep kube-apiserver
kubectl get secrets -n rutas-norte-pro
sudo tail -1 /var/log/kubernetes/audit/audit.log | jq '{user:.user.username,res:.objectRef.resource,level:.level}'The trap: the rules are evaluated in order and the first match wins. If you put - level: Metadata (the catch-all) at the top, none of the rest ever applies. And without the volumes, the apiserver will not start.
Task 3 — Encrypt Secrets in etcd (weight ~9 %, target: 12 min)
Context:
rutas-norte-pro. Encrypt Secrets at rest usingaescbc. Make sure the Secretbookings-postgres-credentials, which already exists, ends up encrypted.
sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/apiserver.bak
sudo mkdir -p /etc/kubernetes/enc
KEY=$(head -c 32 /dev/urandom | base64)
sudo tee /etc/kubernetes/enc/enc.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: ${KEY}
- identity: {}
EOF
sudo chmod 600 /etc/kubernetes/enc/enc.yaml
sudo vim /etc/kubernetes/manifests/kube-apiserver.yaml - --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
volumeMounts:
- name: enc
mountPath: /etc/kubernetes/enc
readOnly: true
volumes:
- name: enc
hostPath:
path: /etc/kubernetes/enc
type: DirectoryOrCreate# Wait for the API to come back
until kubectl get nodes >/dev/null 2>&1; do sleep 3; done
# Re-encrypt the existing Secrets
kubectl get secrets -n rutas-norte-pro -o json | kubectl replace -f -Check:
sudo ETCDCTL_API=3 etcdctl get /registry/secrets/rutas-norte-pro/bookings-postgres-credentials \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key | hexdump -C | head -2The trap: without the kubectl replace, the old Secrets stay in the clear and the task does not score. And identity: {} must come after aescbc, never before.
Task 4 — Default-deny NetworkPolicy (weight ~7 %, target: 7 min)
Context:
rutas-norte-pro. Apply default deny to all inbound and outbound traffic in the namespace. Then allow the pods to keep resolving DNS and letbookings-apireachbookings-postgreson 5432.
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all
namespace: rutas-norte-pro
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: rutas-norte-pro
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-to-postgres
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: bookings-api
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels:
app: bookings-postgres
ports:
- protocol: TCP
port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-from-api
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: bookings-postgres
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels:
app: bookings-api
ports:
- protocol: TCP
port: 5432
EOFCheck:
kubectl exec deploy/bookings-api -n rutas-norte-pro -- nslookup bookings-postgres
kubectl exec deploy/bookings-api -n rutas-norte-pro -- nc -zv bookings-postgres 5432
kubectl exec deploy/web-store -n rutas-norte-pro -- nc -zv -w 3 bookings-postgres 5432 # must failThe trap: you have to open both directions. The egress from bookings-api is not enough if bookings-postgres has ingress denied. And without the DNS policy, the name is not even resolved.
Task 5 — Trivy scan and withdrawal of vulnerable images (weight ~6 %, target: 6 min)
Context:
rutas-norte-pre. Scan the images of the pods in the namespace. Delete the pods whose image has CRITICAL severity vulnerabilities. Save the list of discarded images in/opt/vulnerable.txt.
kubectl get pods -n rutas-norte-pre \
-o custom-columns='POD:.metadata.name,IMG:.spec.containers[*].image' --no-headersweb-store-1 nginx:1.27-alpine
bookings-api-1 node:18.0.0
redis-1 redis:7-alpine
worker-1 ubuntu:20.04> /opt/vulnerable.txt
for img in nginx:1.27-alpine node:18.0.0 redis:7-alpine ubuntu:20.04; do
n=$(trivy image --severity CRITICAL --quiet --format json "$img" \
| jq '[.Results[].Vulnerabilities // []] | flatten | length')
echo "$img -> CRITICAL: $n"
[ "$n" -gt 0 ] && echo "$img" >> /opt/vulnerable.txt
donenginx:1.27-alpine -> CRITICAL: 0
node:18.0.0 -> CRITICAL: 7
redis:7-alpine -> CRITICAL: 0
ubuntu:20.04 -> CRITICAL: 3The trap: if the pods belong to a Deployment, deleting them achieves nothing: they are recreated with the same image. You have to scale to 0 or delete the Deployment, depending on what the statement asks. Read it carefully.
Task 6 — AppArmor on a pod (weight ~7 %, target: 8 min)
Context:
rutas-norte-pro. On nodenode-1there is an unloaded AppArmor profile at/opt/profiles/deny-write. Load it in enforce mode and create the Podauditor(imagebusybox:1.36, commandsleep 3600) on that node with that profile applied.
The name shown by aa-status is the one you have to use, not the file name.
apiVersion: v1
kind: Pod
metadata:
name: auditor
namespace: rutas-norte-pro
spec:
nodeName: node-1
containers:
- name: auditor
image: busybox:1.36
command: ["sleep", "3600"]
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-writeCheck:
kubectl get pod auditor -n rutas-norte-pro -o wide
kubectl exec auditor -n rutas-norte-pro -- touch /tmp/xThe trap: nodeName: node-1 is essential; if the pod is scheduled on another node where the profile is not loaded, it ends up Blocked with the event cannot enforce AppArmor profile. And if the pod stays Pending, check that node-1 is not cordoned.
Task 7 — Falco rule and evidence capture (weight ~8 %, target: 9 min)
Context:
rutas-norte-pro. Falco is installed onnode-1. Change the output of the rule that detects shells in containers so it shows exactlytime,user,container-id,container-name,process, and save the detections from the last 10 minutes into/opt/shells.log.
ssh node-1
sudo tee -a /etc/falco/falco_rules.local.yaml <<'EOF'
- rule: Terminal shell in container
output: >
%evt.time,%user.name,%container.id,%container.name,%proc.name
override:
output: replace
EOF
sudo falco --validate /etc/falco/falco_rules.local.yaml
sudo systemctl restart falco
sudo systemctl status falco --no-pager | head -5# Trigger a detection to verify (from another terminal)
kubectl exec -it deploy/bookings-api -n rutas-norte-pro -- sh -c "echo test"
# Capture the evidence
sudo journalctl -u falco --since "10 minutes ago" --no-pager \
| grep "Terminal shell" > /opt/shells.log
cat /opt/shells.logThe trap: you have to edit falco_rules.local.yaml, never falco_rules.yaml. And without systemctl restart falco the change has no effect. If falco --validate reports a syntax error, fix it before restarting: an invalid file leaves the service down.
Task 8 — Admission policy: allowed registries only (weight ~8 %, target: 9 min)
Context:
rutas-norte-pro. Kyverno is installed. Create a policy that rejects, inrutas-norte-pro, any Pod whose image does not come fromregistry.rutasnorte.es/. Prove that it works.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: corporate-registry-only
spec:
validationFailureAction: Enforce
background: false
rules:
- name: check-registry
match:
any:
- resources:
kinds:
- Pod
namespaces:
- rutas-norte-pro
validate:
message: "Only images from registry.rutasnorte.es/ are allowed"
pattern:
spec:
=(initContainers):
- image: "registry.rutasnorte.es/*"
containers:
- image: "registry.rutasnorte.es/*"Check:
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
policy Pod/rutas-norte-pro/test-ko for resource violation:
corporate-registry-only:
check-registry: 'validation error: Only images from
registry.rutasnorte.es/ are allowed'The trap: validationFailureAction: Audit only records; to reject you need Enforce. And the =() prefix on initContainers means "if this field exists, validate it"; without it, a pod with no initContainers would fail validation.
Common Mistakes and Tips
Mistakes that cost points
| Mistake | Consequence | Prevention |
|---|---|---|
| Breaking the apiserver with no backup | You lose every task on that cluster | cp to /root/ before editing |
Backup inside manifests/ |
Two apiservers in conflict | Keep it outside that directory |
| Audit arguments with no volumes | The apiserver will not start | Arguments and volumeMounts and volumes |
| Catch-all rule at the top of the audit policy | The rest never apply | Order matters: specific to general |
identity: {} before the encryption provider |
Nothing gets encrypted | The encrypter goes first |
| Not re-encrypting the existing Secrets | Half the task unscored | kubectl get secrets -A -o json | kubectl replace -f - |
| Denying everything and forgetting DNS | The namespace becomes unusable | The DNS policy always goes alongside the deny policy |
Absolute path in localhostProfile (seccomp) |
The pod will not start | Path relative to /var/lib/kubelet/seccomp/ |
Editing falco_rules.yaml |
Change in the wrong file | Use falco_rules.local.yaml |
Forgetting systemctl restart after changing config |
The change does not take effect | Restart and verify with status |
Modifying securityContext with kubectl edit |
Immutable fields: it fails | Export, delete, recreate |
| Deleting pods managed by a Deployment | They are recreated identically | Scale to 0 or fix the template |
Tips
- Repeat the apiserver tasks until they are automatic. Auditing and encryption are worth the most points and eat the most time if you hesitate.
- Keep an
until kubectl get nodeshandy to wait for the API to come back instead of staring at the screen. - Check which tools are installed as soon as you start a task that needs them:
which trivy kube-bench falco. - The Falco and Trivy documentation is allowed. Know where the Falco field section is (
%evt.time,%container.name, ...) and the Trivy flags section. - Always verify with a negative test. It is not enough for the allowed case to work: you have to see the forbidden one fail.
- Think defence, not offence. The exam measures hardening, detection and response. Every correct answer points that way.
- Remember the requirement: no valid CKA, no CKS.
Exercises
Exercise 1 — Full apiserver hardening cycle
On a practice cluster built with kubeadm, and timing yourself:
- Run
kube-bench run --targets=masterand note down every[FAIL]in section 1.2. - Fix at least three of them by editing
/etc/kubernetes/manifests/kube-apiserver.yaml. - Verify that the API comes back and that
kube-benchnow shows[PASS]on those controls. - Back up the manifest before you start and demonstrate that you know how to restore it.
Target: 20 minutes, with the cluster working at the end.
Exercise 2 — Responding to a suspicious pod
Simulate the full containment procedure in rutas-norte-pro:
- Create a Deployment
bookings-apiwith 3 replicas and a Service that selects it. - Pick one of the pods as the "suspect".
- Isolate it from the Service without killing it.
- Cut off all its network communication with a targeted NetworkPolicy, leaving the rest of the namespace working.
- Preserve logs,
describeand events in/opt/evidence/. - Check that the Service is still serving with 2 endpoints and that the isolated pod has no connectivity.
Exercise 3 — End-to-end supply chain
For the image nginx:1.27-alpine:
- Scan it with Trivy filtering by CRITICAL and HIGH with a patch available.
- Create a Kyverno policy requiring every pod in
rutas-norte-proto haverunAsNonRoot: trueandallowPrivilegeEscalation: false. - Prove that a non-compliant pod is rejected and a compliant one is admitted.
- Also enable Pod Security Admission at
restrictedlevel inwarnmode onrutas-norte-preand check the warning.
Solutions
Solution to Exercise 1
# 0. Backup ALWAYS first
sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/apiserver.bak
# 1. Diagnosis
kube-bench run --targets=master | grep -E '^\[FAIL\]' | head[FAIL] 1.2.20 Ensure that the --profiling argument is set to false
[FAIL] 1.2.21 Ensure that the --audit-log-path argument is set
[FAIL] 1.2.22 Ensure that the --audit-log-maxage argument is set to 30 or as appropriateRemembering that --audit-log-path needs its volume:
volumeMounts:
- mountPath: /var/log/kubernetes/audit
name: audit-log
volumes:
- name: audit-log
hostPath:
path: /var/log/kubernetes/audit
type: DirectoryOrCreate# 3. Wait and verify
until kubectl get nodes >/dev/null 2>&1; do sleep 3; done
kubectl get nodes
kube-bench run --targets=master --check=1.2.20,1.2.21,1.2.22[PASS] 1.2.20 Ensure that the --profiling argument is set to false
[PASS] 1.2.21 Ensure that the --audit-log-path argument is set
[PASS] 1.2.22 Ensure that the --audit-log-maxage argument is set to 304. Restore (to practise recovery):
sudo cp /root/apiserver.bak /etc/kubernetes/manifests/kube-apiserver.yaml
until kubectl get nodes >/dev/null 2>&1; do sleep 3; doneThe usual mistake in this exercise is adding --audit-log-path without the volume: the apiserver starts and dies in a loop, and the diagnosis is sudo crictl logs <id>.
Solution to Exercise 2
# 1. Setup
kubectl create deployment bookings-api --image=nginx:1.27-alpine \
--replicas=3 -n rutas-norte-pro
kubectl expose deployment bookings-api --port=80 -n rutas-norte-pro
kubectl get endpoints bookings-api -n rutas-norte-pro# 2-3. Pick one and isolate it from the Service
SUSPECT=$(kubectl get pods -n rutas-norte-pro -l app=bookings-api \
-o jsonpath='{.items[0].metadata.name}')
kubectl label pod $SUSPECT -n rutas-norte-pro app-
kubectl label pod $SUSPECT -n rutas-norte-pro quarantine=trueCareful: when you remove the app label, the ReplicaSet considers it lost and creates a new pod. That is exactly what you want: the service recovers on its own while the suspect stays alive for analysis.
# 4. Total network cut-off for the quarantined pod
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: quarantine
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
quarantine: "true"
policyTypes: [Ingress, Egress]
EOF
# 5. Evidence
mkdir -p /opt/evidence
kubectl logs $SUSPECT -n rutas-norte-pro > /opt/evidence/logs.txt
kubectl describe pod $SUSPECT -n rutas-norte-pro > /opt/evidence/describe.txt
kubectl get events -n rutas-norte-pro --sort-by=.lastTimestamp > /opt/evidence/events.txt
# 6. Verification
kubectl get endpoints bookings-api -n rutas-norte-pro # 3 endpoints, none of them the suspect
kubectl exec $SUSPECT -n rutas-norte-pro -- \
timeout 3 wget -qO- http://bookings-api || echo "isolated correctly"A NetworkPolicy with no ingress or egress rules is a total cut-off. The pod still exists, its logs can be read (that goes through the API, not through its network) and kubectl exec works for the same reason.
Solution to Exercise 3
--ignore-unfixed is key: it filters out the noise of vulnerabilities with no patch available, which you cannot remediate by upgrading.
# 2. Kyverno policy
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-non-root
spec:
validationFailureAction: Enforce
background: false
rules:
- name: check-securitycontext
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["rutas-norte-pro"]
validate:
message: "Pods must set runAsNonRoot=true and allowPrivilegeEscalation=false"
pattern:
spec:
=(securityContext):
runAsNonRoot: true
containers:
- securityContext:
allowPrivilegeEscalation: falsekubectl apply -f require-non-root.yaml
# 3. Negative test
kubectl run bad --image=nginx:1.27-alpine -n rutas-norte-pro
# Error from server: admission webhook denied the request: ...
# 3b. Positive test
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: good
namespace: rutas-norte-pro
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
containers:
- name: c
image: nginx:1.27-alpine
securityContext:
allowPrivilegeEscalation: false
EOF
kubectl get pod good -n rutas-norte-pro# 4. Pod Security Admission in warn mode
kubectl label namespace rutas-norte-pre \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=v1.30 --overwrite
kubectl run warned --image=nginx:1.27-alpine -n rutas-norte-preWarning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false,
unrestricted capabilities, runAsNonRoot != true, seccompProfile
pod/warned createdNote the difference: Kyverno with Enforce rejects; PSA in warn mode warns and creates. Knowing how to choose the mechanism and the mode according to what the statement asks is exactly what this domain measures.
Reminder: these policies are study exercises. Before applying anything like them to a real environment, have it reviewed by a security professional who knows the organisation's context.
Conclusion
The CKS is the certification that closes the triangle: the CKA proves you can keep the cluster standing, the CKAD that you can build on it, and the CKS that you can defend it. It is the most demanding of the three because it takes you out of kubectl and into the node's files, external tools and decisions where a mistake leaves the control plane down.
The essentials of this lesson:
- The CKS requires a valid CKA to sit. There is no way around it.
- Beyond
kubernetes.io, the exam allows the Trivy, Falco and AppArmor documentation. Make the most of it. - The three heaviest domains —microservices, supply chain and runtime, ~60 % between them— are the ones from module 8 of this course.
- Nine tools have to be handled without hesitation: kube-bench, Trivy, Falco, AppArmor, seccomp, audit-policy, Pod Security Admission, Kyverno/OPA and gVisor with RuntimeClass, plus default-deny NetworkPolicies.
- Six procedures must be on autopilot: minimal RBAC, disabling token automounting, encrypting etcd, hardening a pod, isolating a suspicious pod and reading the audit log.
- Before touching the apiserver: back it up outside
manifests/. Afterwards: verify that the API comes back. - The traps that fail the most people: forgotten volumes in the audit configuration,
identityfirst in the encryption list, Secrets left un-re-encrypted, DNS blocked by the total deny, and AppArmor profiles on the wrong node. - Always check the current official curriculum on the Linux Foundation / CNCF website, including the list of allowed documentation, which has changed between revisions.
- And remember: any security configuration destined for a real environment must be reviewed by a security professional.
You now know the three certifications, their curricula and how they map onto what you have studied. What is missing is the part none of the three teaches, and which decides more passes than it seems: exam technique. In the next lesson we will look at how to prepare the proctoring environment, what to type in the first sixty seconds of the exam, how to use the allowed documentation without losing time, how to divide the minutes between tasks, which mistakes cost the most points and what to do —before, during and after— so that exam day holds no surprises.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
