Kapitel 4. Nachverfolgung mit BPF

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In der Softwareentwicklung ist Tracing eine Methode, um Daten für Profiling und Debugging zu sammeln. Das Ziel ist es, zur Laufzeit nützliche Informationen für die spätere Analyse bereitzustellen. Der Hauptvorteil der BPF für das Tracing ist, dass du auf fast alle Informationen aus dem Linux-Kernel und deinen Anwendungen zugreifen kannst. Im Vergleich zu anderen Tracing-Technologien fügt die BPF der Systemleistung und der Latenzzeit nur ein Minimum an Overhead hinzu und erfordert von den Entwicklern keine Änderungen an ihren Anwendungen, nur um Daten aus ihnen zu sammeln.

Der Linux-Kernel bietet verschiedene Instrumentierungsmöglichkeiten, die in Verbindung mit der BPF genutzt werden können. In diesem Kapitel sprechen wir über diese verschiedenen Möglichkeiten. Wir zeigen dir, wie der Kernel diese Fähigkeiten in deinem Betriebssystem offenlegt, damit du weißt, wie du die Informationen findest, die deinen BPF-Programmen zur Verfügung stehen.

Das Ziel von Tracing ist es, dir ein tiefes Verständnis für jedes System zu geben, indem du alle verfügbaren Daten auf eine nützliche Art und Weise darstellst. Wir werden über verschiedene Datendarstellungen sprechen und darüber, wie du sie in unterschiedlichen Szenarien nutzen kannst.

Ab diesem Kapitel werden wir ein leistungsfähiges Toolkit zum Schreiben von BPF-Programmen verwenden: die BPF Compiler Collection (BCC). BCC ist eine Sammlung von Komponenten, die das Erstellen von BPF-Programmen berechenbarer macht. Selbst wenn du Clang und LLVM beherrschst, wirst du wahrscheinlich nicht mehr Zeit als nötig damit verbringen wollen, dieselben Hilfsprogramme zu erstellen und sicherzustellen, dass der BPF-Verifizierer deine Programme nicht ablehnt. BCC bietet wiederverwendbare Komponenten für gängige Strukturen wie Perf-Event-Maps und eine Integration mit dem LLVM-Backend, um bessere Debugging-Optionen zu bieten. Darüber hinaus enthält BCC Bindungen für verschiedene Programmiersprachen; in unseren Beispielen werden wir Python verwenden. Diese Bindungen ermöglichen es dir, den User-Space-Teil deiner BPF-Programme in einer Hochsprache zu schreiben, was zu nützlicheren Programmen führt. Wir verwenden BCC auch in den folgenden Kapiteln, um unsere Beispiele übersichtlicher zu gestalten.

Der erste Schritt, um Programme im Linux-Kernel zu verfolgen, besteht darin, die Erweiterungspunkte zu identifizieren, die der Kernel für BPF-Programme zur Verfügung stellt. Diese Erweiterungspunkte werden üblicherweise Sonden genannt.

Sonden

Eine der Definitionen im englischen Wörterbuch für das Wort " probe" lautet wie folgt:

Ein unbemanntes Erkundungsraumschiff, das Informationen über seine Umgebung übermittelt.

Diese Definition weckt in uns Erinnerungen an Science-Fiction-Filme und epische NASA-Missionen - und wahrscheinlich auch in dir. Wenn wir über das Aufspüren von Sonden sprechen, können wir eine sehr ähnliche Definition verwenden.

Tracing Probes sind Erkundungsprogramme, die Informationen über die Umgebung, in der sie ausgeführt werden, übermitteln sollen.

Sie sammeln Daten in deinem System und stellen sie dir zur Verfügung, damit du sie untersuchen und analysieren kannst. Früher mussten für die Verwendung von Sonden in Linux Programme geschrieben werden, die in Kernelmodule kompiliert wurden, was in Produktionssystemen zu katastrophalen Problemen führen konnte. Im Laufe der Jahre wurden sie so weiterentwickelt, dass sie sicherer ausgeführt werden können, aber immer noch umständlich zu schreiben und zu testen sind. Tools wie SystemTap haben neue Protokolle zum Schreiben von Sonden eingeführt und den Weg geebnet, um viel umfangreichere Informationen aus dem Linux-Kernel und allen Programmen, die im User-Space laufen, zu erhalten.

Die BPF sammelt mit Hilfe von Tracing Probes Informationen für die Fehlersuche und Analyse. Der Sicherheitscharakter der BPF-Programme macht sie attraktiver als Tools, die auf eine Neukompilierung des Kernels angewiesen sind. Die Neukompilierung des Kernels, um externe Module einzubinden, birgt das Risiko von Abstürzen aufgrund von fehlerhaftem Code. Die BPF-Prüfung eliminiert dieses Risiko, indem sie das Programm analysiert, bevor es in den Kernel geladen wird. Die BPF-Entwickler haben sich die Vorteile der Prüfdefinitionen zunutze gemacht und den Kernel so verändert, dass er BPF-Programme und nicht Kernelmodule ausführt, wenn eine Codeausführung eine dieser Definitionen findet.

Um herauszufinden, was in deinem System vor sich geht, ist es wichtig, die verschiedenen Arten von Sonden zu verstehen, die du definieren kannst. In diesem Abschnitt erläutern wir die verschiedenen Sonden-Definitionen, wie du sie in deinem System findest und wie du BPF-Programme mit ihnen verbindest.

In diesem Kapitel behandeln wir vier verschiedene Arten von Sonden:

Kernel-Sonden

Diese geben dir dynamischen Zugriff auf interne Komponenten im Kernel.

Tracepoints

Diese bieten statischen Zugriff auf interne Komponenten im Kernel.

Sonden für den Benutzerraum

Diese geben dir dynamischen Zugriff auf Programme, die im User-Space laufen.

Vom Benutzer statisch definierte Tracepoints

Diese ermöglichen den statischen Zugriff auf Programme, die im User-Space laufen.

Beginnen wir mit den Kernel-Sonden.

Kernel-Sonden

Kernel-Sonden ermöglichen es dir, dynamische Flags oder Unterbrechungen in fast allen Kernel-Befehlen mit einem Minimum an Overhead zu setzen. Wenn der Kernel eines dieser Flags erreicht, führt er den Code aus, der an die Probe angehängt ist, und setzt dann seine normale Routine fort. Kernel-Sonden können dir Informationen über alle Vorgänge in deinem System geben, z. B. über Dateien, die in deinem System geöffnet sind, oder über Binärdateien, die ausgeführt werden. Eine wichtige Sache, die du bei Kernel Probes beachten musst, ist, dass sie keine stabile Application Binary Interface (ABI) haben, was bedeutet, dass sie sich zwischen verschiedenen Kernel-Versionen ändern können. Wenn du versuchst, dieselbe Sonde an zwei Systeme mit unterschiedlichen Kernelversionen anzuschließen, kann derselbe Code nicht mehr funktionieren.

Kernel-Sonden werden in zwei Kategorien unterteilt: kprobes und kretprobes. Ihre Verwendung hängt davon ab, an welcher Stelle des Ausführungszyklus du dein BPF-Programm einfügen kannst. In diesem Abschnitt erfährst du, wie du BPF-Programme mit diesen Sonden verbinden und Informationen aus dem Kernel extrahieren kannst.

KProben

Mit Kprobes kannst du BPF-Programme einfügen, bevor eine Kernel-Anweisung ausgeführt wird. Du musst die Funktionssignatur kennen, in die du einbrechen willst, und wie wir bereits erwähnt haben, ist dies keine stabile ABI. Du solltest also vorsichtig sein, wenn du das gleiche Programm in verschiedenen Kernel-Versionen ausführen willst. Wenn der Kernel bei der Anweisung ankommt, bei der du deine Sonde gesetzt hast, schaltet er sich in deinen Code ein, führt dein BPF-Programm aus und kehrt zur ursprünglichen Anweisung zurück.

Um dir zu zeigen, wie du kprobes verwenden kannst, werden wir ein BPF-Programm schreiben, das den Namen jeder Binärdatei ausgibt, die in deinem System ausgeführt wird. Wir verwenden in diesem Beispiel das Python-Frontend für die BCC-Tools,, aber du kannst es auch mit jedem anderen BPF-Tool schreiben:

from bcc import BPF

