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:
# At the pod level
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # Use the runtime's default profile
containers:
- name: app
image: my-app:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
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=latest
The 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:
# Run the container with Unconfined seccomp (logs all syscalls)
kubectl patch deployment my-app --type=merge -p='{
"spec": {
"template": {
"spec": {
"securityContext": {
"seccompProfile": {"type": "Unconfined"}
}
}
}
}
}'
Check the node's audit log:
# On the node
journalctl -k | grep "type=SECCOMP" | head -50
Or use strace in a debug container:
kubectl debug -it <pod-name> --image=ubuntu:22.04 -- strace -f -e trace=all ls /app
Step 4: Write a Custom Seccomp Profile
A custom profile allows only the syscalls your application actually uses:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
"syscalls": [
{
"names": [
"accept4", "bind", "brk", "clock_gettime", "clone",
"close", "connect", "epoll_create1", "epoll_ctl", "epoll_wait",
"execve", "exit", "exit_group", "fcntl", "fstat",
"futex", "getdents64", "getpid", "gettid", "getuid",
"ioctl", "listen", "lseek", "mmap", "mprotect",
"munmap", "nanosleep", "openat", "read", "recvfrom",
"recvmsg", "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "select",
"sendmsg", "sendto", "set_robust_list", "set_tid_address", "setuid",
"sigaltstack", "socket", "stat", "uname", "wait4",
"write", "writev"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
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:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: seccomp-profile-installer
namespace: kube-system
spec:
selector:
matchLabels:
app: seccomp-profile-installer
template:
metadata:
labels:
app: seccomp-profile-installer
spec:
hostPID: true
initContainers:
- name: installer
image: busybox
command:
- sh
- -c
- |
mkdir -p /host/var/lib/kubelet/seccomp/profiles
cp /profiles/my-app.json /host/var/lib/kubelet/seccomp/profiles/
volumeMounts:
- name: host
mountPath: /host
- name: profiles
mountPath: /profiles
containers:
- name: pause
image: gcr.io/google_containers/pause:3.1
volumes:
- name: host
hostPath:
path: /
- name: profiles
configMap:
name: seccomp-profiles
---
apiVersion: v1
kind: ConfigMap
metadata:
name: seccomp-profiles
namespace: kube-system
data:
my-app.json: |
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [...]
}
Reference the profile in your pod:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/my-app.json
Or 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.yaml
Part 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-status
Most 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 apparmor
Step 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-app
Step 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-app
For older clusters, use the annotation:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-my-app
Step 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:
spec:
template:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-my-app
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/my-app.json
runAsNonRoot: true
runAsUser: 1000
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
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
# Verify seccomp is applied
kubectl get pod my-app-xxx -o jsonpath='{.spec.securityContext.seccompProfile}'
# Verify AppArmor annotation is present
kubectl get pod my-app-xxx -o jsonpath='{.metadata.annotations}'
# Check that dangerous syscalls are blocked
kubectl exec my-app-xxx -- python3 -c "
import ctypes, os
CLONE_NEWNS = 0x20000
# This should fail with EPERM under RuntimeDefault
ret = ctypes.CDLL(None, use_errno=True).unshare(CLONE_NEWNS)
print('exit code:', ret, 'errno:', ctypes.get_errno())
"
# Should print errno: 1 (EPERM) — unshare is blocked
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.