Skip to content

Écrire du code BPF en Rust : un guide pratique

Rust apporte la sécurité mémoire et la performance au développement BPF. Voici une présentation pratique de l'écriture de code BPF en Rust, de A à Z.

Red Sift
Published: December 19, 2019·Updated: July 15, 2025·14 min read

Si vous faites de la programmation système, vous avez probablement beaucoup entendu parler de BPF ces derniers temps. C'est une nouvelle technologie Linux en vogue qui permet d'exécuter des programmes fournis par l'utilisateur dans le noyau. Elle est utilisée par Netflix, Facebook, Google, Cloudflare et bien d'autres entreprises pour mettre en œuvre des choses comme l'équilibrage de charge ultra-rapide, l'atténuation des attaques DDoS et la surveillance des performances.

Au cours des derniers mois, j'ai travaillé avec Red Sift sur RedBPF, une boîte à outils BPF pour Rust. Red Sift utilise RedBPF pour alimenter l'agent de surveillance de sécurité InGRAINd. Peter a récemment publié un article de blog sur RedBPF et InGRAINd, et a animé un atelier lors de RustFest Barcelone. Depuis, nous avons continué à améliorer RedBPF, en corrigeant des bugs, en améliorant et en ajoutant de nouvelles API, en ajoutant la prise en charge des noyaux Google Kubernetes Engine, et bien plus encore. Nous avons également terminé le processus de re-licenciement du projet vers Apache2/MIT – le schéma de licence utilisé par bon nombre des crates les plus importantes de l'écosystème Rust – ce qui devrait faciliter encore davantage l'adoption de RedBPF.

Dans cet article, je vais détailler ce qu'est RedBPF, quels en sont les principaux composants, et à quoi ressemble le processus complet d'écriture d'un programme BPF.

Vous n'avez pas vraiment besoin d'être un expert BPF pour lire cet article, car dans la section suivante, je vais vous donner un aperçu rapide et de très haut niveau des principaux concepts que vous devez connaître pour suivre le reste. Si vous souhaitez approfondir le sujet, le livre de Brendan Gregg BPF Performance Tools vient de sortir et il est plutôt excellent. Et si vous n'êtes pas du genre à lire des livres, je vais également fournir d'autres liens utiles à la fin.

Le cours accéléré le plus rapide sur BPF

BPF est une machine virtuelle qui permet d'exécuter des programmes définis par l'utilisateur dans le noyau lorsque certains événements se produisent sur un système Linux. Disons par exemple que vous souhaitez surveiller une activité de fichier suspecte, enregistrer la latence de réponse réseau ou même tracer des applications de l'espace utilisateur – vous pouvez écrire de petits programmes BPF, demander qu'ils soient attachés au bon endroit dans le noyau, et mettre en œuvre l'instrumentation nécessaire.

La VM BPF utilise son propre jeu d'instructions. Vous pouvez écrire directement le bytecode, mais les gens utilisent généralement bpftrace ou écrivent du code C et le compilent avec le BPF Compiler Collection (BCC).

bpftrace est un outil incroyable qui vous permet d'écrire des programmes BPF à l'aide d'un langage de haut niveau ad hoc. Il est excellent pour les scripts courts et l'instrumentation manuelle, tandis que BCC est mieux adapté aux outils plus complexes, ou pour l'intégration avec d'autres applications et systèmes.

BCC tire parti de la prise en charge de la cible BPF par LLVM. Il vous permet d'écrire du code C qui est ensuite compilé avec clang en bytecode BPF pouvant être exécuté par la VM BPF dans le noyau. Il existe certaines restrictions sur le type de code C que vous pouvez écrire – notamment, vous ne pouvez pas utiliser de boucles – mais à part cela, écrire un programme BPF en C ne semble pas très différent de cibler n'importe quelle autre plateforme embarquée (quelque peu particulière).