bpf_source = """
int do_sys_execve(struct pt_regs *ctx, void filename, void argv, void envp) { 1
  char comm[16];
  bpf_get_current_comm(&comm, sizeof(comm));
  bpf_trace_printk("executing program: %s", comm);
  return 0;
}
"""

bpf = BPF(text = bpf_source)	2
execve_function = bpf.get_syscall_fnname("execve")		3
bpf.attach_kprobe(event = execve_function, fn_name = "do_sys_execve")	4
bpf.trace_print()
1

Unser BPF-Programm wird gestartet. Das Hilfsprogramm bpf_get_current_comm holt sich den Namen des aktuellen Befehls, den der Kernel ausführt, und speichert ihn in unserer Variable comm. Wir haben diese Variable als Array mit fester Länge definiert, weil der Kernel eine Beschränkung von 16 Zeichen für Befehlsnamen hat. Nachdem wir den Befehlsnamen erhalten haben, geben wir ihn in unserem Debug-Trace aus, damit die Person, die das Python-Skript ausführt, alle von der BPF erfassten Befehle sehen kann.

2

Lade das BPF-Programm in den Kernel.

3

Verknüpfe das Programm mit dem Syscall execve. Der Name dieses Syscalls hat sich in verschiedenen Kernelversionen geändert. BCC bietet eine Funktion, mit der du diesen Namen abrufen kannst, ohne dich an die jeweilige Kernelversion erinnern zu müssen.

4

Der Code gibt das Trace-Log aus, sodass du alle Befehle sehen kannst, die du mit diesem Programm verfolgst.

Kretprobes

Kretprobes werden in dein BPF-Programm eingefügt, wenn eine Kernel-Anweisung nach ihrer Ausführung einen Wert zurückgibt. Normalerweise willst du kprobes und kretprobes in einem einzigen BPF-Programm kombinieren, damit du ein vollständiges Bild vom Verhalten der Anweisung bekommst.

Wir werden ein ähnliches Beispiel wie im vorherigen Abschnitt verwenden, um dir zu zeigen, wie Kretprobes funktionieren:

from bcc import BPF

bpf_source = """
int ret_sys_execve(struct pt_regs *ctx) {	1
  int return_value;
  char comm[16];
  bpf_get_current_comm(&comm, sizeof(comm));
  return_value = PT_REGS_RC(ctx);

  bpf_trace_printk("program: %s, return: %d", comm, return_value);
  return 0;
}
"""

bpf = BPF(text = bpf_source)	   2
execve_function = bpf.get_syscall_fnname("execve")
bpf.attach_kretprobe(event = execve_function, fn_name = "ret_sys_execve")  3
bpf.trace_print()
1

Definiere die Funktion, die das BPF-Programm implementiert. Der Kernel führt sie unmittelbar nach Beendigung des Syscalls execve aus. PT_REGS_RC ist ein Makro, das den zurückgegebenen Wert aus dem BPF-Register für diesen speziellen Kontext liest. Außerdem verwenden wir bpf_trace_printk, um den Befehl und den zurückgegebenen Wert in unserem Debug-Protokoll auszugeben.

2

Initialisiere das BPF-Programm und lade es in den Kernel.

3

Ändere die Anhängefunktion auf attach_kretprobe.

Kernel-Sonden sind eine gute Möglichkeit, auf den Kernel zuzugreifen. Aber wie wir bereits erwähnt haben, können sie instabil sein, weil du dich an dynamische Punkte im Kernel-Quellcode anhängst, die sich von einer Version zur nächsten ändern oder verschwinden können. Jetzt lernst du eine andere Methode kennen, um Programme an den Kernel anzuhängen, die sicherer ist.

Tracepoints

Tracepoints sind statische Markierungen im Code des Kernels, die du verwenden kannst, um Code in einem laufenden Kernel anzuhängen. Der Hauptunterschied zu kprobes ist, dass sie von den Kernelentwicklern kodiert werden, wenn sie Änderungen im Kernel implementieren; deshalb bezeichnen wir sie als statisch. Weil sie statisch sind, ist die ABI für Tracepoints stabiler; der Kernel garantiert immer, dass ein Tracepoint in einer alten Version auch in neuen Versionen existiert. Da die Entwickler sie jedoch zum Kernel hinzufügen müssen, decken sie möglicherweise nicht alle Subsysteme ab, die den Kernel bilden.

Wie in Kapitel 2 erwähnt, kannst du alle verfügbaren Tracepoints in deinem System sehen, indem du alle Dateien in /sys/kernel/debug/tracing/events auflistest. Du kannst zum Beispiel alle Tracepoints für die BPF selbst finden, indem du die in /sys/kernel/debug/tracing/events/bpf definierten Ereignisse auflistest:

sudo ls -la /sys/kernel/debug/tracing/events/bpf
total 0
drwxr-xr-x  14 root root 0 Feb  4 16:13 .
drwxr-xr-x 106 root root 0 Feb  4 16:14 ..
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_map_create
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_map_delete_elem
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_map_lookup_elem
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_map_next_key
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_map_update_elem
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_obj_get_map
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_obj_get_prog
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_obj_pin_map
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_obj_pin_prog
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_prog_get_type
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_prog_load
drwxr-xr-x   2 root root 0 Feb  4 16:13 bpf_prog_put_rcu
-rw-r--r--   1 root root 0 Feb  4 16:13 enable
-rw-r--r--   1 root root 0 Feb  4 16:13 filter

Jedes Unterverzeichnis, das in dieser Ausgabe aufgeführt ist, entspricht einem Tracepoint, an den wir BPF-Programme anhängen können. Aber es gibt noch zwei weitere Dateien. Mit der ersten Datei, enable, kannst du alle Tracepoints für das BPF-Subsystem aktivieren und deaktivieren. Wenn der Inhalt der Datei 0 ist, sind die Tracepoints deaktiviert; wenn der Inhalt der Datei 1 ist, sind die Tracepoints aktiviert. Die Filterdatei ermöglicht es dir, Ausdrücke zu schreiben, die das Trace-Subsystem im Kernel zum Filtern von Ereignissen verwenden wird. Die BPF verwendet diese Datei nicht; mehr dazu findest du in der Tracing-Dokumentation des Kernels.

Das Schreiben von BPF-Programmen, die die Vorteile von Tracepoints nutzen, ist ähnlich wie das Tracen mit kprobes. Hier ist ein Beispiel, das ein BPF-Programm verwendet, um alle Anwendungen in deinem System zu verfolgen, die andere BPF-Programme laden:

from bcc import BPF

bpf_source = """
int trace_bpf_prog_load(void ctx) {	1
  char comm[16];
  bpf_get_current_comm(&comm, sizeof(comm));

  bpf_trace_printk("%s is loading a BPF program", comm);
  return 0;
}
"""

bpf = BPF(text = bpf_source)
bpf.attach_tracepoint(tp = "bpf:bpf_prog_load",
                      fn_name = "trace_bpf_prog_load") 2
bpf.trace_print()
1

Deklariere die Funktion, die das BPF-Programm definiert. Dieser Code kommt dir sicher schon bekannt vor; es gibt nur ein paar syntaktische Änderungen gegenüber dem ersten Beispiel, das du gesehen hast, als wir über kprobes gesprochen haben.

2

Der Hauptunterschied in diesem Programm: Statt das Programm an eine kprobe anzuhängen, hängen wir es an einen tracepoint. BCC folgt einer Konvention, um Tracepoints zu benennen: Zuerst gibst du das zu verfolgende Subsystem an - in diesem Fallbpf -, gefolgt von einem Doppelpunkt und dem Tracepoint im Subsystem, pbf_prog_load. Das bedeutet, dass dieses Programm jedes Mal, wenn der Kernel die Funktion bpf_prog_load ausführt, das Ereignis empfängt und den Namen der Anwendung ausgibt, die den Befehl bpf_prog_load ausführt.

Kernel-Sonden und Tracepoints geben dir vollen Zugriff auf den Kernel. Wir empfehlen dir, wann immer möglich Tracepoints zu verwenden, aber fühle dich nicht verpflichtet, dich an Tracepoints zu halten, nur weil sie sicherer sind. Nutze die dynamische Natur der Kernel-Sonden. Im nächsten Abschnitt gehen wir darauf ein, wie du einen ähnlichen Einblick in Programme bekommst, die im User-Space laufen.

Benutzerraum-Sonden

Mit User-Space-Probes kannst du dynamische Flags in Programmen setzen, die im User-Space laufen. Sie sind das Äquivalent zu Kernel-Probes für die Instrumentierung von Programmen, die außerhalb des Kernels laufen. Wenn du eine Uprobe definierst, erstellt der Kernel eine Trap um die angehängte Anweisung. Wenn deine Anwendung diese Anweisung erreicht, löst der Kernel ein Ereignis aus, das deine Sondenfunktion als Callback hat. Uprobes ermöglichen dir außerdem den Zugriff auf alle Bibliotheken, mit denen dein Programm verknüpft ist, und du kannst diese Aufrufe zurückverfolgen, wenn du den richtigen Namen der Anweisung kennst.

Ähnlich wie Kernel-Probes werden auch User-Space-Probes in zwei Kategorien unterteilt: Uprobes und Uretprobes, je nachdem, an welcher Stelle im Ausführungszyklus du dein BPF-Programm einfügen kannst. Fangen wir gleich mit ein paar Beispielen an.

Uprobes

Im Allgemeinen sind Uprobes Haken, die der Kernel in den Befehlssatz eines Programms einfügt, bevor ein bestimmter Befehl ausgeführt wird. Du musst vorsichtig sein, wenn du Uprobes an verschiedene Versionen desselben Programms anhängst, weil sich die Funktionssignaturen zwischen diesen Versionen intern ändern können. Die einzige Möglichkeit, um sicherzustellen, dass ein BPF-Programm in zwei verschiedenen Versionen läuft, ist sicherzustellen, dass sich die Signatur nicht geändert hat. Unter Linux kannst du mit dem Befehl nm alle Symbole auflisten, die in einer ELF-Objektdatei enthalten sind. So kannst du zum Beispiel überprüfen, ob die Anweisung, die du nachverfolgen willst, in deinem Programm noch existiert:

package main
import "fmt"

func main() {
        fmt.Println("Hello, BPF")
}

Du kannst dieses Go-Programm mit dem Befehl go build -o hello-bpf main.go kompilieren. Du kannst den Befehl nm verwenden, um Informationen über alle Befehlspunkte zu erhalten, die die Binärdatei enthält. nm ist ein Programm, das in den GNU Development Tools enthalten ist und Symbole aus Objektdateien auflistet. Wenn du die Symbole mit main im Namen filterst, erhältst du eine ähnliche Liste wie diese:

nm hello-bpf | grep main
0000000004850b0 T main.init
00000000567f06 B main.initdone.
00000000485040 T main.main
000000004c84a0 R main.statictmp_0
00000000428660 T runtime.main
0000000044da30 T runtime.main.func1
00000000044da80 T runtime.main.func2
000000000054b928 B runtime.main_init_done
00000000004c8180 R runtime.mainPC
0000000000567f1a B runtime.mainStarted

Jetzt, wo du eine Liste der Symbole hast, kannst du nachverfolgen, wann sie ausgeführt werden, sogar zwischen verschiedenen Prozessen, die das gleiche Binary ausführen.

Um zu verfolgen, wann die Hauptfunktion in unserem vorherigen Go-Beispiel ausgeführt wird, schreiben wir ein BPF-Programm und hängen es an einen Uprobe an, der unterbrochen wird, bevor ein Prozess diese Anweisung aufruft:

from bcc import BPF

bpf_source = """
int trace_go_main(struct pt_regs *ctx) {
  u64 pid = bpf_get_current_pid_tgid();		1
  bpf_trace_printk("New hello-bpf process running with PID: %d", pid);
}
"""

bpf = BPF(text = bpf_source)
bpf.attach_uprobe(name = "hello-bpf",
    sym = "main.main", fn_name = "trace_go_main")	2
bpf.trace_print()
1

Benutze die Funktion bpf_get_current_pid_tgid, um die Prozesskennung (PID) des Prozesses zu erhalten, der unser Programm hello-bpf ausführt.

2

Hänge dieses Programm an einen Uprobe an. Dieser Aufruf muss wissen, dass das Objekt, das wir verfolgen wollen, hello-bpf, der absolute Pfad zu der Objektdatei ist. Außerdem braucht er das Symbol, das wir innerhalb des Objekts verfolgen, in diesem Fall main.main, und das BPF-Programm, das wir ausführen wollen. So erhalten wir jedes Mal, wenn jemand hello-bpf in unserem System ausführt, ein neues Protokoll in unserer Trace-Pipe.

Uret-Proben

Uretprobes sind die parallele Sonde zu kretprobes, aber für User-Space-Programme. Sie verknüpfen BPF-Programme mit Anweisungen, die Werte zurückgeben, und geben dir Zugriff auf diese zurückgegebenen Werte, indem du von deinem BPF-Code aus auf die Register zugreifst.

Die Kombination von Uprobes und Uretrobes ermöglicht es dir, komplexere BPF-Programme zu schreiben. Sie geben dir einen ganzheitlicheren Überblick über die in deinem System laufenden Anwendungen. Wenn du Tracing-Code einschleusen kannst, bevor eine Funktion ausgeführt wird und unmittelbar nachdem sie beendet wurde, kannst du mehr Daten sammeln und das Verhalten der Anwendung messen. Ein häufiger Anwendungsfall ist die Messung der Ausführungsdauer einer Funktion, ohne dass du eine einzige Codezeile in deiner Anwendung ändern musst.

Wir werden das Go-Programm, das wir in "Uprobes" geschrieben haben, wiederverwenden, um zu messen, wie lange die Ausführung der Hauptfunktion dauert. Dieses BPF-Beispiel ist länger als die vorherigen Beispiele, deshalb haben wir es in verschiedene Codeblöcke unterteilt:

bpf_source = """
int trace_go_main(struct pt_regs *ctx) {
  u64 pid = bpf_get_current_pid_tgid();
  bpf_trace_printk("New hello-bpf process running with PID: %d", pid); 1
}
"""

bpf = BPF(text = bpf_source)
bpf.attach_uprobe(name = "hello-bpf", 	2
    sym = "main.main", fn_name = "trace_go_main")	3
bpf.trace_print()
1

Erstelle eine BPF-Hashtabelle. Diese Tabelle ermöglicht es uns, Daten zwischen den Funktionen uprobe und uretprobe auszutauschen. In diesem Fall verwenden wir die PID der Anwendung als Tabellenschlüssel und speichern die Startzeit der Funktion als Wert. Die beiden interessantesten Vorgänge in unserer uprobe-Funktion werden im Folgenden beschrieben.

2

Erfasst die aktuelle Zeit im System in Nanosekunden, wie sie vom Kernel gesehen wird.

3

Erstelle einen Eintrag in unserem Cache mit der PID des Programms und der aktuellen Uhrzeit. Wir können davon ausgehen, dass diese Zeit die Startzeit der Funktion der Anwendung ist. Deklarieren wir jetzt unsere Funktion uretprobe:

Implementiere die Funktion zum Anhängen, wenn deine Anweisung beendet ist. Diese Uretprobe-Funktion ist ähnlich wie die anderen, die du in "Kretprobes" gesehen hast :

bpf_source += """
static int print_duration(struct pt_regs *ctx) {
  u64 pid = bpf_get_current_pid_tgid();       1
  u64 start_time_ns = cache.lookup(&pid);
  if (start_time_ns == 0) {
    return 0;
  }
  u64 duration_ns = bpf_ktime_get_ns() - start_time_ns;
  bpf_trace_printk("Function call duration: %d", duration_ns);  2
  return 0;     3
}
"""
1

Erhalte die PID für unsere Anwendung; wir brauchen sie, um die Startzeit zu finden. Wir verwenden die Map-Funktion lookup, um diese Zeit aus der Map zu holen, in der wir sie gespeichert haben, bevor die Funktion ausgeführt wurde.

2

Berechne die Funktionsdauer, indem du diese Zeit von der aktuellen Zeit abziehst.

3

Gib die Latenz in unserem Trace-Log aus, damit wir sie im Terminal anzeigen können.

Jetzt muss der Rest des Programms diese beiden BPF-Funktionen mit den richtigen Sonden verbinden:

bpf = BPF(text = bpf_source)
bpf.attach_uprobe(name = "hello-bpf", sym = "main.main",
           fn_name = "trace_start_time")
bpf.attach_uretprobe(name = "hello-bpf", sym = "main.main",
           fn_name = "print_duration")
bpf.trace_print()

Wir haben eine Zeile zu unserem ursprünglichen uprobe-Beispiel hinzugefügt, in der wir unsere Druckfunktion an die uretprobe für unsere Anwendung anhängen.

In diesem Abschnitt hast du gesehen, wie du mit der BPF Vorgänge im User-Space verfolgen kannst. Indem du BPF-Funktionen kombinierst, die an verschiedenen Punkten im Lebenszyklus deiner Anwendung ausgeführt werden, kannst du viel mehr Informationen aus ihr herausholen. Wie wir bereits zu Beginn dieses Abschnitts erwähnt haben, sind User-Space-Probes zwar leistungsstark, aber auch instabil. Unsere BPF-Beispiele können nicht mehr funktionieren, nur weil jemand beschließt, die Funktion einer Anwendung umzubenennen. Sehen wir uns nun eine stabilere Methode an, um User-Space-Programme zu verfolgen.

Statisch definierte Benutzer-Tracepoints

Benutzerdefinierte statische Tracepoints (USDTs) bieten statische Tracepoints für Anwendungen im User-Space. Dies ist ein bequemer Weg, um Anwendungen zu instrumentieren, denn sie bieten einen einfachen Einstieg in die Tracing-Funktionen der BPF. Du kannst sie auch als Konvention verwenden, um Anwendungen in der Produktion zu verfolgen, unabhängig von der Programmiersprache, mit der diese Anwendungen geschrieben wurden.

USDTs wurden durch DTrace bekannt, ein Tool, das ursprünglich bei Sun Microsystems für die dynamische Instrumentierung von Unix-Systemen entwickelt wurde. DTrace war bis vor kurzem aufgrund von Lizenzproblemen nicht für Linux verfügbar, aber die Linux-Kernel-Entwickler haben sich bei der Implementierung von USDTs stark von der ursprünglichen Arbeit in DTrace inspirieren lassen.

Ähnlich wie bei den statischen Kernel-Tracepoints, die du vorhin gesehen hast, müssen Entwickler bei den USDTs ihren Code mit Anweisungen instrumentieren, die der Kernel als Traps zur Ausführung von BPF-Programmen verwendet. Die Hello World-Version der USDTs besteht nur aus ein paar Codezeilen:

 #include <sys/sdt.h>
 int main() {
   DTRACE_PROBE("hello-usdt", "probe-main");
 }

In diesem Beispiel verwenden wir ein Makro, das Linux zur Verfügung stellt, um unseren ersten USDT zu definieren. Du kannst schon sehen, woher der Kernel seine Inspiration nimmt. DTRACE_PROBE registriert den Tracepoint, den der Kernel verwendet, um unseren BPF-Funktions-Callback zu injizieren. Das erste Argument in diesem Makro ist das Programm, das den Trace meldet. Das zweite Argument ist der Name des Trace, den wir melden.

Viele Anwendungen, die du vielleicht in deinem System installiert hast, nutzen diese Art von Sonde, um dir auf vorhersehbare Weise Zugriff auf Laufzeit-Tracing-Daten zu geben. Die beliebte Datenbank MySQL zum Beispiel gibt alle möglichen Informationen über statisch definierte Tracepoints preis. Du kannst Informationen von Abfragen, die auf dem Server ausgeführt werden, sowie von vielen anderen Benutzeroperationen sammeln. Node.js, die JavaScript-Laufzeitumgebung, die auf der V8-Engine von Chrome aufbaut, bietet ebenfalls Tracepoints, mit denen du Laufzeitinformationen extrahieren kannst.

Bevor wir dir zeigen, wie du BPF-Programme an benutzerdefinierte Tracepoints anhängen kannst, müssen wir über die Auffindbarkeit sprechen. Da diese Tracepoints im Binärformat in den ausführbaren Dateien definiert sind, brauchen wir eine Möglichkeit, die von einem Programm definierten Sonden aufzulisten, ohne den Quellcode durchforsten zu müssen. Eine Möglichkeit, diese Informationen zu erhalten, ist das direkte Lesen der ELF-Binärdatei. Zuerst kompilieren wir unser vorheriges Hello World USDT-Beispiel; dafür können wir GCC verwenden:

gcc -o hello_usdt hello_usdt.c

Dieser Befehl erzeugt eine Binärdatei mit dem Namen hello_usdt, die wir verwenden können, um mit verschiedenen Tools die darin definierten Tracepoints zu entdecken. Linux bietet ein Dienstprogramm namens readelf, das dir Informationen über ELF-Dateien anzeigt. Du kannst es mit unserem kompilierten Beispiel verwenden:

readelf -n ./hello_usdt

Du kannst die USDT, die wir definiert haben, in der Ausgabe dieses Befehls sehen:

Displaying notes found in: .note.stapsdt
  Owner                 Data size        Description
  stapsdt              0x00000033        NT_STAPSDT (SystemTap probe descriptors)
    Provider: "hello-usdt"
    Name: "probe-main"

readelf kann dir eine Menge Informationen über eine Binärdatei geben; in unserem kleinen Beispiel zeigt es nur ein paar Zeilen an, aber bei komplizierteren Binärdateien wird die Ausgabe mühsam zu analysieren.

Eine bessere Möglichkeit, die in einer Binärdatei definierten Tracepoints herauszufinden, ist die Verwendung des BCC-Tools tplist, das sowohl Kernel-Tracepoints als auch USDTs anzeigen kann. Der Vorteil dieses Tools liegt in der Einfachheit seiner Ausgabe: Es zeigt dir nur die Tracepoints an, ohne zusätzliche Informationen über die ausführbare Datei. Die Verwendung ist ähnlich wie bei readelf:

 tplist -l ./hello_usdt

Es listet jeden Tracepoint, den du definierst, in einzelnen Zeilen auf. In unserem Beispiel wird nur eine einzige Zeile mit unserer probe-main Definition angezeigt:

 ./hello_usdt "hello-usdt":"probe-main"

Wenn du die unterstützten Tracepoints in deiner Binärdatei kennst, kannst du ihnen BPF-Programme auf ähnliche Weise wie in den vorherigen Beispielen zuordnen:

from bcc import BPF, USDT

bpf_source = """
#include <uapi/linux/ptrace.h>
int trace_binary_exec(struct pt_regs *ctx) {
  u64 pid = bpf_get_current_pid_tgid();
  bpf_trace_printk("New hello_usdt process running with PID: %d", pid);
}
"""

usdt = USDT(path = "./hello_usdt")	1
usdt.enable_probe(probe = "probe-main", fn_name = "trace_binary_exec")     2
bpf = BPF(text = bpf_source, usdt = usdt)	3
bpf.trace_print()

In diesem Beispiel gibt es eine wichtige Änderung, die etwas Erklärung erfordert.

1

Erstelle ein USDT-Objekt; in unseren vorherigen Beispielen haben wir das nicht getan. USDTs sind nicht Teil der BPF, d.h. du kannst sie verwenden, ohne mit der BPF VM zu interagieren. Da sie unabhängig voneinander sind, macht es Sinn, dass ihre Verwendung unabhängig vom BPF-Code ist.

2

Hänge die BPF-Funktion an die Sonde in unserer Anwendung an, um Programmausführungen zu verfolgen.

3

Initialisiere unsere BPF-Umgebung mit der Tracepoint-Definition, die wir gerade erstellt haben. Dadurch wird BCC informiert, dass es den Code erzeugen muss, um unser BPF-Programm mit der Prüfpunktdefinition in unserer Binärdatei zu verbinden. Wenn beide miteinander verbunden sind, können wir die von unserem BPF-Programm erzeugten Traces ausdrucken, um neue Ausführungen unseres Binärbeispiels zu entdecken.

USDTs Bindungen für andere Sprachen

Du kannst USDTs auch verwenden, um Anwendungen zu verfolgen, die mit anderen Programmiersprachen als C geschrieben wurden. Auf GitHub findest du Bindungen für Python, Ruby, Go, Node.js und viele andere Sprachen. Die Ruby-Bindings gehören wegen ihrer Einfachheit und ihrer Interoperabilität mit Frameworks wie Rails zu unseren Favoriten. Dale Hamel, der derzeit bei Shopify arbeitet, hat in seinem Blog einen hervorragenden Bericht über die Verwendung von USDTs geschrieben. Er unterhält auch eine Bibliothek namens ruby-static-tracing, mit der das Tracing von Ruby- und Rails-Anwendungen noch einfacher ist.

Die statische Tracing-Bibliothek von Hamel ermöglicht es dir, Tracing-Funktionen auf Klassenebene einzubauen, ohne dass du die Tracing-Logik zu jeder Methode in der Klasse hinzufügen musst. In komplexen Szenarien bietet sie dir außerdem bequeme Methoden, um selbst spezielle Tracing-Endpunkte zu registrieren.

Um ruby-static-tracing in deinen Anwendungen zu nutzen, musst du zunächst festlegen, wann die Tracepoints aktiviert werden sollen. Du kannst sie standardmäßig einschalten, wenn die Anwendung startet. Wenn du aber den Overhead vermeiden willst, der durch das ständige Sammeln von Daten entsteht, kannst du sie mit einem Syscall-Signal aktivieren. Hamel empfiehlt, PROF als Signal zu verwenden:

require 'ruby-static-tracing'

StaticTracing.configure do |config|
  config.mode = StaticTracing::Configuration::Modes::SIGNAL
  config.signal = StaticTracing::Configuration::Modes::SIGNALS::SIGPROF
end

Mit dieser Konfiguration kannst du den Befehl kill verwenden, um die statischen Tracepoints deiner Anwendung bei Bedarf zu aktivieren. Im nächsten Beispiel gehen wir davon aus, dass auf unserem Rechner nur ein Ruby-Prozess läuft, dessen Prozesskennung wir mit pgrep ermitteln können:

kill -SIGPROF `pgrep -nx ruby`

Neben der Konfiguration, wann die Tracepoints aktiv sind, möchtest du vielleicht auch einige der eingebauten Tracing-Mechanismen nutzen, die ruby-static-tracing bietet. Zum Zeitpunkt der Erstellung dieses Artikels enthält die Bibliothek Tracepoints zur Messung der Latenzzeit und zum Sammeln von Stack Traces. Wir finden es toll, dass eine mühsame Aufgabe wie die Messung der Latenzzeit von Funktionen mit diesem eingebauten Modul fast trivial wird. Zuerst musst du den Latenz-Tracer zu deiner Anfangskonfiguration hinzufügen:

require 'ruby-static-tracing'
require 'ruby-static-tracing/tracer/concerns/latency_tracer'

StaticTracing.configure do |config|
  config.add_tracer(StaticTracing::Tracer::Latency)
end

Danach erzeugt jede Klasse, die das Latenzmodul enthält, statische Tracepoints für jede definierte öffentliche Methode. Wenn Tracing aktiviert ist, kannst du diese Tracepoints abfragen, um Zeitdaten zu sammeln. In unserem nächsten Beispiel erzeugt ruby-static-tracing einen statischen Tracepoint mit dem Namen usdt:/proc/X/fd/Y:user_model:find und folgt dabei der Konvention, den Klassennamen als Namensraum für den Tracepoint und den Methodennamen als Tracepoint-Namen zu verwenden:

class UserModel
  def find(id)
  end

  include StaticTracing::Tracer::Concerns::Latency
end

Jetzt können wir BCC verwenden, um die Latenzinformationen für jeden Aufruf unserer Methode find zu extrahieren. Dazu verwenden wir die eingebauten BCC-Funktionen bpf_usdt_readarg und bpf_usdt_readarg_p. Diese Funktionen lesen die Argumente, die jedes Mal gesetzt werden, wenn der Code unserer Anwendung ausgeführt wird. ruby-static-tracing setzt immer den Methodennamen als erstes Argument für den Tracepoint, während es den berechneten Wert als zweites Argument setzt. Das nächste Snippet implementiert das BPF-Programm, das die Tracepoint-Informationen abruft und im Tracing-Log ausgibt:

bpf_source = """
#include <uapi/linux/ptrace.h>
int trace_latency(struct pt_regs *ctx) {
  char method[64];
  u64 latency;

  bpf_usdt_readarg_p(1, ctx, &method, sizeof(method));
  bpf_usdt_readarg(2, ctx, &latency);

  bpf_trace_printk("method %s took %d ms", method, latency);
}
"""

Außerdem müssen wir das vorherige BPF-Programm in den Kernel laden. Da wir eine bestimmte Anwendung verfolgen, die bereits auf unserem Rechner läuft, können wir das Programm an die Prozesskennung anhängen:

parser = argparse.ArgumentParser()
parser.add_argument("-p", "--pid", type = int, help = "Process ID")	1
args = parser.parse_args()

usdt = USDT(pid = int(args.pid))
usdt.enable_probe(probe = "latency", fn_name = "trace_latency")		2
bpf = BPF(text = bpf_source, usdt = usdt)
bpf.trace_print()
1

Gib diese PID an.

2

Aktiviere die Sonde, lade das Programm in den Kernel und drucke das Tracing-Protokoll. (Dieser Abschnitt ähnelt sehr dem, den du zuvor gesehen hast).

In diesem Abschnitt haben wir dir gezeigt, wie du Anwendungen introspektierst, die Tracepoints statisch definieren. Viele bekannte Bibliotheken und Programmiersprachen enthalten diese Sonden, um dir bei der Fehlersuche in laufenden Anwendungen zu helfen und einen besseren Einblick zu bekommen, wenn sie in Produktionsumgebungen laufen. Das ist aber nur die Spitze des Eisbergs: Wenn du die Daten hast, musst du sie sinnvoll nutzen. Damit befassen wir uns als Nächstes.

Visualisierung von Verfolgungsdaten

Bisher haben wir Beispiele gezeigt, die Daten in unserer Debug-Ausgabe ausgeben. Das ist in Produktionsumgebungen nicht sehr nützlich. Du willst diese Daten sinnvoll nutzen, aber niemand möchte lange und komplizierte Logs lesen. Wenn wir Änderungen bei der Latenz und der CPU-Auslastung überwachen wollen, ist es einfacher, sich Diagramme über einen bestimmten Zeitraum anzusehen, als Zahlen aus einem Dateistrom zu aggregieren.

In diesem Abschnitt werden verschiedene Möglichkeiten zur Darstellung von BPF-Tracing-Daten untersucht. Einerseits zeigen wir dir, wie BPF-Programme Informationen in Aggregaten für dich strukturieren können. Andererseits erfährst du, wie du diese Informationen in einer portablen Darstellung exportieren und handelsübliche Tools nutzen kannst, um auf eine umfassendere Darstellung zuzugreifen und deine Ergebnisse mit anderen zu teilen.

Flammengrafiken

Flammendiagramme sind Diagramme, die dir zeigen, wie dein System Zeit verbringt. Sie können dir eine klare Darstellung davon geben, welcher Code in einer Anwendung am häufigsten ausgeführt wird. Brendan Gregg, der Erfinder der Flame-Graphen, stellt auf GitHub eine Reihe von Skripten zur Verfügung, mit denen du diese Visualisierungsformate leicht erstellen kannst. Wir verwenden diese Skripte, um aus den mit der BPF gesammelten Daten Flame-Graphen zu erstellen (siehe unten). In Abbildung 4-1 siehst du, wie diese Diagramme aussehen.

Flame graph visualization
Abbildung 4-1. Ein CPU-Flammendiagramm

Es gibt zwei wichtige Dinge, an die du dich erinnern musst, wenn du ein Flammendiagramm betrachtest:

  • Die x-Achse ist alphabetisch geordnet. Die Breite jedes Stapels zeigt an, wie oft er in den gesammelten Daten auftaucht. Dies kann damit korreliert werden, wie oft dieser Codepfad besucht wurde, während der Profiler aktiviert war.

  • Die y-Achse zeigt die Stack Traces in der Reihenfolge an, in der der Profiler sie liest, damit die Trace-Hierarchie erhalten bleibt.

Die bekanntesten Flame-Graphen stellen den Code dar, der die CPU in deinem System am häufigsten beansprucht; diese werden On-CPU-Graphen genannt. Eine weitere interessante Visualisierung von Flame-Graphen sind Off-CPU-Graphen; sie zeigen die Zeit, die eine CPU mit anderen Aufgaben verbringt, die nichts mit deiner Anwendung zu tun haben. Durch die Kombination von On-CPU- und Off-CPU-Diagrammen erhältst du einen vollständigen Überblick darüber, wofür dein System CPU-Zyklen aufwendet.

Sowohl On-CPU- als auch Off-CPU-Diagramme verwenden Stack Traces, um anzuzeigen, wo das System Zeit verbringt. Einige Programmiersprachen, wie z. B. Go, enthalten immer Trace-Informationen in ihren Binärdateien, aber andere, wie z. B. C++ und Java, erfordern einige zusätzliche Arbeit, um Stack Traces lesbar zu machen. Wenn deine Anwendung Stack-Trace-Informationen enthält, können BPF-Programme diese nutzen, um die häufigsten Codepfade aus der Sicht des Kernels zusammenzufassen.

Hinweis

Die Stack-Trace-Aggregation im Kernel hat Vor- und Nachteile. Einerseits ist es ein effizienter Weg, um die Häufigkeit von Stack-Traces zu zählen, da dies im Kernel geschieht, wodurch vermieden wird, dass alle Stack-Informationen an den User-Space gesendet werden und der Datenaustausch zwischen Kernel und User-Space reduziert wird. Andererseits kann die Anzahl der zu verarbeitenden Ereignisse für Off-CPU-Graphen sehr hoch sein, da du jedes Ereignis verfolgen musst, das während des Kontextwechsels deiner Anwendung auftritt. Das kann zu einem erheblichen Overhead in deinem System führen, wenn du zu lange versuchst, das Profil zu erstellen. Behalte dies im Hinterkopf, wenn du mit Flame-Graphen arbeitest.

BCC bietet mehrere Hilfsprogramme, die dir bei der Aggregation und Visualisierung von Stack Traces helfen, aber das wichtigste ist das Makro BPF_STACK_TRACE. Dieses Makro erzeugt eine BPF-Map vom Typ BPF_MAP_TYPE_STACK_TRACE, um die Stacks zu speichern, die dein BPF-Programm anhäuft. Darüber hinaus wird diese BPF-Map mit Methoden erweitert, mit denen du die Stack-Informationen aus dem Programmkontext extrahieren und die gesammelten Stack-Traces durchlaufen kannst, wenn du sie nach der Aggregation verwenden willst.

Im nächsten Beispiel erstellen wir einen einfachen BPF-Profiler, der die Stack Traces von User-Space-Anwendungen ausgibt. Mit den Traces, die unser Profiler sammelt, erstellen wir On-CPU-Flame-Graphen. Um diesen Profiler zu testen, werden wir ein minimales Go-Programm schreiben, das CPU-Last erzeugt. Dies ist der Code für diese Minimalanwendung:

package main

import "time"

func main() {
	j := 3
	for time.Since(time.Now()) < time.Second {
		for i := 1; i < 1000000; i++ {
			j *= i
		}
	}
}

Wenn du diesen Code in einer Datei namens main.go speicherst und ihn mit go run main.go ausführst, wirst du sehen, dass die CPU-Auslastung deines Systems deutlich ansteigt. Du kannst die Ausführung stoppen, indem du Strg-C auf deiner Tastatur drückst, und die CPU-Auslastung geht wieder auf den Normalwert zurück.

Der erste Teil unseres BPF-Programms dient dazu, die Profiler-Strukturen zu initialisieren:

bpf_source = """
#include <uapi/linux/ptrace.h>
#include <uapi/linux/bpf_perf_event.h>
#include <linux/sched.h>

struct trace_t { 	1
  int stack_id;
}

BPF_HASH(cache, struct trace_t);	2
BPF_STACK_TRACE(traces, 10000);		3
"""
1

Initialisiere eine Struktur, die den Referenzbezeichner für jeden der Stack Frames speichert, die unser Profiler empfängt. Wir verwenden diese Bezeichner später, um herauszufinden, welcher Codepfad zu diesem Zeitpunkt ausgeführt wurde.

2

Initialisiere eine BPF-Hash-Map, mit der wir zusammenfassen, wie oft wir denselben Strack-Frame sehen. Die Flame-Graph-Skripte verwenden diesen aggregierten Wert, um festzustellen, wie häufig derselbe Code ausgeführt wird.

3

Initialisiere unsere BPF Stack Trace Map. Wir legen eine maximale Größe für diese Map fest, die aber variieren kann, je nachdem, wie viele Daten du verarbeiten willst. Es wäre besser, diesen Wert als Variable zu haben, aber wir wissen, dass unsere Go-Anwendung nicht sehr groß ist, also sind 10.000 Elemente ausreichend.

Als Nächstes implementieren wir die Funktion, die Stack Traces in unserem Profiler zusammenfasst:

bpf_source += """
int collect_stack_traces(struct bpf_perf_event_data *ctx) {
  u32 pid = bpf_get_current_pid_tgid() >> 32;		1
  if (pid != PROGRAM_PID)
    return 0;

  struct trace_t trace = {	2
    .stack_id = traces.get_stackid(&ctx->regs, BPF_F_USER_STACK)
  };

  cache.increment(trace);	3
  return 0;
}
"""
1

Überprüfe, ob die Prozess-ID für das Programm im aktuellen BPF-Kontext diejenige für unsere Go-Anwendung ist; andernfalls ignorieren wir das Ereignis. Wir haben den Wert für PROGRAM_PID noch nicht definiert. Wir müssen diesen String im Python-Teil des Profilers ersetzen, bevor wir das BPF-Programm initialisieren. Dies ist eine derzeitige Einschränkung in der Art und Weise, wie BCC das BPF-Programm initialisiert; wir können keine Variablen aus dem Userspace übergeben, und als gängige Praxis werden diese Zeichenketten im Code vor der Initialisierung ersetzt.

2

Erstelle einen Trace, um seine Nutzung zu erfassen. Wir holen uns die Stack-ID aus dem Programmkontext mit , der eingebauten Funktion get_stackid. Dies ist einer der Helfer, die BCC zu unserer Stack Trace Map hinzufügt. Mit dem Flag BPF_F_USER_STACK geben wir an, dass wir die Stack-ID für die User-Space-Anwendung abrufen wollen und es uns egal ist, was im Kernel passiert.

3

Erhöhe den Zähler für unseren Trace, um zu verfolgen, wie oft derselbe Code ausgeübt wird.

Als Nächstes werden wir unseren Stack Trace Collector an alle Perf-Ereignisse im Kernel anhängen:

program_pid = int(sys.argv[0])		1
bpf_source = bpf_source.replace('PROGRAM_PID', program_pid) 2

bpf = BPF(text = bpf_source)
bpf.attach_perf_event(ev_type = PerfType.SOFTWARE,          3
                      ev_config = PerfSWConfig.CPU_CLOCK,
                      fn_name = 'collect_stack_traces')
1

Das erste Argument für unser Python-Programm. Dies ist der Prozessbezeichner für die Go-Anwendung, die wir profilieren wollen.

2

Verwende die in Python eingebaute Funktion replace, um die Zeichenkette PROGRAM_ID in unserem BPF-Quelltext mit dem Argument zu vertauschen, das wir dem Profiler übergeben haben.

3

Hänge das BPF-Programm an alle Software-Perf-Ereignisse an. Andere Ereignisse wie Hardware-Ereignisse werden dabei ignoriert. Außerdem konfigurieren wir unser BPF-Programm so, dass es die CPU-Uhr als Zeitquelle verwendet, damit wir die Ausführungszeit messen können.

Schließlich müssen wir den Code implementieren, der die Stack Traces in unsere Standardausgabe ausgibt, wenn der Profiler unterbrochen wird:

try:
  sleep(99999999)
except KeyboardInterrupt:
  signal.signal(signal.SIGINT, signal_ignore)

for trace, acc in sorted(cache.items(), key=lambda cache: cache[1].value):   1
  line = []
  if trace.stack_id < 0 and trace.stack_id == -errno.EFAULT		     2
    line = ['Unknown stack']
  else
    stack_trace = list(traces.walk(trace.stack_id))
    for stack_address in reversed(stack_trace)			             3
      line.extend(bpf.sym(stack_address, program_pid))		     	4

  frame = b";".join(line).decode('utf-8', 'replace')		     	5
  print("%s %d" % (frame, acc.value))
1

Iteriere über alle gesammelten Spuren, damit wir sie der Reihe nach ausdrucken können.

2

Überprüfe, ob wir Stack-Identifikatoren erhalten haben, die wir später mit bestimmten Codezeilen in Verbindung bringen können. Wenn wir einen ungültigen Wert erhalten, verwenden wir einen Platzhalter in unserem Flammendiagramm.

3

Iteriere über alle Einträge im Stack Trace in umgekehrter Reihenfolge. Das machen wir, weil wir den ersten, zuletzt ausgeführten Codepfad ganz oben sehen wollen, wie du es in jedem Stack Trace erwarten würdest.

4

Verwende den BCC-Helfer sym, um die Speicheradresse für den Stack-Frame in einen Funktionsnamen in unserem Quellcode zu übersetzen.

5

Formatiere die Stack-Trace-Zeile durch Semikolons getrennt. Das ist das Format, das die Flame-Graph-Skripte später erwarten, um unsere Visualisierung erzeugen zu können.

Nachdem wir unseren BPF-Profiler fertiggestellt haben, können wir ihn als sudo ausführen, um Stack Traces für unser Go-Programm zu sammeln. Wir müssen die Prozess-ID des Go-Programms an unseren Profiler übergeben, um sicherzustellen, dass wir nur Traces für diese Anwendung sammeln; diese PID können wir mit pgrep finden. So führst du den Profiler aus, wenn du ihn in einer Datei namens profiler.py speicherst:

./profiler.py `pgrep -nx go` > /tmp/profile.out

pgrep sucht in der PID nach einem Prozess, der auf deinem System läuft und dessen Name mit go übereinstimmt. Wir senden die Ausgabe unseres Profilers in eine temporäre Datei, damit wir die Visualisierung des Flammengraphen erstellen können.

Wie bereits erwähnt, werden wir die FlameGraph-Skripte von Brendan Gregg verwenden, um eine SVG-Datei für unser Diagramm zu erstellen; du findest diese Skripte in seinem GitHub-Repository. Nachdem du das Repository heruntergeladen hast, kannst du flamegraph.pl verwenden, um das Diagramm zu erstellen. Du kannst das Diagramm mit deinem Lieblingsbrowser öffnen; wir verwenden in diesem Beispiel Firefox:

./flamegraph.pl /tmp/profile.out > /tmp/flamegraph.svg && \
  firefox /tmp/flamefraph.svg

Diese Art von Profiler ist nützlich, um Leistungsprobleme in deinem System aufzuspüren. BCC enthält bereits einen fortschrittlicheren Profiler als den in unserem Beispiel, den du direkt in deinen Produktionsumgebungen einsetzen kannst. Neben dem Profiler enthält BCC Tools, mit denen du Off-CPU-Flame-Graphen und viele andere Visualisierungen zur Analyse von Systemen erstellen kannst.

Flammendiagramme sind nützlich für die Leistungsanalyse. Wir nutzen sie häufig bei unserer täglichen Arbeit. In vielen Szenarien willst du nicht nur heiße Codepfade visualisieren, sondern auch messen, wie oft Ereignisse in deinen Systemen auftreten. Darauf konzentrieren wir uns als Nächstes.

Histogramme

Histogramme sind Diagramme, die dir zeigen, wie häufig verschiedene Wertebereiche auftreten. Die numerischen Daten, die dies darstellen sollen, werden in Bereiche unterteilt, und jeder Bereich enthält die Anzahl des Auftretens eines beliebigen Datenpunkts innerhalb des Bereichs. Die Häufigkeit, die Histogramme messen, ist die Kombination aus der Höhe und der Breite der einzelnen Bereiche. Wenn die Eimer in gleiche Bereiche unterteilt sind, entspricht diese Häufigkeit der Höhe des Histogramms. Sind die Bereiche jedoch nicht gleichmäßig aufgeteilt, musst du jede Höhe mit jeder Breite multiplizieren, um die richtige Häufigkeit zu ermitteln.

Histogramme sind eine grundlegende Komponente für die Analyse der Systemleistung. Sie sind ein großartiges Werkzeug, um die Verteilung von messbaren Ereignissen, wie z. B. die Latenzzeit von Anweisungen, darzustellen, da sie mehr korrekte Informationen liefern als andere Messungen, wie z. B. Durchschnittswerte.

BPF-Programme können Histogramme auf der Grundlage vieler Metriken erstellen. Du kannst BPF-Maps verwenden, um die Informationen zu sammeln, sie in Bereiche zu klassifizieren und dann die Histogrammdarstellung für deine Daten zu erzeugen. Die Implementierung dieser Logik ist nicht kompliziert, aber es wird mühsam, wenn du jedes Mal Histogramme ausdrucken willst, wenn du die Ausgabe eines Programms analysieren musst. BCC enthält eine Standardimplementierung, die du in jedem Programm wiederverwenden kannst, ohne dass du jedes Mal manuell die Häufigkeit und das Bucketing berechnen musst. Der Kernel-Quellcode enthält jedoch eine fantastische Implementierung, die du dir in den BPF-Beispielen ansehen solltest.

Als lustiges Experiment zeigen wir dir, wie du die Histogramme von BCC nutzen kannst, um die Latenz zu visualisieren, die durch das Laden von BPF-Programmen entsteht, wenn eine Anwendung die Anweisung bpf_prog_load aufruft. Wir verwenden kprobes, um zu erfassen, wie lange es dauert, bis dieser Befehl ausgeführt wird, und wir sammeln die Ergebnisse in einem Histogramm, das wir später visualisieren werden. Wir haben dieses Beispiel in mehrere Teile aufgeteilt, um es einfacher zu machen, ihm zu folgen.

Dieser erste Teil enthält die ursprüngliche Quelle für unser BPF-Programm:

bpf_source = """
#include <uapi/linux/ptrace.h>

BPF_HASH(cache, u64, u64);
BPF_HISTOGRAM(histogram);

int trace_bpf_prog_load_start(void ctx) {		1
  u64 pid = bpf_get_current_pid_tgid();			2
  u64 start_time_ns = bpf_ktime_get_ns();
  cache.update(&pid, &start_time_ns);			3
  return 0;
}
"""
1

Verwende ein Makro, um eine BPF-Hash-Map zu erstellen, um den Anfangszeitpunkt zu speichern, wenn die Anweisung bpf_prog_load ausgelöst wird.

2

Verwende ein neues Makro, um eine BPF-Histogrammkarte zu erstellen. Dies ist keine native BPF-Map; BCC enthält dieses Makro, um dir die Erstellung dieser Visualisierungen zu erleichtern. Das BPF-Histogramm verwendet Array-Maps, um die Informationen zu speichern. Außerdem gibt es mehrere Helfer, die das Bucketing durchführen und das endgültige Diagramm erstellen.

3

Verwende die PID des Programms, um zu speichern, wann die Anwendung den Befehl auslöst, den wir verfolgen wollen. (Diese Funktion wird dir bekannt vorkommen - wir haben sie aus dem vorherigen Uprobes-Beispiel übernommen),

Schauen wir uns an, wie wir das Delta für die Latenzzeit berechnen und in unserem Histogramm speichern. Auch die ersten Zeilen dieses neuen Codeblocks werden dir bekannt vorkommen, denn wir folgen immer noch dem Beispiel, über das wir in "Uprobes" gesprochen haben .

bpf_source += """
int trace_bpf_prog_load_return(void ctx) {
  u64 *start_time_ns, delta;
  u64 pid = bpf_get_current_pid_tgid();
  start_time_ns = cache.lookup(&pid);
  if (start_time_ns == 0)
    return 0;

  delta = bpf_ktime_get_ns() - *start_time_ns;		1
  histogram.increment(bpf_log2l(delta));		2
  return 0;
}
"""
1

Berechne das Delta zwischen dem Zeitpunkt, an dem der Befehl aufgerufen wurde, und der Zeit, die unser Programm brauchte, um hier anzukommen; wir können davon ausgehen, dass es auch die Zeit ist, in der der Befehl abgeschlossen wurde.

2

Speichere dieses Delta in unserem Histogramm. In dieser Zeile führen wir zwei Operationen durch. Zunächst verwenden wir die eingebaute Funktion bpf_log2l, um den Bucket Identifier für den Wert des Deltas zu erzeugen. Diese Funktion erzeugt eine stabile Verteilung der Werte über die Zeit. Dann fügen wir mit der Funktion increment ein neues Element zu diesem Bucket hinzu. Standardmäßig fügt increment dem Wert 1 hinzu, wenn der Bucket im Histogramm existiert, oder es beginnt einen neuen Bucket mit dem Wert 1.

