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

Da die BPF-Virtual Machine weiterhin entwickelt wird und in der Praxis aktiv eingesetzt wird, haben wir für Sie einen Artikel übersetzt, der ihre Hauptmerkmale und den aktuellen Stand beschreibt.
In den letzten Jahren sind Werkzeuge und Techniken zur Programmierung, die darauf abzielen, die Einschränkungen des Linux-Kernels in Fällen zu kompensieren, in denen eine hochleistungsfähige Paketverarbeitung erforderlich ist, immer beliebter geworden. Eine der bekanntesten Techniken dieser Art wird genannt Kernel-Bypass (kernel bypass) und ermöglicht es, die Netzwerkschicht des Kernels zu umgehen und die gesamte Paketverarbeitung aus dem Benutzermodus durchzuführen. Kernel-Bypass impliziert auch die Steuerung der Netzwerkkarte aus dem Benutzermodus. Mit anderen Worten, wenn wir mit der Netzwerkkarte arbeiten, verlassen wir uns auf den Treiber. dem Benutzermodus.
Indem wir die volle Kontrolle über die Netzwerkkarte an ein Programm aus dem Benutzermodus übergeben, reduzieren wir die Kosten, die durch die Arbeit des Kernels (Kontextwechsel, Verarbeitung auf Netzwerkebene, Interrupts usw.) verursacht werden, was besonders wichtig ist, wenn wir mit Geschwindigkeiten von 10 Gbit/s oder mehr arbeiten. Kernel-Bypass plus eine Kombination von anderen Möglichkeiten (Paketverarbeitung) und sorgfältige Leistungseinstellungen (NUMA-Berücksichtigung, CPU-Isolierung, usw.) bilden die Grundlagen der hochleistungsfähigen Netzwerkverarbeitung im Benutzermodus. Ein herausragendes Beispiel für einen solchen neuen Ansatz zur Paketverarbeitung ist von Intel (Data Plane Development Kit), obwohl es auch andere weithin bekannte Werkzeuge und Techniken gibt, darunter VPP von Cisco (Vector Packet Processing), Netmap und natürlich .
Die Organisation von Netzwerkinteraktionen im Benutzermodus hat eine Reihe von Nachteilen:
- Der Kernel des Betriebssystems ist eine Abstraktionsebene für Hardware-Ressourcen. Da Programme im Benutzermodus ihre Ressourcen direkt verwalten müssen, sind sie auch für die Verwaltung ihrer eigenen Hardware verantwortlich. Häufig bedeutet dies, dass sie eigene Treiber programmieren müssen.
- Da wir vollständig auf den Kernelraum verzichten, verzichten wir auch auf alle Netzwerkfunktionen, die vom Kernel bereitgestellt werden. Anwendungen im Benutzermodus müssen die Funktionen, die möglicherweise bereits vom Kernel oder Betriebssystem bereitgestellt werden, neu implementieren.
- Programme arbeiten im Sandbox-Modus, was ihre Interaktionsmöglichkeiten erheblich einschränkt und ihnen die Integration mit anderen Teilen des Betriebssystems erschwert.
Im Wesentlichen wird bei der Organisation von Netzwerkinteraktionen im Benutzerraum eine Leistungssteigerung erreicht, indem die Paketverarbeitung aus dem Kernel in den Benutzerraum verlagert wird. XDP macht genau das Gegenteil: Es verschiebt Netzwerkprogramme (Filter, Konverter, Routing usw.) aus dem Benutzerraum in den Kernelbereich. XDP ermöglicht es uns, eine Netzwerkfunktion auszuführen, sobald ein Paket auf die Netzwerkschnittstelle gelangt und bevor es beginnt, in die Netzwerk-Subsystem des Kernels aufzusteigen. Infolgedessen wird die Paketverarbeitungsgeschwindigkeit erheblich erhöht. Aber wie erlaubt der Kernel dem Benutzer, seine Programme im Kernelraum 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 dabei im Wesentlichen um ein Modell einer virtuellen Maschine. Diese virtuelle Maschine wurde ursprünglich für die Verarbeitung von Paketfiltern entworfen, daher der Name.
Eines der bekanntesten Werkzeuge, das BPF verwendet, ist tcpdump. Beim Erfassen von Paketen mit tcpdump kann der Benutzer einen Ausdruck zur Filterung von Paketen angeben. Es werden nur die Pakete erfasst, die diesem Ausdruck entsprechen. Zum Beispiel betrifft der Ausdruck “tcp dst port 80” alle TCP-Pakete, die an Port 80 gesendet werden. 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 Prinzip das, was das oben genannte Programm macht:
- Anweisung (000): läd das Paket mit einem Offset von 12 als 16-Bit-Wort in den Akkumulator. Das Offset von 12 entspricht dem Ethertype des Pakets.
- Anweisung (001): vergleicht den Wert im Akkumulator mit 0x86dd, also mit dem Ethertype-Wert für IPv6. Wenn das Ergebnis true ist, springt der Programmcounter zu Anweisung (002), andernfalls zu (006).
- Anleitung (006): vergleicht den Wert mit 0x800 (Ethertype-Wert für IPv4). Wenn die Antwort wahr ist, wechselt das Programm zu (007), andernfalls zu (015).
Und so weiter, bis das Paketfilterprogramm ein Ergebnis zurückliefert. Normalerweise handelt es sich um einen Boolean. Die Rückgabe eines Nicht-Null-Werts (Anleitung (014)) bedeutet, dass das Paket zutrifft, während die Rückgabe eines Nullwerts (Anleitung (015)) bedeutet, dass das Paket nicht passt.
Die BPF-virtuelle Maschine und ihr Bytecode wurden Ende 1992 von Steve McCanne und Van Jacobson vorgestellt, als ihre Studie veröffentlicht wurde. , erstmals wurde diese Technologie 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 Speicherpaketmodell (Ladeanweisungen werden implizit auf das Paket angewendet), Register (A und X; Akku- und Indexregister), den Scratch-Speicher und einen impliziten Programmzähler. Interessanterweise wurde der BPF-Bytecode nach dem Vorbild der Motorola 6502 ISA modelliert. Wie Steve McCanne in seinem auf Sharkfest '11 erinnerte, war er bereits in der Oberstufe mit der 6502-Assemblerprogrammierung vertraut, als er auf einem Apple II programmierte, und dieses Wissen beeinflusste seine Arbeit am Design des BPF-Bytecodes.
Die BPF-Unterstützung wurde in den Linux-Kernel in Version v2.5 und höher implementiert, hauptsächlich durch die Bemühungen von Jay Sullivan. Der BPF-Code blieb bis 2011 ohne wesentliche Änderungen, als Eric Dumazet den BPF-Interpreter für den JIT-Betrieb (Quelle: ). Danach konnte der Kernel anstelle der Interpretation des BPF-Bytecodes die BPF-Programme direkt in die Zielarchitektur konvertieren: x86, ARM, MIPS usw.
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 erhielt den Namen eBPF. Ich denke, eine Zeit lang koexistierten beide virtuellen Maschinen, aber mittlerweile erfolgt die Paketfilterung auf Basis von eBPF. Tatsächlich versteht man in vielen modernen Dokumentationen unter BPF eBPF, während das klassische BPF heute als cBPF bekannt ist.
eBPF erweitert die klassische BPF-virtuelle Maschine 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. In eBPF sind auch zusätzliche Operationcodes (BPF_MOV, BPF_JNE, BPF_CALL…) verfügbar.
- Von der Netzwerkschicht entkoppelt. BPF war an ein paketbasiertes Datenmodell gebunden. Da es zur Paketfilterung verwendet wurde, befand sich der Code in einem subsystems, das Netzwerkinteraktionen ermöglicht. Die eBPF-VM ist jedoch nicht mehr an ein Datenmodell gebunden und kann für verschiedene Zwecke eingesetzt werden. So kann jetzt ein eBPF-Programm an einen Tracepoint oder einen Kprobe angeschlossen werden. Dies eröffnet den Weg zu eBPF-Instrumentierung, Leistungsanalyse und vielen anderen Anwendungsmöglichkeiten im Kontext anderer Kernsubsysteme. Der eBPF-Code befindet sich nun auf seinem eigenen Pfad: kernel/bpf.
- Globale Datenspeicher, die Karten genannt werden. Karten sind Schlüssel-Wert-Speicher, die den Datenaustausch zwischen dem Benutzermodus und dem Kernelraum ermöglichen. In eBPF gibt es Karten mehrerer Typen.
- Hilfsfunktionen. Insbesondere zum Überschreiben von Paketen, Berechnen von Prüfziffern oder Klonen von Paketen. Diese Funktionen werden im Kernel ausgeführt und beziehen sich nicht auf Programme im Benutzermodus. Darüber hinaus können aus eBPF-Programmen Systemaufrufe getätigt werden.
- Endaufrufe. Die Größe eines eBPF-Programms ist auf 4096 Byte begrenzt. Die Möglichkeit des Endaufrufs erlaubt es einem eBPF-Programm, die Kontrolle an ein neues eBPF-Programm zu übergeben und dieses Limit zu umgehen (so können bis zu 32 Programme verknüpft werden).
eBPF: ein Beispiel
Im Linux-Kernel-Quellcode 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 verfügbaren Beispiele in samples/bpf/ verwenden. Ich werde einige Codeabschnitte betrachten und erklären, wie sie funktionieren. Als Beispiel habe ich das Programm tracex4.
Im Allgemeinen besteht jedes der Beispiele in samples/bpf/ aus zwei Dateien. In diesem Fall:
tracex4_kern.c, enthält den Quellcode, der im Kernel als eBPF-Bytecode ausgeführt werden soll.tracex4_user.c, enthält das Programm aus dem Benutzermodus.
In diesem Fall müssen wir kompilieren tracex4_kern.c in Bytecode eBPF. Derzeit gibt es gcc keinen Serverteil für eBPF. Zum Glück clang kann Bytecode eBPF erzeugen. verwendet clang für die Kompilierung tracex4_kern.c in eine Objektdatei.
Ich habe bereits erwähnt, dass eine der interessantesten Funktionen von eBPF die Maps sind. tracex4_kern definiert eine Map:
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 Map-Typen, die eBPF anbietet. In diesem Fall handelt es sich einfach um einen Hash. Außerdem könnten Sie die Deklaration bemerkt haben SEC("maps"). SEC ist ein Makro, das verwendet wird, um einen neuen Abschnitt der Binärdatei zu erstellen. Tatsächlich werden im Beispiel tracex4_kern zwei weitere Abschnitte definiert:
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 des Aufrufers 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 Map zu entfernen (kprobe/kmem_cache_free) und einen neuen Eintrag zur Map hinzuzufügen (kretprobe/kmem_cache_alloc_node). Alle Funktionsnamen, die in Großbuchstaben geschrieben sind, entsprechen Makros, die in definiert sind .
Wenn ich einen Dump der Abschnitte der Objektdatei ausgeben würde, 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, ALLOCKIEREN, LADEN, SCHREIBGESCHÜTZT, CODE
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
INHALT, ALLOCKIEREN, LADEN, RELOCATION, SCHREIBGESCHÜTZT, CODE
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
INHALT, ALLOCKIEREN, LADEN, RELOCATION, SCHREIBGESCHÜTZT, CODE
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
INHALT, ALLOCKIEREN, LADEN, DATEN
4 Lizenz 00000004 0000000000000000 0000000000000000 00000164 2**0
INHALT, ALLOCKIEREN, LADEN, DATEN
5 Version 00000004 0000000000000000 0000000000000000 00000168 2**2
INHALT, ALLOCKIEREN, LADEN, DATEN
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
INHALT, ALLOCKIEREN, LADEN, RELOCATION, SCHREIBGESCHÜTZT, DATEN
Es gibt auch , das Hauptprogramm. Im Grunde hört dieses Programm Ereignisse kmem_cache_alloc_node. Wenn ein solches Ereignis eintritt, wird der entsprechende eBPF-Code ausgeführt. Der Code speichert das IP-Attribut des Objekts in der Map, und dieses Objekt wird dann zyklisch im Hauptprogramm ausgegeben. Beispiel:
$ sudo .\/tracex4
obj 0xffff8d6430f60a00 ist 2 Sekunden alt, wurde an der IP ffffffff9891ad90 zugewiesen
obj 0xffff8d6062ca5e00 ist 23 Sekunden alt, wurde an der IP ffffffff98090e8f zugewiesen
obj 0xffff8d5f80161780 ist 6 Sekunden alt, wurde an der IP ffffffff98090e8f zugewiesen
Wie sind das Programm im Benutzerspeicher und das eBPF-Programm verbunden? Bei der Initialisierung tracex4_user.c lädt die Objektdatei tracex4_kern.o mithilfe der Funktion 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 werden die in der eBPF-Datei definierten Sonden hinzugefügt /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. Jedes hat immer zwei Dateien:
XXX_kern.c: eBPF-Programm.XXX_user.c: Hauptprogramm.
Das eBPF-Programm definiert Karten und Funktionen, die an den Abschnitt gebunden sind. Wenn der Kernel ein Ereignis eines bestimmten Typs ausgibt (zum Beispiel, tracepoint), werden die gebundenen Funktionen ausgeführt. Die Karten ermöglichen den Datenaustausch zwischen dem Kernelprogramm und dem Benutzeranwendungsprogramm.
Fazit
In diesem Artikel wurden BPF und eBPF grob umrissen. Ich weiß, dass es heute viele Informationen und Ressourcen zu eBPF gibt, daher empfehle ich noch einige Materialien für die vertiefte Studie
Ich empfehle zu lesen:
- Jonathan Corbet. Einführung in BPF und wie es sich zu eBPF entwickelt hat.
- Brendan Gregg. Artikel von LWN.net. Brendan twittert oft über eBPF und führt eine Liste von Ressourcen zu diesem Thema. .
- Julia Evans. Kommentare zu der Präsentation von Suchakra Sharma "The BSD Packet Filter: A New Architecture for User-level Packet Capture". Die Anmerkungen sind gut und helfen wirklich, die Folien zu verstehen.
- Ferris Ellis. Longread mit , aber es ist lesenswert. Einer der besten Artikel über eBPF, die ich gesehen habe.
Quelle: habr.com
