Wenn Sie Systemprogrammierung betreiben, haben Sie in letzter Zeit wahrscheinlich häufig von BPF gehört. Es ist eine angesagte neue Linux-Technologie, die das Ausführen von benutzerdefinierten Programmen im Kernel ermöglicht. Sie wird von Netflix, Facebook, Google, Cloudflare und vielen anderen Unternehmen genutzt, um Dinge wie blitzschnelles Load Balancing, DDoS-Abwehr und Performance-Monitoring zu implementieren.
In den letzten Monaten habe ich mit Red Sift an RedBPF gearbeitet, einem BPF-Toolkit für Rust. Red Sift nutzt RedBPF, um den Security-Monitoring-Agent InGRAINd zu betreiben. Peter hat vor Kurzem über RedBPF und InGRAINd gebloggt und einen Workshop auf der RustFest Barcelona geleitet. Seitdem haben wir RedBPF weiter verbessert, Bugs behoben, APIs verbessert und neue hinzugefügt, Unterstützung für Google Kubernetes Engine-Kernel ergänzt und mehr. Wir haben außerdem die Neulizenzierung des Projekts auf Apache2/MIT abgeschlossen – das Lizenzschema, das von vielen der bekanntesten Crates im Rust-Ökosystem verwendet wird – was hoffentlich die Einführung von RedBPF noch einfacher machen wird.
In diesem Beitrag werde ich näher darauf eingehen, was RedBPF ist, welche Hauptkomponenten es hat und wie der gesamte Prozess des Schreibens eines BPF-Programms aussieht.
Sie müssen kein BPF-Experte sein, um diesen Beitrag zu lesen, denn im nächsten Abschnitt gebe ich einen kurzen, sehr grundlegenden Überblick über die wichtigsten Konzepte, die Sie kennen müssen, um dem Rest zu folgen. Wenn Sie tiefer einsteigen möchten: Brendan Greggs Buch BPF Performance Tools ist gerade erschienen und wirklich hervorragend. Und falls Sie nicht so der Bücher-Typ sind, stelle ich am Ende auch noch ein paar andere nützliche Links bereit.
Der schnellste BPF-Crashkurs
BPF ist eine virtuelle Maschine, die es ermöglicht, benutzerdefinierte Programme im Kernel auszuführen, wenn bestimmte Ereignisse auf einem Linux-System auftreten. Angenommen, Sie möchten verdächtige Dateiaktivitäten überwachen, die Netzwerk-Antwortlatenz protokollieren oder sogar User-Space-Apps tracen – Sie können kleine BPF-Programme schreiben, deren Anbindung an die richtige Stelle im Kernel beantragen und die nötige Instrumentierung implementieren.
Die BPF-VM verwendet einen eigenen Befehlssatz. Man kann den Bytecode direkt schreiben, typischerweise nutzt man jedoch bpftrace oder schreibt C-Code und kompiliert ihn mit der BPF Compiler Collection (BCC).
bpftrace ist ein großartiges Tool, mit dem Sie BPF-Programme mithilfe einer Ad-hoc-Hochsprache schreiben können. Es ist ausgezeichnet für kurze Skripte und manuelle Instrumentierung, während BCC besser für komplexere Tools geeignet ist oder wenn man mit anderen Anwendungen und Systemen integriert.
BCC nutzt die BPF-Zielunterstützung von LLVM. Es ermöglicht Ihnen, C-Code zu schreiben, der dann mit clang zu BPF-Bytecode kompiliert wird, der von der BPF-VM im Kernel ausgeführt werden kann. Es gibt einige Einschränkungen bei der Art von C-Code, den Sie schreiben können – vor allem können Sie keine Schleifen verwenden – aber ansonsten fühlt sich das Schreiben eines BPF-Programms in C nicht allzu anders an als das Entwickeln für eine andere (etwas eigenwillige) Embedded-Plattform.
Da BPF-Programme im Linux-Kernel ausgeführt werden, kann BPF-Bytecode nicht einfach so ausgeführt werden, sondern muss in den Kernel geladen werden. Die meisten Anwendungen, die BPF verwenden, sind daher in zwei Teile aufgeteilt: den im Kernel laufenden BPF-Code und einen User-Space-Prozess, der für das Laden des Codes in den Kernel und die Interaktion mit ihm verantwortlich ist.
Schematisch lässt sich der Prozess der Entwicklung eines BPF-Programms mit den folgenden Schritten zusammenfassen:
- Den BPF-Code in C schreiben
- Den Code für die BPF-VM kompilieren
- Eine User-Space-Komponente schreiben, die die Ausgabe von Schritt 2 in die BPF-VM lädt
- Die BPF-API verwenden, um Daten zwischen der User-Space-Komponente und dem BPF-Code auszutauschen
RedBPF enthält APIs und Tools, um alle oben genannten Schritte außer Schritt 1) zu implementieren. Mit RedBPF wird Schritt 1 zu:
- Den BPF-Code in Rust schreiben
Ich habe viele Details ausgelassen und einige Dinge vereinfacht, aber wenn Sie zuvor nichts über BPF wussten, sollten Sie nun genug verstehen, um weiter zu folgen. Als Nächstes werde ich zeigen, wie RedBPF genau verwendet werden kann, um die oben genannten Schritte umzusetzen.
Was ist also RedBPF?
RedBPF ist eine Sammlung von Rust-Crates. Sie umfasst:
- redbpf-macros und redbpf-probes: Sie stellen die Kernel-Space-BPF-API bereit (Schritt 1)
- redbpf: stellt die User-Space-BPF-API bereit. Insbesondere stellt es die API zum Laden des BPF-Bytecodes bereit (Schritt 3)
- cargo-bpf: ein Cargo-Plugin, das das Erstellen, Bauen und Debuggen von BPF-Programmen vereinfacht (Schritt 2)
Ein einfacher HTTP-Tracer (Kernel-Seite)
Ich zeige nun ein sehr einfaches, mit RedBPF in Rust geschriebenes BPF-Programm, das im Kernel ausgeführt werden kann. Es verfolgt alle eingehenden HTTP-Anfragen auf einer bestimmten Netzwerkschnittstelle mithilfe der eXpress Data Path (XDP)-APIs. XDP-Programme klinken sich direkt in den NIC-Treiber ein (können aber auch auf einer höheren Ebene ausgeführt werden) und bieten schnellen, geringen Overhead-Zugriff auf eingehende Daten, bevor diese in den Rest des Netzwerkstacks gelangen.
#![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 }Datei: bpf_examples/src/trace_http/main.rsbpf_examples/src/trace_http/main.rs
Das Erste, was auffällt, ist, dass das Programm ein #![no_std] #![no_main]-Binary ist:
#![no_std] #![no_main] ...Die BPF-VM unterstützt viele der von der stdlib benötigten Funktionen nicht, und BPF-Programme werden als Reaktion auf bestimmte Ereignisse ausgeführt, sodass sie keinen konventionellen main-Einstiegspunkt haben. Die erzeugten Binaries werden nicht direkt ausgeführt, sondern mithilfe der von redbpf bereitgestellten API in den Kernel geladen, wie wir später sehen werden.
Das Nächste, was auffällt, ist der Makroaufruf program!():
program!(0xFFFFFFFE, "GPL");BPF-Programme müssen angeben, mit welcher Kernel-Version sie kompatibel sind und unter welcher Lizenz sie vertrieben werden. 0xFFFFFFFE ist ein spezieller Wert, der jede Kernel-Version bedeutet. Die Lizenz muss angegeben werden, da die VM je nach Lizenz bestimmte APIs verfügbar macht oder nicht (wenn das Programm nicht GPL ist, kann es keine GPL-Funktionalität im Kernel nutzen). Neben der Möglichkeit, Version und Lizenz anzugeben, generiert program!() auch einen globalen Boilerplate-Code, der benötigt wird, damit das Programm korrekt kompiliert und geladen werden kann.
trace_http() ist die Funktion, die Netzwerkdaten analysiert und nach HTTP-Anfragen sucht:
#[xdp] pub extern "C" fn trace_http(ctx: XdpContext) -> XdpAction { ... }Wie Sie sehen können, ist sie mit dem Attribut-Makro #[xdp] versehen, das Teil der Crate redbpf-macros ist. #[xdp] erledigt einige Dinge, wird aber, wie wir später sehen werden, hauptsächlich verwendet, um dem Bytecode-Loader in redbpf zu signalisieren, dass die Funktion ein XDP-Programm ist.
Die Funktion nimmt einen XdpContext entgegen und gibt eine XdpAction zurück. XdpContext bietet eine höhere Abstraktionsebene über den zugrunde liegenden xdp_md-Pointer, der von der BPF-VM an XDP-Programme übergeben wird. #[xdp] bildet transparent zwischen den beiden Typen ab. Der Rückgabewert – XdpAction – kann verwendet werden, um anzugeben, was mit den gerade inspizierten Daten geschehen soll: ob sie weiter durch den Netzwerkstack geleitet, verworfen, an eine andere Schnittstelle umgeleitet werden sollen usw.
Die eigentliche Parsing-Logik ist recht einfach: Wenn das Transportprotokoll TCP ist und die Payload wie eine HTTP-Anfrage aussieht, wird die Anfrage an den User Space gesendet, wo sie analysiert werden kann. Die BPF-API stellt mehrere Datenstrukturen – sogenannte Maps – bereit, die verwendet werden können, um Daten über mehrere Programmaufrufe hinweg zu speichern und zu aggregieren, und die auch zum Austausch von Daten mit dem User Space verwendet werden können. Die von unserem Programm verwendete Map – eine PerfMap – ermöglicht es BPF-Programmen, Daten in einem via mmap() gemappten, für den User Space zugänglichen Shared Memory zu speichern.
#[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 }Die globale Variable requests ist unsere PerfMap, die, wie Sie sehen können, mit dem Attribut #[map] versehen ist. Das Attribut wird verwendet, um die Map zu benennen und sie in einer speziellen ELF-Sektion namens maps/<name> (in unserem Fall maps/requests) der resultierenden Binary zu platzieren, sodass der User-Space-Loader sie finden und initialisieren kann.
Für jede HTTP-Anfrage erstellen wir eine RequestInfo-Struktur, die Quelladresse, Zieladresse, Quellport und Zielport der Anfrage enthält. Anschließend fügen wir sie, eingebettet in einen MapData-Wert, in die PerfMap ein. MapData::with_payload() wird verwendet, um dem Treiber mitzuteilen, dass wir data.len() Bytes aus dem aktuellen Paket unmittelbar nach den RequestInfo-Daten in die Map einfügen möchten. data.offset() wird verwendet, um dem User Space den Offset anzugeben, an dem die HTTP-Daten beginnen (nach den Ethernet-, IP- und TCP-Headern).
Den HTTP-Tracer bauen und debuggen
Wenn Sie den im vorherigen Abschnitt gezeigten Code bauen und wie im folgenden Abschnitt gezeigt laden möchten, können Sie das Repository mit dem obigen Code von http://github.com/alessandrod/bpf_examples klonen.
Um den Code zu bauen, wechseln Sie mit cd in den geklonten Ordner und führen Sie aus:
$ cargo install cargo-bpf $ cargo bpf buildWenn der Build erfolgreich ist, wird das kompilierte BPF-Programm unter target/release/bpf-programs/trace_http/trace_http.elf abgelegt. Um sicherzustellen, dass das Programm korrekt geladen wird und funktioniert, können Sie cargo bpf load verwenden (muss als root ausgeführt werden):
# cargo bpf load -i en0 target/release/bpf-programs/trace_http/trace_http.elf Loaded: trace_http, XDPErsetzen Sie en0 durch die Netzwerkschnittstelle, die Sie tracen möchten. Wenn Sie dann auf dieser Schnittstelle einen HTTP-Server ausführen und eine HTTP-Anfrage senden, sollten Sie etwa Folgendes sehen:
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 0000006cFür jede Anfrage gibt cargo bpf -- Event: [MAP NAME] -- gefolgt von einem Hex-Dump der vom Kernel-Code gesendeten Daten aus.
Einen eigenen User-Space-Loader schreiben
cargo bpf load ist während der Entwicklung sehr praktisch. Sobald der BPF-Code jedoch funktioniert, möchten Sie wahrscheinlich etwas Nützlicheres mit den gesammelten Daten tun. Für unser einfaches Beispiel nehmen wir an, dass wir für jede Anfrage eine Zeile im folgenden Format ausgeben möchten:
1.2.3.4 - GET /foo HTTP/1Wobei die linke Seite die IP-Adresse des Clients ist und die rechte Seite die HTTP-Request-Zeile. Hier ist ein einfaches Programm, das redbpf::load::Loader verwendet, um das zu tun:
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 }Datei: bpf_example_loader/src/main.rsbpf_example_loader/src/main.rs
Das Erste, was auffällt, ist, dass das Programm asynchron ist und async-await verwendet. Um den BPF-Code zu laden, wird die API redbpf::load::Loader verwendet:
let mut loader = Loader::new() .xdp(Some(interface), XdpFlags::default()) .load_file(&file.into()) .awaitLoader ist eine High-Level-API, die die niedrigere Module-API verwendet und Events aus Maps als einheitlichen Stream von Vec<Box<[u8]>> bereitstellt. Die Loader-API steht zur Verfügung, wenn redbpf mit dem aktivierten Cargo-Feature load kompiliert wird.
Die Events werden dann folgendermaßen verarbeitet:
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); } } });Ein neuer Task wird gestartet, um den Stream loader.events zu verarbeiten. Jedes Stream-Element ist ein Vec von Byte-Slices (die Events werden in Batches abgerufen, daher ein Vec). Jedes Byte-Slice ist die Byte-Darstellung eines MapData<RequestInfo>, das wir aus dem Kernel-Space in die Map eingefügt haben. Jedes Slice wird zurück in MapData<RequestInfo> gecastet, damit RequestInfo und payload() extrahiert werden können. Der restliche Code extrahiert anschließend die HTTP-Request-Zeile, wandelt die IP-Adresse in eine leichter verwendbare Form um und gibt das Request-Log aus.
Zum Schluss die allerletzte Zeile:
signal::ctrl_c().awaitXDP-Programme müssen entladen werden, was Loader in seiner Drop-Implementierung erledigt. Wir fangen daher STRG-C (SIGINT) ab und lassen den Prozess ordnungsgemäß beenden.
Ich habe das Loader-Paket unter https://github.com/alessandrod/bpf_example_loader hochgeladen. Wenn Sie es neben bpf_examples klonen, es bauen und anschließend als root ausführen mit:
# cargo run -- en0 ../bpf_examples/target/release/bpf-programs/trace_http/trace_http.elfErsetzen Sie en0 durch Ihre Netzwerkschnittstelle, führen Sie einige HTTP-Anfragen aus, und Sie sollten etwa Folgendes erhalten:
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/1Abschließende Bemerkungen und Links
Dieser Beitrag ist am Ende dichter und länger geworden, als ich erwartet hatte, danke also, dass Sie bis hierhin dabei geblieben sind! Ich wollte eigentlich noch eine zweite Art von Programm zeigen – einen kprobe –, aber ich gehe davon aus, dass Sie jetzt zu Ihrem eigenen Leben zurückkehren möchten. Das ist in Ordnung, ich werde kprobes in einem anderen Beitrag behandeln. In der Zwischenzeit können Sie mir gerne eine E-Mail schreiben oder mir auf Twitter unter @alessandrod eine Nachricht senden.
Wenn es mir gelungen ist, Ihr Interesse an RedBPF zu wecken, empfehle ich Ihnen sehr, sich mit cargo bpf vertraut zu machen, da es die Dinge erheblich vereinfacht. Bitte beachten Sie, dass sich RedBPF ziemlich schnell weiterentwickelt, also erwarten Sie einige raue Kanten, und wie immer sind Bugreports und Patches herzlich willkommen!
Und nun die Links, die ich am Anfang versprochen habe:
- https://docs.cilium.io/en/latest/bpf/ – wenn ich Ihr Interesse an BPF wirklich geweckt habe, ist das ein absolutes Muss. Großes Lob an die Cilium-Leute.
- http://www.brendangregg.com/ebpf.html – ich habe bereits auf Brendans Buch verlinkt, aber diese Seite verweist auf noch mehr großartige BPF-Ressourcen.
- https://github.com/iovisor/bcc – BCC enthält einige großartige Tools, portieren Sie gerne einige davon nach Rust!
- https://www.iovisor.org/technology/xdp – XDP-Dokumentation vom IO Visor Project.
- https://www.kernel.org/doc/Documentation/kprobes.txt – offizielle Kernel-Dokumentation zu kprobes.