Der letzte Teil des Codes, den wir schreiben müssen, fügt diese beiden Funktionen an die gültigen kprobes an und gibt das Histogramm auf dem Bildschirm aus, damit wir die Latenzverteilung sehen können. In diesem Abschnitt initialisieren wir unser BPF-Programm und warten auf Ereignisse, um das Histogramm zu erstellen:

bpf = BPF(text = bpf_source)			1
bpf.attach_kprobe(event = "bpf_prog_load",
    fn_name = "trace_bpf_prog_load_start")
bpf.attach_kretprobe(event = "bpf_prog_load",
    fn_name = "trace_bpf_prog_load_return")

try:						2
  sleep(99999999)
except KeyboardInterrupt:
  print()

bpf["histogram"].print_log2_hist("msecs")	3
1

Initialisiere die BPF und füge unsere Funktionen zu kprobes hinzu.

2

Lass unser Programm warten, damit wir so viele Ereignisse wie nötig von unserem System sammeln können.

3

Drucke die Histogrammkarte in unserem Terminal mit der nachverfolgten Verteilung der Ereignisse - dies ist ein weiteres BCC-Makro, mit dem wir die Histogrammkarte erhalten.

Wie wir bereits zu Beginn dieses Abschnitts erwähnt haben, können Histogramme nützlich sein, um Anomalien in deinem System zu beobachten. Die BCC-Tools enthalten zahlreiche Skripte, die Histogramme zur Darstellung von Daten nutzen; wir empfehlen dir, einen Blick darauf zu werfen, wenn du Inspiration brauchst, um in dein System einzutauchen.

Perf Ereignisse

Wir glauben, dass Perf-Ereignisse wahrscheinlich die wichtigste Kommunikationsmethode sind, die du beherrschen musst, um BPF Tracing erfolgreich zu nutzen. Im vorherigen Kapitel haben wir über die BPF Perf Event Array Maps gesprochen. Sie ermöglichen es dir, Daten in einem Pufferring zu speichern, der in Echtzeit mit User-Space-Programmen synchronisiert wird. Das ist ideal, wenn du in deinem BPF-Programm eine große Menge an Daten sammelst und die Verarbeitung und Visualisierung an ein User-Space-Programm auslagern willst. So hast du mehr Kontrolle über die Präsentationsschicht, weil du nicht durch die BPF VM in deinen Programmierfähigkeiten eingeschränkt bist. Die meisten BPF-Tracing-Programme, die du finden kannst, verwenden Perf-Ereignisse nur für diesen Zweck.

Hier zeigen wir dir, wie du sie verwendest, um Informationen über die Ausführung von Binärdateien zu extrahieren und diese Informationen zu klassifizieren, um auszugeben, welche Binärdateien in deinem System am häufigsten ausgeführt werden. Wir haben dieses Beispiel in zwei Codeblöcke unterteilt, damit du dem Beispiel leicht folgen kannst. Im ersten Block definieren wir unser BPF-Programm und verbinden es mit einer kprobe, wie wir es in "Sonden" getan haben :

bpf_source = """
#include <uapi/linux/ptrace.h>

BPF_PERF_OUTPUT(events);		1

int do_sys_execve(struct pt_regs *ctx, void filename, void argv, void envp) {
  char comm[16];
  bpf_get_current_comm(&comm, sizeof(comm));

  events.perf_submit(ctx, &comm, sizeof(comm));		2
  return 0;
}
"""

bpf = BPF(text = bpf_source)						3
execve_function = bpf.get_syscall_fnname("execve")
bpf.attach_kprobe(event = execve_function, fn_name = "do_sys_execve")

In der ersten Zeile dieses Beispiels importieren wir eine Bibliothek aus der Standardbibliothek von Python. Wir werden einen Python-Zähler verwenden, um die Ereignisse zu sammeln, die wir von unserem BPF-Programm erhalten.

1

Verwende BPF_PERF_OUTPUT, um eine Perf-Events-Map zu deklarieren. Dies ist ein praktisches Makro, das BCC zur Verfügung stellt, um diese Art von Map zu deklarieren. Wir nennen diese Map Events.

2

Nachdem wir den Namen des Programms, das der Kernel ausgeführt hat, erhalten haben, senden wir es zur Aggregation an den Benutzerbereich. Das machen wir mit perf_submit. Diese Funktion aktualisiert die Perf-Events-Map mit unserer neuen Information.

3

Initialisiere das BPF-Programm und hänge es an die kprobe, damit es ausgelöst wird, wenn ein neues Programm in unserem System ausgeführt wird.

Nachdem wir nun den Code geschrieben haben, um alle Programme zu sammeln, die in unserem System ausgeführt werden, müssen wir sie im User-Space zusammenfassen. Der nächste Codeschnipsel enthält eine Menge Informationen, deshalb gehen wir die wichtigsten Zeilen mit dir durch:

from collections import Counter
aggregates = Counter()			1

def aggregate_programs(cpu, data, size):	2
  comm = bpf["events"].event(data)		 3
  aggregates[comm] += 1

bpf["events"].open_perf_buffer(aggregate_programs)	4
while True:
    try:
      bpf.perf_buffer_poll()
    except KeyboardInterrupt:				5
      break

for (comm, times) in aggregates.most_common():
  print("Program {} executed {} times".format(comm, times))
1

Deklariere einen Zähler, um unsere Programminformationen zu speichern. Wir verwenden den Namen des Programms als Schlüssel und die Werte werden Zähler sein. Wir verwenden die Funktion aggregate_programs, um die Daten aus der Perf-Events-Map zu sammeln. In diesem Beispiel siehst du, wie wir das BCC-Makro verwenden, um auf die Map zuzugreifen und das nächste eingehende Datenereignis vom oberen Ende des Stacks zu extrahieren.

2

Erhöht die Anzahl, wie oft wir ein Ereignis mit demselben Programmnamen erhalten haben.

3

Verwende die Funktion open_perf_buffer, um BCC mitzuteilen, dass es die Funktion aggregate_programs jedes Mal ausführen muss, wenn es ein Ereignis aus der Perf-Ereigniskarte erhält.

4

BCC fragt die Ereignisse nach dem Öffnen des Ringpuffers ab, bis wir dieses Python-Programm unterbrechen. Je länger du wartest, desto mehr Informationen wirst du verarbeiten müssen. Du kannst sehen, wie wir perf_buffer_poll für diesen Zweck nutzen.

5

Verwende die Funktion most_common, um die Liste der Elemente im Zähler abzurufen und in einer Schleife zuerst die am häufigsten ausgeführten Programme in deinem System zu drucken.

Perf-Ereignisse können die Tür öffnen, um alle Daten, die die BPF offenlegt, auf neue und unerwartete Weise zu verarbeiten. Wir haben dir ein Beispiel gezeigt, um deine Fantasie anzuregen, wenn du beliebige Daten aus dem Kernel sammeln willst. Du kannst viele weitere Beispiele in den Tools finden, die BCC für das Tracing bereitstellt.

Fazit

In diesem Kapitel haben wir nur an der Oberfläche des Tracing mit der BPF gekratzt. Der Linux-Kernel gibt dir Zugang zu Informationen, die mit anderen Tools schwieriger zu erhalten sind. Die BPF macht diesen Prozess berechenbarer, weil sie eine gemeinsame Schnittstelle für den Zugriff auf diese Daten bietet. In späteren Kapiteln wirst du weitere Beispiele sehen, die einige der hier beschriebenen Techniken anwenden, z. B. das Anhängen von Funktionen an Tracepoints. Sie werden dir helfen, das hier Gelernte zu festigen.

Wir haben in diesem Kapitel das BCC-Framework verwendet, um die meisten Beispiele zu schreiben. Du kannst die gleichen Beispiele auch in C implementieren, wie wir es in den vorherigen Kapiteln getan haben, aber BCC bietet einige eingebaute Funktionen, die das Schreiben von Tracing-Programmen viel zugänglicher machen als C. Wenn du Lust auf eine lustige Herausforderung hast, kannst du versuchen, diese Beispiele in C umzuschreiben.

Im nächsten Kapitel zeigen wir dir einige Tools, die die System-Community auf die BPF aufgesetzt hat, um Leistungsanalysen und Tracing durchzuführen. Du kannst zwar deine eigenen Programme schreiben, aber mit diesen speziellen Tools hast du Zugriff auf viele der Informationen, die wir hier gesehen haben, in verpackter Form. Auf diese Weise musst du keine Tools umschreiben, die bereits existieren.

Get Linux Observabilität mit BPF now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.