Security

Hardening Pods with Seccomp and AppArmor Profiles

Advanced45 min to complete11 min read

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
Kubernetes
Security
Seccomp
AppArmor
Container Security

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:

yaml
# 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:

bash
# 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:

bash
# 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:

bash
# On the node
journalctl -k | grep "type=SECCOMP" | head -50

Or use strace in a debug container:

bash
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:

json
{
  "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:

yaml
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:

yaml
securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/my-app.json

Or use the Security Profiles Operator (SPO) which manages this automatically:

bash
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

bash
# 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:

bash
# 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:

bash
# 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:

yaml
securityContext:
  appArmorProfile:
    type: Localhost
    localhostProfile: k8s-my-app

For older clusters, use the annotation:

yaml
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:

bash
# 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:

yaml
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

bash
# 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.