CKS — Pod Security Lab

12 minute read

Pod Security Lab

This lab brings together everything from the Pod Security chapter — Pod Security Admission, security contexts, runtime sandboxing, and workload hardening. Each exercise mirrors a realistic CKS exam task.

Prerequisites

  • A Kubernetes cluster (kubeadm, minikube, or kind) running v1.25+
  • kubectl configured and working
  • For sandboxing exercises: gVisor (runsc) installed on at least one node (optional — can be simulated)
  • Cluster admin access

Lab 1: Apply PSA Labels to Enforce Restricted Level

Task

Configure the namespace secure-apps to enforce the Restricted Pod Security Standard. Set audit and warn modes to Restricted as well. Pin to version v1.30.

Steps

# Create the namespace
kubectl create namespace secure-apps

# Apply PSA labels
kubectl label namespace secure-apps \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=v1.30 \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/audit-version=v1.30 \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=v1.30

# Verify labels
kubectl get namespace secure-apps -o yaml

Expected Output

apiVersion: v1
kind: Namespace
metadata:
  labels:
    kubernetes.io/metadata.name: secure-apps
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.30
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.30
  name: secure-apps

Verification

# Confirm all six PSA labels are present
kubectl get ns secure-apps --show-labels | grep pod-security

Lab 2: Test PSA — Deploy a Privileged Pod (Expect Rejection)

Task

Attempt to deploy a non-compliant Pod in the secure-apps namespace. The Pod should be rejected by PSA.

Steps

# Create a non-compliant Pod manifest
cat <<'EOF' > /tmp/non-compliant-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: non-compliant
  namespace: secure-apps
spec:
  containers:
  - name: test
    image: nginx:1.27
    # No security context — violates Restricted level
EOF

# Apply and observe rejection
kubectl apply -f /tmp/non-compliant-pod.yaml

Expected Output

Error from server (Forbidden): error when creating "/tmp/non-compliant-pod.yaml":
pods "non-compliant" is forbidden: violates PodSecurity "restricted:v1.30":
  allowPrivilegeEscalation != false
    (container "test" must set securityContext.allowPrivilegeEscalation=false)
  unrestricted capabilities
    (container "test" must set securityContext.capabilities.drop=["ALL"])
  runAsNonRoot != true
    (pod or container "test" must set securityContext.runAsNonRoot=true)
  seccompProfile
    (pod or container "test" must set securityContext.seccompProfile.type
     to "RuntimeDefault" or "Localhost")

Analysis

The Pod was rejected because it violates four Restricted requirements:

  1. allowPrivilegeEscalation not set to false
  2. Capabilities not dropped
  3. runAsNonRoot not set to true
  4. No Seccomp profile specified

Each violation is listed in the error message — this is how PSA provides actionable feedback.


Lab 3: Deploy a Compliant Pod with Full Security Context

Task

Create and deploy a Pod that passes the Restricted Pod Security Standard in the secure-apps namespace.

Steps

cat <<'EOF' > /tmp/compliant-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: compliant-web
  namespace: secure-apps
  labels:
    app: web
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  automountServiceAccountToken: false
  containers:
  - name: nginx
    image: nginx:1.27-alpine
    ports:
    - containerPort: 8080
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    resources:
      requests:
        cpu: 50m
        memory: 64Mi
      limits:
        cpu: 200m
        memory: 128Mi
    volumeMounts:
    - name: tmp
      mountPath: /tmp
    - name: cache
      mountPath: /var/cache/nginx
    - name: run
      mountPath: /var/run
  volumes:
  - name: tmp
    emptyDir: {}
  - name: cache
    emptyDir: {}
  - name: run
    emptyDir: {}
EOF

kubectl apply -f /tmp/compliant-pod.yaml

Expected Output

pod/compliant-web created

Verification

# Confirm Pod is running
kubectl get pod compliant-web -n secure-apps
# NAME            READY   STATUS    RESTARTS   AGE
# compliant-web   1/1     Running   0          10s

# Verify security settings inside the container
kubectl exec -n secure-apps compliant-web -- id
# uid=1000 gid=1000 groups=1000

kubectl exec -n secure-apps compliant-web -- cat /proc/1/status | grep -i seccomp
# Seccomp:	2
# Seccomp_filters:	1

# Verify read-only root filesystem
kubectl exec -n secure-apps compliant-web -- touch /test-file 2>&1
# touch: /test-file: Read-only file system

# Verify writable emptyDir mount
kubectl exec -n secure-apps compliant-web -- touch /tmp/test-file
# (no error — writable via emptyDir)

# Verify capabilities
kubectl exec -n secure-apps compliant-web -- cat /proc/1/status | grep Cap
# CapPrm: 0000000000000000
# CapEff: 0000000000000000
# All zeros = no capabilities

Lab 4: Configure Security Contexts — Hardening Exercise

Task

A Deployment is running in the secure-apps namespace but is non-compliant. Fix it to pass the Restricted PSS level.

Setup (Create the Broken Deployment)

First, temporarily set the namespace to warn-only mode:

kubectl label namespace secure-apps \
  pod-security.kubernetes.io/enforce=privileged \
  --overwrite

cat <<'EOF' > /tmp/broken-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  namespace: secure-apps
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
      - name: api
        image: python:3.12-slim
        command: ["python", "-m", "http.server", "8080"]
        ports:
        - containerPort: 8080
EOF

kubectl apply -f /tmp/broken-deploy.yaml

# Wait for Pods to be running
kubectl rollout status deploy/api-server -n secure-apps

# Now re-enable Restricted enforcement
kubectl label namespace secure-apps \
  pod-security.kubernetes.io/enforce=restricted \
  --overwrite

Task: Fix the Deployment

# The existing Pods keep running (PSA doesn't terminate running Pods)
# But if you try to scale or restart, new Pods will be rejected

# Try restarting
kubectl rollout restart deploy/api-server -n secure-apps

# Check — new ReplicaSet can't create Pods
kubectl get rs -n secure-apps
kubectl describe rs -n secure-apps -l app=api | grep -A 5 Events

Solution

cat <<'EOF' > /tmp/fixed-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  namespace: secure-apps
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      automountServiceAccountToken: false
      containers:
      - name: api
        image: python:3.12-slim
        command: ["python", "-m", "http.server", "8080"]
        ports:
        - containerPort: 8080
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            cpu: 50m
            memory: 64Mi
          limits:
            cpu: 200m
            memory: 128Mi
        volumeMounts:
        - name: tmp
          mountPath: /tmp
      volumes:
      - name: tmp
        emptyDir: {}
EOF

kubectl apply -f /tmp/fixed-deploy.yaml

# Verify the rollout succeeds
kubectl rollout status deploy/api-server -n secure-apps
# deployment "api-server" successfully rolled out

kubectl get pods -n secure-apps -l app=api
# NAME                          READY   STATUS    RESTARTS   AGE
# api-server-new-abc123         1/1     Running   0          15s
# api-server-new-def456         1/1     Running   0          15s

What Was Fixed

Field Before After
runAsNonRoot Not set true
runAsUser Not set (root) 1000
seccompProfile Not set RuntimeDefault
allowPrivilegeEscalation Not set (true default) false
capabilities Default (14 caps) drop: ["ALL"]
readOnlyRootFilesystem Not set (false default) true
automountServiceAccountToken Not set (true default) false

Lab 5: Create a RuntimeClass for gVisor and Deploy a Sandboxed Pod

Task

Create a RuntimeClass named gvisor with handler runsc, then deploy a Pod using the sandbox runtime.

Steps

# Step 1: Create the RuntimeClass
cat <<'EOF' > /tmp/runtimeclass-gvisor.yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
overhead:
  podFixed:
    cpu: "250m"
    memory: "120Mi"
EOF

kubectl apply -f /tmp/runtimeclass-gvisor.yaml

# Verify
kubectl get runtimeclass
# NAME     HANDLER   AGE
# gvisor   runsc     5s
# Step 2: Deploy a sandboxed Pod
# Note: This requires gVisor (runsc) installed on nodes.
# If runsc is not installed, the Pod will fail to schedule.

cat <<'EOF' > /tmp/sandboxed-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: sandboxed-app
  namespace: secure-apps
  labels:
    app: sandboxed
spec:
  runtimeClassName: gvisor
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  automountServiceAccountToken: false
  containers:
  - name: app
    image: nginx:1.27-alpine
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    resources:
      requests:
        cpu: 100m
        memory: 128Mi
      limits:
        cpu: 500m
        memory: 256Mi
    volumeMounts:
    - name: tmp
      mountPath: /tmp
    - name: cache
      mountPath: /var/cache/nginx
    - name: run
      mountPath: /var/run
  volumes:
  - name: tmp
    emptyDir: {}
  - name: cache
    emptyDir: {}
  - name: run
    emptyDir: {}
EOF

kubectl apply -f /tmp/sandboxed-pod.yaml

Expected Output (if gVisor is installed)

kubectl get pod sandboxed-app -n secure-apps
# NAME            READY   STATUS    RESTARTS   AGE
# sandboxed-app   1/1     Running   0          10s

