CKS — AppArmor Profiles
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 |