Hardening Pods with Seccomp and AppArmor Profiles
Quick answer
Restrict what system calls a container can make using seccomp, and what filesystem paths it can access using AppArmor. Two complementary Linux security mechanisms that drastically limit the blast radius of a compromised pod.
advanced · 45 min
Before you begin
- kubectl access with cluster-admin
- Linux nodes (seccomp is Linux-specific)
- AppArmor-enabled kernel (most distributions ship with it)
- Basic understanding of Linux system calls
A compromised container that can make arbitrary system calls can escape to the host. Seccomp filters which system calls the container can make. AppArmor restricts filesystem paths, capabilities, and network access. Together they follow the principle of least privilege at the kernel level.
Most teams skip these because they feel complex. This tutorial starts with the safe, easy defaults and shows you when and how to write custom profiles.
Why This Matters
A container in Kubernetes shares the host kernel. Even with a non-root user inside the container, certain syscalls (ptrace, mount, unshare, setns) can allow privilege escalation or container escape.
Seccomp: "Only allow these syscalls." AppArmor: "Only allow access to these files, capabilities, and network operations."
Part 1: Seccomp
Step 1: Enable RuntimeDefault for All Pods
RuntimeDefault uses the container runtime's (containerd or crio) built-in seccomp profile, which blocks ~100 dangerous syscalls while allowing everything a typical application needs.
Apply it at the namespace level with a LimitRange or at the pod level:
1# At the pod level
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: my-app
6spec:
7 template:
8 spec:
9 securityContext:
10 seccompProfile:
11 type: RuntimeDefault # Use the runtime's default profile
12 containers:
13 - name: app
14 image: my-app:latest
15 securityContext:
16 allowPrivilegeEscalation: false
17 readOnlyRootFilesystem: true
18 runAsNonRoot: true
19 capabilities:
20 drop: ["ALL"]This is the minimum you should apply to every workload. It blocks ptrace, reboot, kexec_load, open_by_handle_at, and about 100 others that a web service has no business calling.
Step 2: Enforce RuntimeDefault Cluster-Wide
Use a MutatingAdmissionWebhook or a pod security standard:
# Apply the Restricted pod security standard to a namespace
# This BLOCKS pods that don't set seccompProfile — it does NOT inject it automatically
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latestThe restricted standard requires that pods explicitly set seccompProfile.type to RuntimeDefault or Localhost — pods that omit the field are rejected. This requirement was added in Kubernetes 1.25 and is the most commonly missed check when migrating from PodSecurityPolicy, which had no equivalent mandatory seccomp field. The standard also requires runAsNonRoot, no privilege escalation, and dropping all capabilities.
Step 3: Audit Mode — See What Syscalls Your App Makes
Before writing a custom profile, audit what syscalls your app actually needs:
1# Run the container with Unconfined seccomp (logs all syscalls)
2kubectl patch deployment my-app --type=merge -p='{
3 "spec": {
4 "template": {
5 "spec": {
6 "securityContext": {
7 "seccompProfile": {"type": "Unconfined"}
8 }
9 }
10 }
11 }
12}'Check the node's audit log:
# On the node
journalctl -k | grep "type=SECCOMP" | head -50Or use strace in a debug container:
kubectl debug -it <pod-name> --image=ubuntu:22.04 -- strace -f -e trace=all ls /appStep 4: Write a Custom Seccomp Profile
A custom profile allows only the syscalls your application actually uses:
1{
2 "defaultAction": "SCMP_ACT_ERRNO",
3 "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
4 "syscalls": [
5 {
6 "names": [
7 "accept4", "bind", "brk", "clock_gettime", "clone",
8 "close", "connect", "epoll_create1", "epoll_ctl", "epoll_wait",
9 "execve", "exit", "exit_group", "fcntl", "fstat",
10 "futex", "getdents64", "getpid", "gettid", "getuid",
11 "ioctl", "listen", "lseek", "mmap", "mprotect",
12 "munmap", "nanosleep", "openat", "read", "recvfrom",
13 "recvmsg", "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "select",
14 "sendmsg", "sendto", "set_robust_list", "set_tid_address", "setuid",
15 "sigaltstack", "socket", "stat", "uname", "wait4",
16 "write", "writev"
17 ],
18 "action": "SCMP_ACT_ALLOW"
19 }
20 ]
21}This is a starting point for a Node.js HTTP server. defaultAction: SCMP_ACT_ERRNO means any syscall not in the allowlist returns EPERM.
Step 5: Load the Profile on Nodes
Custom seccomp profiles must be present on every node at a path known to kubelet. By default: /var/lib/kubelet/seccomp/.
For a managed cluster, use a DaemonSet to distribute profiles:
1apiVersion: apps/v1
2kind: DaemonSet
3metadata:
4 name: seccomp-profile-installer
5 namespace: kube-system
6spec:
7 selector:
8 matchLabels:
9 app: seccomp-profile-installer
10 template:
11 metadata:
12 labels:
13 app: seccomp-profile-installer
14 spec:
15 hostPID: true
16 initContainers:
17 - name: installer
18 image: busybox
19 command:
20 - sh
21 - -c
22 - |
23 mkdir -p /host/var/lib/kubelet/seccomp/profiles
24 cp /profiles/my-app.json /host/var/lib/kubelet/seccomp/profiles/
25 volumeMounts:
26 - name: host
27 mountPath: /host
28 - name: profiles
29 mountPath: /profiles
30 containers:
31 - name: pause
32 image: registry.k8s.io/pause:3.9
33 volumes:
34 - name: host
35 hostPath:
36 path: /
37 - name: profiles
38 configMap:
39 name: seccomp-profiles
40---
41apiVersion: v1
42kind: ConfigMap
43metadata:
44 name: seccomp-profiles
45 namespace: kube-system
46data:
47 my-app.json: |
48 {
49 "defaultAction": "SCMP_ACT_ERRNO",
50 "syscalls": [...]
51 }Reference the profile in your pod:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/my-app.jsonOr use the Security Profiles Operator (SPO) which manages this automatically:
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/security-profiles-operator/main/deploy/operator.yamlPart 2: AppArmor
AppArmor profiles restrict filesystem access, capabilities, and network operations at the process level.
Step 6: Check AppArmor Status on Nodes
# On a node
cat /sys/module/apparmor/parameters/enabled
# Y = enabled
aa-statusMost cloud providers (GKE, AKS, EKS with Amazon Linux 2023) ship with AppArmor enabled.
Step 7: Use the Runtime's Default AppArmor Profile
Container runtimes load a default AppArmor profile (docker-default or cri-containerd.apparmor.d) that's already applied to all containers unless you override it.
Check that it's active:
# On the node, find a running container
container_id=$(crictl ps --name my-app -q | head -1)
crictl inspect $container_id | grep -i apparmorStep 8: Write a Custom AppArmor Profile
A custom profile for a Node.js HTTP server:
# /etc/apparmor.d/k8s-my-app
#include <tunables/global>
profile k8s-my-app flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
#include <abstractions/nameservice>
# Allow reading the application directory
/app/** r,
/app/node_modules/** r,
# Allow writing to /tmp for temporary files
/tmp/** rw,
# Allow network access (TCP)
network tcp,
network udp,
# Allow reading system info
/proc/self/environ r,
/proc/self/fd/ r,
/proc/self/status r,
# Deny write to /etc
deny /etc/** w,
# Deny access to /proc/kcore and dangerous procfs
deny /proc/kcore r,
deny /proc/sysrq-trigger rw,
deny /proc/sys/kernel/core_pattern rw,
# Allow executing node binary
/usr/local/bin/node ix,
/usr/bin/node ix,
}
Load the profile on nodes:
# On each node
apparmor_parser -r -W /etc/apparmor.d/k8s-my-app
aa-status | grep k8s-my-appStep 9: Apply the Profile to a Pod
In Kubernetes 1.30+, AppArmor is a first-class API field:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-my-appFor older clusters, use the annotation:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-my-appStep 10: Audit Mode
Test the profile in complain mode before enforce mode:
# Load in complain mode
apparmor_parser -r -W -C /etc/apparmor.d/k8s-my-app
# Watch the audit log
journalctl -k -f | grep "apparmor"
# apparmor="ALLOWED" operation="open" profile="k8s-my-app" name="/sensitive/path"Once you're confident the profile doesn't block legitimate operations, switch to enforce mode by reloading without -C.
Combining Both
The strongest posture combines both:
1spec:
2 template:
3 spec:
4 securityContext:
5 seccompProfile:
6 type: Localhost
7 localhostProfile: profiles/my-app.json
8 appArmorProfile: # Kubernetes 1.30+ first-class field
9 type: Localhost
10 localhostProfile: k8s-my-app
11 runAsNonRoot: true
12 runAsUser: 1000
13 containers:
14 - name: app
15 securityContext:
16 allowPrivilegeEscalation: false
17 readOnlyRootFilesystem: true
18 capabilities:
19 drop: ["ALL"]For clusters older than 1.30, use the annotation form instead of appArmorProfile:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-my-appSeccomp: filters syscalls the kernel receives. AppArmor: enforces MAC (mandatory access control) policy on file, network, and capability access. They complement each other — a bypass of one doesn't bypass the other.
Validation
1# Verify seccomp is applied
2kubectl get pod my-app-xxx -o jsonpath='{.spec.securityContext.seccompProfile}'
3
4# Verify AppArmor annotation is present
5kubectl get pod my-app-xxx -o jsonpath='{.metadata.annotations}'
6
7# Check that dangerous syscalls are blocked
8kubectl exec my-app-xxx -- python3 -c "
9import ctypes, os
10CLONE_NEWNS = 0x20000
11# This should fail with EPERM under RuntimeDefault
12ret = ctypes.CDLL(None, use_errno=True).unshare(CLONE_NEWNS)
13print('exit code:', ret, 'errno:', ctypes.get_errno())
14"
15# Should print errno: 1 (EPERM) — unshare is blockedFrequently Asked Questions
What is the difference between seccomp and AppArmor?
Seccomp filters system calls — it limits what the kernel interface a process may use. AppArmor restricts access to resources such as file paths, capabilities and network operations. They operate at different layers and compose well: seccomp reduces kernel attack surface, AppArmor constrains what the process can reach if it is compromised.
Is RuntimeDefault enough, or do I need a custom profile?
RuntimeDefault is a large improvement over unconfined and costs nothing to adopt — it blocks a substantial set of rarely used syscalls. A custom profile is worth writing for high-value workloads or when you need to block something specific. Start by moving everything to RuntimeDefault, which is where most of the benefit is.
How do I find out which syscalls my application needs?
Record them rather than guessing. Run the workload in audit mode and collect what it actually calls, then build the profile from that trace. Exercise realistic paths including startup, shutdown and error handling — a profile derived from the happy path blocks something the first time a failure occurs.
Why does my pod fail to start after adding an AppArmor profile?
Usually the profile is not loaded on the node the pod scheduled onto. AppArmor profiles are a node-level resource and must be present before a pod referencing them can run, so a cluster with mixed nodes fails intermittently depending on scheduling. Distribute profiles with a DaemonSet or bake them into the node image.
Official References
- Seccomp Security Profiles — Official Kubernetes tutorial for seccomp profiles: RuntimeDefault, Localhost profiles, and syscall auditing
- AppArmor — Kubernetes docs for loading and applying AppArmor profiles to pods
- Security Profiles Operator — A Kubernetes operator that manages seccomp and AppArmor profiles as CRDs
- Linux man page: seccomp(2) — The underlying Linux syscall that seccomp profiles hook into
- OCI Runtime Spec — Linux Seccomp — How container runtimes (containerd, crun) apply seccomp profiles from the OCI spec
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.