В этой статье поговорим о том, как создавать по-настоящему переносимые eBPF-программы.
В идеальном мире все системы были бы полностью обновлены, регулярно пропатчены и работали бы на последней версии ядра.
Но давайте смотреть правде в глаза — так редко бывает.
Некоторые среды до сих пор используют устаревшие версии Ubuntu или Fedora, а в других ядро даже не собрано с поддержкой BTF (BPF Type Format).
А если вы поддерживаете какие-либо инструменты с открытым исходным кодом, ситуация становится ещё сложнее. У вас нет никакого контроля над тем, на какой системе пользователи будут запускать вашу программу.
Всё это усложняет задачу обеспечения стабильной работы ваших eBPF-программ в разных дистрибутивах и напрямую влияет на то, будут ли вообще использовать ваш eBPF-инструмент.
Так как же сделать eBPF-программы по-настоящему переносимыми?
Чтобы лучше понять проблему, рассмотрим гипотетический пример.
Предположим, вы компилируете eBPF-программу на ядре версии 5.3, но она не запускается на версии 5.4.
Почему? Потому что каждая версия ядра поставляется со своими заголовочными файлами ядра, которые определяют структуры данных и разметку памяти. Даже незначительные изменения в этих определениях могут ломать eBPF-программы.
Возьмём, к примеру, структуры. Допустим, у нас есть структура, представляющая заголовок TCP в ядре 5.3:

В следующем выпуске ядра, 5.4, разработчики ядра могут решить вынести эти поля в новую структуру, переименовать поле seq в seque или просто сдвинуть эти поля вверх или вниз, изменив их смещение:

Видите проблему?
Ваш код может зависеть от конкретных полей или смещений, а они вполне могут меняться от версии ядра к версии.
Поскольку сама eBPF-программа никак не может повлиять на такие изменения, возникает естественная необходимость в решении, которое обеспечит переносимость eBPF-программ.
Если поискать информацию в интернете, вы найдёте множество материалов, где для решения этой проблемы рекомендуют использовать BPF CO-RE (Compile Once – Run Everywhere, «скомпилировал один раз — запускай везде»).
Иначе говоря, вместо того чтобы писать программы вот так:

Следует заменить семейство вспомогательных функций (helpers) BPF_PROBE_READ() на семейство BPF_CORE_READ(), которое позволяет обращаться к полям структур так, чтобы этот доступ автоматически подстраивался под разные версии ядра:

Если кратко, семейство хелперов BPF_CORE_READ() позволяет выполнять переносимое чтение структур ядра.
Это означает, что если определённое поле структуры (например, filename в примере) находится по другому смещению в другой операционной системе или версии ядра, эти функции всё равно смогут корректно его найти и прочитать.
Под капотом это работает благодаря информации о релокации BPF CO-RE и BTF (BPF Type Format).
Постойте-ка, что? Информация о релокации CO-RE? BTF?
Если заглянуть практически в любой промышленный код eBPF, вы заметите, что везде подключается заголовочный файл vmlinux.h.
Этот файл содержит определения всех структур ядра, таких как trace_event_raw_sys_enter из примера выше, сгенерированные на основе текущего запущенного ядра.
💡 Сгенерировать этот заголовочный файл можно с помощью команды:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
И вот здесь начинается самое интересное — этот заголовочный файл содержит несколько специальных строк как в начале, так и в конце:

В верхней части vmlinux.h вы увидите строку __attribute((preserve_access_index)), которая сообщает компилятору, что необходимо добавить информацию о релокации BPF CO-RE (Compile Once – Run Everywhere) для каждого поля структуры, к которому обращается ваша eBPFпрограмма, в объектный файл eBPF.
Иными словами, когда вы обращаетесь к полю структуры ядра (например, filename в приведённом примере), компилятор не просто жёстко фиксирует его смещение. Вместо этого он сохраняет метаданные — такие как имя поля, его тип, смещение и родительскую структуру — в структуре bpf_core_relo.

Атрибут clang attribute push гарантирует, что это правило применяется ко всем определениям структур до соответствующего clang attribute pop в конце файла.
Структура релокации BPF CO-RE (или, если угодно, информация о релокации) включает следующие поля:
insn_off: указывает инструкцию, к которой применяется релокация, например инструкцию, устанавливающую значение регистра.type_id: ссылается на метаданные BTF (BPF Type Format), которые описывают структуру целевой структуры ядра.access_str_off: задаёт способ доступа к конкретному полю относительно структуры.
Для приведённого выше примера с tracepoint информация BTF выглядит следующим образом:

Чтобы ваша eBPF-программа работала на разных версиях ядра, где разметка структур может отличаться, целевое ядро также должно быть скомпилировано с поддержкой BTF. Без этого программа не сможет определить корректные смещения полей во время выполнения.
Почему это необходимо?
Когда ваша eBPF-программа загружается с помощью загрузчика BPF, например libbpf, загрузчик сравнивает BTF-данные программы с BTF-данными целевого ядра. Затем он сопоставляет типы, обновляет смещения и корректирует доступ к полям, чтобы программа могла правильно читать данные ядра.
Этот процесс называется релокацией смещений полей (field offset relocation).
Одно из неочевидных ограничений такого подхода заключается в том, что инструменты, использующие BTF-данные, неявно зависят от того, чтобы целевое ядро было собрано с поддержкой BTF.

Без поддержки BTF в целевом ядре загрузчик не сможет выполнить релокацию смещений полей, и программа либо не загрузится, либо будет работать некорректно.
Но можно ли как-то избавиться от этой зависимости?
Да, можно.
Сейчас покажу как.
Компания Aqua Security поддерживает репозиторий btfhub-archive, в котором собраны готовые BTF-файлы для широкого набора ядер, не содержащих встроенный BTF.
Вы можете скачать подходящие BTF-файлы для тех ядер, которые хотите поддерживать, и встроить их прямо в свою eBPF-программу. Это полностью устраняет необходимость в поддержке BTF на целевой системе.
На этом месте можно было бы остановиться и показать простой пример того, как это делается, но я пошёл немного дальше.

В своём репозитории на GitHub я собрал полноценное решение, которое:
генерирует каркас eBPF для моей примерной программы
автоматически загружает и встраивает BTF-данные из
btfhub-archiveдля всех поддерживаемых версий ядра и операционной системыминимизирует BTF-данные, оставляя только те типы, которые действительно используются в примерной eBPF-программе
создаёт единый исполняемый файл, который может работать на широком диапазоне ядер без необходимости в поддержке BTF на целевой системе
Я добавил в репозиторий много подробностей и полезных комментариев, так что обязательно загляните туда, если захотите глубже разобраться в теме или адаптировать это решение под свои задачи.
Если вы уже упирались в то, что код завязан на детали конкретного ядра, значит, пора разбираться не только в eBPF, но и в самом устройстве Linux глубже. Курс «Разработка ядра Linux» помогает собрать эту базу на практике: от архитектуры ядра и модулей до структур данных, синхронизации, прерываний и управления памятью. Это тот уровень понимания, который позволяет уверенно работать с низкоуровневыми механизмами и сложными системными задачами.
Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса. До 30 апреля за успешное прохождение теста действует скидка 15% на курс.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:
14 апреля, 20:00. «Grafana Stack — закрываем все современные потребности Observability». Записаться
21 апреля, 20:00. «Связанные списки в ядре Linux: от API до реального кода». Записаться
Больше демо-уроков от преподавателей по инфраструктуре и не только смотрите в календаре мероприятий.