Comme les programmes BPF sont exécutés dans le noyau Linux, le bytecode BPF ne peut pas être exécuté tel quel, mais doit être chargé dans le noyau. La plupart des applications qui utilisent BPF sont donc divisées en deux parties : le code BPF exécuté dans le noyau, et un processus de l'espace utilisateur chargé de charger le code dans le noyau et d'interagir avec lui.

Schématiquement, le processus de développement d'un programme BPF peut être résumé par les étapes suivantes :

  1. Écrire le code BPF en C
  2. Compiler le code pour la VM BPF
  3. Écrire un composant de l'espace utilisateur qui charge le résultat de l'étape 2 dans la VM BPF
  4. Utiliser l'API BPF pour échanger des données entre le composant de l'espace utilisateur et le code BPF

RedBPF inclut des API et des outils pour mettre en œuvre toutes les étapes ci-dessus, à l'exception de l'étape 1). Avec RedBPF, l'étape 1 devient :

  1. Écrire le code BPF en Rust

J'ai passé rapidement sur de nombreux détails et simplifié à l'extrême certaines choses, mais si vous ne connaissiez rien à BPF, vous devriez maintenant en comprendre suffisamment pour suivre la suite. Ensuite, je vais montrer exactement comment RedBPF peut être utilisé pour mettre en œuvre les étapes ci-dessus.

Alors, qu'est-ce que RedBPF ?

RedBPF est une collection de crates Rust. Elle comprend :

  • redbpf-macros et redbpf-probes : ils fournissent l'API BPF côté noyau (étape 1)
  • redbpf : fournit l'API BPF côté espace utilisateur. Notamment, elle fournit l'API pour charger le bytecode BPF (étape 3)
  • cargo-bpf : un plugin cargo qui simplifie la création, la construction et le débogage des programmes BPF (étape 2)

Un traceur HTTP simple (côté noyau)

Je vais maintenant montrer un programme BPF très simple écrit en Rust à l'aide de RedBPF, qui peut être exécuté dans le noyau. Il trace toutes les requêtes HTTP entrantes sur une interface réseau donnée à l'aide des API eXpress Data Path (XDP). Les programmes XDP s'accrochent directement au pilote de la carte réseau (mais peuvent revenir à un niveau supérieur), offrant un accès rapide et à faible surcharge aux données entrantes avant qu'elles n'entrent dans le reste de la pile réseau.

Code
#![no_std] #![no_main] use redbpf_macros::{map, program, xdp}; use redbpf_probes::bindings::*; use redbpf_probes::xdp::{PerfMap, Transport, XdpAction, XdpContext, MapData}; use bpf_examples::trace_http::RequestInfo; program!(0xFFFFFFFE, "GPL"); #[map("requests")] static mut requests: PerfMap<RequestInfo> = PerfMap::with_max_entries(1024); #[xdp] pub extern "C" fn trace_http(ctx: XdpContext) -> XdpAction { let (ip, transport, data) = match (ctx.ip(), ctx.transport(), ctx.data()) { (Some(ip), Some(t @ Transport::TCP(_)), Some(data)) => (unsafe { *ip }, t, data), _ => return XdpAction::Pass, }; let buff: [u8; 6] = match data.read() { Some(b) => b, None => return XdpAction::Pass, }; if &buff[..4] != b"GET " && &buff[..4] != b"HEAD" && &buff[..4] != b"PUT " && &buff[..4] != b"POST" && &buff[..6] != b"DELETE" { return XdpAction::Pass; } let info = RequestInfo { saddr: ip.saddr, daddr: ip.daddr, sport: transport.source(), dport: transport.dest(), }; unsafe { requests.insert( &ctx, MapData::with_payload(info, data.offset() as u32, data.len() as u32), ) }; XdpAction::Pass }

Fichier : bpf_examples/src/trace_http/main.rsbpf_examples/src/trace_http/main.rs

