Hallo, Habr! Wir informieren Sie, dass wir das Buch "".

Da sich die BPF-VM weiterentwickelt und aktiv in der Praxis eingesetzt wird, haben wir für Sie einen Artikel übersetzt, der ihre Hauptfunktionen und den aktuellen Stand beschreibt.
In den letzten Jahren haben Programmierwerkzeuge und Techniken, die darauf abzielen, die Einschränkungen des Linux-Kernels zu kompensieren, an Beliebtheit gewonnen, besonders wenn es um hochleistungsfähige Paketverarbeitung geht. Eine der bekanntesten Methoden in dieser Hinsicht wird als Kernelumgehung (kernel bypass) bezeichnet und ermöglicht es, die gesamte Paketverarbeitung aus dem Benutzerspace durchzuführen, indem der Netzwerklevel des Kernels umgangen wird. Die Kernelumgehung beinhaltet auch die Verwaltung der Netzwerkkarte aus dem Benutzerspace. Mit anderen Worten, bei der Arbeit mit der Netzwerkkarte verlassen wir uns auf den Treiber. Benutzerspace.
Indem wir die vollständige Kontrolle über die Netzwerkkarte an ein Programm im Benutzerspeicherraum übergeben, reduzieren wir die Kosten, die durch den Betrieb des Kernels verursacht werden (Kontextwechsel, Netzwerkverarbeitung, Unterbrechungen usw.), was bei Geschwindigkeiten von 10 Gb/s oder mehr besonders wichtig ist. Der Umweg über den Kernel plus die Kombination anderer Funktionen (Batchverarbeitung) und präzise Leistungsoptimierung (NUMA-Berechnung, CPU-Isolierung, usw.) sind grundlegend für die hochleistungsfähige Netzwerkverarbeitung im Benutzerraum. Ein vorbildliches Beispiel für diesen neuen Ansatz zur Paketverarbeitung ist das von Intel (Data Plane Development Kit), obwohl es auch andere weit verbreitete Werkzeuge und Methoden gibt, darunter VPP von Cisco (Vector Packet Processing), Netmap und natürlich .
Die Organisation von Netzwerkinteraktionen im Benutzerspeicherraum hat einige Nachteile:
- Der Kern des Betriebssystems ist eine Abstraktionsschicht für die Hardware-Ressourcen. Da Benutzeranwendungen direkt mit ihren Ressourcen umgehen müssen, sind sie auch dafür verantwortlich, ihre eigene Hardware zu verwalten. Oftmals bedeutet dies, dass sie eigene Treiber programmieren müssen.
- Da wir vollständig auf den Kernraum verzichten, geben wir auch alle Netzwerkfunktionen auf, die vom Kern bereitgestellt werden. Benutzeranwendungen müssen die Funktionen, die möglicherweise bereits vom Kern oder Betriebssystem bereitgestellt werden, selbst neu implementieren.
- Programme arbeiten im Sandbox-Modus, was ihre Interaktionsmöglichkeiten erheblich einschränkt und ihre Integration mit anderen Teilen des Betriebssystems erschwert.
Im Wesentlichen wird bei der Organisation von Netzwerkinteraktionen im Benutzerspeicher die Leistung gesteigert, indem die Verarbeitung von Paketen aus dem Kernel in den Benutzerspeicher verlagert wird. XDP macht genau das Gegenteil: Es verschiebt Netzwerkprogramme (Filter, Konverter, Routing usw.) aus dem Benutzerspeicher in den Kernelbereich. Mit XDP können wir eine Netzwerkfunktion ausführen, sobald das Paket das Netzwerk-Interface erreicht und bevor es beginnt, in das Netzwerk-Subsystem des Kernels aufzusteigen. Dadurch wird die Geschwindigkeit der Paketverarbeitung deutlich erhöht. Aber wie ermöglicht der Kernel dem Benutzer, seine Programme im Kernelspeicher auszuführen? Bevor wir diese Frage beantworten, lassen Sie uns betrachten, was BPF ist.
BPF und eBPF
Trotz des nicht ganz verständlichen Namens BPF (Berkeley Packet Filter) handelt es sich im Grunde um ein Modell einer virtuellen Maschine. Diese virtuelle Maschine wurde ursprünglich für die Verarbeitung von Paketfiltern entwickelt, daher der Name.
Eines der bekanntesten Werkzeuge, das BPF verwendet, ist tcpdump. Bei der Paketerfassung mit Hilfe von tcpdump Der Benutzer kann einen Ausdruck zur Paketfilterung festlegen. Es werden nur die Pakete erfasst, die diesem Ausdruck entsprechen. Zum Beispiel bezieht sich der Ausdruck „tcp dst port 80“ auf alle TCP-Pakete, die an Port 80 eingehen. Der Compiler kann diesen Ausdruck verkürzen, indem er ihn in BPF-Bytecode umwandelt.
$ sudo tcpdump -d "tcp dst port 80"
(000) ldh [12]
(001) jeq #0x86dd jt 2 jf 6
(002) ldb [20]
(003) jeq #0x6 jt 4 jf 15
(004) ldh [56]
(005) jeq #0x50 jt 14 jf 15
(006) jeq #0x800 jt 7 jf 15
(007) ldb [23]
(008) jeq #0x6 jt 9 jf 15
(009) ldh [20]
(010) jset #0x1fff jt 15 jf 11
(011) ldxb 4*([14]&0xf)
(012) ldh [x + 16]
(013) jeq #0x50 jt 14 jf 15
(014) ret #262144
(015) ret #0
Das ist im Wesentlichen das, was das obige Programm tut:
- Anweisung (000): Lädt das Paket mit einer Offset von 12 in das Akkumulatorregister als 16-Bit-Wort. Das Offset 12 entspricht dem Ethertype des Pakets.
- Anweisung (001): Vergleicht den Wert im Akkumulator mit 0x86dd, also dem Ethertype-Wert für IPv6. Wenn das Ergebnis true ist, springt der Programmzähler zur Anweisung (002), andernfalls zu (006).
- Anweisung (006): Vergleicht den Wert mit 0x800 (Ethertype-Wert für IPv4). Wenn die Antwort true ist, springt das Programm zu (007), andernfalls zu (015).
Und so weiter, bis das Paketfilterungsprogramm ein Ergebnis zurückgibt. Normalerweise ist das ein Boolean. Der Rückgabewert ungleich null (Anweisung (014)) bedeutet, dass das Paket passt, während der Rückgabewert null (Anweisung (015)) bedeutet, dass das Paket nicht passt.
Die BPF-virtuelle Maschine und ihr Bytecode wurden von Steve McCanne und Van Jacobson Ende 1992 vorgeschlagen, als ihr Artikel veröffentlicht wurde. , die Technologie wurde erstmals auf der Usenix-Konferenz im Winter 1993 vorgestellt.
Da BPF eine virtuelle Maschine ist, definiert sie die Umgebung, in der Programme ausgeführt werden. Neben dem Bytecode definiert sie auch das Paketmodell des Speichers (Ladeanweisungen werden implizit auf das Paket angewendet), Register (A und X; Akkumulator- und Indexregister), Scratch-Speicher und einen impliziten Programmzähler. Interessanterweise wurde der BPF-Bytecode nach dem Vorbild der Motorola 6502 ISA skizziert. Wie Steve McCanne in seinem auf Sharkfest '11 erinnerte, kannte er die 6502-Assembler-Programmierung schon aus der Schulzeit, als er auf dem Apple II programmierte, und dieses Wissen hatte Einfluss auf seine Arbeit bei der Gestaltung des BPF-Bytecodes.
Die Unterstützung für BPF wurde im Linux-Kernel in Version v2.5 und höher implementiert, hauptsächlich durch die Bemühungen von Jay Sullivans. Der BPF-Code blieb bis 2011 ohne größere Änderungen, als Eric Dumazet den BPF-Interpreter für den JIT-Modus umgestaltet hat (Quelle: ). Danach konnte der Kernel anstelle der Interpretation des BPF-Bytecodes Programme direkt auf die Zielarchitektur: x86, ARM, MIPS usw. umwandeln.
Später, im Jahr 2014, schlug Alexey Starovoitov einen neuen JIT-Mechanismus für BPF vor. Tatsächlich wurde dieser neue JIT zu einer neuen Architektur auf Basis von BPF und bekam den Namen eBPF. Ich denke, eine Zeit lang existierten beide virtuellen Maschinen nebeneinander, aber gegenwärtig erfolgt die Paketfilterung auf Basis von eBPF. Tatsächlich wird in vielen Beispielen moderner Dokumentation unter BPF eBPF verstanden, während das klassische BPF heute als cBPF bekannt ist.
eBPF erweitert die klassische BPF-VM in mehreren Aspekten:
- Basiert auf modernen 64-Bit-Architekturen. eBPF verwendet 64-Bit-Register und erhöht die Anzahl der verfügbaren Register von 2 (Akku und X) auf 10. eBPF bietet auch zusätzliche Operationcodes (BPF_MOV, BPF_JNE, BPF_CALL…).
- Entkoppelt von der Netzwerkschicht. BPF war an ein paketbasiertes Datenmodell gebunden. Da es zur Paketfilterung verwendet wurde, war der Code in der Subsystem, das für die Netzwerkinteraktionen verantwortlich ist. Die eBPF-VM ist jedoch nicht mehr an das Datenmodell gebunden und kann für jede Art von Anwendung verwendet werden. So kann das eBPF-Programm jetzt mit einem Tracepoint oder einem Kprobe verbunden werden. Dies eröffnet Möglichkeiten zur eBPF-Instrumentierung, Leistungsanalyse und vielen anderen Anwendungsmöglichkeiten in anderen Subsystemen des Kernels. Jetzt befindet sich der eBPF-Code auf eigenem Weg: kernel/bpf.
- Globale Datenspeicher namens Maps. Maps sind Speicher vom Typ „Schlüssel-Wert“, die den Austausch von Daten zwischen dem Benutzerspeicher und dem Kernel-Speicher ermöglichen. In eBPF stehen Maps verschiedener Typen zur Verfügung.
- Hilfsfunktionen. Insbesondere für die Paketumschreibung, die Berechnung der Prüfziffer oder das Klonen von Paketen. Diese Funktionen werden im Kernel ausgeführt und sind nicht Teil der Programme im Benutzerspeicher. Außerdem können eBPF-Programme Systemaufrufe ausführen.
- Endaufrufe. Die Größe eines eBPF-Programms ist auf 4096 Bytes beschränkt. Die Möglichkeit von Endaufrufen erlaubt es einem eBPF-Programm, die Kontrolle an ein neues eBPF-Programm zu übergeben und somit diese Einschränkung zu umgehen (es können bis zu 32 Programme miteinander verbunden werden).
eBPF: ein Beispiel
Im Quellcode des Linux-Kernels gibt es mehrere Beispiele für eBPF. Sie sind unter samples/bpf/ verfügbar. Um diese Beispiele zu kompilieren, geben Sie einfach ein:
$ sudo make samples/bpf/
Ich werde kein neues Beispiel für eBPF selbst schreiben, sondern eines der Muster verwenden, die unter samples/bpf/ verfügbar sind. Ich werde einige Codeabschnitte betrachten und erklären, wie sie funktionieren. Als Beispiel habe ich das Programm tracex4.
Im Allgemeinen besteht jedes der Beispiele unter samples/bpf/ aus zwei Dateien. In diesem Fall:
tracex4_kern.c, enthält den Quellcode, der im Kernel als eBPF-B Bytecode ausgeführt werden soll.tracex4_user.c, enthält das Programm aus dem Benutzerspeicher.
In diesem Fall müssen wir tracex4_kern.c in eBPF-Bytecode kompilieren. Momentan fehlt gcc die Serverkomponente für eBPF. Glücklicherweise clang kann Bytecode für eBPF generieren. nutzt clang zur Kompilierung tracex4_kern.c in eine Objektdatei.
Ich habe bereits erwähnt, dass eine der interessantesten Funktionen von eBPF Karten sind. tracex4_kern definiert eine Karte:
struct pair {
u64 val;
u64 ip;
};
struct bpf_map_def SEC("maps") my_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(long),
.value_size = sizeof(struct pair),
.max_entries = 1000000,
}; BPF_MAP_TYPE_HASH – einer der vielen Kartentypen, die von eBPF angeboten werden. In diesem Fall handelt es sich einfach um einen Hash. Sie haben vielleicht auch die Deklaration SEC("maps"). SEC ist ein Makro, das verwendet wird, um einen neuen Abschnitt der Binärdatei zu erstellen. Tatsächlich definieren im Beispiel tracex4_kern zwei weitere Abschnitte:
SEC("kprobe/kmem_cache_free")
int bpf_prog1(struct pt_regs *ctx)
{
long ptr = PT_REGS_PARM2(ctx);
bpf_map_delete_elem(&my_map, &ptr);
return 0;
}
SEC("kretprobe/kmem_cache_alloc_node")
int bpf_prog2(struct pt_regs *ctx)
{
long ptr = PT_REGS_RC(ctx);
long ip = 0;
// wir erhalten die IP-Adresse der aufrufenden Seite kmem_cache_alloc_node()
BPF_KRETPROBE_READ_RET_IP(ip, ctx);
struct pair v = {
.val = bpf_ktime_get_ns(),
.ip = ip,
};
bpf_map_update_elem(&my_map, &ptr, &v, BPF_ANY);
return 0;
} Diese beiden Funktionen ermöglichen es, einen Eintrag aus der Karte zu löschen (kprobe/kmem_cache_free) und einen neuen Eintrag in die Karte hinzuzufügen (kretprobe/kmem_cache_alloc_node). Alle Funktionsnamen, die in Großbuchstaben geschrieben sind, entsprechen den in .
Wenn ich einen Dump der Abschnitte der Objektdatei ausgeben lasse, sollte ich sehen, dass diese neuen Abschnitte bereits definiert sind:
$ objdump -h tracex4_kern.o
tracex4_kern.o: Dateiformat elf64-little
Abschnitte:
Idx Name Größe VMA LMA Datei off Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
INHALT, ZUWEISEN, LADEN, SCHREIBGESCHÜTZT, CODE
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
INHALT, ZUWEISEN, LADEN, RELATIVIEREN, SCHREIBGESCHÜTZT, CODE
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
INHALT, ZUWEISEN, LADEN, RELATIVIEREN, SCHREIBGESCHÜTZT, CODE
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
INHALT, ZUWEISEN, LADEN, DATEN
4 lizenz 00000004 0000000000000000 0000000000000000 00000164 2**0
INHALT, ZUWEISEN, LADEN, DATEN
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
INHALT, ZUWEISEN, LADEN, DATEN
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
INHALT, ZUWEISEN, LADEN, RELATIVIEREN, SCHREIBGESCHÜTZT, DATEN
Es gibt auch , das Hauptprogramm. Grundsätzlich hört dieses Programm auf Ereignisse kmem_cache_alloc_node. Wenn ein solches Ereignis eintritt, wird der entsprechende eBPF-Code ausgeführt. Der Code speichert die IP-Eigenschaft des Objekts in einer Karte und dann wird dieses Objekt zyklisch im Hauptprogramm ausgegeben. Beispiel:
$ sudo ./tracex4
Objekt 0xffff8d6430f60a00 ist 2 Sekunden alt, wurde an der IP ffffffff9891ad90 zugewiesen.
Objekt 0xffff8d6062ca5e00 ist 23 Sekunden alt, wurde an der IP ffffffff98090e8f zugewiesen.
Objekt 0xffff8d5f80161780 ist 6 Sekunden alt, wurde an der IP ffffffff98090e8f zugewiesen.
Wie sind das Benutzerprogram und das eBPF-Programm verbunden? Bei der Initialisierung tracex4_user.c wird die Objektdatei tracex4_kern.o danach wurde die Anzeige des Abschnitts im Adressraum des Prozesses verborgen BPF-Datei laden.
int main(int ac, char **argv)
{
struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
char filename[256];
int i;
snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);
if (setrlimit(RLIMIT_MEMLOCK, &r)) {
perror("setrlimit(RLIMIT_MEMLOCK, RLIM_INFINITY)");
return 1;
}
if (load_bpf_file(filename)) {
printf("%s", bpf_log_buf);
return 1;
}
for (i = 0; ; i++) {
print_old_objects(map_fd[1]);
sleep(1);
}
return 0;
} Bei der Ausführung Wächter, die in der eBPF-Datei definiert sind, werden zu /sys/kernel/debug/tracing/kprobe_events. Jetzt hören wir auf diese Ereignisse, und unser Programm kann etwas tun, wenn sie eintreten.
$ sudo cat /sys/kernel/debug/tracing/kprobe_events
p:kprobes/kmem_cache_free kmem_cache_free
r:kprobes/kmem_cache_alloc_node kmem_cache_alloc_node
Alle anderen Programme in sample/bpf/ sind ähnlich strukturiert. Es gibt immer zwei Dateien:
XXX_kern.c: eBPF-Programm.XXX_user.c: Hauptprogramm.
Das eBPF-Programm definiert Karten und Funktionen, die an die Sektion gebunden sind. Wenn das Kernel ein Ereignis eines bestimmten Typs ausgibt (zum Beispiel, Tracepoint), werden die gebundenen Funktionen ausgeführt. Karten ermöglichen den Datenaustausch zwischen dem Kernel-Programm und dem Benutzerprogramm.
Fazit
In diesem Artikel wurden die Grundlagen von BPF und eBPF umrissen. Ich weiß, dass es heute viele Informationen und Ressourcen über eBPF gibt, deshalb empfehle ich noch einige Materialien zur weiteren Vertiefung.
Ich empfehle zu lesen:
- Jonathan Corbet. Eine Einführung in BPF und wie es sich zu eBPF entwickelt hat.
- Brendan Gregg. Artikel von LWN.net. Brendan tweetet oft über eBPF und führt eine Liste von Ressourcen zu diesem Thema auf seiner .
- Julia Evans. Kommentare zur Präsentation von Suchakra Sharma „The BSD Packet Filter: Eine neue Architektur für die Erfassung von Paketen auf Benutzerebene“. Die Kommentare sind gut und helfen wirklich, die Folien zu verstehen.
- Ferris Ellis. Ein Longread mit , aber es lohnt sich, ihn zu lesen. Einer der besten Artikel über eBPF, die ich gesehen habe.
Quelle: habr.com
