AppArmor and seccomp¶
Security context settings like runAsNonRoot and dropped capabilities control who a container process is and what privileges it holds. seccomp and AppArmor go a level deeper: they constrain what the process can ask the kernel to do -- regardless of its user or capabilities. They are the last line of defense when an attacker already has code execution inside your container, and they are core CKS material.
Two filters, two questions¶
| seccomp | AppArmor | |
|---|---|---|
| Filters | system calls (the verbs: open, connect, ptrace, ...) |
resource access (the nouns: this file path, this capability, raw network) |
| Model | allowlist/denylist of syscalls | per-program Mandatory Access Control profile |
| Question answered | "may this process make this syscall at all?" | "may this process touch this specific thing?" |
| Scope in Kubernetes | any Linux node | nodes with AppArmor enabled (Debian/Ubuntu family) |
| Pod field | securityContext.seccompProfile |
securityContext.appArmorProfile |
They compose: seccomp can forbid mount entirely, while AppArmor allows reads of /app/** but denies /etc/shadow. Defense in depth means running both -- which is exactly what RuntimeDefault gives you for near-zero effort.
Start here: RuntimeDefault¶
Both mechanisms ship with a sane default profile maintained by the container runtime (containerd/CRI-O). The default seccomp profile blocks ~50 dangerous or obscure syscalls (ptrace of other processes, kernel module loading, reboot, clock changes) while permitting everything ordinary applications use. Breakage is rare; protection is real.
apiVersion: v1
kind: Pod
metadata:
name: hardened
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # pod-wide seccomp default
containers:
- name: app
image: ghcr.io/example/app:v1.2.0
securityContext:
appArmorProfile:
type: RuntimeDefault # per-container AppArmor default
Notes that matter:
seccompProfile.type: RuntimeDefaultis required by therestrictedPod Security level -- it's the setting legacy workloads most often lack.- The kubelet flag
--seccomp-default(config:seccompDefault: true) appliesRuntimeDefaultto every pod that doesn't specify a profile -- the right cluster-wide baseline, GA since 1.27. appArmorProfileas a first-class field is GA since Kubernetes 1.30. Before that, AppArmor used the annotationcontainer.apparmor.security.beta.kubernetes.io/<container-name>: <profile>-- you will still meet the annotation form in older clusters and exam materials; know both.
Custom seccomp profiles¶
A profile is a JSON document. The two useful starting shapes:
Audit-only (log syscalls, block nothing -- the profiling tool):
Allowlist (deny by default, permit what the app needs):
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["read", "write", "openat", "close", "fstat", "mmap",
"brk", "rt_sigaction", "futex", "exit_group",
"socket", "connect", "sendto", "recvfrom",
"execve", "arch_prctl", "access", "getpid"],
"action": "SCMP_ACT_ALLOW"
}
]
}
SCMP_ACT_ERRNO makes blocked syscalls fail with EPERM (app sees an error); SCMP_ACT_KILL_PROCESS kills the container instead -- louder, easier to notice, harsher on retries.
Loading and referencing a Localhost profile¶
Custom profiles are files on each node, under the kubelet's seccomp root /var/lib/kubelet/seccomp/. The pod references them by relative path:
# On every node that may run the pod:
sudo mkdir -p /var/lib/kubelet/seccomp/profiles
sudo cp audit.json /var/lib/kubelet/seccomp/profiles/
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/audit.json # relative to /var/lib/kubelet/seccomp
If the file is missing on the scheduled node, the pod fails with CreateContainerError / "cannot load seccomp profile" -- distributing profiles (via DaemonSet, node image, or the security-profiles-operator) is part of the design, not an afterthought.
The realistic workflow¶
- Run the workload under the audit profile (
SCMP_ACT_LOG). - Exercise it thoroughly; harvest the syscall names from the node's audit/syslog (
grep SECCOMP /var/log/syslogorjournalctl -k). - Build the allowlist from what you observed; switch
defaultActiontoSCMP_ACT_ERRNO. - Watch for
EPERM-shaped breakage on rarely-exercised paths (signal handling, diagnostics) -- the classic gap in observed-behavior allowlists.
For most teams, this effort is reserved for high-risk workloads; everything else gets RuntimeDefault.
Custom AppArmor profiles¶
An AppArmor profile is a text policy loaded into the kernel on each node with apparmor_parser:
# /etc/apparmor.d/k8s-deny-write
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
file, # allow file operations in general...
deny /** w, # ...but deny writes everywhere
deny /etc/shadow r, # and reads of credentials
}
# On every relevant node:
sudo apparmor_parser -q /etc/apparmor.d/k8s-deny-write
sudo aa-status | grep k8s-deny-write # verify it's loaded
Reference it from the pod (GA field, plus the legacy annotation you may still encounter):
spec:
containers:
- name: app
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-write
# Legacy (pre-1.30) annotation form -- deprecated but exam-relevant:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-deny-write
If the profile isn't loaded on the node, the pod is rejected or the container is blocked with an AppArmor error in events -- same distribution problem, same solutions, as seccomp.
Verifying what's actually applied¶
Trust, but verify -- from inside and outside the container:
# Seccomp mode of the running process (0=off, 1=strict, 2=filter)
kubectl exec hardened -- grep Seccomp /proc/1/status
# Seccomp: 2
# AppArmor profile attached to the process
kubectl exec hardened -- cat /proc/1/attr/current
# k8s-deny-write (enforce)
# On the node: which profiles are loaded, and in which mode
sudo aa-status
# Prove the policy bites
kubectl exec hardened -- touch /tmp/x
# touch: /tmp/x: Permission denied ← AppArmor deny /** w working
Failure modes cheat sheet¶
| Symptom | Cause |
|---|---|
CreateContainerError, "cannot load seccomp profile" |
Localhost profile file missing under /var/lib/kubelet/seccomp/ on that node |
| Pod rejected / blocked with AppArmor error in events | profile not loaded (apparmor_parser) on that node, or profile name typo |
App gets Permission denied / Operation not permitted on odd operations |
over-tight allowlist -- rebuild from audit logs |
| Works on some nodes, fails on others | profile distributed to only part of the fleet |
restricted PSA namespace rejects the pod |
missing seccompProfile: RuntimeDefault |
Certification notes¶
- CKS tasks follow a predictable script: load a given profile on the correct node (
apparmor_parser/ copy JSON to/var/lib/kubelet/seccomp/), attach it to a pod viasecurityContext, and verify (aa-status,kubectl exec ... grep Seccomp /proc/1/status). Practice the full loop, including SSH-ing to the right node. - Memorize the two Localhost reference forms: seccomp paths are relative to the kubelet seccomp root; AppArmor uses the profile name, not a file path.
- Know the legacy AppArmor annotation format -- older exam environments and clusters still use it.
- Remember which PSA level demands seccomp:
restrictedrequiresRuntimeDefault(orLocalhost).
Key Takeaways¶
- seccomp filters syscalls (verbs); AppArmor gates resource access (nouns); run both.
RuntimeDefaultis high-value, low-risk hardening -- and mandatory under therestrictedPod Security level.- Custom profiles are node-local artifacts: loading and distributing them is your job; the pod only references them.
- Build allowlists from audit mode, not guesswork, and expect gaps on rarely-exercised code paths.
- Verify from
/proc:Seccomp:in/proc/1/status, profile name in/proc/1/attr/current.
Related Concepts¶
- Security Context -- the surrounding hardening fields
- Pod Security -- where
RuntimeDefaultbecomes policy - Falco -- observing syscalls instead of blocking them
- CKS Exam Guide