# Verify RuntimeClass
kubectl get pod sandboxed-app -n secure-apps -o jsonpath='{.spec.runtimeClassName}'
# gvisor

Expected Output (if gVisor is NOT installed)

kubectl describe pod sandboxed-app -n secure-apps | tail -5
# Events:
#   Warning  FailedCreatePodSandBox  ...  failed to create containerd task:
#     failed to find runtime handler "runsc"

This is expected in environments without gVisor. The key CKS skill is knowing how to create the RuntimeClass and Pod YAML correctly.


Lab 6: Verify Runtime Isolation

Task

Verify that a gVisor-sandboxed Pod provides different runtime characteristics compared to a standard Pod.

Steps (requires gVisor installed)

# Check kernel version — gVisor reports its own
kubectl exec -n secure-apps sandboxed-app -- uname -r
# 4.4.0    ← gVisor's emulated kernel (NOT the host kernel)

# Compare with a standard Pod
kubectl exec -n secure-apps compliant-web -- uname -r
# 5.15.0-86-generic    ← Host kernel (shared by standard containers)

# Check dmesg — gVisor has distinctive startup messages
kubectl exec -n secure-apps sandboxed-app -- dmesg 2>/dev/null | head -3
# [    0.000000] Starting gVisor...
# [    0.000000] Checking navel for lint...
# [    0.000000] Creating process schedule...

# Compare process view — gVisor provides process isolation
kubectl exec -n secure-apps sandboxed-app -- ls /proc/ | head -10
# 1
# self
# ...

# Test: attempt a dangerous syscall — blocked by gVisor
kubectl exec -n secure-apps sandboxed-app -- mount -t tmpfs tmpfs /mnt 2>&1
# mount: permission denied (or similar)

Comparison Table

Check Standard Pod (runc) Sandboxed Pod (gVisor)
uname -r Host kernel version (e.g., 5.15.0) gVisor version (e.g., 4.4.0)
dmesg Host kernel messages gVisor startup messages
Kernel CVE impact Pod vulnerable if host kernel has CVE Pod isolated — host kernel not exposed
Syscall path Container → Host Kernel Container → gVisor Sentry → Limited host access

Cleanup

# Remove all lab resources
kubectl delete namespace secure-apps
kubectl delete runtimeclass gvisor

# Remove temporary files
rm -f /tmp/non-compliant-pod.yaml \
  /tmp/compliant-pod.yaml \
  /tmp/broken-deploy.yaml \
  /tmp/fixed-deploy.yaml \
  /tmp/runtimeclass-gvisor.yaml \
  /tmp/sandboxed-pod.yaml

# Verify cleanup
kubectl get namespace secure-apps 2>&1
# Error from server (NotFound): namespaces "secure-apps" not found
kubectl get runtimeclass gvisor 2>&1
# Error from server (NotFound): runtimeclasses.node.k8s.io "gvisor" not found

Lab Summary

Lab Skill Tested CKS Relevance
Lab 1 Apply PSA labels to namespaces High — direct exam task
Lab 2 Understand PSA rejection behavior High — troubleshooting
Lab 3 Write Restricted-compliant Pod specs High — core exam skill
Lab 4 Fix non-compliant Deployments High — common exam scenario
Lab 5 Create RuntimeClass and sandboxed Pods Medium — may appear
Lab 6 Verify runtime isolation Medium — understanding

Practice Questions

Q1: A namespace has enforce: restricted set. You deployed a Deployment with 3 replicas, but kubectl get pods shows 0 Pods. The Deployment shows 0/3 ready. Where do you look for the error?

Check the ReplicaSet events, not the Deployment events.

PSA enforces at the Pod level. The Deployment creates a ReplicaSet, and the ReplicaSet attempts to create Pods. When PSA rejects the Pods, the failure is recorded in the ReplicaSet’s events.

# Step 1: Find the ReplicaSet
kubectl get rs -n <namespace> -l <deployment-labels>

# Step 2: Check ReplicaSet events
kubectl describe rs -n <namespace> <replicaset-name>
# Events:
#   Warning  FailedCreate  replicaset-controller
#   pods "deploy-abc123-xyz" is forbidden:
#     violates PodSecurity "restricted:latest":
#       allowPrivilegeEscalation != false ...

# Step 3: Fix the Deployment's Pod template
kubectl edit deploy <name> -n <namespace>
# Add the required security context fields

# Step 4: Verify
kubectl rollout status deploy/<name> -n <namespace>

The Deployment itself doesn’t show the PSA error — you must inspect the ReplicaSet.

Q2: Write the kubectl commands to: (1) create namespace staging, (2) enforce Baseline, (3) set audit and warn to Restricted. Explain this strategy.
# Create namespace
kubectl create namespace staging

# Apply PSA labels
kubectl label namespace staging \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/warn=restricted

Strategy explanation:

This is a gradual rollout approach:

  • enforce: baseline — Immediately blocks dangerous settings (hostNetwork, privileged, hostPID). This prevents known privilege escalation.
  • warn: restricted — Shows developers which Pods would fail if Restricted were enforced. Developers see warnings in kubectl output.
  • audit: restricted — Logs violations to the API server audit log. Security teams can track which workloads need to be fixed.

Migration path:

  1. Deploy with this label configuration
  2. Fix all workloads that generate Restricted warnings
  3. Once all Pods are Restricted-compliant, switch: kubectl label ns staging pod-security.kubernetes.io/enforce=restricted --overwrite

This avoids breaking existing workloads while providing visibility into what needs to change.

Q3: You need to run a CNI plugin (Calico) as a DaemonSet that requires hostNetwork: true and privileged: true. All namespaces have enforce: restricted. How do you allow this DaemonSet?

Option 1: Label kube-system with Privileged (recommended)

kubectl label namespace kube-system \
  pod-security.kubernetes.io/enforce=privileged \
  --overwrite

Calico typically runs in kube-system. Setting this namespace to Privileged allows the CNI DaemonSet while all other namespaces remain Restricted.

Option 2: Exempt kube-system via cluster-level AdmissionConfiguration

# /etc/kubernetes/psa/admission-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: PodSecurity
  configuration:
    apiVersion: pod-security.admission.config.k8s.io/v1
    kind: PodSecurityConfiguration
    defaults:
      enforce: "restricted"
      enforce-version: "latest"
    exemptions:
      namespaces:
      - kube-system

Why this is necessary: CNI plugins need host-level access to configure networking (iptables, routing, veth pairs). They cannot run under Restricted or even Baseline. Exempting kube-system is a standard practice — Kubernetes itself recommends it.

Q4: Create a Deployment with 2 replicas in namespace secure-apps that: runs as non-root, drops all capabilities, has a read-only root filesystem, uses a Seccomp profile, writes logs to /app/logs, and uses a gVisor sandbox runtime.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secure-service
  namespace: secure-apps
spec:
  replicas: 2
  selector:
    matchLabels:
      app: secure-service
  template:
    metadata:
      labels:
        app: secure-service
    spec:
      runtimeClassName: gvisor
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      automountServiceAccountToken: false
      containers:
      - name: app
        image: myapp:1.0
        ports:
        - containerPort: 8080
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 500m
            memory: 256Mi
        volumeMounts:
        - name: logs
          mountPath: /app/logs
        - name: tmp
          mountPath: /tmp
      volumes:
      - name: logs
        emptyDir: {}
      - name: tmp
        emptyDir: {}

Compliance checklist:

  • runtimeClassName: gvisor ✓ (sandbox isolation)
  • runAsNonRoot: true ✓
  • seccompProfile: RuntimeDefault ✓
  • allowPrivilegeEscalation: false ✓
  • capabilities.drop: ["ALL"] ✓
  • readOnlyRootFilesystem: true ✓ (with emptyDir for /app/logs and /tmp)
  • automountServiceAccountToken: false ✓
  • Resource limits set ✓
Q5: After setting enforce: restricted on a namespace, existing Pods are still running with root access. Is this expected? What happens if you restart them?

Yes, this is expected. PSA only checks Pods at admission time (when they are created). It does not retroactively terminate or modify running Pods.

What happens with existing Pods:

  • Pods that were running before the label was applied continue to run unchanged
  • They maintain their original security context (even if non-compliant)
  • PSA does not scan or evict existing Pods

What happens when you restart them:

kubectl rollout restart deploy/my-app -n the-namespace
  • The Deployment creates a new ReplicaSet
  • The new ReplicaSet tries to create new Pods
  • PSA checks the new Pods at admission → rejected if non-compliant
  • Old Pods are terminated (by the rollout) but new ones can’t start
  • Result: zero running Pods until the manifests are fixed

Safe migration steps:

  1. Apply warn: restricted first → identify non-compliant workloads
  2. Fix all Deployment manifests to be Restricted-compliant
  3. Test with kubectl apply --dry-run=server in a Restricted namespace
  4. Apply enforce: restricted → now all new Pod creation is checked
  5. Restart Deployments → new compliant Pods replace old non-compliant ones

Previous Up Next
mTLS & Pod-to-Pod Encryption Pod Security OPA Gatekeeper

Updated: