Aller au contenu
English
Conteneurs & Orchestration medium

Seccomp Kubernetes : filtrer les appels système d'un conteneur

65 min de lecture

Seccomp filtre les appels système qu'un conteneur peut adresser au noyau Linux, et un profil RuntimeDefault en bloque déjà une quarantaine sans qu'aucune application classique ne s'en aperçoive. C'est la protection la moins chère du durcissement Kubernetes : une ligne de securityContext, aucun fichier à distribuer, aucun redémarrage de nœud. Ce guide vous montre comment l'appliquer, comment écrire un profil sur mesure quand le profil du runtime ne suffit pas, et surtout comment prouver que le filtre agit, parce qu'un manifeste qui déclare un profil ne garantit pas que le noyau l'applique.

Prérequis : un cluster Kubernetes et kubectl. Contrairement à AppArmor, seccomp fonctionne sur des nœuds qui sont eux-mêmes des conteneurs, kind ou k3d compris : tout ce guide se pratique sur un poste de travail.

  • Ce qu'est un appel système et pourquoi le filtrer réduit la surface d'attaque
  • Appliquer RuntimeDefault à un Pod, la forme à viser par défaut
  • Écrire un profil Localhost et comprendre ce qu'il coûte à maintenir
  • Vérifier dans /proc que le filtre est réellement en place
  • Reconnaître ce qui annule seccomp sans émettre le moindre avertissement

Qu'est-ce qu'un appel système, et pourquoi le filtrer ?

Section intitulée « Qu'est-ce qu'un appel système, et pourquoi le filtrer ? »

Un appel système, ou syscall, est la seule porte par laquelle un programme demande quelque chose au noyau Linux : ouvrir un fichier, écrire sur une socket, créer un processus, monter un système de fichiers. Un noyau x86_64 récent en expose plus de 300. Une application web classique en utilise quelques dizaines, et ignore tout le reste.

Ce reste est précisément le problème. Un conteneur sans profil seccomp peut appeler les 300, y compris ceux qui ne servent qu'à l'administration du noyau ou à l'évasion d'un bac à sable : mount, unshare, ptrace, kexec_load, perf_event_open. Aucun d'eux n'est utile à une application métier, et chacun a déjà servi de marche à une élévation de privilèges. Seccomp (pour SECure COMPuting) installe un filtre dans le noyau qui refuse les appels non autorisés, et ce filtre s'applique au processus avant même que le code de l'application démarre.

Ces trois types ne sont pas trois crans d'un même curseur. RuntimeDefault est le seul à viser par défaut, les deux autres sont des exceptions qui se justifient. Unconfined désactive complètement le filtre et n'a sa place que le temps d'identifier l'appel qui bloque une application. Localhost impose de distribuer et de maintenir un fichier JSON sur chaque nœud, ce qui est une dette opérationnelle réelle.

TypeDescriptionQuand l'utiliser
UnconfinedAucune restriction, défaut historiqueDébogage uniquement
RuntimeDefaultProfil livré par le runtime de conteneursProduction, point de départ universel
LocalhostProfil JSON personnalisé, présent sur le nœudQuand RuntimeDefault est trop restrictif

Le profil RuntimeDefault est celui que le runtime embarque, containerd ou CRI-O selon votre cluster. La documentation Docker, dont ce profil est issu, indique qu'il « désactive environ 44 appels système sur plus de 300 ». Autrement dit, une application classique ne verra aucune différence, et c'est exactement ce qui rend son activation généralisable sans campagne de tests.

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

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@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3

Les deux niveaux existent, et leur priorité est celle qu'on attend : le securityContext du conteneur prime sur celui du Pod. Un Pod qui déclare RuntimeDefault pour tout le monde peut donc héberger un conteneur qui repasse en Unconfined, et c'est une porte de sortie légitime pour un sidecar de débogage.

spec:
securityContext:
seccompProfile:
type: RuntimeDefault # s'applique à tous les conteneurs
containers:
- name: app # hérite de RuntimeDefault
image: registry.example.net/facturation:2.3.1
- name: debug
image: registry.example.net/outils:1.4.0
securityContext:
seccompProfile:
type: Unconfined # cette surcharge l'emporte

C'est aussi la première chose à regarder quand un Pod semble protégé et ne l'est pas : la déclaration du Pod ne dit rien de ce que chaque conteneur applique. Une revue de manifeste doit descendre au niveau conteneur, et une politique d'admission doit tester les deux niveaux dans cet ordre.