La première chose à remarquer est que le programme est un binaire #![no_std] #![no_main] :

Code
#![no_std] #![no_main] ...

La VM BPF ne prend pas en charge de nombreuses fonctionnalités requises par la stdlib et les programmes BPF sont exécutés en réponse à des événements désignés, ils n'ont donc pas de point d'entrée conventionnel main. Les binaires produits ne sont pas exécutés, mais sont chargés dans le noyau à l'aide de l'API fournie par redbpf, comme nous le verrons plus tard.

La chose suivante à remarquer est l'appel à la macro program!() :

Code
program!(0xFFFFFFFE, "GPL");

Les programmes BPF doivent spécifier avec quelle version du noyau ils sont compatibles et sous quelle licence ils sont distribués. 0xFFFFFFFE est une valeur spéciale signifiant n'importe quelle version du noyau. La licence doit être déclarée car la VM va rendre certaines API disponibles ou non en fonction de celle-ci (si le programme n'est pas sous licence GPL, il ne pourra pas utiliser les fonctionnalités GPL du noyau). En plus de vous permettre de spécifier la version et la licence, program!() génère également du code générique global nécessaire pour que le programme compile et se charge correctement.

trace_http() est la fonction qui analyse les données réseau à la recherche de requêtes HTTP :

Code
#[xdp] pub extern "C" fn trace_http(ctx: XdpContext) -> XdpAction { ... }

Comme vous pouvez le voir, elle est annotée avec l'attribut macro #[xdp], qui fait partie de la crate redbpf-macros. #[xdp] fait plusieurs choses, mais comme nous le verrons plus tard, il est utilisé principalement pour signaler au chargeur de bytecode dans redbpf que la fonction est un programme XDP.

La fonction prend un XdpContext et renvoie un XdpAction. XdpContext fournit une abstraction de plus haut niveau sur le pointeur xdp_md sous-jacent fourni par la VM BPF aux programmes XDP. #[xdp] fait le mappage de manière transparente entre les deux types. La valeur de retour – XdpAction – peut être utilisée pour indiquer ce qu'il faut faire des données actuellement inspectées : si elles doivent être transmises dans la pile réseau, supprimées, redirigées vers une autre interface, etc.

La logique d'analyse réelle est assez simple : si le protocole de transport est TCP et que la charge utile ressemble à une requête HTTP, la requête est envoyée à l'espace utilisateur où elle peut être analysée. L'API BPF fournit plusieurs structures de données – appelées maps – qui peuvent être utilisées pour stocker et agréger des données à travers les invocations de programme et peuvent également être utilisées pour échanger des données avec l'espace utilisateur. La map utilisée par notre programme – une PerfMap – permet aux programmes BPF de stocker des données dans une mémoire partagée mmap()ée accessible par l'espace utilisateur.

Code
#[map("requests")] static mut requests: PerfMap<RequestInfo> = PerfMap::with_max_entries(1024); #[xdp] pub extern "C" fn trace_http(ctx: XdpContext) -> XdpAction { ... let info = RequestInfo { saddr: ip.saddr, daddr: ip.daddr, sport: transport.source(), dport: transport.dest(), }; unsafe { requests.insert( &ctx, MapData::with_payload(info, data.offset() as u32, data.len() as u32), ) }; XdpAction::Pass }

La variable globale requests est notre PerfMap, qui, comme vous pouvez le voir, est annotée avec l'attribut #[map]. L'attribut est utilisé pour nommer la map et faire en sorte qu'elle soit placée dans une section ELF spéciale appelée maps/<name> (dans notre cas maps/requests) du binaire résultant, afin que le chargeur de l'espace utilisateur puisse la trouver et l'initialiser.

