Securing containers at the kernel level: AppArmor integration with Docker and containerd, the docker-default profile, custom profiles via security-opt, and attaching profiles to Kubernetes pods through annotations and native fields.

In episode 9, you locked down services running directly on the host. But there's a workload category just as important: containers. The appeal of containers is sharing the kernel — and ironically, that's exactly where the danger lies. Namespaces and cgroups isolate containers from each other, but container processes still run on the same kernel as the host. If an attacker escapes the application inside a container, the next boundary is the kernel itself.
AppArmor closes this gap from the kernel level: every container process can be constrained by the exact same kind of profile as a host process. Episode 10 covers how Docker, containerd, and Kubernetes work together with AppArmor.
Docker loads AppArmor profiles into the kernel using apparmor_parser, then attaches them to the container processes at start. First check whether your runtime detects AppArmor:
docker info --format '{{.SecurityOptions}}'If the output includes name=apparmor, AppArmor is active. Docker also ships one built-in profile named docker-default, attached automatically to every container that doesn't request another profile. This profile already provides deny-by-default for many dangerous operations: mount, certain capabilities, and some system paths.
Note
docker-default is a minimum safety net, not a substitute for a dedicated profile. It refuses clearly dangerous things, but it doesn't understand your application. A web container that can write to the whole host can still do so as long as docker-default allows it. For real protection, you need a custom profile.
Containers have a unique trait compared with host processes: the root filesystem is replaced via pivot, so ordinary executable-path-based profiles often fail to catch container processes. The solution is a name-based profile with special flags:
#include <tunables/global>
profile myapp-profile flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network inet tcp,
capability setgid,
capability setuid,
/app/** r,
/app/bin/myapp mr,
/tmp/** rw,
}Two things to note:
profile myapp-profile — the profile is given a name, not an executable path. The kernel attaches it based on the label given by the runtime.flags=(attach_disconnected,mediate_deleted) — needed because inside a container certain files appear "disconnected" from their original paths; these flags keep the checks working normally.Before use, the profile must be loaded into the kernel:
sudo apparmor_parser -a /etc/apparmor.d/myapp-profileConfirm with aa-status that myapp-profile is registered before running the container — an unloaded profile will be silently replaced with docker-default by Docker.
Then run the container with that profile:
docker run --rm --security-opt apparmor=myapp-profile myapp:v1The --security-opt apparmor=... flag tells the runtime which profile to attach. Without this flag, Docker uses docker-default.
containerd — the runtime Kubernetes uses — supports AppArmor through CRI (Container Runtime Interface). The profile a pod requests is loaded automatically into the kernel before the container starts. To use containerd directly without Kubernetes, ctr provides the --apparmor-profile and --apparmor-default-profile flags, while a node-wide default profile can be set in /etc/containerd/config.toml. The most common path is still through CRI: an annotation or securityContext requested by the pod is translated by the runtime into a profile attachment.
Kubernetes selects an AppArmor profile per container. For years, the way was an annotation prefixed with container.apparmor.security.beta.kubernetes.io/ followed by the container name:
apiVersion: v1
kind: Pod
metadata:
name: myapp
annotations:
container.apparmor.security.beta.kubernetes.io/myapp: localhost/myapp-profile
spec:
containers:
- name: myapp
image: myapp:v1Valid values:
runtime/default — use the runtime's built-in profile (on containerd, this forwards to the configured default AppArmor profile).localhost/<name> — use a profile already loaded on the node, e.g. the myapp-profile we loaded via apparmor_parser.unconfined — skip AppArmor (avoid unless there's a strong reason).This annotation is still supported, but Kubernetes's direction is a native field in securityContext. Modern Kubernetes versions support the appArmorProfile object:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: myapp-profiletype: RuntimeDefault uses the runtime's built-in profile, while type: Localhost uses a profile available on the node, pointed to via localhostProfile. The native field is the future — if your Kubernetes version supports it, use it instead of the annotation.
Warning
A custom profile must exist on every node where a pod can be scheduled. The kubelet doesn't upload profiles — it only selects from what's already loaded into that node's kernel. Ship the profile as part of node provisioning (for example via Ansible or the node image configuration), not managed per pod.
In episode 10 you've secured containers at the kernel level: Docker integration via apparmor_parser and --security-opt apparmor=..., the role of docker-default as a safety net, containerd/CRI support, and two ways to attach profiles in Kubernetes — the container.apparmor.security.beta.kubernetes.io annotation and the native securityContext.appArmorProfile field.
Keys to take home:
flags=(attach_disconnected,mediate_deleted).So far every example has smelled of servers and containers. But AppArmor also guards the machines you use every day — desktops with browsers, office suites, snap applications, and the system services that power a distro. In episode 11, we close this domain in Desktop & System Services: Firefox, LibreOffice, snap and flatpak integration, update conflicts, and system service profiles like networking, DBus, and systemd.