Hardening Pods with Seccomp and AppArmor Profiles
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.
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 enforces RuntimeDefault automatically
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latestThe restricted standard also requires runAsNonRoot, no privilege escalation, and dropping all capabilities — all good defaults.
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: gcr.io/google_containers/pause:3.1
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 metadata:
4 annotations:
5 container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-my-app
6 spec:
7 securityContext:
8 seccompProfile:
9 type: Localhost
10 localhostProfile: profiles/my-app.json
11 runAsNonRoot: true
12 runAsUser: 1000
13 containers:
14 - name: app
15 securityContext:
16 allowPrivilegeEscalation: false
17 readOnlyRootFilesystem: true
18 capabilities:
19 drop: ["ALL"]Seccomp: 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 blockedOfficial 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.