Use AppArmor Profiles in Kubernetes

Apply Kubernetes appArmorProfile types RuntimeDefault, Localhost, and Unconfined on Pods and containers, load a custom AppArmor profile on worker nodes, and diagnose allowed and denied file access from kubelet events and host logs.

Published

Updated

Read time 10 min read

Reviewed byDeepak Prasad

Kubernetes AppArmor profiles comparing RuntimeDefault, Localhost, and Unconfined path-based confinement on a Linux worker node
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.3
apparmor 4.0.1
apparmor-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.

IMPORTANT
This guide configures AppArmor path and capability rules on Linux nodes that ship AppArmor in the kernel. It does not cover seccomp syscall filters, capability add/drop, SELinux contexts, or Pod Security Admission policy. Many Enterprise Linux nodes use SELinux instead of AppArmor—verify the LSM on your workers before copying these steps.

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_override and dac_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:

bash
cat /sys/module/apparmor/parameters/enabled
output
Y

Check the userspace tools and service:

bash
aa-status | head -8
output
apparmor 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
   plasmashell
bash
systemctl is-active apparmor
output
active

List profiles the kernel already knows about:

bash
grep k8s-apparmor-demo-profile /sys/kernel/security/apparmor/profiles || true

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

bash
grep CONFIG_SECURITY_APPARMOR /boot/config-$(uname -r)
output
# CONFIG_SECURITY_APPARMOR is not set
bash
getenforce
output
Permissive
bash
systemctl is-active apparmor 2>/dev/null || echo apparmor-inactive
output
inactive
apparmor-inactive

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

yaml
securityContext:
  appArmorProfile:
    type: RuntimeDefault

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

bash
export KUBECONFIG=/etc/kubernetes/admin.conf
kubectl create namespace apparmor-lab --dry-run=client -o yaml | kubectl apply -f -
output
namespace/apparmor-lab created

Pin early tests to one worker while you distribute profiles:

yaml
nodeSelector:
  kubernetes.io/hostname: worker01

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

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

bash
kubectl apply -f apparmor-runtime-default.yaml
sleep 8
kubectl get pod -n apparmor-lab apparmor-runtime-default -o wide

On an AppArmor-enabled node, the Pod should reach Running or Completed. On the Rocky lab worker without kernel AppArmor, kubelet blocks enforcement:

output
NAME                       READY   STATUS     RESTARTS   AGE   IP       NODE       NOMINATED NODE   READINESS GATES
apparmor-runtime-default   0/1     AppArmor   0          12s   <none>   worker01   <none>           <none>
bash
kubectl describe pod -n apparmor-lab apparmor-runtime-default | tail -8
output
Events:
  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 worker01

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

yaml
spec:
  securityContext:
    appArmorProfile:
      type: Unconfined
bash
kubectl get pod -n apparmor-lab apparmor-unconfined -o wide
output
NAME                  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:

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

bash
kubectl exec pod-name -c explicit-unconfined -- cat /proc/1/attr/current

On 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/:

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

bash
install -m 0644 k8s-apparmor-demo-profile /etc/apparmor.d/k8s-apparmor-demo-profile
apparmor_parser -r /etc/apparmor.d/k8s-apparmor-demo-profile

Verify the kernel lists the profile in enforce mode:

bash
grep k8s-apparmor-demo-profile /sys/kernel/security/apparmor/profiles
output
k8s-apparmor-demo-profile (enforce)

Prepare test directories on the host:

bash
mkdir -p /tmp/allowed /tmp/protected

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

bash
echo unconfined-write > /tmp/protected/before.txt
cat /tmp/protected/before.txt
output
unconfined-write

Under the profile, the allowed path succeeds:

bash
aa-exec -p k8s-apparmor-demo-profile -- sh -c 'echo allowed-write > /tmp/allowed/ok.txt && cat /tmp/allowed/ok.txt'
output
allowed-write

The denied path returns Permission denied to the process:

bash
aa-exec -p k8s-apparmor-demo-profile -- sh -c 'echo denied-write > /tmp/protected/blocked.txt' 2>&1 || echo exit:$?
output
sh: 1: cannot create /tmp/protected/blocked.txt: Permission denied
exit:2

Read the host audit trail:

bash
journalctl -k --no-pager | grep -i 'apparmor=.*DENIED' | tail -2
output
Jul 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:

yaml
securityContext:
  appArmorProfile:
    type: Localhost
    localhostProfile: k8s-apparmor-demo-profile

Deploy on an AppArmor-enabled worker where the profile is already loaded:

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

bash
kubectl apply -f apparmor-localhost-missing.yaml
sleep 8
kubectl describe pod -n apparmor-lab apparmor-localhost-missing | tail -8
output
Events:
  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 worker01

Distribute 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 nodeSelector or taints so Pods land only on prepared hosts
  • Partial distribution produces node-dependent failures—a Pod succeeds on worker-a and stalls on worker-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:

yaml
# Legacy (do not use in new YAML)
metadata:
  annotations:
    container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-apparmor-demo-profile
yaml
# Current API
spec:
  containers:
  - name: app
    securityContext:
      appArmorProfile:
        type: Localhost
        localhostProfile: k8s-apparmor-demo-profile

Convert 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


References


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.


Frequently Asked Questions

1. What is the difference between RuntimeDefault and Localhost AppArmor in Kubernetes?

RuntimeDefault applies the container runtime default AppArmor profile for the image. Localhost uses a profile you preload on the node kernel and reference by name in localhostProfile. RuntimeDefault needs no host profile file; Localhost requires the same profile loaded on every node that can run the Pod.

2. Does Kubernetes load AppArmor profiles onto nodes for me?

No. Kubernetes only references profile names in the Pod spec. You load profiles with distribution tools such as apparmor_parser, configuration management, or the Security Profiles Operator. Copying a file into /etc/apparmor.d without loading it leaves the kernel unaware of the profile.

3. Where do AppArmor denials appear when a Pod is blocked?

The container process usually sees Permission denied on the blocked path. The application may log an error or exit non-zero. On the host, check dmesg, journalctl -k, or audit logs for apparmor=DENIED lines. kubectl logs shows the user-space symptom, not the LSM audit record.

4. What happens when AppArmor is disabled on a worker node?

Kubelet admits Unconfined Pods but rejects RuntimeDefault and Localhost with an AppArmor pod status and an event such as Cannot enforce AppArmor: AppArmor is not enabled on the host. Fix kernel and distribution support on the node before scheduling confined workloads there.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)