Skip to main content
RunBook Academy

Docker & ContainersII Β· Linux InternalsSeccomp and LSMs

Seccomp and AppArmor β€” restricting what a container can do

Advanced⏱ ~28 min

What you'll learn

  • Explain seccomp, AppArmor, and SELinux and when each applies to Docker
  • Customize seccomp profiles safely
  • Diagnose why a container fails after a seccomp or AppArmor profile is applied

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-09

Not yet marked complete on this device.

Capabilities narrow what a container can do at the kernel-authority level. Seccomp narrows it further by restricting which syscalls a process can make. AppArmor and SELinux go further still, applying mandatory access control to file, capability, and network operations.

Seccomp

Seccomp (secure computing mode) is a Linux kernel feature that filters syscalls. A seccomp profile is an allowlist of syscalls plus a default action for everything not on it.

Docker applies a default seccomp profile whose allowlist names over 400 syscalls; everything else is denied. The denied ones are those that have caused real-world container escapes: unshare and clone with namespace flags, bpf, keyctl, add_key, perf_event_open, mount and umount2, pivot_root, setns, kexec_load, and the kernel-module family.

Read-only / Safebisect a denial: is it seccomp or is it permissions?
IMAGE=debian:bookworm-slim
docker run --rm "$IMAGE" sh -c 'unshare --user --map-root-user true; echo "default profile: exit $?"'
docker run --rm --security-opt seccomp=unconfined "$IMAGE" \
  sh -c 'unshare --user --map-root-user true; echo "unconfined:      exit $?"'
unshare: unshare failed: Operation not permitted
default profile: exit 1
unconfined:      exit 0

Illustrative output

Two different exit codes for the same command is the signature. If both runs fail identically, seccomp is not involved and you are back to ownership, mode bits, or an LSM.

Read-only / Safeconfirm which profile a container is actually running under
CONTAINER=web
docker inspect --format '{{.HostConfig.SecurityOpt}}' "$CONTAINER"
docker exec "$CONTAINER" grep Seccomp /proc/1/status
[]
Seccomp:	2
Seccomp_filters:	1

Illustrative output

Seccomp: 2 in /proc/<pid>/status means filter mode is active. Seccomp: 0 means the process has no filter at all β€” which, in a container, means somebody set seccomp=unconfined and probably did not write it down.

Custom seccomp profiles

Configuration changeapply custom seccomp
docker run --security-opt seccomp=./seccomp.json nginx
Read-only / Safeinspect the default profile
URL=https://raw.githubusercontent.com/moby/profiles/main/seccomp/default.json
curl -fsS "$URL" \
  | jq '{defaultAction, defaultErrnoRet, groups: (.syscalls | length), allowed: ([.syscalls[].names[]] | length)}'
{
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"groups": 33,
"allowed": 442
}

defaultErrnoRet: 1 is EPERM. Read that output again before you write a custom profile: whatever you deny, the container will keep running and the failure will surface inside the application.

Read-only / Safefind the syscalls with a non-default errno
URL=https://raw.githubusercontent.com/moby/profiles/main/seccomp/default.json
curl -fsS "$URL" \
  | jq -r '.syscalls[] | select(.action != "SCMP_ACT_ALLOW") | "\(.action) errno=\(.errnoRet) \(.names | join(","))"'
SCMP_ACT_ERRNO errno=38 clone3

Errno 38 is ENOSYS. clone3 is the one syscall the profile lies about on purpose, so that glibc’s runtime probe concludes β€œthis kernel is too old” and takes its clone fallback path instead of treating the denial as fatal.

Custom profiles are most commonly used to:

  • Tighten the default profile (remove syscalls your workload does not use).
  • Loosen the default profile (re-enable a syscall the workload needs but Docker blocks).
  • Comply with a specific security baseline (CIS Docker Benchmark, PSP, etc.).

The cost of a custom profile is operational, and it is deferred. When the workload upgrades to a library version that reaches for a syscall you did not allow, the profile has to be updated β€” but the bill does not arrive as a crash at deploy time. It arrives as EPERM on some code path that only runs during a failover, a backup, or a certificate rotation, weeks later, to whoever is on call. Every custom profile you own is a promise to re-test it on every dependency bump.

AppArmor

