seccomp: filter syscalls
RuntimeDefault and curated Localhost profiles.
A container is just a process sharing the host kernel. There's no little computer inside it; the isolation you picture is a few kernel features (namespaces, cgroups, capabilities) drawing lines around one ordinary process. Every file that process opens and every packet it sends goes through a syscall, a call down into that one shared kernel. There are roughly 400 of them. Your app probably touches 60. The rest are attack surface you never asked for: calls like mount, ptrace, kexec_load, and bpf that mostly exist so an attacker who already runs code in your container can reach for a kernel bug and climb out onto the node.
seccomp (secure computing mode) is the kernel feature that decides which syscalls a process is allowed to make. It works like a phone that only rings for numbers already in your contacts and drops the rest to voicemail. That's a syscall allow-list, and it's the cheapest real cut you can make to that 340-call surface. Here's what catches teams out: Kubernetes leaves every pod seccomp-unconfined by default. The filter your container runtime already ships is sitting right there, switched off, until you ask for it.
The one-line win: RuntimeDefault
Your runtime (containerd or CRI-O) already ships a curated seccomp profile that blocks the dangerous calls and leaves normal apps alone. It's the same profile Docker has shipped and hardened for years. RuntimeDefault just tells the kubelet to switch it on for the pod. It's the highest-value line in the whole spec: almost no chance of breaking a working app, and it's exactly what the restricted Pod Security Standard asks for. Set it at the pod level and every container inherits it, unless a container names its own seccompProfile, which then wins for that one container.
apiVersion: v1kind: Podmetadata: { name: web, namespace: payments }spec:securityContext:seccompProfile:type: RuntimeDefault # the runtime's curated allow-listcontainers:- name: appimage: registry.internal/web:1.4.2
# apply, then prove two things: the profile is attached, and it blocks$ kubectl apply -f pod-seccomp.yamlpod/web created$ kubectl get pod web -n payments \-o jsonpath='{.spec.securityContext.seccompProfile.type}'RuntimeDefault# creating a user namespace calls unshare, which the default profile denies$ kubectl exec -n payments web -- unshare --map-root-user --userunshare: unshare failed: Operation not permitted
Force it on every pod, and prove a bare one bounces
One good pod is easy. The next engineer who applies a pod with no profile is the problem. Close that gap at admission, the moment a pod is created, before anything runs. Think of admission control as a bouncer checking a guest list at the door: a pod that fails the check never gets created, so there's nothing to clean up later. Pod Security Admission, or PSA, is built into the API server. Label a namespace to enforce the restricted standard, and any pod that doesn't meet it gets turned away at creation. A missing seccomp profile is one of the things restricted checks. For rules the built-in levels don't cover, Kyverno or Gatekeeper (the latter built on OPA, the Open Policy Agent) can demand one specific named profile.
# enforce the restricted standard in the namespace$ kubectl label ns payments \pod-security.kubernetes.io/enforce=restricted --overwritenamespace/payments labeled# a bare pod fails restricted on several controls, seccomp among them$ kubectl run nofilter --image=registry.internal/web:1.4.2 -n paymentsError from server (Forbidden): pods "nofilter" is forbidden: violatesPodSecurity "restricted:latest": allowPrivilegeEscalation != false(container "nofilter" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "nofilter" must setsecurityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod orcontainer "nofilter" must set securityContext.runAsNonRoot=true),seccompProfile (pod or container "nofilter" must setsecurityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
There's also a cluster-wide switch. Turn on the kubelet's SeccompDefault feature (the --seccomp-default flag, or seccompDefault: true in the kubelet config) and every pod that never names a profile gets RuntimeDefault instead of Unconfined. It's a strong default to reach for. It also changes behavior for every workload on the node at once, so roll it out node by node and watch for an app that was quietly leaning on a syscall the default profile blocks.
When RuntimeDefault isn't tight enough: a Localhost profile
RuntimeDefault fits almost everything. But a high-value target, say a payment signer or an internet-facing file parser, doesn't need most of what even the default allows. For those you cut a profile by hand that permits only the syscalls that one app actually uses. That's a Localhost profile: a JSON file you drop on every node, referenced by a path relative to the kubelet's seccomp directory. The kubelet reads from /var/lib/kubelet/seccomp/, so localhostProfile: profiles/web.json means /var/lib/kubelet/seccomp/profiles/web.json. It's the difference between the runtime's off-the-shelf guest list and one you write name by name.
You don't guess that list. You record it. Run the app under an audit profile whose default action is SCMP_ACT_LOG, which allows every call but writes a line to the kernel audit log for it. Exercise the app hard, then read back which syscalls it actually made. Reference the audit profile from the pod with seccompProfile: { type: Localhost, localhostProfile: profiles/audit.json }.
{"defaultAction": "SCMP_ACT_LOG","architectures": ["SCMP_ARCH_X86_64"],"syscalls": []}
# with the app running under audit.json, read back what it called.# auditd stores every SCMP_ACT_LOG hit as a type=SECCOMP record$ sudo ausearch -m SECCOMP -i -ts today | head -6----type=SECCOMP msg=audit(07/27/2026 02:10:01.101:409) : ... pid=31245 ... syscall=execve ... code=log----type=SECCOMP msg=audit(07/27/2026 02:10:01.101:410) : ... pid=31245 ... syscall=arch_prctl ... code=log----type=SECCOMP msg=audit(07/27/2026 02:10:01.102:411) : ... pid=31245 ... syscall=mmap ... code=log# now the whole list, deduplicated: this is what the profile has to allow$ sudo ausearch -m SECCOMP -i -ts today \| grep -o 'syscall=[a-z0-9_]*' | cut -d= -f2 | sort -uarch_prctlbrkclock_gettimecloseconnectexecveexitexit_groupfstatfutexgetrandommmapmprotectmunmapnewfstatatopenatpread64readrseqrt_sigactionrt_sigprocmaskset_robust_listset_tid_addresssocketwrite# no auditd on the node? the kernel prints the same records itself,# with the numeric type and raw syscall numbers (59 = execve)$ sudo journalctl -k | grep 'type=1326' | tail -1audit: type=1326 audit(1753581001.101:409): ... pid=31245 ... syscall=59 ... code=0x7ffc0000
Now flip the default action from log to deny, and allow exactly what the audit recorded. Twenty-five distinct calls, for a program whose day job is to open one socket and sign some bytes. Most of that list runs before your main function does: the dynamic loader asks for arch_prctl, mmap and mprotect to map the binary and its libraries, and glibc startup adds set_tid_address, rseq and a few more. Drop one of those because it looks like plumbing and the container dies the moment it starts, with an error that reads like a broken image rather than a broken profile. If a name on the list surprises you, find out which code path makes it before you allow it.
SCMP_ACT_ERRNO refuses anything not on the list and hands the process an EPERM (Operation not permitted) instead of killing it outright, so a blocked call looks like a plain permission error inside the container. Keep execve and execveat on the list, by the way: the profile is loaded just before your program is launched, so if the very call that starts it isn't allowed, the container never even boots.
{"defaultAction": "SCMP_ACT_ERRNO","architectures": ["SCMP_ARCH_X86_64"],"syscalls": [{ "names": ["execve", "execveat", "arch_prctl", "brk", "mmap","mprotect", "munmap", "openat", "read", "pread64","fstat", "newfstatat", "close", "socket", "connect","write", "futex", "rseq", "set_tid_address","set_robust_list", "rt_sigaction", "rt_sigprocmask","getrandom", "clock_gettime", "exit", "exit_group"],"action": "SCMP_ACT_ALLOW" }]}
# the signer pod references localhostProfile: profiles/web.json$ kubectl get pod signer -n payments \-o jsonpath='{.spec.securityContext.seccompProfile.localhostProfile}'profiles/web.json# a syscall we deliberately left off the list is refused with EPERM$ kubectl exec -n payments signer -- chmod 4755 /bin/busyboxchmod: /bin/busybox: Operation not permitted
Seccomp does its filtering inside the kernel, at syscall entry: the call arrives, the filter checks it against your profile, and a denied call never reaches the code that implements it. That is what shrinks the blast radius of many container-escape bugs, without you rewriting the app.
Custom profiles break quietly when incomplete. Record the allow list in git next to the Deployment, and test the app under load before you mandate Localhost.
Combining seccomp with dropped capabilities and a read-only root filesystem is how you turn "RCE in the app" into "stuck unprivileged process."
The failure you actually hit is a library upgrade. A new libc or a new crypto library starts calling something the audit run never saw, and the app returns "operation not permitted" from somewhere odd instead of failing cleanly. Version the profile file on the node, web-1.4.json beside web-1.5.json, so a rollback is a one-line edit to localhostProfile rather than a redeploy of every node.
Try this
Create a pod with RuntimeDefault, then try a bare pod without a profile under a restricted namespace and watch admission or runtime bounce it.
$ kubectl -n payments apply -f - <<'EOF'apiVersion: v1kind: Podmetadata: { name: seccomp-ok }spec:securityContext:seccompProfile: { type: RuntimeDefault }containers:- name: cimage: busybox:1.36command: ["sleep","3600"]securityContext:allowPrivilegeEscalation: falserunAsNonRoot: truerunAsUser: 1000capabilities: { drop: ["ALL"] }EOFpod/seccomp-ok created$ kubectl -n payments get pod seccomp-ok -o jsonpath='{.status.containerStatuses[0].state}'{"running":{"startedAt":"2026-07-24T02:10:01Z"}}$ kubectl -n payments exec seccomp-ok -- cat /proc/1/status | grep SeccompSeccomp: 2
Takeaway
RuntimeDefault is the one-line win. Enforce it with PSS or a policy engine, and only build Localhost profiles when you can prove the extra syscalls are required.