Aller au contenu
Conteneurs & Orchestration medium

AppArmor et Seccomp dans Kubernetes - Profils de sécurité

20 min de lecture

AppArmor et Seccomp sont deux mécanismes du noyau Linux qui limitent ce qu'un conteneur peut faire, même si son processus s'exécute en root. Là où RBAC contrôle l'accès aux ressources Kubernetes, AppArmor et Seccomp contrôlent l'accès aux appels système et aux fichiers du nœud. Ce guide se concentre sur leur application dans Kubernetes, pour les bases Linux, consultez le guide AppArmor Linux.

MécanismeContrôle principalExemples de restrictions
SeccompAppels système (syscalls)Bloquer unshare, mount, ptrace, setuid
AppArmorFichiers, capacités, réseau, comportementsInterdire /etc en écriture, bloquer ptrace, limiter le réseau
  • Cluster Kubernetes 1.30+ pour securityContext.appArmorProfile (le champ est stable depuis 1.31)
  • Nœuds Linux avec AppArmor activé (aa-enabled répond Yes)
  • Accès kubectl avec droits de créer des pods

Seccomp (SECure COMPuting) restreint les appels système (syscalls) qu'un processus peut effectuer. Sans profil, un conteneur peut appeler n'importe quel syscall Linux, c'est une large surface d'attaque.

Ces trois types ne sont pas trois niveaux d'un même curseur : RuntimeDefault est le seul que vous devez viser par défaut, les deux autres sont des exceptions à justifier. Lisez la colonne « Quand l'utiliser » comme une politique, pas comme un catalogue : Unconfined désactive complètement le filtre et n'a sa place que le temps d'identifier le syscall qui bloque une application, Localhost impose de distribuer et de maintenir un fichier JSON sur chaque nœud.

TypeDescriptionQuand l'utiliser
UnconfinedAucune restriction (défaut historique)Débogage uniquement
RuntimeDefaultProfil par défaut du runtime conteneurProduction, point de départ universel
LocalhostProfil JSON personnalisé sur le nœudQuand RuntimeDefault est trop restrictif

Le profil RuntimeDefault correspond au profil seccomp livré par le runtime. Pour Docker, sa documentation indique qu'il « désactive environ 44 appels système sur plus de 300 », dont mount, unshare, ptrace, kexec_load, perf_event_open et les appels io_uring_*. Autrement dit : une application classique ne verra aucune différence, et c'est bien l'intérêt de l'activer partout.

Déclaré au niveau spec.securityContext, le profil s'applique à tous les conteneurs du Pod, y compris les init containers et les sidecars. C'est la forme à privilégier : une déclaration unique, aucun conteneur oublié.

pod-seccomp.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-seccomp
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # Profil du runtime (containerd/cri-o)
containers:
- name: app
image: nginx:1.31-alpine

Utiliser un profil Seccomp personnalisé (Localhost)

Section intitulée « Utiliser un profil Seccomp personnalisé (Localhost) »

Si une application utilise des syscalls non couverts par RuntimeDefault, créez un profil JSON sur chaque nœud. Le champ décisif est defaultAction : avec SCMP_ACT_ERRNO, tout appel absent de la liste names est refusé avec EPERM, ce qui donne une liste blanche stricte. L'exemple ci-dessous est volontairement minimal et ne suffira à presque aucune application réelle : c'est un point de départ à compléter en observant les refus, pas un profil à copier en production.

Fenêtre de terminal
# Répertoire attendu par kubelet
sudo mkdir -p /var/lib/kubelet/seccomp/profiles
# Profil personnalisé
sudo tee /var/lib/kubelet/seccomp/profiles/myapp.json > /dev/null <<'EOF'
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["read", "write", "close", "fstat", "mmap", "mprotect",
"munmap", "brk", "rt_sigaction", "rt_sigprocmask",
"ioctl", "access", "execve", "exit_group", "openat",
"newfstatat", "pread64", "prlimit64"],
"action": "SCMP_ACT_ALLOW"
}
]
}
EOF
pod-seccomp-custom.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-seccomp-custom
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/myapp.json # Relatif à /var/lib/kubelet/seccomp/
containers:
- name: app
image: registry.example.net/facturation:2.3.1 # jamais :latest

Si le fichier n'existe pas sur le nœud qui accueille le Pod, le kubelet refuse de démarrer le conteneur : le Pod reste bloqué au lieu de démarrer sans protection. C'est un comportement souhaitable, mais il transforme la distribution du profil sur tous les nœuds en prérequis opérationnel.

La première commande relit seulement ce que le manifeste déclare, elle ne prouve rien sur ce que le noyau applique. La seconde va lire l'état réel du processus dans /proc, et c'est la seule qui fait foi. Le chiffre renvoyé par Seccomp: est celui du noyau Linux, pas de Kubernetes.

Fenêtre de terminal
# Via kubectl : ce que le manifeste déclare
kubectl get pod <pod-name> -o jsonpath='{.spec.securityContext.seccompProfile}'
# Depuis le nœud (PID du conteneur) : ce que le noyau applique
grep Seccomp /proc/<pid>/status
# 0 = Unconfined, 1 = Strict, 2 = Filter (profil en place)

La valeur attendue est 2. Un 0 sur un Pod qui déclare RuntimeDefault signale presque toujours un conteneur en mode privileged, qui annule le filtre.

AppArmor restreint l'accès d'un processus aux fichiers, répertoires, capabilities et appels réseau via des profils déclaratifs chargés dans le noyau.

AppArmor est une fonctionnalité du noyau du nœud, pas de Kubernetes : si le module n'est pas actif, aucun manifeste ne le fera apparaître. Commencez donc toujours par cette vérification, avant même d'écrire un profil. Le fichier /sys/kernel/security/apparmor/profiles est la source la plus fiable, c'est celle que le kubelet consulte pour décider s'il accepte un Pod demandant un profil Localhost.

Fenêtre de terminal
# Sur le nœud (via ssh ou node debug)
aa-enabled # répond Yes ou No
aa-status # Lister les profils chargés
apparmor_parser -V # Version
# Profils disponibles
ls /etc/apparmor.d/
cat /sys/kernel/security/apparmor/profiles

Sur une distribution Debian ou Ubuntu, aa-enabled répond Yes d'origine. Sur RHEL, Rocky ou AlmaLinux, la réponse sera No : ces distributions utilisent SELinux, et les profils AppArmor de cette page n'y sont pas transposables.

Un profil AppArmor se lit ligne à ligne comme une liste de chemins suivis de permissions : r lecture, w écriture, x exécution, i héritage du profil courant. Ce qui n'est pas explicitement autorisé est refusé. Les deux deny de l'exemple sont donc redondants sur le papier, mais ils rendent l'intention lisible et prennent le dessus sur une règle plus permissive héritée d'un #include.