Quand une application a besoin d'appels que RuntimeDefault refuse, ou quand vous voulez au contraire une liste blanche stricte, le profil passe par un fichier JSON déposé sur le nœud. Le champ décisif est defaultAction : avec SCMP_ACT_ERRNO, tout appel absent de la liste names est refusé avec EPERM.

Fenêtre de terminal
# Répertoire attendu par le kubelet
sudo mkdir -p /var/lib/kubelet/seccomp/profiles
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

Cet exemple 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. Trois actions se rencontrent dans ces fichiers, et elles n'ont pas le même usage : SCMP_ACT_ALLOW autorise, SCMP_ACT_ERRNO refuse en rendant une erreur au programme, et SCMP_ACT_LOG laisse passer en journalisant, ce qui en fait l'outil de mise au point d'un profil avant de le durcir.

Le manifeste désigne ensuite le fichier par un chemin relatif à /var/lib/kubelet/seccomp/ :

pod-seccomp-custom.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-seccomp-custom
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/myapp.json
containers:
- name: app
image: registry.example.net/facturation:2.3.1

Si le fichier n'existe pas sur le nœud qui a récolté le Pod, le kubelet refuse de démarrer le conteneur au lieu de le lancer sans protection. Le comportement est le bon, mais il transforme la présence du profil sur tous les nœuds en prérequis opérationnel : dans un cluster hétérogène, un Pod qui atterrit sur le mauvais nœud reste bloqué.

Comment vérifier qu'un profil s'applique vraiment ?

Section intitulée « Comment vérifier qu'un profil s'applique vraiment ? »

Ne concluez jamais depuis le manifeste. La première commande relit ce que le Pod déclare, ce qui ne prouve rien sur l'état du processus. La seconde lit l'attribut réel dans /proc, et c'est la seule qui fait foi. Le chiffre renvoyé est celui du noyau Linux, pas de Kubernetes.

Fenêtre de terminal
# Ce que le manifeste déclare
kubectl get pod app-seccomp -o jsonpath='{.spec.securityContext.seccompProfile}'
# Ce que le noyau applique, lu depuis le conteneur lui-même
kubectl exec app-seccomp -- grep Seccomp /proc/1/status
Sortie attendue
Seccomp: 2
Seccomp_filters: 1

La valeur qui compte est 2, pour « filtre en place ». 0 signifie Unconfined et 1 correspond au mode strict historique, que Kubernetes n'utilise pas. Le contraste se mesure sur deux Pods identiques à un champ près, sur le même cluster :

PodsecurityContext.seccompProfile/proc/1/status
avectype: RuntimeDefaultSeccomp: 2 et Seccomp_filters: 1
sansaucunSeccomp: 0

Si le Pod n'a pas de shell, ou si vous enquêtez sur un conteneur déjà arrêté, il faut passer par le nœud et retrouver le processus par son identifiant :

Fenêtre de terminal
crictl inspect <container-id> | jq '.info.pid'
grep Seccomp /proc/<pid>/status

C'est la raison pour laquelle la vérification par /proc n'est pas une coquetterie. Un 0 sur un Pod qui déclare pourtant RuntimeDefault ne signale presque jamais une faute de frappe : il signale un conteneur privilégié, et donc un durcissement annulé en silence. Le même piège vaut pour AppArmor, dont les profils sont ignorés dans les mêmes conditions.

Les deux mécanismes vivent dans le noyau Linux et se ressemblent de loin, mais ils ne filtrent pas la même chose et ne se remplacent pas. Les appliquer ensemble est ce que recouvre l'expression défense en profondeur.

MécanismeCe qu'il contrôleExemples
SeccompAppels systèmeBloquer unshare, mount, ptrace, setuid
AppArmorFichiers, capacités, réseauInterdire /etc en écriture, limiter le réseau

Une différence pratique les sépare, et elle décide de ce que vous pouvez éprouver en lab : AppArmor exige un nœud dont le noyau l'active, ce qui exclut les clusters dont les nœuds sont des conteneurs, tandis que seccomp fonctionne sur ces mêmes nœuds. Le détail des profils AppArmor, de leur chargement et de leur vérification vit sur sa page dédiée.

Deux leviers existent, et on les confond souvent. seccompDefault: true côté kubelet mutate le comportement par défaut : tout Pod sans déclaration reçoit RuntimeDefault. La Pod Security Admission, elle, ne mutate rien : son profil restricted refuse les Pods qui déclarent Unconfined, et laisse passer ceux qui ne déclarent rien.

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

La combinaison qui tient en production est donc la somme des deux : seccompDefault pour que l'absence de déclaration devienne protectrice, et la PSA pour que personne ne revienne en arrière explicitement. Pour aller plus loin, exiger un profil Localhost précis par exemple, il faut une politique d'admission écrite avec Kyverno ou Gatekeeper.

Le symptôme le plus déroutant est le premier du tableau : l'application démarre puis meurt sans message clair, parce qu'un appel qu'elle a besoin d'émettre est refusé. La méthode est toujours la même, repasser le profil en mode journalisation pour voir ce qui est refusé avant de durcir à nouveau.

SymptômeCause probableSolution
L'application plante au démarrageUn appel système nécessaire est refuséPasser le profil en SCMP_ACT_LOG, lire journalctl -k et dmesg | grep -i seccomp
Le conteneur ne démarre pas du toutProfil Localhost absent du nœud d'atterrissageVérifier le nœud avec kubectl get pod -o wide, déposer le fichier, ou revenir à RuntimeDefault
Seccomp: 0 alors que le Pod déclare un profilConteneur en privileged: trueRetirer privileged, le filtre est annulé sans avertissement
Le profil semble ignoréSurcharge au niveau conteneurLe securityContext du conteneur prime sur celui du Pod
Le Pod est refusé à l'admissionUnconfined explicite dans un namespace restrictedDéclarer RuntimeDefault, ou justifier une exception de namespace

Quatre limites expliquent pourquoi seccomp n'est pas déployé partout, alors qu'il coûte presque rien. Les deux premières relèvent de l'infrastructure, les deux dernières de vos propres manifestes.

  • Linux uniquement : rien de tout cela ne s'applique aux nœuds Windows.
  • Dépendance au runtime : le contenu exact de RuntimeDefault vient de containerd ou CRI-O, il n'est pas défini par Kubernetes.
  • Localhost se distribue à la main : le fichier doit exister sur tous les nœuds où le Pod peut atterrir, et les environnements managés limitent souvent l'accès aux nœuds.
  • privileged annule tout : un seul conteneur privilégié suffit à vider la mesure de son sens.

Une limite conceptuelle s'ajoute, plus importante que les quatre autres : seccomp filtre des appels système, pas des intentions. Un processus autorisé à openat peut ouvrir n'importe quel fichier auquel ses permissions lui donnent accès. C'est AppArmor, SELinux ou un bac à sable de runtime qui répondent à cette question-là.

Seccomp relève du domaine System Hardening, qui pèse 15 % de l'épreuve. Trois points sont discriminants, et ce sont rarement les plus évidents : la priorité du niveau conteneur sur le niveau Pod, la différence entre seccompDefault côté kubelet et le profil restricted de la Pod Security Admission, et le fait qu'un conteneur privilégié annule le filtre. Savoir écrire le champ seccompProfile ne suffit pas, l'examen porte sur ce qui se passe quand deux mécanismes se contredisent.

Les questions portent sur ce qui décide vraiment en production comme à l'examen : le profil par défaut en l'absence de déclaration, la priorité entre les deux niveaux, et ce que la Pod Security Admission fait réellement. Une réponse ratée désigne la section à relire.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 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 qu'un conteneur adresse au noyau, environ 44 bloqués sur 300 avec RuntimeDefault
  • RuntimeDefault est le point de départ universel : aucune application classique ne le remarque
  • Sans seccompDefault: true côté kubelet, un Pod qui ne déclare rien reste Unconfined
  • La PSA ne mutate pas : elle rejette Unconfined explicite, elle n'ajoute jamais de profil
  • Le niveau conteneur prime sur le niveau Pod, y compris pour revenir à Unconfined
  • Un profil Localhost doit exister sur chaque nœud d'atterrissage possible
  • privileged: true annule le filtre en silence : seul /proc/1/status fait foi

Un profil ne se juge pas à son JSON mais à ce qu'il refuse. Ce lab vous fait écrire un profil seccomp qui interdit une famille d'appels système, l'appliquer par le securityContext, puis prouver que le confinement tient depuis l'intérieur du conteneur : l'appel interdit échoue, et tout le reste continue de fonctionner.

  • Kyverno : Exiger un profil seccomp à l'admission, là où la Pod Security Admission s'arrête.
  • Falco : Détecter les appels système suspects que votre profil laisse passer.
  • Runtime Sandboxes : L'isolation par gVisor ou Kata, un cran au-dessus d'un filtre d'appels système.

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