| Tested on | Rocky Linux 10.2 (Red Quartz) — kubeadm worker node with containerd |
|---|---|
| Package | kubectl 1.36.3kubelet 1.36.3containerd 2.2.5 |
| Applies to | Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora Kubernetes cluster |
| Cert prep | CKS |
| Lab environment | Multi-node kubeadm cluster with containerd and SSH node access — install Kubernetes with kubeadm |
| Privilege | root on worker nodes to install profile files under /var/lib/kubelet/seccomp/; cluster-admin kubectl to create test Pods |
| Scope | seccomp concepts, prerequisite checks, Unconfined versus RuntimeDefault versus Localhost, Pod and container seccompProfile YAML, custom profile that denies chmod, kubelet profile path, denial testing, multi-node distribution notes, and troubleshooting. Does not cover Linux capabilities, AppArmor, full OCI seccomp JSON reference, profile-generation tooling, runtime installation, or offensive syscall research. |
| Related guides | Harden Linux nodes for Kubernetes Kubernetes security architecture |
seccomp (secure computing mode) filters which Linux system calls a container process may invoke. Kubernetes exposes that filter through seccompProfile on the Pod or container securityContext. This walkthrough compares Unconfined, RuntimeDefault, and Localhost on a kubeadm lab worker, deploys a custom profile that blocks chmod, and reads the resulting logs and exit codes.
What seccomp restricts
seccomp sits between your application and the Linux kernel:
- It filters system calls (not individual files or network ports).
- It reduces kernel attack surface when a workload is compromised.
- It applies per Pod or per container through
securityContext.seccompProfile. - It complements capabilities, AppArmor or SELinux, and sandboxed runtimes such as gVisor.
SCMP_ACT_ERRNO, used in this lab, returns an errno such as EPERM. Other seccomp actions may log the call, send SIGSYS, notify userspace, or terminate the process. In shell utilities you often see Operation not permitted. The container keeps running unless the application treats that error as fatal.
Verify prerequisites
seccomp filtering requires Linux nodes with kernel and runtime support. On a worker node, read the kernel build configuration from /boot (Rocky Linux and RHEL do not ship /proc/config.gz by default):
grep -E '^CONFIG_SECCOMP(_FILTER)?=' "/boot/config-$(uname -r)"CONFIG_SECCOMP=y
CONFIG_SECCOMP_FILTER=yIf /boot/config-$(uname -r) is missing but /proc/config.gz exists, use it as a fallback:
zgrep -E '^CONFIG_SECCOMP(_FILTER)?=' /proc/config.gzConfirm the cluster version and runtime from the control plane:
export KUBECONFIG=/etc/kubernetes/admin.conf
kubectl versionClient Version: v1.36.3
Server Version: v1.36.3Kubelet stores Localhost profiles under /var/lib/kubelet/seccomp/ by default. The directory may not exist until you create it:
ls -ld /var/lib/kubelet/seccomp/ 2>/dev/null || echo "create before Localhost profiles"Check whether the kubelet applies a default seccomp profile when the Pod spec omits one:
grep '^seccompDefault:' /var/lib/kubelet/config.yaml ||
echo "seccompDefault omitted; default is false"seccompDefault omitted; default is falseseccompDefault is configured per kubelet and defaults to false. When seccompDefault is absent or false in the kubelet configuration, containers without an explicit profile run as Unconfined. When seccompDefault: true, the kubelet applies RuntimeDefault. Pod Security Admission does not set or mutate seccomp profiles; the Restricted policy rejects Pods that do not explicitly request RuntimeDefault or Localhost.
Understand profile types
| Type | Behavior |
|---|---|
Unconfined |
No seccomp filtering applied by Kubernetes for that scope |
RuntimeDefault |
Uses the container runtime default OCI seccomp profile |
Localhost |
Loads a JSON profile from the kubelet seccomp directory on the node |
Privileged containers ignore your seccompProfile and run unconfined. Pod Security Standards Restricted expects RuntimeDefault or Localhost. Pod Security Admission checks the selected type but does not inspect whether the Localhost JSON is actually stricter—see Pod Security Standards for admission context.
Prepare the lab namespace
Create an isolated namespace for test Pods:
kubectl create namespace seccomp-lab --dry-run=client -o yaml | kubectl apply -f -namespace/seccomp-lab createdPin workloads to the worker where you install Localhost profiles:
nodeSelector:
kubernetes.io/hostname: worker01Remove the selector after you distribute profiles to every node in production.
Apply RuntimeDefault to a Pod
RuntimeDefault is the usual production baseline when you do not need a custom syscall list. Apply a Pod that runs chmod under the runtime profile:
apiVersion: v1
kind: Pod
metadata:
name: seccomp-runtime-default
namespace: seccomp-lab
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: worker01
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: test
image: busybox:1.36
command: ["sh", "-c", "touch /tmp/f && chmod 600 /tmp/f && echo chmod-ok"]Deploy and read the result:
kubectl apply -f seccomp-runtime-default.yamlkubectl wait -n seccomp-lab \
--for=jsonpath='{.status.phase}'=Succeeded \
pod/seccomp-runtime-default \
--timeout=60skubectl get pod -n seccomp-lab seccomp-runtime-defaultkubectl logs -n seccomp-lab seccomp-runtime-defaultNAME READY STATUS RESTARTS AGE
seccomp-runtime-default 0/1 Completed 0 8s
chmod-okOn containerd 2.2.x with Kubernetes 1.36, the runtime default profile still allows chmod for this busybox test. That is expected—RuntimeDefault is the runtime curated list, not an empty allow-all policy.
Confirm the runtime applied a seccomp policy on worker01. Select the Pod sandbox first so you inspect the container you just created:
POD_ID=$(crictl pods \
--namespace seccomp-lab \
--name seccomp-runtime-default \
-q | head -1)
CONTAINER_ID=$(crictl ps -a \
--pod "$POD_ID" \
--name test \
-q | head -1)
crictl inspect "$CONTAINER_ID" |
jq '.info.runtimeSpec.linux.seccomp'{
"architectures": [
"SCMP_ARCH_X86_64",
"SCMP_ARCH_X86",
"SCMP_ARCH_X32"
],
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"action": "SCMP_ACT_ALLOW",
"names": [
"accept",
"accept4",
"access",
...
]
}
]
}RuntimeDefault produces a populated seccomp policy in the OCI runtime spec.
Compare Unconfined
Unconfined removes seccomp filtering for the Pod scope. Use it only when a legacy workload truly cannot run under a profile:
apiVersion: v1
kind: Pod
metadata:
name: seccomp-unconfined
namespace: seccomp-lab
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: worker01
securityContext:
seccompProfile:
type: Unconfined
containers:
- name: test
image: busybox:1.36
command: ["sh", "-c", "touch /tmp/f && chmod 600 /tmp/f && echo chmod-ok"]Deploy and confirm the same chmod test completes:
kubectl apply -f seccomp-unconfined.yamlkubectl wait -n seccomp-lab \
--for=jsonpath='{.status.phase}'=Succeeded \
pod/seccomp-unconfined \
--timeout=60skubectl logs -n seccomp-lab seccomp-unconfinedchmod-okBoth Unconfined and RuntimeDefault print chmod-ok, so logs alone do not prove a difference. Inspect the runtime spec on worker01:
POD_ID=$(crictl pods \
--namespace seccomp-lab \
--name seccomp-unconfined \
-q | head -1)
CONTAINER_ID=$(crictl ps -a \
--pod "$POD_ID" \
--name test \
-q | head -1)
crictl inspect "$CONTAINER_ID" |
jq '.info.runtimeSpec.linux.seccomp'nullUnconfined leaves linux.seccomp unset in the runtime spec—no seccomp policy is applied.
Keep Unconfined off production paths unless you document an exception. It is wider than RuntimeDefault and fails Restricted policy checks.
Create a Localhost profile
Author an OCI seccomp JSON profile on the node. This lab denies chmod, fchmod, and fchmodat while allowing everything else:
cat > deny-chmod.json <<'EOF'
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["chmod", "fchmod", "fchmodat"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 1
}
]
}
EOFThis deny-list profile exists only to demonstrate an observable syscall failure. It is not a production allow-list and should not be treated as stronger than RuntimeDefault. Restricted policy permits Localhost, but does not evaluate profile contents.
Pick a syscall your test binary actually calls so you can observe a clean failure without blocking container startup syscalls such as execve or openat.
Install the profile on the node
Create the kubelet seccomp tree and install the file on every node that may run the Pod:
mkdir -p /var/lib/kubelet/seccomp/profiles
install -m 0644 deny-chmod.json /var/lib/kubelet/seccomp/profiles/deny-chmod.json
ls -l /var/lib/kubelet/seccomp/profiles/deny-chmod.json-rw-r--r--. 1 root root 175 Jul 28 03:24 /var/lib/kubelet/seccomp/profiles/deny-chmod.jsonKubelet resolves Localhost paths relative to /var/lib/kubelet/seccomp/. A missing file surfaces at container creation time—not at schedule time.
Reference the Localhost profile in YAML
Point localhostProfile at the path relative to the seccomp root. Do not prefix /var/lib/kubelet/seccomp/ in the manifest. Kubelet joins the root and relative path when loading the profile.
Deploy a Pod that uses the installed profile. Localhost profile availability is checked during container creation:
apiVersion: v1
kind: Pod
metadata:
name: seccomp-localhost
namespace: seccomp-lab
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: worker01
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/deny-chmod.json
containers:
- name: test
image: busybox:1.36
command: ["sh", "-c", "set -e; touch /tmp/f; chmod 600 /tmp/f; echo chmod-ok"]Apply the manifest and wait for the denied chmod to fail the container:
kubectl apply -f seccomp-localhost.yamlkubectl wait -n seccomp-lab \
--for=jsonpath='{.status.phase}'=Failed \
pod/seccomp-localhost \
--timeout=60skubectl get pod -n seccomp-lab seccomp-localhostkubectl logs -n seccomp-lab seccomp-localhostkubectl get pod -n seccomp-lab seccomp-localhost -o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}{"\n"}'NAME READY STATUS RESTARTS AGE
seccomp-localhost 0/1 Error 0 8s
chmod: /tmp/f: Operation not permitted
1Inspect the applied Localhost policy on worker01:
POD_ID=$(crictl pods \
--namespace seccomp-lab \
--name seccomp-localhost \
-q | head -1)
CONTAINER_ID=$(crictl ps -a \
--pod "$POD_ID" \
--name test \
-q | head -1)
crictl inspect "$CONTAINER_ID" |
jq '.info.runtimeSpec.linux.seccomp'{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"action": "SCMP_ACT_ERRNO",
"errnoRet": 1,
"names": [
"chmod",
"fchmod",
"fchmodat"
]
}
]
}The runtime spec contains the custom deny rule for chmod, fchmod, and fchmodat.
Override one container in a multi-container Pod
Pod-level seccompProfile sets the default for every container. A container-level securityContext overrides only that container:
apiVersion: v1
kind: Pod
metadata:
name: seccomp-multi
namespace: seccomp-lab
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: worker01
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: default-profile
image: busybox:1.36
command: ["sh", "-c", "touch /tmp/a && chmod 600 /tmp/a && echo default-ok"]
- name: localhost-profile
image: busybox:1.36
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/deny-chmod.json
command: ["sh", "-c", "set -e; touch /tmp/b; chmod 600 /tmp/b; echo localhost-ok"]Deploy and read each container log after the Pod finishes:
kubectl apply -f seccomp-multi.yamlkubectl wait -n seccomp-lab \
--for=jsonpath='{.status.phase}'=Failed \
pod/seccomp-multi \
--timeout=60skubectl logs -n seccomp-lab seccomp-multi -c default-profilekubectl logs -n seccomp-lab seccomp-multi -c localhost-profiledefault-ok
chmod: /tmp/b: Operation not permittedThe first container inherited RuntimeDefault and completed. The second container used the Localhost profile and failed on chmod while the Pod-level setting still applied to the sibling.
Compare the three modes
| Profile | chmod result | Pod phase |
|---|---|---|
| Unconfined | chmod-ok |
Succeeded |
| RuntimeDefault | chmod-ok |
Succeeded |
| Localhost deny-chmod | Operation not permitted, exit 1 |
Failed |
The blocked syscall is chmod. For syscall logging in production, use SCMP_ACT_LOG in the profile or the Security Profiles Operator recording features—not the SCMP_ACT_ERRNO denial used in this lab.
Distribute profiles across nodes
Localhost profiles are node-local files. A Pod scheduled to a node without profiles/deny-chmod.json fails with CreateContainerError:
Warning Failed kubelet Error: failed to create containerd container: cannot load seccomp profile "/var/lib/kubelet/seccomp/profiles/does-not-exist.json": open ... no such file or directoryDistribution options:
- Configuration management (Ansible, Salt, cloud-init) copying versioned JSON into
/var/lib/kubelet/seccomp/profiles/ - The Security Profiles Operator
- Node labels and taints during rollout so Pods land only on nodes that already have the profile
- Filename versioning (
deny-chmod-v2.json) so rollbacks do not overwrite files in use
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
CreateContainerError on start |
Missing or invalid Localhost JSON | Confirm file path under /var/lib/kubelet/seccomp/; validate JSON syntax |
Operation not permitted at runtime |
Profile denies the syscall your app needs | Adjust profile allow list or pick a narrower test syscall |
| Works on one node only | Profile not copied to all workers | Distribute file with CM or Security Profiles Operator |
seccompProfile ignored |
Container is privileged: true |
Remove privileged; use capabilities instead |
| RuntimeDefault blocks unexpected syscall | Runtime profile differs by containerd or CRI-O version | Test on target runtime; fall back to explicit Localhost profile |
| Pod Security Admission rejects spec | Namespace enforces Restricted | Use RuntimeDefault or Localhost; avoid Unconfined |
What's Next
- Use AppArmor Profiles in Kubernetes
- Kubernetes SecurityContext with Practical Examples
- Linux Capabilities in Kubernetes
References
- Seccomp profiles — Kubernetes documentation
- OCI runtime spec — seccomp — profile JSON format
Summary
You compared three seccomp modes on a Linux worker: Unconfined and RuntimeDefault both let the lab chmod test succeed, while a Localhost profile installed at /var/lib/kubelet/seccomp/profiles/deny-chmod.json returned Operation not permitted and exit code 1. crictl inspect on worker01 showed that RuntimeDefault applied a populated seccomp policy, Unconfined left seccomp unset, and the Localhost container carried the custom deny rule. Pod-level seccompProfile sets the default; container-level settings override it for individual containers in the same Pod.
The operational gotcha is node locality—localhostProfile is a relative path on the kubelet host, not a cluster object. A missing file produces CreateContainerError with the full absolute path in the event message, which is the fastest way to spot a half-finished rollout.
For production, start with RuntimeDefault on new workloads, add Localhost profiles only where you have tested syscall requirements, and distribute JSON with configuration management or the Security Profiles Operator. Pair seccomp with SecurityContext and Pod Security Standards so admission and syscall filtering tell the same story.

