Книга: BPF для мониторинга Linux
Назад: 3. Карты BPF
Дальше: 5. Утилиты BPF

4. Трассировка с помощью BPF

В программной инженерии трассировка — это метод сбора данных для профилирования и отладки. Цель этих действий — собрать во время выполнения полезную информацию для последующего анализа. Основное преимущество использования BPF для трассировки в том, что вы можете получить доступ практически к любой части информации ядра Linux с помощью своих приложений. При этом BPF не дает заметного снижения производительности и не приводит к большей загрузке системы, по сравнению с другими технологиями трассировки, и от разработчиков не требуется вносить изменения в приложения только с целью сбора данных.

Ядро Linux предоставляет несколько инструментальных возможностей, которые можно использовать вместе с BPF. В этой главе мы поговорим о них. Мы покажем, как все происходит в ядре и как найти информацию, доступную для ваших программ BPF.

Цель трассировки — дать вам глубокое понимание любой системы, получив все доступные данные и представив их вам наиболее полезным способом. Мы поговорим о нескольких разных представлениях данных и о том, как вы можете применить их в разных сценариях.

Начиная с этой главы, мы собираемся задействовать мощный инструментарий для написания BPF-программ — BPF Compiler Collection (BCC). Отметьте, пожалуйста, что GCC — это основной компилятор программ для UNIX. BCC — это набор компонентов, которые делают построение BPF-программ более предсказуемым. Даже если вы владеете Clang и LLVM, то, вероятно, не захотите тратить больше времени, чем необходимо, на создание одних и тех же утилит и обеспечение того, чтобы верификатор BPF не отбрасывал ваши программы. BCC предоставляет повторно используемые компоненты для общих структур, таких как карты событий Perf, и интеграцию с бэкендом LLVM, чтобы задействовать наилучшие варианты отладки. Кроме того, BCC включает привязки для нескольких языков программирования — в наших примерах использован Python. Эти привязки позволяют вам писать часть программ BPF пользовательского пространства на языке высокого уровня, что значительно упрощает создание программ. В последующих главах мы также применим BCC, чтобы показать реальные примеры.

Первым шагом для трассировки программ в ядре Linux является определение точек расширения, которые оно предоставляет для присоединения программ BPF. Эти точки обычно называют зондами.

Зонды

Вот одно из определений в словаре английского языка для слова «зонд»: «Беспилотный исследовательский космический корабль, предназначенный для передачи информации об окружающей среде».

Это определение вызывает у нас (вероятно, у вас тоже) воспоминания о научно-фантастических фильмах и эпических миссиях НАСА. Говоря о трассировке с помощью зондов, можно использовать очень похожее определение: «Трассировочные зонды — это исследовательские программы, предназначенные для передачи информации о среде, в которой они работают».

Зонды собирают данные в вашей системе и предоставляют эту информацию для изучения и анализа. Традиционно их применение в Linux подразумевало написание программ, которые были бы скомпилированы в модули ядра, что могло вызвать катастрофические последствия в производственных системах. С годами они стали более безопасными для выполнения, но все еще были громоздкими в написании и тестировании. Такие инструменты, как SystemTap, ввели новые протоколы для написания зондов и упростили получение более конкретной информации из ядра Linux и всех программ, работающих в пространстве пользователя.

BPF использует трассировки для сбора, отладки и анализа информации. Безопасная природа программ BPF делает их более привлекательными, чем те инструменты, которые все еще требуют перекомпиляции ядра. Перекомпиляция ядра с включением внешних модулей может повысить вероятность сбоев из-за неправильного поведения кода. Верификатор BPF устраняет этот риск, анализируя программу перед загрузкой в ядро. Разработчики BPF (BPF — это сетевой фильтр, разработанный командой FreeBSD, отсюда и название) использовали определения для зондов и изменили ядро так, чтобы, когда встречается одно из этих определений, выполнялись программы BPF, а не модули ядра.

Понимание того, что представляют собой различные типы зондов, которые вы можете задать, чрезвычайно важно для исследования происходящего в вашей системе. В этом разделе мы классифицируем различные определения зондов и расскажем, как их обнаруживать в вашей системе и как связывать с ними BPF-программы.

В этой главе рассмотрим четыре типа зондов.

Зонды ядра. Предоставляют динамический доступ к внутренним компонентам ядра.

• Точки трассировки. Обеспечивают статический доступ к внутренним компонентам ядра.

• Зонды в пользовательском пространстве. Дают динамический доступ к программам, работающим в пользовательском пространстве.

• Статически определенные пользователем точки трассировки. Обеспечивают статический доступ к программам, запущенным в пространстве пользователя.

Начнем с зондов ядра.

Зонды ядра

Зонды ядра позволяют с минимальными издержками устанавливать динамические флаги или точки останова практически в любой инструкции ядра. Дойдя до одного из этих флагов, ядро выполняет код, прикрепленный к тесту, а затем возобновляет обычную работу. Зонды ядра могут дать вам информацию обо всем, что происходит в системе, например об открытых в ней файлах и исполняемых двоичных файлах. Важно иметь в виду, что у зондов ядра нет стабильного двоичного интерфейса приложения (ABI), то есть они могут меняться между версиями ядра. Один и тот же код может перестать работать, если вы попытаетесь подключить один и тот же зонд к двум системам с разными версиями ядра.

Зонды ядра делятся на две категории — kprobes и kretprobes. Их использование зависит от того, где в цикле выполнения вы можете вставить программу BPF. В этом разделе рассказывается, как с помощью каждой из них присоединять программы BPF к зондам и извлекать информацию из ядра.

Kprobes

Kprobes позволяет вставлять программы BPF перед выполнением любой инструкции ядра. Вам нужно знать сигнатуру функции, которую предстоит исследовать (как говорилось ранее, это нестабильный ABI, поэтому вы должны быть осторожны при настройке зондов, если собираетесь запускать одну и ту же программу на разных версиях ядра). Когда в ходе работы ядра запускается инструкция, в которой вы установили зонд, оно попадает в ваш код, запускает вашу программу BPF и возвращается к выполнению в исходную точку.

Чтобы показать вам, как использовать kprobes, мы напишем программу BPF, которая выводит имя любого двоичного файла, выполняемого в вашей системе. В этом примере применен внешний интерфейс Python для инструментов BCC, но вы можете написать его с помощью любого другого инструмента BPF:

from bcc import BPF

 

bpf_source = """

int do_sys_execve(struct pt_regs *ctx, void filename, void argv, void envp) {                                                                      

  char comm[16];

  bpf_get_current_comm(&comm, sizeof(comm));

  bpf_trace_printk("executing program: %s", comm);

  return 0;

}

"""

 

bpf = BPF(text = bpf_source)                                           

execve_function = bpf.get_syscall_fnname("execve")                     

bpf.attach_kprobe(event = execve_function, fn_name = "do_sys_execve")  

bpf.trace_print()

Наша BPF-программа начинает работать. Помощник bpf_get_current_comm извлечет имя текущей команды, под управлением которой работает ядро, и сохранит его в переменной comm. Мы определили это как массив фиксированной длины, потому что ядро имеет ограничение 16 символов для имен команд. После получения имени команды мы печатаем его в трассировке отладки, чтобы человек, который запустил сценарий Python, смог увидеть все команды, отображаемые BPF.

Загрузка программы BPF в ядро.

Связывание программы с системным вызовом execve. Имя этого системного вызова меняется в разных версиях ядра, и BCC предоставляет функцию для его получения, причем не требуется запоминать, какую версию ядра вы используете.

Код выводит журнал трассировки, поэтому вы можете видеть все команды, которые вы отслеживаете с помощью этой программы.

Kretprobes

Kretprobes запустит вашу программу BPF после выполнения ядром определенной инструкции и вернет значение. Обычно и kprobes, и kretprobes объединяются в одну BPF-программу, чтобы иметь полное представление о поведении инструкции.

Используем пример, аналогичный приведенному в предыдущем разделе, чтобы показать вам, как работает kretprobes:

from bcc import BPF

 

bpf_source = """

int ret_sys_execve(struct pt_regs *ctx) {                                 

  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)                                              

execve_function = bpf.get_syscall_fnname("execve")

bpf.attach_kretprobe(event = execve_function, fn_name = "ret_sys_execve")

bpf.trace_print()

Определение функции, которая реализует программу BPF. Ядро выполнит ее сразу после завершения системного вызова execve. PT_REGS_RC — это макрос, который будет считывать возвращенное значение из реестра BPF для этого конкретного контекста. Мы также используем bpf_trace_printk, чтобы отметить команду и возвращенное ей значение в нашем журнале отладки.

Инициализация программы BPF и ее загрузка в ядро.

Замена присоединенной функции на attach_kretprobe.

Что такое аргумент контекста

Возможно, вы заметили, что у обеих BPF-программ первый аргумент в присоединенной функции один и тот же — ctx. Этот параметр, называемый контекстом, дает доступ к информации, которую ядро обрабатывает в настоящее время. Следовательно, контекст зависит от типа программы BPF, которую вы используете в данный момент. Процессор будет хранить информацию о текущей задаче, которую выполняет ядро. Эта структура также зависит от архитектуры вашей системы: набор регистров процессора ARM отличается от имеющегося в процессоре x64. Вы можете получить доступ к этим регистрам, не беспокоясь об архитектуре, с помощью макросов, определенных в ядре, например, PT_REGS_RC.

Зонды ядра — это мощный способ доступа к ядру. Но как мы упоминали ранее, они могут быть нестабильными, потому что вы привязываетесь к динамическим точкам в исходном коде ядра, которые могут измениться или исчезнуть в следующей версии. Есть другой, более безопасный способ прикрепления программ к ядру.

Точки трассировки

Точки трассировки — это статичные маркеры в коде ядра, которые можно использовать для присоединения кода в работающем ядре. Основное их отличие от kprobes заключается в том, что они встроены в ядро разработчиками. Вот почему мы называем их статичными. ABI для точек трассировки более стабилен: ядро всегда гарантирует, что точки трассировки, имеющиеся в старой версии, будут существовать и в новых версиях. Однако, учитывая, что разработчикам необходимо добавить их в ядро, они не всегда охватывают все подсистемы, из которых состоит ядро.

Как мы упоминали в главе 2, вы можете найти все доступные точки трассировки в своей системе, просмотрев все файлы в /sys/kernel/debug/tracing/events. Например, можете найти все точки трассировки для самого BPF, глядя на события, определенные в /sys/kernel/debug/tracing/events/bpf:

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

Каждый подкаталог, указанный в этих выходных данных, соответствует точке трассировки, к которой мы можем присоединить программы BPF. Но там есть еще два дополнительных файла. Первый файл, enable, позволяет включать и отключать все точки трассировки для подсистемы BPF. Если содержимое файла равно 0, точки трассировки отключены, если 1 — включены. Файл filter позволяет задать способ фильтрования событий подсистемой Trace в ядре. BPF не использует данный файл. Подробнее об этом можно узнать из документации по трассировке ядра (oreil.ly/miNRd).

Написание программ BPF с применением точек трассировки аналогично трассировке с помощью kprobes. Вот пример, который задействует программу BPF для трассировки всех приложений в вашей системе, загружающих другие программы BPF:

from bcc import BPF

 

bpf_source = """

int trace_bpf_prog_load(void ctx) {                      

  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")   

bpf.trace_print()

Объявление функции, которая определяет программу BPF. Этот код уже должен быть вам знаком. Есть лишь несколько синтаксических изменений в первом примере, который вы видели, когда мы говорили о kprobes.

Основное отличие этой программы: вместо того чтобы прикреплять программу к kprobe, мы прикрепляем ее к точке трассировки. BCC придерживается соглашения об именовании точек трассировки: сначала указывается подсистема для трассировки — в данном случае bpf, затем двоеточие, за которым следует точка трассировки в подсистеме pbf_prog_load. Это означает, что каждый раз, когда ядро выполняет функцию bpf_prog_load, программа получит событие и выведет имя приложения, которое выполняет данную инструкцию bpf_prog_load.

Зонды ядра и точки трассировки предоставят вам полный доступ к ядру. Мы рекомендуем использовать точки трассировки всякий раз, когда это возможно, но вы не обязаны применять их только потому, что они безопаснее. Воспользуйтесь преимуществами динамических возможностей зондов ядра. В следующем разделе мы обсудим, как получить аналогичный уровень наблюдаемости в программах, работающих в пользовательском пространстве.

Зонды пользовательского пространства

Зонды пользовательского пространства позволяют устанавливать динамические флаги в программах, работающих в пользовательском пространстве. Они являются эквивалентом зондов ядра для программ, работающих вне ядра. Когда вы определяете uprobe, ядро создает ловушку вокруг прикрепленной инструкции. Когда ваше приложение достигнет этой инструкции, ядро запускает событие, использующее зондовую функцию в качестве обратного вызова. uprobe также дают вам доступ к любой библиотеке, с которой связана ваша программа, и вы можете отслеживать такие вызовы, если знаете правильное имя инструкции.

Как и зонды ядра, зонды пользовательского пространства подразделяются на две категории: uprobes и uretprobes — в зависимости от того, где в цикле выполнения вы можете вставить свою BPF-программу. Рассмотрим несколько примеров.

Uprobes

Вообще говоря, uprobes — это ловушки, которые ядро вставляет в набор команд программы перед выполнением конкретной инструкции. Вы должны быть осторожны, когда присоединяете uprobes к различным версиям одной и той же программы, потому что сигнатуры функций могут меняться от версии к версии. Единственный способ гарантировать, что программа BPF будет работать в разных версиях, — это убедиться в том, что подпись не изменилась. Можете использовать команду nm в Linux, чтобы получить список всех символов, включенных в объектный файл ELF, что является хорошим способом проверить, существует ли еще инструкция, которую вы отслеживаете, в вашей программе, например:

package main

import "fmt"

 

func main() {

        fmt.Println("Hello, BPF")

}

Вы можете скомпилировать программу Go, используя gobuild-ohello-bpfmain.go. Можно ввести команду nm, чтобы получить информацию обо всех точках инструкций, которые содержит двоичный файл. nm — это программа, включенная в GNU Development Tools, которая выводит символы из объектных файлов. Если вы отфильтруете то, что содержит main, то получите список, подобный следующему:

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

Теперь, когда у вас есть список символов, можете отслеживать, когда они выполняются, даже между разными процессами, выполняющими один и тот же бинарный код.

Чтобы отследить, когда будет выполнена основная функция в предыдущем примере, напишем программу BPF и прикрепим ее к uprobe, который прервется до того, как какой-либо процесс вызовет эту инструкцию:

from bcc import BPF

 

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);

}

"""

 

bpf = BPF(text = bpf_source)

bpf.attach_uprobe(name = "hello-bpf",

    sym = "main.main", fn_name = "trace_go_main")   

bpf.trace_print()

Используем функцию bpf_get_current_pid_tgid, чтобы получить идентификатор процесса (PID), который выполняет программу hello-bpf.

Подключаем эту программу к uprobe. Вызов должен знать, что объект, который мы хотим отследить, hello-bpf, является абсолютным путем к объектному файлу. Ему также нужны символ, который мы отслеживаем внутри объекта, — в данном случае main.main — и программа BPF, которую хотим запустить. При этом каждый раз, когда кто-то запускает hello-bpf в вашей системе, мы получаем новую запись трассировки.

Uretprobes

Uretprobes — это то же самое, что и зонд kretprobes, но для программ пользовательского пространства. Они присоединяют программы BPF к инструкциям, возвращающим значения, и предоставляют вам возвращенные значения для получения доступа к регистрам из вашего кода BPF.

Комбинирование uprobes и uretprobes позволяет вам писать более сложные программы BPF. Они могут дать более целостное представление о приложениях, работающих в вашей системе. Если вы вставите код трассировки до запуска функции и сразу после ее завершения, то сможете собрать больше данных и оценить поведение приложения. Обычный вариант — измерение времени выполнения функции без отслеживания каждой строки кода в своем приложении.

Мы повторно используем программу Go, описанную в разделе «Uprobes», чтобы измерить, сколько времени потребуется для выполнения основной функции. Этот пример BPF длиннее, чем предыдущие, поэтому мы разделили его на несколько блоков:

bpf_source = """

BPF_HASH(cache, u64, u64);                        

 

int trace_start_time(struct pt_regs *ctx) {

  u64 pid = bpf_get_current_pid_tgid();

  u64 start_time_ns = bpf_ktime_get_ns();         

  cache.update(&pid, &start_time_ns);             

  return 0;

}

"""

Создание хеш-карты BPF. Эта таблица позволяет функциям uprobe и uretprobe обмениваться данными. В этом случае мы используем PID приложения в качестве ключа таблицы и сохраняем время запуска функции в качестве значения. Две наиболее интересные операции в функции uprobe выполняются так, как описано далее.

Получаем текущее время в системе в наносекундах, как задано ядром.

Создаем запись в нашем кэше с PID программы и текущим временем. Можно предположить, что это время запуска функции приложения.

Теперь объявим функцию uretprobe. Реализуйте функцию, которую нужно вызвать, когда ваша инструкция отработает. Эта функция uretprobe похожа на другие, которые вы видели в разделе «Kretprobes»:

bpf_source += """

static int print_duration(struct pt_regs *ctx) {

  u64 pid = bpf_get_current_pid_tgid();                          

  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);   

  return 0;                                                      

}

"""

Получаем PID для нашего приложения — это нужно для того, чтобы определить момент старта. Используем функцию lookup, чтобы извлечь это время из карты, где мы его сохранили до запуска функции.

Рассчитываем продолжительность выполнения функции, определив разницу во времени.

Отмечаем задержку в нашем журнале трассировки, чтобы потом отобразить ее в терминале.

Теперь остальная часть программы должна прикрепить две функции BPF к правильным зондам:

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()

Мы добавили строку в оригинальный пример uprobe там, где к uretprobe присоединяется функция печати для приложения.

В этом разделе вы увидели, как отслеживать операции, которые протекают в пространстве пользователя, с помощью BPF. Комбинируя функции BPF, выполняющиеся в разные моменты жизненного цикла приложения, можно получить гораздо более богатую информацию. Однако, как упоминалось в начале этого раздела, хотя зонды пользовательского пространства являются мощным средством, они также нестабильны. Функции BPF могут перестать работать только потому, что кто-то изменит имя функции приложения. А сейчас рассмотрим более устойчивый способ трассировки программ пользовательского пространства.

Статические точки трассировки пользовательского пространства

Статически определенные пользователем точки трассировки (USDT) — это статические точки трассировки для приложений в пользовательском пространстве. Их совокупное использование — это удобный способ для инструментов приложений, потому что накладные расходы на точку входа в возможности трассировки, которые предлагает BPF, невелики. Вы можете использовать их и как соглашение для отслеживания приложений в рабочей среде, независимо от языка программирования, на котором они написаны.

USDT были применены в DTrace, инструменте, изначально разработанном в Sun Microsystems для динамического инструментария систем Unix. До недавнего времени DTrace не был доступен в Linux из-за проблем с лицензированием, однако разработчики ядра Linux исходили из опыта создания DTrace при реализации USDT.

Подобно статическим точкам трассировки ядра, USDT требуют, чтобы разработчики дополнили свой код инструкциями, которые ядро будет использовать в качестве ловушек для выполнения программ BPF. Версия USDTs Hello World состоит всего из нескольких строк кода:

#include <sys/sdt.h>

int main() {

  DTRACE_PROBE("hello-usdt", "probe-main");

}

В этом примере мы применим макрос, который Linux предоставляет для определения нашего первого USDT. Вы уже можете видеть, откуда ядро получает информацию. DTRACE_PROBE регистрирует точку трассировки, которую ядро будет использовать для внедрения нашего обратного вызова функции BPF. Первым аргументом в этом макросе является программа, сообщающая о трассировке, вторым — название трассы, о которой мы хотим узнать.

Многие приложения, которые вы, возможно, установили в своей системе, используют этот тип проверки для доступа к данным трассировки во время выполнения каким-либо предсказуемым образом. Например, популярная база данных MySQL предоставляет все виды информации, задействуя статически определенные точки трассировки. Вы можете собирать информацию из запросов, выполняемых на сервере, а также из многих других пользовательских операций. Node.js — среда выполнения JavaScript, построенная на основе движка Chrome V8, — также предоставляет точки трассировки, которые можно использовать для извлечения информации о времени выполнения.

Прежде чем показать вам, как присоединять программы BPF к определенной пользовательской точке трассировки, нужно поговорить о том, как их обнаружить. Поскольку точки трассировки определены в двоичном формате внутри исполняемых файлов, нам нужен способ найти список зондов, определенных программой, не копаясь в исходном коде. Одним из способов извлечь эту информацию является непосредственное чтение двоичного файла в формате ELF. Мы можем перекомпилировать предыдущий пример Hello World USDT с помощью GCC:

gcc -o hello_usdt hello_usdt.c

Эта команда сгенерирует двоичный файл с именем hello_usdt, с помощью которого можно применить несколько инструментов для нахождения точек трассировки. Linux предоставляет утилиту readelf для отображения информации о файлах ELF. Испробуйте ее на только что скомпилированном примере:

readelf -n ./hello_usdt

Вы можете увидеть USDT, который мы определили на основе вывода команды:

Displaying notes found in: .note.stapsdt

  Owner             Data size      Description

  stapsdt          0x00000033      NT_STAPSDT (SystemTap probe descriptors)

  Provider: "hello-usdt"

  Name: "probe-main"

readelf способна дать намного больше информации о двоичном файле. В нашем небольшом примере она показывает лишь несколько строк информации, но ее вывод слишком подробен для анализа более сложных двоичных файлов.

Лучшим вариантом для обнаружения точек трассировки, определенных в двоичном файле, является использование инструмента BCC tplist, который может отображать как точки трассировки ядра, так и USDT. Преимущество этого инструмента в простоте его вывода: он показывает только определения точек трассировки без дополнительной информации об исполняемом файле. Используется примерно так же, как readelf:

tplist -l ./hello_usdt

Здесь перечислены все точки трассировки, которые вы задаете. В нашем примере отображается только одна строка с определением probe-main:

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

После того как вы узнаете поддерживаемые точки трассировки в своем двоичном файле, можете присоединить к ним BPF-программы аналогично тому, как было показано в предыдущих примерах:

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")                                       

usdt.enable_probe(probe = "probe-main", fn_name = "trace_binary_exec")   

bpf = BPF(text = bpf_source, usdt = usdt)                                

bpf.trace_print()

В этом примере есть серьезное изменение, которое требует объяснения.

Создать объект USDT. Мы не делали этого в предыдущих примерах. USDT не являются частью BPF, то есть вы можете использовать их, не взаимодействуя с виртуальной машиной BPF. Поскольку они не зависят друг от друга, их применение не зависит от кода BPF.

Присоединить функцию BPF для отслеживания выполнения программы к зонду в нашем приложении.

Инициализировать среду BPF с помощью определения точки трассировки, которое мы только что создали. Это сообщит BCC, что ему необходимо сгенерировать код для соединения нашей программы BPF с определением зонда в создаваемом двоичном файле. Когда они оба присоединены, мы можем получить трассировки, сгенерированные BPF-программой, чтобы отследить выполнение основной программы.

USDT и другие языки программирования

Можно использовать USDT также для отслеживания приложений, написанных на других языках программирования, помимо C. Вы найдете на GitHub привязки для Python, Ruby, Go, Node.js и многих других языков. Привязки для Ruby являются одними из наших любимых из-за их простоты и совместимости с такими фреймворками, как Rails. Дейл Хэмел, который в настоящее время работает в Shopify, написал в своем блоге отличный отчет о применении USDT (https://oreil.ly/7pgNO). Он также поддерживает библиотеку ruby-static-tracing (https://oreil.ly/ge6cu), которая делает отслеживание приложений Ruby и Rails еще более простым.

Библиотека статической трассировки Хэмела позволяет задействовать возможности трассировки на уровне класса, не требуя добавления логики трассировки для каждого метода в этом классе. В сложных сценариях это обеспечивает удобные методы самостоятельной регистрации выделенных конечных точек трассировки.

Чтобы использовать ruby-static-tracing в своих приложениях, сначала укажите, когда будут включены точки трассировки. Можете включить их по умолчанию при запуске приложения, но если хотите избежать непрерывного сбора данных, активируйте их с помощью сигнала системного вызова. Дэйл Хэмел рекомендует взять в качестве этого сигнала PROF:

require 'ruby-static-tracing'

 

StaticTracing.configure do |config|

  config.mode = StaticTracing::Configuration::Modes::SIGNAL

  config.signal = StaticTracing::Configuration::Modes::SIGNALS::SIGPROF

end

В такой конфигурации вы можете добавить команду kill для включения статических точек трассировки вашего приложения по желанию. В следующем примере мы предполагаем, что на машине работает только процесс Ruby, и можем получить его идентификатор процесса с помощью pgrep:

kill -SIGPROF 'pgrep -nx ruby'

Помимо настройки активизации точек трассировки, вы можете использовать некоторые встроенные механизмы трассировки, которые предоставляет ruby-static-tracing. На момент написания книги библиотека содержала точки трассировки для измерения задержки и сбора трассировок стека. Нам очень нравится, что с помощью этого встроенного модуля утомительная задача измерения задержки функции становится почти тривиальной. Вначале нужно добавить в первоначальную конфигурацию трассировщик задержки:

require 'ruby-static-tracing'

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

 

StaticTracing.configure do |config|

  config.add_tracer(StaticTracing::Tracer::Latency)

end

После этого каждый класс, включающий модуль задержки, генерирует статические точки трассировки для каждого определенного открытого метода. Когда трассировка включена, вы можете запрашивать эти точки для сбора данных о времени. В следующем примере ruby-static-tracing генерирует статическую точку трассировки с именем usdt:/proc/X/fd/:user_model:find, выполняя соглашение об использовании имени класса в качестве пространства имен для точки трассировки и имени метода — в качестве имени точки трассировки:

class UserModel

  def find(id)

  end

 

  include StaticTracing::Tracer::Concerns::Latency

end

Теперь можно задействовать BCC для извлечения информации о задержке для каждого вызова метода find. Для этого мы используем встроенные функции BCC bpf_usdt_readarg и bpf_usdt_readarg_p. Они читают аргументы, устанавливаемые каждый раз, когда выполняется код нашего приложения. ruby-static-tracing всегда задает имя метода в качестве первого аргумента для точки трассировки, тогда как в качестве второго аргумента устанавливается вычисленное значение. Следующий фрагмент реализует программу BPF, которая получает информацию о точке трассировки и выводит ее в журнале трассировки:

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);

}

"""

Нам также нужно загрузить предыдущую BPF-программу в ядро. Поскольку мы отслеживаем конкретное приложение, которое уже запущено на компьютере, то можем прикрепить программу к определенному идентификатору процесса:

parser = argparse.ArgumentParser()

parser.add_argument("-p", "--pid", type = int, help = "Process ID")   

args = parser.parse_args()

 

usdt = USDT(pid = int(args.pid))

usdt.enable_probe(probe = "latency", fn_name = "trace_latency")       

bpf = BPF(text = bpf_source, usdt = usdt)

bpf.trace_print()

Указываем PID.

Включаем зонд, загружаем программу в ядро и печатаем журнал трассировки. (Этот раздел очень похож на тот, который вы видели ранее.)

Мы показали, как анализировать приложения, которые статически определяют точки трассировки. Многие известные библиотеки и языки программирования включают в себя эти зонды, что помогает отлаживать запущенные приложения, особенно чтобы отслеживать их работу в производственных средах. Однако это лишь верхушка айсберга — получив данные, вы должны разобраться в них. Это именно то, чем мы займемся в дальнейшем.

Визуализация данных трассировки

Пока мы приводили примеры, которые выводят данные в нашем отладочном отчете. Это не очень полезно в производственной среде. Разобраться в этих данных стоит, однако никто не любит копаться в длинных сложных журналах. Если мы хотим отслеживать изменения в том, почему происходят задержки и загрузка ЦП, лучше просмотреть графики за определенный период, чем разбирать числа из файла.

В этом разделе рассматриваются различные способы представления данных трассировки BPF. Мы покажем, как программы BPF могут структурировать информацию. Также вы узнаете, как экспортировать эту информацию в переносимое представление, как использовать готовые инструменты для получения более информативного представления и делиться своими результатами с коллегами.

Флейм-графы

Флейм-графы — это диаграммы, которые помогают визуализировать то, на что ваша система тратит время. Они могут дать вам четкое представление о том, какой код в приложении выполняется чаще. Брендан Грегг, разработчик флейм-графов, поддерживает набор сценариев для простой генерации этих форматов визуализации в GitHub (oreil.ly/3iiZx). Мы используем такие сценарии для создания флейм-графов на основе данных, собранных с помощью BPF, далее в этом разделе. Как выглядят графики, вы можете увидеть на рис. 4.1.

04_01.tif 

Рис. 4.1. Флейм-граф процессора

Следует помнить две важные вещи.

На оси X данные упорядочены в алфавитном порядке. Ширина каждого стека показывает, как часто он появляется в собираемых данных, что может быть связано с тем, как часто этот путь кода посещался, когда был включен профилировщик.

• По оси Y показаны трассы стека, упорядоченные по мере их чтения профилировщиком, с сохранением иерархии трасс.

Самые известные флейм-графы показывают интенсивность кода, наиболее часто используемого процессором. Они называются графиками по процессору. Другой интересной визуализацией являются графики вне процессора, они показывают время, которое процессор тратит на задачи, не связанные с вашим приложением. Комбинируя графики на процессоре и вне процессора, вы можете получить полное представление о том, на что система расходует циклы процессора.

Графики и на процессоре, и вне его применяют трассировки стека, чтобы показать, на что система тратит время. Некоторые языки программирования, такие как Go, всегда включают информацию трассировки в свои двоичные файлы, а другие, такие как C++ и Java, требуют дополнительных усилий, чтобы сделать трассировки стека читабельными. После того как ваше приложение сделает доступной информацию о трассировке стека, программы BPF могут использовать ее для объединения наиболее часто задействуемых путей кода, как это видит ядро.

6824.png

У объединения трассировки стека в ядре есть свои преимущества и недостатки. С одной стороны, это эффективный способ подсчета частоты трассировки стека, потому что все происходит в ядре, никакая информация стека не отправляется в пространство пользователя и тем самым уменьшается обмен данными между ядром и пространством пользователя. С другой — количество событий, которые нужно обработать для графиков вне процессора, может быть довольно большим, потому что вы отслеживаете каждое событие, которое происходит во время переключения контекста вашего приложения. Это может значительно нагрузить вашу систему, если вы попытаетесь профилировать его слишком долго. Имейте это в виду, работая с флейм-графами.

BCC предоставляет несколько утилит, которые помогут вам объединить и визуализировать трассировки стека, но особенно важен макрос BPF_STACK_TRACE. Он генерирует карту BPF типа BPF_MAP_TYPE_STACK_TRACE для хранения стеков, которые накапливает ваша программа BPF. Кроме того, эта карта BPF расширена за счет методов, позволяющих извлекать информацию о стеке из контекста программы и просматривать накопленные трассировки стека, если вы хотите использовать их после объединения.

В следующем примере создадим простой профилировщик BPF, который выводит трассировки стека, собранные из приложений пользовательского пространства. Мы генерируем флейм-графы ЦП с трассировками, которые собирает профилировщик. Чтобы протестировать его, напишем маленькую программу на Go, которая создает нагрузку на процессор. Вот код для этого приложения:

package main

 

import "time"

 

func main() {

        j := 3

        for time.Since(time.Now()) < time.Second {

                for i := 1; i < 1000000; i++ {

                        j *= i

                }

        }

}

Если вы сохраните этот код в файле с именем main.go и запустите его с помощью gorunmain.go, то увидите, что загрузка ЦП вашей системы значительно возросла. Вы можете остановить выполнение, нажав сочетание клавиш Ctrl+C, и загрузка ЦП вернется к норме.

Первая часть программы BPF инициализирует структуры профилировщика:

bpf_source = """

#include <uapi/linux/ptrace.h>

#include <uapi/linux/bpf_perf_event.h>

#include <linux/sched.h>

 

struct trace_t {                  

  int stack_id;

}

 

BPF_HASH(cache, struct trace_t);  

BPF_STACK_TRACE(traces, 10000);   

"""

Инициализируем структуру, где будет храниться ссылочный идентификатор для каждого из стековых кадров, которые получает профилировщик. Применим эти идентификаторы позже, чтобы узнать, каким путем код выполнялся в то время.

Инициализируем хеш-карту BPF, с помощью которой соберем сведения о том, как часто появляется один и тот же кадр. Сценарии флейм-графа используют это агрегированное значение, чтобы определить, как часто выполняется один и тот же код.

Инициализируем нашу карту трассировки стека BPF. Мы устанавливаем максимальный размер этой карты, но он может варьироваться в зависимости от объема данных, которые требуется обработать. Было бы лучше, если бы этим значением была переменная, но так как приложение на Go не очень большое, то 10 000 элементов будет достаточно.

Далее реализуем функцию, которая объединяет трассировки стека в профилировщике:

bpf_source += """

int collect_stack_traces(struct bpf_perf_event_data *ctx) {

  u32 pid = bpf_get_current_pid_tgid() >> 32;           

  if (pid != PROGRAM_PID)

    return 0;

 

  struct trace_t trace = {                              

    .stack_id = traces.get_stackid(&ctx->regs, BPF_F_USER_STACK)

  };

 

  cache.increment(trace);                               

  return 0;

}

"""

Проверяем то, что идентификатор процесса для программы в текущем контексте BPF является идентификатором для нашего приложения Go, в противном случае событие игнорируется. На данный момент мы не определили значение для PROGRAM_PID. Давайте заменим эту строку в части Python профилировщика перед инициализацией программы BPF. Это текущее ограничение для способа, которым BCC инициализирует программу BPF: мы не можем передавать переменные из пространства пользователя, и, как правило, эти строки заменяются в коде перед инициализацией.

Создаем трассировку, чтобы собрать все возможные данные. Извлекаем идентификатор стека из контекста программы с помощью встроенной функции get_stackid. Это один из помощников, который BCC добавляет к нашей карте трассировки стека. Мы также используем флаг BPF_F_USER_STACK, чтобы указать, что следует получить идентификатор стека для приложения пользовательского пространства, при этом неважно, что происходит в ядре.

Добавляем значение счетчика трассировки, чтобы отследить частоту выполнения одного и того же кода.

Теперь присоединим наш сборщик трассировки стека ко всем событиям Perf в ядре:

program_pid = int(sys.argv[0])                                

bpf_source = bpf_source.replace('PROGRAM_PID', program_pid)   

 

bpf = BPF(text = bpf_source)

bpf.attach_perf_event(ev_type = PerfType.SOFTWARE,            

                      ev_config = PerfSWConfig.CPU_CLOCK,

                      fn_name = 'collect_stack_traces')

Первый аргумент — для нашей программы Python. Это идентификатор процесса для приложения Go, которое мы профилируем.

Используем встроенную функцию Python replace для замены строки PROGRAM_ID в источнике BPF аргументом, предоставленным профилировщику.

Подсоединяем программу BPF ко всем событиям Software Perf, при этом будут игнорироваться любые другие события, например сообщения от оборудования. Мы также настраиваем нашу программу BPF на использование тактов процессора как показателя времени, чтобы понять, сколько же времени заняло выполнение приложения.

Наконец, нам нужно реализовать код, который будет выгружать трассировки стека в стандартный вывод при прерывании работы профилировщика:

try:

  sleep(99999999)

except KeyboardInterrupt:

  signal.signal(signal.SIGINT, signal_ignore)

 

for trace, acc in sorted(cache.items(), key=lambda cache: cache[1].value):  

  line = []

  if trace.stack_id < 0 and trace.stack_id == -errno.EFAULT             

    line = ['Unknown stack']

  else

    stack_trace = list(traces.walk(trace.stack_id))

    for stack_address in reversed(stack_trace)                          

      line.extend(bpf.sym(stack_address, program_pid))                  

 

frame = b";".join(line).decode('utf-8', 'replace')                      

print("%s %d" % (frame, acc.value))

Просмотрим все собранные трассировки, чтобы вывести их по порядку.

Теперь посмотрим, что у нас есть идентификаторы стека, которые позже можно соотнести с конкретными строками кода. Если мы получим недопустимое значение, то будем использовать заполнитель в флейм-графе.

Перебираем все записи в трассировке стека в обратном порядке. Это нужно для того, чтобы найти первый путь выполнения программы, который использовался самым последним, как в любой трассировке стека.

Вызываем вспомогательный BCC sym, чтобы преобразовать адрес памяти для стекового фрейма в имя функции, использованное в нашем исходном коде.

Форматируем строку трассировки стека через точку с запятой. Это формат, который понятен сценариям флейм-графа и применяется, чтобы сгенерировать нужную визуализацию.

Завершив наш профилировщик BPF, можем запустить его, используя sudo для сбора трассировок стека для нашей занятой программы Go. Нужно передать идентификатор процесса программы Go профилировщику, чтобы убедиться, что мы собираем только трассировки для этого приложения. Можем найти этот PID с помощью pgrep. Так можно запустить профилировщик, если нужно сохранить его вывод в файле с именем profile.out:

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

pgrep найдет PID запущенного в вашей системе процесса по имени. Отправляем вывод профилировщика во временный файл, чтобы потом сгенерировать визуализацию флейм-графа.

Как упоминалось ранее, мы применим сценарии FlameGraph Брендана Грегга для генерирования файла SVG для нашего графика. Эти сценарии есть в его репозитории GitHub (https://oreil.ly/orqcb). После того как вы загрузили этот репозиторий, можете создать график с помощью Flamegraph.pl. Откройте график в своем браузере (у нас Firefox):

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

  firefox /tmp/flamefraph.svg

Профилировщик такого типа полезен для отслеживания проблем с производительностью в вашей системе. BCC включает в себя более оптимальный профилировщик, чем тот, что задействован в нашем примере, можете использовать его в своих производственных средах. Помимо профилировщика, в BCC есть инструменты, которые помогут создавать флейм-графы вне процессора и многие другие визуализации для анализа систем.

Флейм-графы полезны для анализа производительности. Мы часто применяем их в повседневной работе. Во многих сценариях, помимо визуализации путей кода, требуется определять, как часто происходят события в системах. Рассмотрим этот вопрос подробнее.

Гистограммы

Гистограммы — это диаграммы, показывающие, как часто встречаются несколько диапазонов значений. Числовые данные для такого представления делятся на сегменты, и каждый из них содержит количество вхождений любой точки данных в сегменте. Частота, которую измеряют гистограммы, является комбинацией высоты и ширины каждого сегмента. Если сегменты разделены на равные диапазоны, эта частота соответствует высоте гистограммы, но, если диапазоны неравные, нужно умножить каждую высоту на каждую ширину, чтобы вычислить нужную частоту.

Гистограммы являются основой для анализа производительности систем. Это отличный инструмент для представления распределения измеримых событий, таких как время выполнения инструкций, потому что с их помощью можно увидеть больше, чем посредством других измерений, например, средних значений.

BPF-программы могут создавать гистограммы на основе множества метрик. Вы можете использовать карты BPF, чтобы собирать информацию, классифицировать ее по группам, а затем генерировать представление гистограммы для своих данных. Реализация этой логики несложна, но она утомляет, если вы хотите получать гистограммы каждый раз, когда нужно проанализировать вывод программы. BCC включает в себя готовую реализацию, которую можно повторно использовать в каждой программе, при этом не требуется каждый раз выполнять группировку и вычислять частоту вручную. Однако в коде ядра имеется прекрасная возможность, которую мы рекомендуем применять в примерах BPF.

В качестве забавного эксперимента покажем, как использовать гистограммы BCC для визуализации задержки, возникающей при загрузке BPF-программ, когда приложение вызывает инструкцию bpf_prog_load. С помощью kprobes определим, сколько времени потребуется для выполнения этой инструкции, и будем накапливать результаты в гистограмме, которую представим позже. Мы разделили этот пример на несколько частей, чтобы легче было понять ход действий.

Первая часть содержит исходный код нашей программы BPF:

bpf_source = """

#include <uapi/linux/ptrace.h>

 

BPF_HASH(cache, u64, u64);

BPF_HISTOGRAM(histogram);

 

int trace_bpf_prog_load_start(void ctx) {   

  u64 pid = bpf_get_current_pid_tgid();     

  u64 start_time_ns = bpf_ktime_get_ns();

  cache.update(&pid, &start_time_ns);       

  return 0;

}

"""

Используйте макрос, чтобы создать хеш-карту BPF для сохранения момента срабатывания инструкции bpf_prog_load.

Возьмите новый макрос для создания карты гистограммы BPF. Это не присущая BPF карта — BCC содержит данный макрос, чтобы вам было проще создавать такие визуализации. Внутри этой гистограммы BPF используются карты массивов для хранения информации. У макроса есть несколько помощников для разделения и создания окончательного графика.

Применяйте PID-программы для того, чтобы найти приложение, когда оно запускает команду, которую мы хотим отследить. (Эта функция покажется вам знакомой — мы взяли ее из примера, в котором исследовали uprobes.)

Посмотрим, как вычислить дельту для задержки и сохранить ее в гистограмме. Начальные строки нового блока кода также будут выглядеть знакомо, потому что мы все еще используем пример, о котором говорили в подразделе «Uprobes»:

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;   

  histogram.increment(bpf_log2l(delta));         

  return 0;

}

"""

Рассчитайте дельту между временем, когда инструкция была вызвана, и временем, которое потребовалось нашей программе, чтобы выполнить код до данного момента. Можно предположить, что это также время выполнения инструкции.

Сохраните дельту в нашей гистограмме. В этой строке мы выполняем две операции. Сначала используем встроенную функцию bpf_log2l, чтобы сгенерировать идентификатор сегмента для значения дельты. Эта функция создает стабильное распределение значений во времени. Затем берем функцию increment, чтобы добавить в этот сегмент новый элемент. По умолчанию приращение добавляет 1 к значению, если сегмент был в гистограмме, или запускает новый сегмент со значением 1, поэтому вам не нужно заранее беспокоиться о том, существует ли значение.

Последний фрагмент кода, который нам нужно написать, присоединяет эти две функции к работающему kprobes и выводит гистограмму на экран, чтобы можно было увидеть распределение задержек. В этом разделе мы инициализируем нашу программу BPF и станем отслеживать события, чтобы сгенерировать гистограмму:

bpf = BPF(text = bpf_source)                

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:                                        

  sleep(99999999)

except KeyboardInterrupt:

  print()

 

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

Инициализируйте BPF и присоедините ваши функции к kprobes.

Включите в программу ожидание, чтобы собрать столько информации о событиях, сколько нужно получить из нашей системы.

Выведите карту гистограммы в терминале с отслеженным распределением событий — это еще один макрос BCC, который позволяет получить карту гистограммы.

Как мы упоминали в начале этого раздела, гистограммы могут быть полезны для наблюдения аномалий в вашей системе. Инструменты BCC содержат многочисленные сценарии, которые используют гистограммы для представления данных. Настоятельно рекомендуем задействовать их, когда вам нужна визуализация, чтобы лучше понять, как работает ваша система.

События Perf

Мы считаем, что события Perf, вероятно, являются наиболее важным методом коммуникации, который вам необходимо освоить, чтобы успешно использовать трассировку BPF. Мы говорили о картах массива событий BPF Perf в предыдущей главе. Они позволяют вам помещать данные в кольцо буфера, которое в реальном времени синхронизируется с программами пользовательского пространства. Прекрасно, если вы собираете большой объем данных в своей программе BPF и хотите перенести обработку и визуализацию в программу пользовательского пространства. Это позволит лучше контролировать уровень представления, потому что вы не ограничены виртуальной машиной BPF в плане программирования. Большинство программ трассировки BPF, которые вам удастся найти, используют события Perf именно для этой цели.

Теперь рассмотрим, как их применить для извлечения информации о выполнении и ее классификации, чтобы вывести, какие двоичные файлы наиболее часто исполняются в вашей системе. Мы разделили этот пример на два блока кода, чтобы было проще. В первом блоке определяем программу BPF и присоединяем ее к kprobe, как делали ранее в подразделе «Probes»:

bpf_source = """

#include <uapi/linux/ptrace.h>

 

BPF_PERF_OUTPUT(events);                             

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));      

  return 0;

}

"""

 

bpf = BPF(text = bpf_source)

execve_function = bpf.get_syscall_fnname("execve")   

bpf.attach_kprobe(event = execve_function, fn_name = "do_sys_execve")

В первой строке этого примера импортируем библиотеку из стандартной библиотеки Python. Мы собираемся применять счетчик Python для агрегирования событий, которые получаем от нашей программы BPF.

Используем BPF_PERF_OUTPUT, чтобы объявить карту событий Perf. Это удобный макрос, который BCC предоставляет для объявления карты такого типа. Назовем эту карту events.

Отправляем эту карту в пользовательское пространство для агрегации после того, как у нас будет имя программы, которую выполнило ядро. Мы делаем это с помощью perf_submit. Эта функция обновляет карту событий Perf, внося в нее новую информацию.

Инициализируем программу BPF и подключаем ее к kprobe, которая будет включаться при запуске новой программы в нашей системе.

Теперь, когда мы написали код для всех программ, выполняемых в нашей системе, нужно собрать их в пространстве пользователя. В следующем фрагменте кода содержится много информации, рассмотрим самую важную:

from collections import Counter

aggregates = Counter()                                 

 

def aggregate_programs(cpu, data, size):               

  comm = bpf["events"].event(data)                     

  aggregates[comm] += 1

 

bpf["events"].open_perf_buffer(aggregate_programs)     

while True:

    try:

      bpf.perf_buffer_poll()

    except KeyboardInterrupt:                          

      break

 

for (comm, times) in aggregates.most_common():

  print("Program {} executed {} times".format(comm, times))

Объявляем счетчик для хранения информации о нашей программе. Поскольку мы используем имя программы в качестве ключа, то счетчиками будут значения. Задействуем функцию aggregate_programs для сбора данных из карты событий Perf. В этом примере вы можете увидеть, как макрос BCC применяется для доступа к карте и извлечения последующего события входящих данных из вершины стека.

Увеличиваем количество поступлений событий с тем же именем программы.

Используем функцию open_perf_buffer, чтобы сообщить BCC, что нужно запускать функцию aggregate_programs каждый раз при получении событий из карты событий Perf.

BCC опрашивает события после открытия кольцевого буфера, пока мы не прервем эту программу Python. Чем дольше вы собираете результаты, тем больше информации придется обрабатывать. Здесь продемонстрировано, как для этой цели применяется perf_buffer_poll.

Функция most_common дает список элементов в счетчике и цикле, чтобы сначала распечатать наиболее часто исполняемые в системе программы.

События Perf могут помочь при обработке всех данных, которые BPF предоставляет новыми и неожиданными способами. Мы рассмотрели пример сбора произвольных данных из ядра. Много других примеров вы можете найти в инструментах, которые включены в BCC для отслеживания.

Резюме

В этой главе мы лишь поверхностно рассмотрели трассировку с помощью BPF. Ядро Linux дает вам доступ к информации, которую труднее получить с помощью других инструментов. BPF делает этот процесс более предсказуемым, предоставляя общий интерфейс для доступа к данным. В последующих главах вы увидите больше примеров, в которых используются некоторые из описанных методов, например присоединение функций к точкам трассировки. Они помогут вам начать применять на практике то, что вы уже узнали.

В этой главе для большинства примеров мы использовали платформу BCC. Если вам интересно, попробуйте реализовать те же примеры на C, как мы делали в предыдущих главах. Однако BCC предоставляет несколько встроенных функций, которые упрощают написание программ трассировки по сравнению с тем, как это происходит с применением C.

В следующей главе расскажем о некоторых инструментах, которые сообщество разработало для использования поверх BPF для анализа и отслеживания производительности. Написание собственных программ — это хорошо, но уже созданные специальные инструменты дают вам доступ к большей части информации. Так что вам не нужно создавать инструменты — они уже существуют.

Назад: 3. Карты BPF
Дальше: 5. Утилиты BPF