CKS — AppArmor Profiles

10 minute read

AppArmor Profiles

AppArmor is a Mandatory Access Control (MAC) system built into the Linux kernel. It restricts what a process can do — which files it can read, which network calls it can make, which capabilities it can use — regardless of the process’s user ID.

For Kubernetes, AppArmor is a defence-in-depth layer. Even if an attacker gains code execution inside a container, an AppArmor profile limits the damage they can do.

CKS Exam Relevance: AppArmor questions are nearly guaranteed. You must be able to load a profile on a node, apply it to a Pod via annotation, and verify enforcement.


1. How AppArmor Works

Without AppArmor:
  Container Process → Kernel → (all syscalls allowed) → Resources

With AppArmor:
  Container Process → AppArmor → Kernel → Resources
                      │
                      ├── Allowed? → Proceed
                      └── Denied? → Block + Log

AppArmor uses profiles that define what a process is allowed to do. Each profile is loaded into the kernel and associated with a process at execution time.

AppArmor vs SELinux

Feature AppArmor SELinux
Approach Path-based rules Label-based rules
Complexity Simpler More complex
Default on Ubuntu, Debian, SUSE RHEL, CentOS, Fedora
Profile creation Easier Steeper learning curve
Kubernetes support Annotations (GA in 1.30) seLinuxOptions in securityContext

Exam Tip: CKS focuses on AppArmor (not SELinux). Know how to work with AppArmor on Ubuntu-based nodes.


2. Profile Modes

Mode Behavior Use Case
enforce Blocks and logs denied actions Production — active protection
complain Logs denied actions but does not block Testing — discover what a profile blocks
unconfined No restrictions Default — no protection
# Check the mode of loaded profiles
sudo aa-status

# Output shows profiles in each mode:
# 47 profiles are loaded.
# 30 profiles are in enforce mode.
# 17 profiles are in complain mode.

Switching Modes

# Switch a profile to complain mode (for testing)
sudo aa-complain /etc/apparmor.d/my-profile

# Switch a profile to enforce mode (for production)
sudo aa-enforce /etc/apparmor.d/my-profile

3. Writing Custom AppArmor Profiles

AppArmor profiles are text files stored in /etc/apparmor.d/.

Profile Structure

#include <tunables/global>

profile <profile-name> flags=(attach_disconnected) {
  #include <abstractions/base>

  # File rules
  /path/to/file  r,         # Read
  /path/to/file  w,         # Write
  /path/to/dir/  r,         # Read directory
  /path/to/dir/** rw,       # Read/write all files recursively

  # Network rules
  network inet tcp,          # Allow TCP
  network inet udp,          # Allow UDP

  # Capability rules
  capability net_bind_service,  # Allow binding to ports < 1024

  # Deny rules (explicit)
  deny /etc/shadow r,        # Deny reading shadow file
  deny /proc/*/mem rw,       # Deny memory access
}

Example 1: Deny File Writes

This profile allows reads but denies all writes. Useful for read-only application containers.

# /etc/apparmor.d/k8s-deny-write
#include <tunables/global>