AppArmor is a Linux Security Module (LSM) that confines programs to a set of file, capability, and network permissions. It is path- based (Ubuntu’s preferred LSM) and ships with templates for many applications.

Docker applies a default AppArmor profile named docker-default. To use a custom profile:

Configuration changecustom apparmor
sudo apparmor_parser -r /etc/apparmor.d/docker-custom
docker run --security-opt apparmor=docker-custom nginx

AppArmor profiles are especially useful for:

  • Filesystem containment (deny writes to /proc, /sys outside expected paths).
  • Network containment (deny outbound except to specific peers).
  • Capability containment (override the capability set with capability rules).

Finding an AppArmor denial, and why dmesg lies about how many there were

An AppArmor denial produces a kernel audit record, not an application-visible error. The application sees EACCES or EPERM; the reason lives in the audit stream. Where that stream goes depends on whether auditd is running, and the two cases look completely different:

  • No auditd. Records go to the kernel ring buffer and are readable with dmesg. The ring buffer is rate-limited: under a burst the kernel prints audit: N callbacks suppressed and drops the rest. So a tight retry loop that trips one denial per request shows a handful of lines in dmesg and hides thousands. Counting denials in dmesg and concluding β€œit only happened twice” is a wrong conclusion drawn from a correct command.
  • auditd running. The kernel hands records to the daemon instead, and they stop appearing in dmesg altogether. dmesg goes silent, which reads exactly like β€œno denials” β€” but the records are in /var/log/audit/audit.log.

Check both, and always check whether auditd is running before you trust an empty result from either.

Read-only / Safefind AppArmor denials for a container
systemctl is-active auditd || echo 'auditd not running: denials go to dmesg'
dmesg | grep -E 'apparmor=("DENIED"|"AUDIT")|callbacks suppressed' | tail -20
sudo ausearch -m AVC -ts recent 2>/dev/null | tail -20
inactive
[172834.119284] audit: type=1400 audit(1754899201.882:412): apparmor="DENIED" operation="mount" class="mount" profile="docker-default" name="/mnt/remote/" pid=48821 comm="mount" fstype="fuse.sshfs"
[172834.119401] audit: 37 callbacks suppressed

Illustrative output

The callbacks suppressed line is the important one. It is the kernel telling you the count you just read is not the count that happened.

Read-only / Safeconfirm which AppArmor profile a container is confined by
CONTAINER=web
docker inspect --format '{{.AppArmorProfile}}' "$CONTAINER"
docker exec "$CONTAINER" cat /proc/1/attr/current 2>/dev/null || echo 'no AppArmor label'
aa-status --profiled 2>/dev/null || sudo aa-status | head -5
docker-default
docker-default (enforce)

Illustrative output

docker-default (enforce) is the working state. (complain) means the profile logs but does not block β€” useful while developing a profile, and a silent hole if it reaches production by accident.

SELinux

SELinux is the other major LSM. It is label-based (RHEL’s preferred LSM) and significantly more expressive than AppArmor.

Docker integrates with SELinux via the --security-opt label=... flag. By default, containers run with the container_t type and limited MCS labels. To customise:

Configuration changeselinux label
docker run --security-opt label=type:container_myapp_t \
  -v /srv/data:/srv/data:z \
  nginx

The :z and :Z mount options tell Docker to relabel the host volume for the container. Misconfiguration here is one of the most common SELinux + Docker issues.

What to do in practice

  1. Start with the default Docker security profile. Verify the workload runs without customisation.
  2. If a syscall or capability is missing, identify the specific call. Add only what is needed.
  3. Test the workload under the custom profile. A profile that breaks the workload is worse than no profile.
  4. Document the rationale for each customisation. Auditors will ask.
  5. Re-test after every dependency upgrade. New libraries introduce new syscalls.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. Under Docker's default seccomp profile, what happens when a process in a container makes a denied syscall?

  2. Q2. A seccomp profile that is too restrictive causes the container to fail noisily, with the cause visible in `docker logs` and the exit code.

  3. Q3. You suspect a seccomp denial. Which single test distinguishes it from a genuine file-permission problem?

  4. Q4. An empty `dmesg` is sufficient evidence that no AppArmor denials occurred.

  5. Q5. Why might a workload suddenly fail after a library upgrade under a custom seccomp profile?

Passing score: 75%. Answers are checked in this browser.