| Tested on | Ubuntu 24.04.4 LTS (AppArmor profile load and denial) Rocky Linux 10.2 (Red Quartz) — kubeadm worker without AppArmor |
|---|---|
| Package | kubectl 1.36.3apparmor 4.0.1apparmor-utils 4.0.0-beta3 |
| 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 (AppArmor-enabled worker nodes required for RuntimeDefault and Localhost enforcement) |
| Privilege | root on AppArmor nodes to load profiles with apparmor_parser; cluster-admin kubectl to create test Pods |
| Scope | AppArmor concepts, prerequisite checks, Unconfined versus RuntimeDefault versus Localhost, Pod and container appArmorProfile YAML, custom profile that allows one path and denies another, profile loading on nodes, denial testing, multi-node distribution, legacy annotation migration note, and troubleshooting. Does not cover seccomp, Linux capabilities, SELinux, full AppArmor policy language, host-application hardening, or Pod Security Admission configuration. |
| Related guides | Harden Linux nodes for Kubernetes Kubernetes security architecture |
AppArmor confines container processes with path-oriented Linux Security Module (LSM) profiles. Kubernetes exposes that confinement through appArmorProfile on the Pod or container securityContext. This walkthrough checks host support, compares the three profile types on a kubeadm lab, loads a focused Localhost profile on an AppArmor-enabled node, and reads kubelet events plus host logs when a write is denied.
What AppArmor controls
AppArmor attaches a named profile to a process and mediates access through path rules. Unlike seccomp, which filters individual syscalls, AppArmor thinks in files, directories, and transitions:
- File reads and writes on specific paths
- Executable transitions between profiles
- Network socket operations (when rules include network mediation)
- Linux capabilities such as
dac_overrideanddac_read_search - Mount-related operations where the profile includes mount rules
- Other kernel-mediated access covered by the profile language
Kubernetes does not author profiles for you. It selects RuntimeDefault, a preloaded Localhost profile, or Unconfined and asks the container runtime to apply that label when the container starts.
Verify prerequisites
Confirm AppArmor on every node that should run confined Pods. On Ubuntu or Debian workers where AppArmor is standard:
cat /sys/module/apparmor/parameters/enabledYCheck the userspace tools and service:
aa-status | head -8apparmor module is loaded.
112 profiles are loaded.
18 profiles are in enforce mode.
/usr/lib/snapd/snap-confine
lsb_release
nvidia_modprobe
nvidia_modprobe//kmod
plasmashellsystemctl is-active apparmoractiveList profiles the kernel already knows about:
grep k8s-apparmor-demo-profile /sys/kernel/security/apparmor/profiles || trueBefore you load a new profile, that grep returns nothing. After apparmor_parser loads the file, you should see k8s-apparmor-demo-profile (enforce).
On the Rocky Linux kubeadm worker in this lab, AppArmor is absent and SELinux is in use instead:
grep CONFIG_SECURITY_APPARMOR /boot/config-$(uname -r)# CONFIG_SECURITY_APPARMOR is not setgetenforcePermissivesystemctl is-active apparmor 2>/dev/null || echo apparmor-inactiveinactive
apparmor-inactiveKubelet on that node rejects RuntimeDefault and Localhost Pods with an AppArmor status, which the next sections demonstrate. Plan AppArmor-enabled workers (commonly Ubuntu) for production enforcement, or use SELinux on distributions that do not compile AppArmor into the kernel.
Use the current API field
From Kubernetes v1.30 onward, set AppArmor on securityContext.appArmorProfile instead of legacy annotations. Pod-level configuration applies to every container unless a container overrides it:
securityContext:
appArmorProfile:
type: RuntimeDefaultContainer-level securityContext.appArmorProfile wins over the Pod default for that container only. Init, sidecar, and ephemeral containers follow the same override rules as regular containers.
Understand profile types
| Type | Behavior |
|---|---|
RuntimeDefault |
Applies the container runtime default AppArmor profile for the image |
Localhost |
Applies a profile preloaded on the node; set localhostProfile to the profile name |
Unconfined |
Runs without AppArmor confinement for that scope |
Localhost requires localhostProfile with the exact name shown in /sys/kernel/security/apparmor/profiles. Unconfined is the widest option—use it only when a workload cannot run under a profile and you accept the risk.
Prepare the lab namespace
Create an isolated namespace for test Pods:
export KUBECONFIG=/etc/kubernetes/admin.conf
kubectl create namespace apparmor-lab --dry-run=client -o yaml | kubectl apply -f -namespace/apparmor-lab createdPin early tests to one worker while you distribute profiles:
nodeSelector:
kubernetes.io/hostname: worker01Remove the selector after the profile is loaded on every schedulable node.
Apply RuntimeDefault to a Pod
RuntimeDefault is the usual baseline when you do not need a custom path policy. Deploy a simple Pod on a worker that should enforce AppArmor:
apiVersion: v1
kind: Pod
metadata:
name: apparmor-runtime-default
namespace: apparmor-lab
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: worker01
securityContext:
appArmorProfile:
type: RuntimeDefault
containers:
- name: test
image: busybox:1.36
command: ["sh", "-c", "echo hello && sleep 5"]Apply the manifest and read Pod status:
kubectl apply -f apparmor-runtime-default.yaml
sleep 8
kubectl get pod -n apparmor-lab apparmor-runtime-default -o wideOn an AppArmor-enabled node, the Pod should reach Running or Completed. On the Rocky lab worker without kernel AppArmor, kubelet blocks enforcement:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
apparmor-runtime-default 0/1 AppArmor 0 12s <none> worker01 <none> <none>kubectl describe pod -n apparmor-lab apparmor-runtime-default | tail -8Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning AppArmor 13s kubelet Cannot enforce AppArmor: AppArmor is not enabled on the host
Normal Scheduled 13s default-scheduler Successfully assigned apparmor-lab/apparmor-runtime-default to worker01The AppArmor status and kubelet warning are the signal that the node cannot honor RuntimeDefault until AppArmor is enabled in the kernel and userspace.
Compare Unconfined
Unconfined tells Kubernetes not to apply AppArmor for that scope. Kubelet still schedules the Pod on nodes without AppArmor support:
spec:
securityContext:
appArmorProfile:
type: Unconfinedkubectl get pod -n apparmor-lab apparmor-unconfined -o wideNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
apparmor-unconfined 0/1 Completed 0 14s 192.168.5.32 worker01 <none> <none>Keep Unconfined off production paths unless you document an exception. Restricted Pod Security Standards expect a confined profile instead.
Override one container in a multi-container Pod
Pod-level appArmorProfile sets the default for every container. A container-level securityContext overrides only that container:
spec:
securityContext:
appArmorProfile:
type: RuntimeDefault
containers:
- name: inherits-runtime
image: busybox:1.36
command: ["sh", "-c", "echo runtime-default && sleep 3"]
- name: explicit-unconfined
image: busybox:1.36
securityContext:
appArmorProfile:
type: Unconfined
command: ["sh", "-c", "echo unconfined-container && sleep 3"]After the Pod completes, inspect each container profile through the runtime on an AppArmor-enabled node:
kubectl exec pod-name -c explicit-unconfined -- cat /proc/1/attr/currentOn containerd, an enforced Localhost profile often prints a line such as k8s-apparmor-demo-profile (enforce). The sibling container shows the runtime default label when you repeat the command with -c inherits-runtime.
Create a Localhost profile
Author a focused profile on the AppArmor node—not a production-ready policy. This lab profile allows normal startup reads, permits writes under /tmp/allowed/, and denies writes under /tmp/protected/:
#include <tunables/global>
profile k8s-apparmor-demo-profile flags=(attach_disconnected) {
#include <abstractions/base>
file,
/tmp/allowed/ rw,
/tmp/allowed/** rw,
/tmp/protected/ r,
/tmp/protected/** r,
deny /tmp/protected/** w,
}The profile name in the first line (k8s-apparmor-demo-profile) must match localhostProfile in your Pod YAML exactly.
Load the profile on the node
Save the policy under /etc/apparmor.d/ and load it into the kernel. Copying the file alone does not register the profile:
install -m 0644 k8s-apparmor-demo-profile /etc/apparmor.d/k8s-apparmor-demo-profile
apparmor_parser -r /etc/apparmor.d/k8s-apparmor-demo-profileVerify the kernel lists the profile in enforce mode:
grep k8s-apparmor-demo-profile /sys/kernel/security/apparmor/profilesk8s-apparmor-demo-profile (enforce)Prepare test directories on the host:
mkdir -p /tmp/allowed /tmp/protectedTest allowed and denied actions on the host
Before you wire the profile into Kubernetes, confirm allow and deny rules with aa-exec on the node:
An unconfined shell can still write the protected path:
echo unconfined-write > /tmp/protected/before.txt
cat /tmp/protected/before.txtunconfined-writeUnder the profile, the allowed path succeeds:
aa-exec -p k8s-apparmor-demo-profile -- sh -c 'echo allowed-write > /tmp/allowed/ok.txt && cat /tmp/allowed/ok.txt'allowed-writeThe denied path returns Permission denied to the process:
aa-exec -p k8s-apparmor-demo-profile -- sh -c 'echo denied-write > /tmp/protected/blocked.txt' 2>&1 || echo exit:$?sh: 1: cannot create /tmp/protected/blocked.txt: Permission denied
exit:2Read the host audit trail:
journalctl -k --no-pager | grep -i 'apparmor=.*DENIED' | tail -2Jul 27 22:48:15 apparmor-lab kernel: audit: type=1400 audit(1785192495.263:117): apparmor="DENIED" operation="capable" class="cap" profile="k8s-apparmor-demo-profile" pid=2787 comm="sh" capability=2 capname="dac_read_search"
Jul 27 22:48:15 apparmor-lab kernel: audit: type=1400 audit(1785192495.263:118): apparmor="DENIED" operation="capable" class="cap" profile="k8s-apparmor-demo-profile" pid=2787 comm="sh" capability=1 capname="dac_override"Denials often surface only on the node. Pair application errors with journalctl -k or dmesg when debugging production incidents.
Reference the Localhost profile in YAML
Point the Pod at the preloaded profile name:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-apparmor-demo-profileDeploy on an AppArmor-enabled worker where the profile is already loaded:
apiVersion: v1
kind: Pod
metadata:
name: apparmor-localhost-demo
namespace: apparmor-lab
spec:
restartPolicy: Never
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-apparmor-demo-profile
containers:
- name: test
image: busybox:1.36
command: ["sh", "-c", "echo allowed > /tmp/allowed/from-pod && cat /tmp/allowed/from-pod"]On AppArmor-enabled nodes, kubelet passes the profile name to containerd and the container process inherits the same rules you validated with aa-exec. After the Pod is Running, read /proc/1/attr/current inside the container—the line should include k8s-apparmor-demo-profile (enforce). Repeat the allowed and denied write tests with kubectl exec; expect the same Permission denied on /tmp/protected/ that you saw on the host.
If the profile name is wrong or not loaded on an AppArmor-enabled node, kubelet fails container creation with an event such as apparmor profile not found k8s-apparmor-demo-profile. Schedule the Pod on a worker without AppArmor and kubelet stops earlier with the same warning as RuntimeDefault:
kubectl apply -f apparmor-localhost-missing.yaml
sleep 8
kubectl describe pod -n apparmor-lab apparmor-localhost-missing | tail -8Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning AppArmor 13s kubelet Cannot enforce AppArmor: AppArmor is not enabled on the host
Normal Scheduled 13s default-scheduler Successfully assigned apparmor-lab/apparmor-localhost-missing to worker01Distribute profiles across nodes
Localhost profiles are node-local kernel state. Kubernetes does not replicate them:
- Load the same profile on every worker that can receive the Pod
- Version profile content in Git and deploy with Ansible, cloud-init, or the Security Profiles Operator
- During rollout, label nodes that already have the profile and add
nodeSelectoror taints so Pods land only on prepared hosts - Partial distribution produces node-dependent failures—a Pod succeeds on
worker-aand stalls onworker-b
The scheduler does not inspect which profiles are loaded. Treat profile distribution as infrastructure work alongside kubelet configuration.
Migrate legacy annotations
Before Kubernetes v1.30, AppArmor used Pod annotations such as container.apparmor.security.beta.kubernetes.io/<container-name>. New manifests should use appArmorProfile:
# Legacy (do not use in new YAML)
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-apparmor-demo-profile# Current API
spec:
containers:
- name: app
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-apparmor-demo-profileConvert existing Deployments during upgrades; admission still accepts annotations on older API paths, but the structured field is stable and visible in kubectl explain.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Pod STATUS is AppArmor |
AppArmor disabled on the node | Enable AppArmor in the kernel and install userspace tools, or schedule on Ubuntu/Debian workers |
apparmor profile not found in Events |
Profile not loaded or name typo | Run apparmor_parser -r on the node; match localhostProfile to /sys/kernel/security/apparmor/profiles |
Permission denied in container logs |
Profile denies the path or capability | Adjust allow rules; check journalctl -k for apparmor=DENIED |
| Pod runs on one node only | Profile missing on other workers | Distribute profile to every eligible node before removing nodeSelector |
| Denial only in host logs | Normal for LSM enforcement | Correlate kubectl logs with dmesg or journalctl -k on the worker |
| Pod setting ignored | Container-level override or Unconfined sibling |
Inspect per-container securityContext; container values override Pod defaults |
Unconfined works but RuntimeDefault fails |
Node lacks AppArmor | Do not treat Unconfined success as proof the node enforces profiles |
What's Next
- Kubernetes SecurityContext with Practical Examples
- Linux Capabilities in Kubernetes
- Kubernetes Pod Security Standards and Admission
References
- Restrict a Container's Access to Resources with AppArmor — Kubernetes documentation
- AppArmor profile API —
securityContext.appArmorProfilefields - AppArmor documentation — profile language and tooling
Summary
You walked through Kubernetes AppArmor from host prerequisites to Pod YAML. On an AppArmor-enabled Ubuntu node, apparmor_parser loaded k8s-apparmor-demo-profile, allowed writes under /tmp/allowed/, and returned Permission denied for /tmp/protected/ with matching audit lines in the kernel log. On a Rocky Linux kubeadm worker without kernel AppArmor, Unconfined Pods completed while RuntimeDefault and Localhost stalled with kubelet events stating AppArmor is not enabled on the host.
The operational split to remember is distribution versus declaration. Kubernetes only references profile names in appArmorProfile; you must load identical policy on every schedulable node before removing placement guards. Container-level securityContext overrides Pod defaults, and denials often appear only in host logs—pair kubectl output with journalctl -k on the worker.
For production, standardize on RuntimeDefault for new workloads, add Localhost profiles only where path rules are tested, and version policy files through configuration management or the Security Profiles Operator. Pair AppArmor with SecurityContext and node hardening so path confinement aligns with the rest of your CKS workload isolation plan.

