Aller au contenu
Administration Linux medium

Écrire et charger son premier programme eBPF

14 min de lecture

À la fin de cette page, vous aurez chargé un vrai programme eBPF dans le noyau et vu défiler, en direct, chaque commande lancée sur votre machine. On commence par la voie sans code (bpftrace, une ligne suffit), puis on écrit un vrai programme en C avec BCC, et on regarde ce qui se passe sous le capot : compilation, vérificateur, chargement, accrochage. Public : administrateur ou développeur à l'aise avec le terminal Linux. Aucune connaissance préalable d'eBPF n'est requise, mais lire d'abord eBPF, c'est quoi aide à situer les termes.

  • Installer la chaîne d'outils eBPF sur une distribution récente
  • Tracer les exécutions de programmes avec une seule ligne de bpftrace
  • Écrire un programme eBPF en C et le charger avec BCC
  • Identifier chaque étape : compilation en bytecode, vérification, chargement, accrochage
  • Lire un refus du vérificateur et comprendre pourquoi il protège la machine
  • Une VM Linux avec un noyau récent (5.x ou 6.x). Ici Ubuntu 24.04, noyau 6.8.0.
  • Un accès root ou sudo : charger un programme eBPF est une opération privilégiée.
  • De quoi provoquer un peu d'activité (lancer des commandes) pour voir passer des événements.

Trois briques suffisent pour commencer : bpftrace (traçage en une ligne), BCC (BPF Compiler Collection, pour écrire des programmes en C pilotés depuis Python) et bpftool (l'outil d'inspection officiel du noyau). On ajoute clang et les en-têtes du noyau, car BCC compile le programme C directement sur la machine cible.

Fenêtre de terminal
sudo apt update
sudo apt install -y bpftrace bpfcc-tools python3-bpfcc \
linux-headers-$(uname -r) linux-tools-$(uname -r) \
clang llvm libbpf-dev

Vérifiez que les versions répondent. Sur la VM de test :

Fenêtre de terminal
bpftrace --version # bpftrace v0.20.2
clang --version # Ubuntu clang version 18.1.3
sudo bpftool version # bpftool v7.4.0

Le moyen le plus rapide de voir eBPF à l'œuvre est bpftrace. Vous décrivez ce que vous voulez observer dans un langage court, et bpftrace se charge de produire le programme eBPF, de le vérifier, de le charger et d'afficher le résultat. Aucune compilation manuelle.

L'exemple canonique : afficher chaque fois qu'un programme en démarre un autre. En Linux, lancer un programme passe par l'appel système execve (c'est lui qui remplace le contenu d'un processus par un nouveau programme). On s'accroche au tracepoint correspondant, un point d'observation stable prévu par le noyau :

Fenêtre de terminal
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%-16s -> %s\n", comm, str(args->filename)); }'

Décortiquons la ligne :

  • tracepoint:syscalls:sys_enter_execve : le point de déclenchement. « À l'entrée de l'appel système execve ».
  • comm : une variable fournie par bpftrace, le nom du processus courant (comm = command, sur 16 caractères).
  • args->filename : l'argument de l'appel, ici le chemin du programme lancé.
  • str(...) : convertit le pointeur en chaîne lisible.

Pendant que cette commande tourne, lancez quelques commandes dans un autre terminal. La sortie réelle, capturée sur la VM :

bash -> /usr/bin/uname
bash -> /usr/bin/id
bash -> /usr/bin/sleep
bash -> /usr/bin/uname
bash -> /usr/bin/id
bash -> /usr/bin/sleep

Vous venez d'observer, depuis le noyau, chaque programme lancé sur la machine, sans modifier ni bash ni les commandes tracées. C'est toute la promesse d'eBPF, en une ligne. Le guide bpftrace explore ce langage en détail.

bpftrace cache la mécanique. Pour la comprendre, écrivons le même genre de sonde « à la main » avec BCC. Le principe de BCC : le programme eBPF est écrit en C, embarqué dans une chaîne de caractères, et un script Python pilote sa compilation, son chargement et la lecture des résultats.

Créez le fichier hello.py :

#!/usr/bin/env python3
from bcc import BPF
# Le programme eBPF, en C, embarque dans une chaine Python.
programme = r"""
int au_demarrage_execve(void *ctx) {
char nom[16];
bpf_get_current_comm(&nom, sizeof(nom));
bpf_trace_printk("execve appele par %s\n", nom);
return 0;
}
"""
b = BPF(text=programme) # compile + verifie + charge
b.attach_kprobe(event=b.get_syscall_fnname("execve"), # accroche a l'entree de execve
fn_name="au_demarrage_execve")
print("En ecoute. Chaque commande lancee sur la machine apparait ci-dessous.\n")
for _ in range(5): # on capture 5 evenements puis on sort
(task, pid, cpu, flags, ts, msg) = b.trace_fields()
print(f"{ts:14.6f} PID {pid:<6d} {msg.decode()}")

Chaque terme du programme C mérite une explication :

  • int au_demarrage_execve(void *ctx) : la fonction eBPF. Son argument ctx (context) est le contexte fourni par le noyau au point d'accrochage ; ici on ne s'en sert pas.
  • bpf_get_current_comm : une fonction d'assistance (helper), une fonction que le noyau met à disposition des programmes eBPF. Celle-ci copie le nom du processus courant dans une variable.
  • bpf_trace_printk : un autre helper, qui écrit une ligne de texte dans un canal de traçage du noyau nommé trace_pipe. C'est le « printf » du monde eBPF, pratique pour débuter (à ne pas utiliser en production, on verra pourquoi).
  • return 0 : un programme eBPF renvoie toujours une valeur ; ici elle n'a pas d'effet.

Côté Python :

  • b.attach_kprobe(...) accroche notre fonction à une kprobe sur execve. Une kprobe (kernel probe) est un point d'accrochage dynamique sur n'importe quelle fonction du noyau, à son entrée. get_syscall_fnname("execve") récupère le nom exact de la fonction noyau derrière execve, qui varie selon l'architecture.
  • b.trace_fields() lit une ligne depuis trace_pipe.

Lancez-le (root obligatoire) en provoquant un peu d'activité à côté :

Fenêtre de terminal
sudo python3 hello.py

Sortie réelle capturée sur la VM :

En ecoute. Chaque commande lancee sur la machine apparait ci-dessous.
422.733187 PID 22427 execve appele par bash
422.733882 PID 22428 execve appele par bash
422.734436 PID 22429 execve appele par bash
422.935559 PID 22430 execve appele par bash
422.936251 PID 22431 execve appele par bash

Le premier nombre est l'horodatage noyau (secondes depuis le démarrage), suivi du PID (Process IDentifier, le numéro du processus) et de notre message. Vous avez écrit, compilé, chargé et exécuté un programme dans le noyau.

L'appel BPF(text=programme) a enchaîné, en une fraction de seconde, tout le cycle de vie décrit dans eBPF, c'est quoi :

  1. Compilation : clang traduit le C en bytecode eBPF, un jeu d'instructions propre à la machine virtuelle du noyau (pas du code machine classique).

  2. Vérification : le noyau confie le bytecode au vérificateur, qui prouve que le programme est sûr avant de l'accepter. S'il échoue, rien n'est chargé.

  3. Compilation JIT : une fois accepté, le bytecode est traduit en code machine natif par le compilateur JIT (Just-In-Time) du noyau, pour tourner à pleine vitesse.

  4. Accrochage : attach_kprobe relie le programme à la kprobe sur execve. À partir de là, chaque execve déclenche notre code.

Pendant qu'un programme BCC tourne, vous pouvez lister les programmes eBPF réellement chargés avec bpftool, l'outil d'inspection livré avec le noyau :

Fenêtre de terminal
sudo bpftool prog show

Extrait réel (le système en charge déjà plusieurs, ici ceux de systemd) :

147: cgroup_device name sd_devices tag fbee7646fdd03110 gpl
loaded_at 2026-07-14T11:41:50+0000 uid 0
xlated 184B jited 103B memlock 4096B
152: cgroup_skb name sd_fw_egress tag 6deef7357e7b4530 gpl
loaded_at 2026-07-14T11:41:50+0000 uid 0
xlated 64B jited 56B memlock 4096B

Deux champs éclairent ce qu'on vient de voir : xlated est la taille du bytecode vérifié (translated), jited celle du code machine produit par le JIT. La colonne de gauche est le type de programme (cgroup_skb, tracing, etc.), qui détermine où il peut s'accrocher.

Le point qui inquiète les débutants, c'est le vérificateur. Voyons-le dire « non », pour constater qu'il protège sans rien casser. La règle la plus fondamentale, documentée par le noyau : un programme eBPF doit pouvoir être prouvé fini. La documentation du vérificateur le formule ainsi : « First step does DAG check to disallow loops ». Une boucle sans condition de sortie est donc rejetée d'office.

Écrivons volontairement une boucle infinie :

programme = r"""
int boucle_sans_fin(void *ctx) {
#pragma clang loop unroll(disable)
for (;;) {
bpf_ktime_get_ns(); // appel noyau : empeche la suppression du code
}
return 0;
}
"""

Au chargement, le noyau refuse, et il explique précisément pourquoi. Journal réel du vérificateur :

0: (85) call bpf_ktime_get_ns#5
1: (05) goto pc-2
...
infinite loop detected at insn 1
processed 10 insns (limit 1000000)
Failed to load BPF program 'boucle_sans_fin': Invalid argument

Trois choses à retenir de cette sortie. D'abord, infinite loop detected : le vérificateur a repéré le retour en arrière (goto pc-2) et refusé. Ensuite, limit 1000000 : le noyau borne l'analyse à un million d'instructions, c'est sa garantie de terminer son propre travail. Enfin, la machine n'a pas bronché : le programme n'a jamais été chargé, le noyau est intact, on récupère juste une erreur Invalid argument. Le vérificateur est un videur, pas un piège.

Ce n'est qu'une de ses règles. La page le vérificateur eBPF expliqué détaille les autres (accès mémoire bornés, pile initialisée, valeurs de map testées contre NULL) et comment lire ses messages.

Charger un programme eBPF est une opération privilégiée, et pour de bonnes raisons : un programme mal intentionné peut observer des données sensibles. Quelques réflexes dès le premier jour.

  • Ne pas exiger root complet en production. Depuis le noyau 5.8, la capacité CAP_BPF (associée à CAP_PERFMON pour le traçage) permet de charger des programmes sans tous les pouvoirs du super-utilisateur. On limite ainsi la casse si l'outil est compromis.
  • bpf_trace_printk est réservé au débogage. Il écrit dans un canal unique et partagé (trace_pipe) : pratique pour apprendre, inadapté dès qu'il y a du volume ou plusieurs programmes. En vrai, on transmet les données via des maps, décrites dans maps : le pont noyau / espace utilisateur.
  • BCC compile sur la cible. C'est simple pour apprendre, mais cela impose clang et les en-têtes du noyau sur chaque machine. La voie de production, libbpf + CO-RE, compile une fois et tourne partout ; voir BCC et libbpf.
SymptômeCause probableSolution
Failed to compile BPF moduleEn-têtes du noyau absentsInstaller linux-headers-$(uname -r) (ou kernel-devel)
Permission denied au chargementProgramme lancé sans privilègeLancer avec sudo, ou accorder CAP_BPF/CAP_PERFMON
infinite loop detected / back-edgeBoucle non bornéeBorner la boucle (compteur fixe) ou la supprimer
Aucune ligne ne s'afficheAucun événement ne se produitProvoquer l'activité tracée (lancer des commandes)
bpftrace: command not foundPaquet non installéInstaller bpftrace (voir plus haut)
  • bpftrace trace en une ligne : idéal pour voir eBPF fonctionner sans écrire de C.
  • BCC laisse écrire le programme en C et le piloter en Python ; il compile sur la machine cible.
  • Charger un programme enchaîne compilation en bytecode, vérification, JIT, accrochage ; c'est instantané mais bien réel.
  • Une kprobe s'accroche à une fonction du noyau, un tracepoint à un point d'observation stable.
  • Les helpers (bpf_get_current_comm, bpf_trace_printk...) sont les seules portes d'entrée vers le noyau offertes aux programmes.
  • Le vérificateur refuse proprement tout programme qu'il ne peut prouver sûr (ici, une boucle infinie), sans jamais mettre la machine en danger.
  • Charger de l'eBPF est privilégié : préférer CAP_BPF à root complet en production.

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