Pour chaque requête HTTP, nous créons une structure RequestInfo qui contient l'adresse source, l'adresse de destination, le port source et le port de destination de la requête. Nous l'insérons ensuite dans la PerfMap enveloppée dans une valeur MapData. MapData::with_payload() est utilisé pour indiquer au pilote que nous voulons que data.len() octets du paquet actuel soient insérés dans la map immédiatement après les données RequestInfo. data.offset() est utilisé pour indiquer à l'espace utilisateur le décalage auquel commencent les données HTTP (après les en-têtes Ethernet, IP et TCP).

Construire et déboguer le traceur HTTP

Si vous souhaitez construire le code présenté dans la section précédente, et le charger comme indiqué dans la section suivante, vous pouvez cloner le dépôt contenant le code ci-dessus depuis http://github.com/alessandrod/bpf_examples.

Pour construire le code, faites cd dans le dossier cloné et exécutez :

Code
$ cargo install cargo-bpf $ cargo bpf build

Si la construction réussit, elle placera le programme BPF compilé sous target/release/bpf-programs/trace_http/trace_http.elf. Pour vous assurer que le programme se charge et fonctionne correctement, vous pouvez utiliser cargo bpf load (doit être exécuté en tant que root) :

Code
# cargo bpf load -i en0 target/release/bpf-programs/trace_http/trace_http.elf Loaded: trace_http, XDP

Remplacez en0 par l'interface réseau que vous souhaitez tracer. Ensuite, si vous exécutez un serveur HTTP sur cette interface, et envoyez une requête HTTP, vous devriez voir quelque chose comme :

Code
Loaded: trace_http, XDP -- Event: requests -- |3cf2510a ac1f0bed c3fd401f 42000000| <.Q.......@.B... 00000000 |51000000 0293e251 265202ad 2f8f62b2| Q......Q&R../.b. 00000010 |08004500 00850000 40002f06 056b3cf2| ..E.....@./..k<. 00000020 |510aac1f 0bedfdc3 1f40aaf5 7507bbd8| Q........@..u... 00000030 |9a7a8018 0816fa89 00000101 080a82b1| .z.............. 00000040 |1819b4d3 62134745 54202f66 6f6f2048| ....b.GET /foo H 00000050 |5454502f 31000000 00000000| TTP/1....... 00000060 0000006c

Pour chaque requête, cargo bpf affichera -- Event: [MAP NAME] -- suivi d'un vidage hexadécimal des données envoyées par le code du noyau.

Écrire un chargeur personnalisé pour l'espace utilisateur

cargo bpf load est très pratique pendant le développement. Une fois que vous avez fait fonctionner le code BPF, vous voudrez probablement faire quelque chose de plus utile avec les données qu'il collecte. Pour notre exemple simple, disons que pour chaque requête, nous voulons afficher une ligne au format :

Code
1.2.3.4 - GET /foo HTTP/1

Où le côté gauche est l'adresse IP du client, et le côté droit est la ligne de requête HTTP. Voici un programme simple qui utilise redbpf::load::Loader pour faire cela :

Code
use std::env; use std::path::PathBuf; use std::io; use std::net::IpAddr; use futures::stream::StreamExt; use tokio; use tokio::runtime::Runtime; use tokio::signal; use redbpf::load::Loader; use redbpf::XdpFlags; use bpf_examples::trace_http::{MapData, RequestInfo}; #[tokio::main] async fn main() -> Result<(), io::Error> { let args: Vec<String> = env::args().collect(); if args.len() != 3 { eprintln!("usage: bpf_example_program [NETWORK_INTERFACE] [FILENAME]"); return Err(io::Error::new(io::ErrorKind::Other, "invalid arguments")); } let interface = args[1].clone(); let file = args[2].clone(); let mut loader = Loader::new() .xdp(Some(interface), XdpFlags::default()) .load_file(&file.into()) .await .expect("error loading file"); tokio::spawn(async move { while let Some((_, events)) = loader.events.next().await { for event in events { let event = unsafe { &*(event.as_ptr() as *const MapData<RequestInfo>) }; let info = &event.data; let payload = String::from_utf8_lossy(event.payload()); let req_line = payload.split("\r\n").next().unwrap(); let ip = IpAddr::from(info.saddr.to_ne_bytes()); println!("{} - {}", ip, req_line); } } }); signal::ctrl_c().await }

Fichier : bpf_example_loader/src/main.rsbpf_example_loader/src/main.rs

La première chose notable est que le programme est asynchrone et utilise async-await. Pour charger le code BPF, il utilise l'API redbpf::load::Loader :

Code
let mut loader = Loader::new() .xdp(Some(interface), XdpFlags::default()) .load_file(&file.into()) .await

Loader est une API de haut niveau qui utilise l'API Module de niveau inférieur et expose les événements des maps sous forme de flux unifié de Vec<Box<[u8]>>. L'API Loader est disponible lors de la compilation de redbpf avec la fonctionnalité cargo load activée.

Les événements sont ensuite traités avec :

Code
tokio::spawn(async move { while let Some((_, events)) = loader.events.next().await { for event in events { let event = unsafe { &*(event.as_ptr() as *const MapData<RequestInfo>) }; let info = &event.data; let payload = String::from_utf8_lossy(event.payload()); let req_line = payload.split("\r\n").next().unwrap(); let ip = IpAddr::from(info.saddr.to_ne_bytes()); println!("{} - {}", ip, req_line); } } });

Une nouvelle tâche est lancée pour traiter le flux loader.events. Chaque élément du flux est un Vec de tranches d'octets (les événements sont récupérés par lots, d'où un Vec). Chaque tranche d'octets est la représentation en octets d'un MapData<RequestInfo> que nous avons inséré dans la map depuis l'espace noyau. Chaque tranche est reconvertie en MapData<RequestInfo> afin que le RequestInfo et payload() puissent être extraits. Le code restant extrait ensuite la ligne de requête HTTP, convertit l'adresse IP en quelque chose de plus facile à manipuler, et affiche le journal de la requête.

Enfin, la toute dernière ligne est :

Code
signal::ctrl_c().await

Les programmes XDP doivent être déchargés, ce qui est quelque chose que Loader fait dans son implémentation de Drop. Nous interceptons donc CTRL-C (SIGINT) et laissons le processus se terminer proprement.

J'ai téléchargé le package du chargeur à l'adresse https://github.com/alessandrod/bpf_example_loader. Si vous le clonez à côté de bpf_examples, construisez-le puis exécutez-le en tant que root avec :

Code
# cargo run -- en0 ../bpf_examples/target/release/bpf-programs/trace_http/trace_http.elf

Remplacez en0 par votre interface réseau, exécutez quelques requêtes HTTP et vous devriez obtenir quelque chose comme :

Code
Loaded: trace_http, XDP 60.242.81.10 - GET /where-has-the-time-gone? HTTP/1 60.242.81.10 - GET /you-will-be-missed-but-you-are HTTP/1 60.242.81.10 - GET /off-to-do-great-things HTTP/1

Notes finales et liens

Cet article s'est avéré plus dense et plus long que prévu, alors merci d'être resté avec moi ! J'allais montrer un second type de programme – un kprobe – mais je suppose que vous avez une vie à laquelle retourner maintenant. Ce n'est pas grave, je couvrirai les kprobes dans un autre article. En attendant, si vous souhaitez me contacter, n'hésitez pas à m'envoyer un email ou à me contacter sur twitter @alessandrod.

Si j'ai réussi à vous intéresser à jouer avec RedBPF, je vous recommande vivement de vous familiariser avec cargo bpf, car cela simplifie grandement les choses. Veuillez garder à l'esprit que RedBPF évolue à un rythme assez rapide, alors attendez-vous à quelques aspérités et, comme toujours, les rapports de bugs et les correctifs sont plus que bienvenus !

Et maintenant, les liens que je vous avais promis au début :

Red Sift
Red Sift