#include <tunables/global>
profile k8s-app-readonly flags=(attach_disconnected) {
#include <abstractions/base>
# Autoriser la lecture/exécution du binaire
/usr/bin/myapp rix,
# Lecture seule sur /etc
/etc/** r,
# Lecture/écriture sur /tmp uniquement
/tmp/** rw,
# Interdire tout accès réseau
deny network,
# Interdire ptrace (empêche les outils d'introspection)
deny ptrace,
}

Kubernetes ne charge jamais un profil AppArmor pour vous : il se contente de demander au runtime d'appliquer un profil déjà présent dans le noyau du nœud. Cette étape est donc à répéter sur chaque nœud éligible, et à rejouer après chaque redémarrage si vous n'avez pas déposé le fichier dans /etc/apparmor.d/ (d'où le service apparmor le recharge au boot).

Fenêtre de terminal
# -r remplace le profil s'il est déjà chargé, -W met le cache à jour
sudo apparmor_parser -r -W /etc/apparmor.d/k8s-app-readonly
# Vérifier qu'il est chargé
sudo aa-status | grep k8s-app-readonly

Un profil se charge en mode enforce par défaut. Pour le charger en mode observation, ajoutez flags=(complain) dans sa déclaration ou passez -C à apparmor_parser.

Appliquer AppArmor à un Pod, syntaxe actuelle (v1.30+)

Section intitulée « Appliquer AppArmor à un Pod, syntaxe actuelle (v1.30+) »

Depuis Kubernetes 1.30, AppArmor se configure via securityContext.appArmorProfile :

pod-apparmor-v130.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-apparmor
spec:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-app-readonly # Nom du profil chargé sur le nœud
containers:
- name: app
image: nginx:1.31-alpine
securityContext:
appArmorProfile: # On peut surcharger par conteneur
type: RuntimeDefault # Utiliser le profil par défaut du runtime

Les trois types disponibles pour appArmorProfile.type :

TypeSignification
UnconfinedPas de profil AppArmor (déconseillé)
RuntimeDefaultProfil par défaut du runtime (docker-default)
LocalhostProfil spécifique chargé sur le nœud

Le nom exact du profil RuntimeDefault dépend du runtime installé sur le nœud. Avec containerd, la documentation Kubernetes montre cri-containerd.apparmor.d dans la sortie de /proc/1/attr/current ; docker-default est le nom historique côté Docker. Ne codez donc jamais ce nom en dur dans un manifeste : demandez RuntimeDefault et laissez le runtime résoudre.

AppArmor et Seccomp peuvent être définis au niveau Pod et au niveau container. Le champ container prend toujours le dessus :

MécanismeNiveau PodNiveau containerPriorité
Seccompspec.securityContext.seccompProfilespec.containers[].securityContext.seccompProfileContainer > Pod
AppArmorspec.securityContext.appArmorProfilespec.containers[].securityContext.appArmorProfileContainer > Pod

Cela permet d'appliquer un profil par défaut au Pod et d'en surcharger un pour un conteneur spécifique (init container, sidecar).

Vous croiserez encore cette syntaxe dans des dépôts existants et dans les questions d'examen. Deux différences comptent : l'annotation se pose sur les métadonnées du Pod et non dans securityContext, et elle nomme explicitement le conteneur visé dans la clé, ce qui interdit toute déclaration au niveau du Pod entier.

pod-apparmor-legacy.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-apparmor-legacy
annotations:
# Format : container.apparmor.security.beta.kubernetes.io/<container-name>
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-app-readonly
# Autres valeurs possibles : runtime/default ou unconfined
spec:
containers:
- name: app
image: nginx:1.31-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752

Ce sont deux mécanismes complémentaires, ils s'appliquent en même temps :

pod-hardened.yaml
apiVersion: v1
kind: Pod
metadata:
name: pod-hardened
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # Seccomp : filtrer les syscalls
runAsNonRoot: true
runAsUser: 1000
containers:
- name: app
image: nginx:1.31-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752
securityContext:
appArmorProfile:
type: RuntimeDefault # AppArmor : restreindre l'accès fichiers/réseau
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

Ce pod applique une défense en profondeur. Chaque ligne couvre une surface différente, et aucune ne remplace les autres :

  • Seccomp RuntimeDefault : environ 44 syscalls dangereux bloqués sur plus de 300
  • AppArmor RuntimeDefault : profil du runtime (cri-containerd.apparmor.d avec containerd)
  • capabilities: drop: ALL : zéro capability Linux
  • readOnlyRootFilesystem: true : système de fichiers immuable
  • image référencée par digest : le contenu exécuté ne peut plus changer sous un même tag

Cette annulation se constate directement avec le même runtime que celui du cluster. Un conteneur normal renvoie Seccomp: 2 et un profil AppArmor docker-default (enforce) ; le même conteneur lancé en --privileged renvoie Seccomp: 0 et unconfined. Aucun message d'erreur n'est émis : la protection disparaît en silence.

Ne vous fiez jamais au manifeste pour conclure qu'un profil est actif. La commande la plus rapide est celle documentée par Kubernetes : lire l'attribut du processus 1 du conteneur depuis le Pod lui-même, sans avoir besoin d'un accès SSH au nœud.

Fenêtre de terminal
# Le plus simple : lire l'attribut du PID 1 depuis le conteneur
kubectl exec pod-hardened -- cat /proc/1/attr/current
# Attendu : cri-containerd.apparmor.d (enforce) ou k8s-app-readonly (enforce)

Si le Pod ne dispose pas de shell, ou si vous enquêtez sur un conteneur déjà arrêté, il faut passer par le nœud :

Fenêtre de terminal
# Vérifier les profils AppArmor actifs sur un nœud
ssh node01
sudo aa-status
# Confirmer le profil d'un conteneur (via le PID)
# 1. Trouver le PID du conteneur sur le nœud
crictl inspect <container-id> | jq '.info.pid'
# 2. Lire le profil
cat /proc/<pid>/attr/current
# Tester qu'un profil bloque bien (mode complain → enforce)
sudo aa-complain /etc/apparmor.d/k8s-app-readonly # Loggue sans bloquer
sudo aa-enforce /etc/apparmor.d/k8s-app-readonly # Bloque effectivement

Le suffixe entre parenthèses est ce qui compte : (enforce) signifie que les violations sont refusées, (complain) qu'elles sont seulement journalisées. Un profil laissé en complain après une phase de mise au point donne l'illusion d'un durcissement sans en apporter aucun.

Appliquer à grande échelle avec PodSecurityAdmission

Section intitulée « Appliquer à grande échelle avec PodSecurityAdmission »

Le profil restricted de la Pod Security Admission ne mutate pas les Pods et n'injecte pas RuntimeDefault. Il refuse les Pods dont seccompProfile.type vaut Unconfined. Pour que RuntimeDefault s'applique par défaut aux Pods qui ne déclarent rien, activez seccompDefault: true côté kubelet.

namespace-pss.yaml
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest

Les Pod Security Standards encadrent aussi AppArmor : avec restricted, seuls RuntimeDefault et Localhost sont autorisés. Utilisez Kyverno ou Gatekeeper si vous voulez aller plus loin, par exemple exiger explicitement un profil Localhost ou gérer des exceptions par namespace :

kyverno-require-apparmor.yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-apparmor-profile
spec:
validationActions: [Deny]
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: >-
object.spec.containers.all(c,
(has(c.securityContext) && has(c.securityContext.appArmorProfile) &&
c.securityContext.appArmorProfile.type in ['RuntimeDefault', 'Localhost'])
||
(has(object.spec.securityContext) && has(object.spec.securityContext.appArmorProfile) &&
object.spec.securityContext.appArmorProfile.type in ['RuntimeDefault', 'Localhost'])
)
message: "Chaque conteneur doit déclarer un appArmorProfile de type RuntimeDefault ou Localhost."

L'expression CEL teste d'abord le securityContext du conteneur, puis celui du Pod : c'est exactement l'ordre de priorité appliqué par Kubernetes. Elle ignore volontairement les annotations dépréciées, sinon la politique validerait des manifestes que les clusters récents n'appliquent plus.

Les deux premières lignes de ce tableau décrivent le même oubli vu de deux endroits différents : le profil n'est pas chargé sur le nœud qui a récolté le Pod. Vérifiez toujours le nœud d'atterrissage (kubectl get pod -o wide) et non un nœud au hasard, car un cluster hétérogène peut n'avoir le profil que sur une partie de la flotte.

SymptômeCause probableSolution
Pod reste PendingProfil AppArmor inexistant sur le nœudaa-status sur le nœud, vérifier le nom du profil
Error: failed to create containerd taskProfil non chargéapparmor_parser -r /etc/apparmor.d/<profil>
Application crashe avec SeccompSyscall bloquéUtiliser un profil d'audit (SCMP_ACT_LOG) ou complain mode AppArmor, lire dmesg | grep KILL et journalctl -k
Annotation AppArmor ignoréeKubernetes ≥ 1.30 avec securityContext conflictuelsecurityContext prime sur l'annotation deprecated
aa-enabled retourne noAppArmor non activé dans le kernelVérifier GRUB_CMDLINE_LINUX="apparmor=1 security=apparmor"

Ces cinq points expliquent pourquoi Seccomp et AppArmor sont rarement déployés à 100 % dans un cluster réel. Les trois premiers relèvent de l'infrastructure, les deux derniers de vos propres manifestes. Retenez surtout le troisième : dès que vous quittez RuntimeDefault, vous ajoutez un fichier à maintenir sur chaque nœud, et cette dette opérationnelle est souvent plus coûteuse que le gain de sécurité obtenu.

  • Linux uniquement : Seccomp et AppArmor ne fonctionnent pas sur les nœuds Windows
  • Support du runtime : le profil RuntimeDefault dépend de l'implémentation containerd/CRI-O du nœud
  • Localhost = distribution manuelle : le profil JSON (Seccomp) ou AppArmor doit être présent sur tous les nœuds où le Pod peut être schedulé, complexité opérationnelle en cluster hétérogène
  • Environnements managés (EKS, GKE, AKS) : l'accès SSH aux nœuds pour charger des profils est souvent limité ou impossible
  • privileged: true annule tout : les conteneurs privilégiés ignorent Seccomp et AppArmor

Les questions portent sur ce qui est réellement discriminant à l'examen CKS : la priorité entre niveau Pod et niveau conteneur, la différence entre seccompDefault côté kubelet et le profil restricted de la Pod Security Admission, et le comportement des conteneurs privilégiés. Une réponse ratée pointe la section à relire.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

10 questions
8 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Seccomp filtre les appels système ; RuntimeDefault suffit pour 95% des workloads
  • AppArmor contrôle l'accès aux fichiers, capabilities et réseau via des profils nommés
  • Sans seccompDefault: true côté kubelet, un Pod sans seccompProfile reste Unconfined
  • La PSA ne mutate pas les Pods : elle valide et rejette ; seccompDefault est distinct
  • Le niveau container prend le dessus sur le niveau Pod pour AppArmor et Seccomp
  • Depuis Kubernetes 1.30 : utiliser securityContext.appArmorProfile (annotations dépréciées), champ stable depuis 1.31
  • Les deux mécanismes sont complémentaires, les appliquer ensemble pour une défense en profondeur
  • Le profil doit être chargé sur chaque nœud où le pod peut s'exécuter

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn