CoursesKubernetes security & hardeningAppArmor: confine behavior

AppArmor: confine behavior

Load a profile, reference it, enforce vs complain.

Advanced14 min · lesson 12 of 24

seccomp (secure computing mode) decides which system calls a container may make. It says nothing about what those calls reach. Give a process the open syscall and it can open any file the kernel will hand over, because to seccomp /etc/shadow and the app's own config file are the same operation: an open. So a container can pass a tight seccomp filter and still read every credential on the node. That's the hole AppArmor is built to plug.

Here's the split. seccomp is a list of verbs a program may use. AppArmor is a list of the nouns those verbs may touch. AppArmor is a Linux Security Module (LSM), a kernel hook that gets a veto on file, capability, and network operations before they complete. You hand it a profile, which is an allow-list: the exact paths a program may read or write, the Linux capabilities it may hold, the network families it may use. Think of it as a keycard cut for three specific doors instead of the building master key. Anything the profile doesn't name is denied, so a web server that gets popped can't drop a binary into /bin when its profile only grants write on one cache directory.

Confirm the module, then load the profile

AppArmor is a host feature. It's path-based and loaded per node, so two things have to be true before a pod can use it: the kernel module is active, and the profile physically exists on whatever node the scheduler picks. Check the module, load the profile with apparmor_parser, then read aa-status to see every loaded profile and whether each one sits in enforce mode (violations blocked) or complain mode (violations only logged).

terminal
$ cat /sys/module/apparmor/parameters/enabled
Y # module active
$ sudo apparmor_parser -q /etc/apparmor.d/k8s-frontend # load the profile
$ sudo aa-status
apparmor module is loaded.
32 profiles are loaded.
32 profiles are in enforce mode.
k8s-frontend
cri-containerd.apparmor.d
...
0 profiles are in complain mode.

What a profile actually says

A profile is a set of allow rules over paths and capabilities. Deny is the default, so you write only what the app legitimately needs. The explicit deny lines below are belt and suspenders: they stop a path from being re-granted by an #include, and they read as intent to the next engineer who opens the file. This one confines a frontend to reading its static content, gives it a single writable cache path, and slams the door on the binary directories and the shadow file.

/etc/apparmor.d/k8s-frontend
#include <tunables/global>
profile k8s-frontend flags=(attach_disconnected) {
#include <abstractions/base>
network inet tcp, # allow TCP
/usr/share/nginx/** r, # read static content
/var/cache/nginx/** rw, # explicit writable path
deny /bin/** wl, # no writing into binary dirs
deny /etc/shadow rwkl, # never touch the shadow file
}

Reference it from the pod

Recent Kubernetes sets the profile as a real field on the security context: type Localhost plus the profile name. Older clusters used a container.apparmor.security.beta.kubernetes.io/<container> annotation, and you'll still meet it in the wild and in some exam environments. Either way the named profile has to be loaded on the node already. The pod spec points at a profile; it doesn't carry one.

pod-apparmor.yaml
spec:
containers:
- name: app
image: registry.internal/frontend:1.4.2
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-frontend
# legacy form, set on pod metadata.annotations (still common in exam envs):
# container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-frontend
The AppArmor lifecycle, and where each step lives
1write profileallow-list of paths + caps2load on nodeapparmor_parser, per host3reference in podsecurityContext.appArmorProfile4kernel enforcesLSM veto at syscall time5denial loggedaudit: apparmor="DENIED"
Loading and referencing are two separate steps on two different planes: apparmor_parser loads the profile into the host kernel, while the pod spec only names it. Get the order wrong and the pod fails at container creation. The kernel does the enforcing at syscall time, which is why a denial lands in the node's audit log, not in Kubernetes events.

Prove the box actually holds

A profile referenced is not a profile enforced. Two things go wrong quietly: the field never made it onto the running container, or the profile loaded but its rules are looser than you assumed. So confirm the profile is on the container, then do the exact thing it forbids and watch the kernel refuse. If that write had succeeded, you'd know the profile never attached.

terminal
# 1) the profile is on the running container
$ kubectl get pod frontend \
-o jsonpath='{.spec.containers[0].securityContext.appArmorProfile.localhostProfile}'
k8s-frontend
# 2) try to write where the profile says no
$ kubectl exec frontend -- sh -c 'echo pwned > /bin/backdoor'
sh: can't create /bin/backdoor: Permission denied
command terminated with exit code 1
# 3) on the node the pod landed on, the denial is in the kernel log
$ sudo journalctl -k | grep 'apparmor="DENIED"' | tail -1
audit: apparmor="DENIED" operation="mknod" profile="k8s-frontend"
name="/bin/backdoor" comm="sh" requested_mask="c" denied_mask="c"

Loosen to learn, then lock

When you write a new profile you rarely know every path the app touches. Health checks, cache warmup, a log rotator firing at midnight, all of it reads and writes files you'll forget. So start loose. Put the profile in complain mode with aa-complain: the kernel logs what it would have blocked but lets it through, so nothing breaks while you learn the app's real footprint. Drive traffic, replay the logged denials with aa-logprof (it walks each one and offers to fold the path in), then flip to enforce and confirm the mode actually changed.

terminal
$ sudo aa-complain /etc/apparmor.d/k8s-frontend
Setting /etc/apparmor.d/k8s-frontend to complain mode.
$ sudo aa-status | grep -A1 'profiles are in complain mode'
1 profiles are in complain mode.
k8s-frontend
# ...drive real traffic, then fold in the paths it logged:
$ sudo aa-logprof # replays each denial, offers to allow the missing paths
# happy with the profile? switch back to blocking and verify it took:
$ sudo aa-enforce /etc/apparmor.d/k8s-frontend
Setting /etc/apparmor.d/k8s-frontend to enforce mode.
$ sudo aa-status | grep 'profiles are in complain mode'
0 profiles are in complain mode.
A profile the node doesn't have fails the pod, not just the profile
The profile has to be loaded on every node the pod could ever schedule to, not only the one you tested on. Reference a Localhost profile that isn't loaded and the container won't start: kubectl describe pod shows a CreateContainerError with a message like 'apparmor profile not found'. The nasty version is a pod that runs fine, gets evicted, reschedules onto a fresh node with no profile, and now won't come back. Ship profiles fleet-wide with a DaemonSet that writes them into /etc/apparmor.d and runs apparmor_parser, or bake them into your node image. Load first, reference second.

Profiles that exist only on some nodes create schedule-dependent security. Bake loading into node bootstrap or a DaemonSet helper you actually monitor.

Start in complain mode only long enough to learn. Leaving production in complain is how you collect logs of attacks that still succeed.

AppArmor and seccomp overlap but do not replace each other. Seccomp filters calls; AppArmor shapes filesystem and network mediation on distributions that support it.

Document the profile name, the node image version that ships it, and the pod annotation key. When Kubernetes moves annotations to fields, update both the loader and the admission policy in the same change so you do not strand workloads on mixed nodes. Write the verification command next to the control in the same pull request, and keep the sample output so the next on-call person can tell pass from fail without guessing.

Try this

On a node with AppArmor enabled, load a restrictive profile, reference it from a pod annotation, and prove a blocked path fails inside the container.

terminal
$ ssh worker-a 'sudo aa-status | head -15'
apparmor module is loaded.
15 profiles are in enforce mode.
k8s-payments-api
$ kubectl -n payments apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: aa-demo
annotations:
container.apparmor.security.beta.kubernetes.io/c: localhost/k8s-payments-api
spec:
containers:
- name: c
image: busybox:1.36
command: ["sleep","3600"]
EOF
pod/aa-demo created
$ kubectl -n payments exec aa-demo -- sh -c 'echo hi > /etc/shadow' || echo DENIED
sh: can't create /etc/shadow: Permission denied
DENIED

Takeaway

AppArmor profiles are MAC rules for paths and capabilities. Load them on every node, reference them from the pod, and prove deny with a write you expect to fail.

Quick check
01A pod sets appArmorProfile.localhostProfile: k8s-frontend, but that profile was never loaded on the node the scheduler picks. What happens?
Incorrect — The kubelet will not run a container asking for a profile it can't enforce; silently dropping the profile would defeat the whole point of asking for one.
Correct — You see a CreateContainerError or a Blocked pod. Load the profile on every candidate node first, then reference it.
Incorrect — Profiles live on the host filesystem and are loaded with apparmor_parser. The pod spec only names one; it doesn't ship it.
Incorrect — There's no automatic fallback. An unresolved Localhost profile is a hard failure at container creation, not a quiet downgrade.
02The lesson frames seccomp and AppArmor as complementary. Which statement captures the split?
Correct — seccomp filters the verbs (syscalls), while AppArmor is an LSM that restricts the nouns those verbs touch.
Incorrect — that reverses the two: seccomp is the syscall filter and AppArmor is path/capability-based.
Incorrect — AppArmor is a Linux Security Module over files, capabilities, and network, not a syscall filter.
Incorrect — both are kernel features, and AppArmor is not a pod-traffic firewall.
03You're writing a fresh AppArmor profile and can't be sure of every path the app touches — health checks, cache warmup, a nightly log rotator. How do you roll it out without breaking the app?
Incorrect — enforce mode blocks the missing paths, so the app breaks while you're still learning them.
Correct — complain mode lets you observe the real footprint safely, then you enforce and confirm the mode actually changed.
Incorrect — Unconfined applies no confinement at all, so you learn nothing and get no protection meanwhile.
Incorrect — loading duplicates doesn't discover paths; complain mode plus aa-logprof is how you build the list.

Related