profile k8s-deny-write flags=(attach_disconnected) {
  #include <abstractions/base>

  # Allow read access to everything
  file,

  # Deny all write operations
  deny /** w,

  # Allow necessary runtime paths (container runtime needs)
  /proc/** r,
  /sys/** r,
  /dev/null rw,
  /dev/zero r,
  /dev/urandom r,
}

Example 2: Deny Network Access

This profile blocks all network operations. Useful for batch processing Pods that should never make network calls.

# /etc/apparmor.d/k8s-deny-network
#include <tunables/global>

profile k8s-deny-network flags=(attach_disconnected) {
  #include <abstractions/base>

  # Allow file access
  file,

  # Deny all network access
  deny network,
}

Example 3: Restricted Web Server

A profile tailored for an nginx container.

# /etc/apparmor.d/k8s-nginx
#include <tunables/global>

profile k8s-nginx flags=(attach_disconnected) {
  #include <abstractions/base>

  # Allow network (nginx needs TCP)
  network inet tcp,
  network inet6 tcp,

  # Allow reading web content
  /usr/share/nginx/** r,
  /etc/nginx/** r,
  /var/log/nginx/** w,
  /run/nginx.pid rw,
  /var/cache/nginx/** rw,

  # Allow reading system libraries
  /lib/** mr,
  /usr/lib/** mr,

  # Deny everything else
  deny /etc/shadow r,
  deny /etc/passwd w,
  deny /proc/*/mem rw,
  deny /root/** rw,

  # Deny shell execution
  deny /bin/sh x,
  deny /bin/bash x,
  deny /bin/dash x,
  deny /usr/bin/curl x,
  deny /usr/bin/wget x,
}

4. Loading Profiles on Nodes

Profiles must be loaded on every node where the Pod might be scheduled.

Load a Profile

# Parse and load the profile into the kernel
sudo apparmor_parser -r /etc/apparmor.d/k8s-deny-write

# -r = replace (reload if already loaded)
# Without -r, use -a to add a new profile
sudo apparmor_parser -a /etc/apparmor.d/k8s-deny-network

Verify Loaded Profiles

# List all loaded profiles and their modes
sudo aa-status

# Check for a specific profile
sudo aa-status | grep k8s-deny-write
# Output: k8s-deny-write (enforce)

# Alternative: check the securityfs
cat /sys/kernel/security/apparmor/profiles | grep k8s-deny-write

Remove a Profile

# Unload a profile
sudo apparmor_parser -R /etc/apparmor.d/k8s-deny-write

# Verify removal
sudo aa-status | grep k8s-deny-write
# (no output)

Persist Profiles Across Reboots

Profiles in /etc/apparmor.d/ are automatically loaded at boot by the apparmor service. Verify:

systemctl is-enabled apparmor
# Output: enabled

5. Applying AppArmor to Kubernetes Pods

Method 1: Annotation (Kubernetes < 1.30)

apiVersion: v1
kind: Pod
metadata:
  name: secure-app
  annotations:
    # Format: container.apparmor.security.beta.kubernetes.io/<container-name>: localhost/<profile-name>
    container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-deny-write
spec:
  containers:
  - name: app              # Must match the container name in the annotation
    image: nginx:1.27
    ports:
    - containerPort: 80

Annotation key format:

container.apparmor.security.beta.kubernetes.io/<container-name>: <profile-ref>

Profile reference values:

Value Meaning
runtime/default Use the container runtime’s default profile
localhost/<profile-name> Use a profile loaded on the node
unconfined No AppArmor profile

Method 2: securityContext (Kubernetes 1.30+ GA)

apiVersion: v1
kind: Pod
metadata:
  name: secure-app
spec:
  containers:
  - name: app
    image: nginx:1.27
    securityContext:
      appArmorProfile:
        type: Localhost
        localhostProfile: k8s-deny-write

appArmorProfile.type values:

Type Meaning
RuntimeDefault Container runtime default profile
Localhost Profile loaded on the node (specify localhostProfile)
Unconfined No profile

Multi-Container Pod

apiVersion: v1
kind: Pod
metadata:
  name: multi-container-app
  annotations:
    container.apparmor.security.beta.kubernetes.io/web: localhost/k8s-nginx
    container.apparmor.security.beta.kubernetes.io/sidecar: localhost/k8s-deny-network
spec:
  containers:
  - name: web
    image: nginx:1.27
  - name: sidecar
    image: busybox
    command: ["sh", "-c", "while true; do echo heartbeat; sleep 60; done"]

6. Complete Example: End-to-End

Step 1: Create the Profile on the Node

# SSH to the node
ssh admin@node01

# Create the profile
cat <<'EOF' | sudo tee /etc/apparmor.d/k8s-deny-write
#include <tunables/global>

profile k8s-deny-write flags=(attach_disconnected) {
  #include <abstractions/base>
  file,
  deny /** w,
}
EOF

# Load the profile
sudo apparmor_parser -r /etc/apparmor.d/k8s-deny-write

# Verify
sudo aa-status | grep k8s-deny-write
# k8s-deny-write (enforce)

Step 2: Deploy a Pod with the Profile

# secure-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: secure-pod
  annotations:
    container.apparmor.security.beta.kubernetes.io/secure: localhost/k8s-deny-write
spec:
  containers:
  - name: secure
    image: nginx:1.27
    ports:
    - containerPort: 80
kubectl apply -f secure-pod.yaml
kubectl get pod secure-pod
# NAME         READY   STATUS    RESTARTS   AGE
# secure-pod   1/1     Running   0          10s

Step 3: Verify AppArmor Is Applied

# Check the Pod's annotation
kubectl get pod secure-pod -o jsonpath='{.metadata.annotations}' | jq .
# {
#   "container.apparmor.security.beta.kubernetes.io/secure": "localhost/k8s-deny-write"
# }

# Check the container's AppArmor status inside the container
kubectl exec secure-pod -- cat /proc/1/attr/current
# k8s-deny-write (enforce)

Step 4: Test Enforcement

# Try to write a file — should be denied
kubectl exec secure-pod -- touch /tmp/testfile
# touch: cannot touch '/tmp/testfile': Permission denied

# Try to read a file — should work
kubectl exec secure-pod -- cat /etc/hostname
# secure-pod

# Try to modify nginx config — should be denied
kubectl exec secure-pod -- sh -c 'echo "test" > /etc/nginx/nginx.conf'
# sh: can't create /etc/nginx/nginx.conf: Permission denied

The Pod is running but cannot write to the filesystem. AppArmor is enforcing the profile.


7. Troubleshooting AppArmor

Pod Stuck in Blocked or Error State

# Check events
kubectl describe pod secure-pod

# Common errors:
# "cannot enforce AppArmor: profile "k8s-deny-write" is not loaded"
# → Profile not loaded on the node where the Pod was scheduled

Fix: Load the profile on the correct node:

# Find which node the Pod is scheduled on
kubectl get pod secure-pod -o wide

# SSH to that node and load the profile
ssh admin@node02
sudo apparmor_parser -r /etc/apparmor.d/k8s-deny-write

Check AppArmor Logs

# AppArmor denials are logged in the kernel log
sudo dmesg | grep apparmor
# Or
sudo journalctl -k | grep apparmor

# Example denial log:
# apparmor="DENIED" operation="open" profile="k8s-deny-write"
# name="/tmp/testfile" pid=12345 comm="touch" requested_mask="wc"

Profile Not Loading

# Check for syntax errors
sudo apparmor_parser -p /etc/apparmor.d/k8s-deny-write

# Common issues:
# - Missing #include <tunables/global>
# - Missing flags=(attach_disconnected) for containers
# - Typo in profile name

8. Common CKS Exam Patterns

Pattern 1: Load and Apply

Task: A profile file is at /opt/apparmor/my-profile on node01.
Load it and apply it to a Pod.

Steps:
1. SSH to node01
2. sudo apparmor_parser -r /opt/apparmor/my-profile
3. sudo aa-status | grep <profile-name>
4. Create/update Pod with annotation

Pattern 2: Fix a Non-Working Profile

Task: A Pod should have AppArmor but isn't working.

Steps:
1. kubectl describe pod → check events for AppArmor errors
2. kubectl get pod -o wide → find the node
3. SSH to node → aa-status → check if profile is loaded
4. If not loaded → apparmor_parser -r /path/to/profile
5. If wrong mode → aa-enforce /path/to/profile
6. Recreate Pod if needed

Pattern 3: Restrict a Container

Task: Container "app" in Pod "web" should not be able to write files.

Steps:
1. Check for existing profile or create deny-write profile
2. Load on node: apparmor_parser -r
3. Add annotation to Pod:
   container.apparmor.security.beta.kubernetes.io/app: localhost/deny-write
4. Verify: kubectl exec → try writing → Permission denied

Summary

Component Detail
What Mandatory Access Control — restricts process capabilities
Profiles location /etc/apparmor.d/
Load profile apparmor_parser -r /etc/apparmor.d/profile
Check profiles aa-status
Apply to Pod Annotation: container.apparmor.security.beta.kubernetes.io/<name>: localhost/<profile>
Apply to Pod (1.30+) securityContext.appArmorProfile.type: Localhost
Modes enforce (blocks), complain (logs), unconfined (off)
Verify in Pod cat /proc/1/attr/current
Logs dmesg \| grep apparmor

Practice Questions

Q1: You have an AppArmor profile at /etc/apparmor.d/k8s-restricted on node01. Load it in enforce mode and verify it is active.
# SSH to node01
ssh admin@node01

# Load the profile (enforce is the default mode)
sudo apparmor_parser -r /etc/apparmor.d/k8s-restricted

# Verify it is loaded and in enforce mode
sudo aa-status | grep k8s-restricted
# k8s-restricted (enforce)

If you need to explicitly set enforce mode:

sudo aa-enforce /etc/apparmor.d/k8s-restricted
Q2: Write a Pod manifest that runs nginx:1.27 with the AppArmor profile k8s-restricted applied to the container named web.
apiVersion: v1
kind: Pod
metadata:
  name: web-pod
  annotations:
    container.apparmor.security.beta.kubernetes.io/web: localhost/k8s-restricted
spec:
  containers:
  - name: web
    image: nginx:1.27
    ports:
    - containerPort: 80

Key points:

  • The annotation key must include the exact container name (web)
  • localhost/ prefix means the profile is loaded on the node
  • The profile must be loaded on whichever node the Pod is scheduled to
Q3: A Pod secure-app should have AppArmor applied but kubectl describe pod shows "AppArmor profile not loaded". How do you debug and fix this?
# Step 1: Find which node the Pod is on
kubectl get pod secure-app -o wide
# NODE: node02

# Step 2: SSH to that node
ssh admin@node02

# Step 3: Check loaded profiles
sudo aa-status | grep <expected-profile-name>
# (no output — profile is not loaded)

# Step 4: Find the profile file
ls /etc/apparmor.d/ | grep k8s
# If it exists, load it:
sudo apparmor_parser -r /etc/apparmor.d/<profile-name>

# If it doesn't exist, copy from another node or create it:
scp admin@node01:/etc/apparmor.d/<profile-name> /etc/apparmor.d/
sudo apparmor_parser -r /etc/apparmor.d/<profile-name>

# Step 5: Verify
sudo aa-status | grep <profile-name>
# <profile-name> (enforce)

# Step 6: Delete and recreate the Pod
kubectl delete pod secure-app
kubectl apply -f secure-app.yaml
Q4: Write an AppArmor profile called k8s-deny-shell that prevents execution of common shells (/bin/sh, /bin/bash, /bin/dash) and network tools (curl, wget).
# /etc/apparmor.d/k8s-deny-shell
#include <tunables/global>

profile k8s-deny-shell flags=(attach_disconnected) {
  #include <abstractions/base>

  # Allow general file access
  file,

  # Allow network (application may need it)
  network,

  # Deny shell execution
  deny /bin/sh x,
  deny /bin/bash x,
  deny /bin/dash x,
  deny /usr/bin/sh x,
  deny /usr/bin/bash x,

  # Deny network tools
  deny /usr/bin/curl x,
  deny /usr/bin/wget x,
  deny /usr/bin/nc x,
  deny /usr/bin/ncat x,
}

Load and test:

sudo apparmor_parser -r /etc/apparmor.d/k8s-deny-shell
sudo aa-status | grep k8s-deny-shell

# In a Pod with this profile:
kubectl exec secure-pod -- /bin/sh
# /bin/sh: Permission denied
Q5: What is the difference between the annotation approach and the securityContext.appArmorProfile approach for applying AppArmor? When do you use each?
Aspect Annotation securityContext
API status Beta GA (Kubernetes 1.30+)
Key container.apparmor.security.beta.kubernetes.io/<name> securityContext.appArmorProfile
Values localhost/<profile>, runtime/default, unconfined type: Localhost, RuntimeDefault, Unconfined
Scope Per container (via annotation key) Per container or per Pod
Validation Weak (just a string annotation) Strong (typed API field)
Enforceable via policy Harder (annotation-based policies) Easier (field-based policies)

When to use each:

  • Kubernetes < 1.30: Use the annotation approach — it’s the only option.
  • Kubernetes 1.30+: Use securityContext.appArmorProfile — it’s GA, type-safe, and integrable with admission policies.

CKS Exam: Know both approaches. The exam environment may use either version. Check kubectl version first.


Previous Up Next
Minimize External Access System Hardening Seccomp

Updated: