AppArmor: confine behavior
Load a profile, reference it, enforce vs complain.
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).
$ cat /sys/module/apparmor/parameters/enabledY # module active$ sudo apparmor_parser -q /etc/apparmor.d/k8s-frontend # load the profile$ sudo aa-statusapparmor module is loaded.32 profiles are loaded.32 profiles are in enforce mode.k8s-frontendcri-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.
#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 pathdeny /bin/** wl, # no writing into binary dirsdeny /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.
spec:containers:- name: appimage: registry.internal/frontend:1.4.2securityContext:appArmorProfile:type: LocalhostlocalhostProfile: k8s-frontend# legacy form, set on pod metadata.annotations (still common in exam envs):# container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-frontend
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.
# 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 deniedcommand 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 -1audit: 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.
$ sudo aa-complain /etc/apparmor.d/k8s-frontendSetting /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-frontendSetting /etc/apparmor.d/k8s-frontend to enforce mode.$ sudo aa-status | grep 'profiles are in complain mode'0 profiles are in complain mode.
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.
$ 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: v1kind: Podmetadata:name: aa-demoannotations:container.apparmor.security.beta.kubernetes.io/c: localhost/k8s-payments-apispec:containers:- name: cimage: busybox:1.36command: ["sleep","3600"]EOFpod/aa-demo created$ kubectl -n payments exec aa-demo -- sh -c 'echo hi > /etc/shadow' || echo DENIEDsh: can't create /etc/shadow: Permission deniedDENIED
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.