Привет, Хабр!

Напомним вам об одной из самых интересных нишевых книг о Linux, изданных нами в последние годы — «Изучаем eBPF: программирование ядра Linux для улучшения безопасности, сетевых функций и наблюдаемости» от Лиз Райс. Под катом предлагаем перевод статьи Люки Кавальина (Luca Cavallin), в которой он даёт подробное введение в функции и возможности этого «фильтра пакетов». В сущности, eBPF — это де‑факто стандартный механизм для безопасного и оперативного введения пользовательского кода в ядро Linux. Статья рассказывает, как правильно обращаться с этим мощным инструментом, и какие возможности он открывает.

Если вы выстраиваете системы, оперируете кластерами и то и дело натыкаетесь на пределы возможностей агентов, таблиц iptables или модулей ядра, то обратите внимание на eBPF. Это как раз та более безопасная, быстрая и динамичная альтернатива, которую вы искали. При помощи eBPF удобно выполнять небольшие проверенные программы внутри ядра Linux so, поэтому с его помощью можно наблюдать систему во время выполнения и влиять на неё. На практике это даёт импульс новой волне инструментов, обеспечивающих наблюдаемостьбезопасность и сетевые взаимодействия без риска повредить пользовательские модули ядра в ходе эксплуатации.

Почему именно eBPF, и почему он сегодня так важен

Технология eBPF зародилась в ходе эволюции классического фильтра пакетов, разработанного в Беркли (Berkeley Packet Filter). Это крошечный движок, написанный на байт-коде и расположенный прямо внутри ядра, который такие инструменты как tcpdump уже давно используют для фильтрации пакетов. Современный eBPF обобщает эту идею: пишется небольшая функция на ограниченном C или Rust, далее она компилируется в байт-код, и затем мы предлагаем ядру её загрузить. Прежде, чем эта программа может быть выполнена, в дело вступает верификатор, который символически исполняет её для проверки свойств безпасности. Так, ваш код ни в коем случае не должен разыменовывать недействительные указатели, должен связывать свои циклы, возвращать именно такое значение, которое подходит для выбранной целевой платформы, а также всегда работать до завершения программы. Если эти условия будут соблюдены, то ядро сможет динамически скомпилировать байт-код в нативные инструкции. Во многом именно поэтому программы с применением eBPF работают так быстро.

eBPF не заменяет модули ядра, а предоставляет вам путь, чтобы управляемо расширять поведение ядра без необходимости писать для этого модуль или отправлять его. Вы загружаете программу во время выполнения, прикрепляете её к событию — например, к сетевому перехватчику получения, вызову функции в ядре или к точке, в которой принимается решение, касающееся безопасности. Затем, справившись с работой, вы открепляете её. Можно даже  закреплять программы и структуры данных в выделенной виртуальной файловой системе (bpffs по адресу /sys/fs/bpf), чтобы они существовали дольше, чем процесс загрузчика. По сравнению с использованием модулей работа в данном случае идёт гораздо более гладко и сопряжена с намного меньшим эксплуатационным риском. Добавьте сюда выигрыш в производительности, добываемый благодаря выполнению кода внутри ядра и работе без переключения контекста. Получается технология, которая отлично сочетается с потребностями современных облачных вычислений, телеметрии при отслеживании больших объёмов данных, решениями о соблюдении политик с минимальной задержкой, а также с необходимостью быстро продвигаться в работе без ущерба для безопасности.

Первая проба eBPF

Базовый цикл работы с eBPF прост: загрузить, прикрепить, пронаблюдать, открепить. Чтобы максимально быстро оценить этот цикл на практике, можно прикрепить маленькую демку к коду, который немедленно даст результат — например, позволит проследить выполнение процесса.

Вот не требующий настройки процесс bpftrace, подсчитывающий вызовы execve() для процесса с заданным именем:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { @[comm] = count(); }'

Если вы хотите немного глубже изучить BCC (набор ресурсов для компиляции BPF), вот вам небольшой код на Python, привязывающий небольшую программу, работающую внутри ядра, к пробе kprobe. При этом в словаре ведётся счётчик PID-идентификаторов:

from bcc import BPF

prog = r"""
BPF_HASH(exec_count, u32, u64);

int on_execve(void *ctx) {
  u32 pid = bpf_get_current_pid_tgid() >> 32;
  u64 *val = exec_count.lookup(&pid);
  u64 one = 1;
  if (val) { (*val)++; } else { exec_count.update(&pid, &one); }
  return 0;
}
"""

b = BPF(text=prog)
b.attach_kprobe(event="do_execveat_common", fn_name="on_execve")
print("Counting execve() per PID... Ctrl-C to stop.")
try:
    b.trace_print()
except KeyboardInterrupt:
    pass

for k, v in b.get_table("exec_count").items():
    print(f"PID {k.value}: {v.value}")

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

От исходного кода до выполнения кода внутри ядра

Программа eBPF воспринимается как крошечная специализированная функция. Можно выбрать точку перехвата и написать функцию с такой сигнатурой, которая соответствует её контексту. Например, xdp_md для XDP, запись для точки трассировки или kprobe/fentry или буфер сокетов для сетевых перехватов.  Она компилируется с опцией clang -target bpf, и получается объект, в котором содержатся инструкции, определения контейнеров, опционально — отладочная информация, а зачастую метаданные BTF (BPF Type Format). Чтобы просмотреть такой объект внутри, часто пользуются утилитами bpftool или llvm-objdump, позволяющими проверить, что именно вы собрали, и примерно прикинуть, о чём здесь будет судить верификатор.

Верификация запускается при загрузке программы. Если проверка пройдена, то ядро может сохранить оригинальный байт-код, транслированное представление, используемое верификатором для анализа, а также динамически скомпилированный нативный образ, подогнанный под ваш ЦП. Далее программа прикрепляется к событию. Примеры событий: XDP на сетевом интерфейсе, действующий ранее стека; TC внутри стека для классификации и оформления; fentry/kprobe для трассировки входа в функцию. Также события удобно прикреплять в точках трассировки, служащих стабильными позициями для событий, либо перехватывать на LSM, чтобы принимать решения, касающиеся безопасности. В современной практике на весь срок прикрепления поддерживаются BPF-ссылки, благодаря которым программы остаются прикреплёнными даже после выхода загрузчика. Позже открепить элемент можно, просто убрав ссылку на дескриптор. Если требуется, чтобы программы и контейнеры просуществовали дольше, чем ваш процесс, закрепите (pin) их в файловой системе bpffs, расположив на предсказуемых путях. Когда же вы пишете код XDP, никогда не забывайте проводить важнейшие проверки границ. Валидируйте заголовки при помощи указателей xdp_md->data и xdp_md->data_end, прежде, чем станете трогать что-то в пакете.

Контейнеры, потоки событий и обнаружимость

В качестве контейнеров BPF могут использоваться различные структуры данных: хеш-таблица, массив, варианты для отдельных ядер ЦП, кэши LRU, префиксные LPM-деревья, очереди и стеки, а также специальный вариант контейнера для кольцевого буфера. Сравнительно старые примеры часто основываются на perf-буферах с применением perf_event_open(), обеспечивающих потоковую передачу событий в пользовательское пространство. В более новых образцах кода более предпочтительны кольцевые буферы, в которых упрощается координация благодаря использованию всего одного файлового дескриптора и прямолинейно устроенной модели производитель/потребитель. Инструмент bpftool неоценим при перечислении загруженных программ, проверке неизменяемых тегов, дампе транслированных и динамически скомпилированных инструкций, создании и проверке контейнеров, а также при чтении данных BTF, описывающих типы и прототипы функций. При закреплении программ и контейнеров в файловой системе bpffs по путям /sys/fs/bpf их впоследствии легко находить от процесса к процессу и от перезапуска к перезапуску.

Типы программ и где они подключаются к работе

У каждой программы eBPF есть тип, и именно от этого типа зависит, какой контекст вы получите, какие вспомогательные функции сможете вызывать, а также какие коды возврата будут валидны. При трассировке у вас есть kprobes и kretprobes, которые следуют функциям ядра, точки трассировки, предъявляемые ядром как стабильные события, а также активируемые через BTF точки перехвата fentry/fexit, с небольшими издержками прикрепляемые к операциям входа в функцию и выхода из неё. Пользовательское пространство можно инструментировать при помощи uprobes и uretprobes, а также принимать детализированные решения о том, в каких именно точках пути безопасности прикреплять программы BPF LSM к хукам безопасности ядра. Со стороны сетевого уровня XDP располагается в пути получения, заложенном для драйвера, где можно разбирать заголовки и решать, что сделать с пакетом —  пропустить, отбросить, перенаправить или передать. В свою очередь, при помощи TC удобно классифицировать и оформлять трафик внутри стека. Благодаря перехватам на уровне сокетов и cgroup политика располагается ближе к процессу, а вытаскиватель метаданных из пакета (flow dissector) и другие подсистемы предоставляют более специализированные точки входа. Для сети основная польза такого подхода заключается в предсказуемости задержек: вместо ветвящихся цепочек iptables получаем скомпилированные пути данных, в которых реализуются балансировка нагрузки на сервисы и соблюдение сетевых политик.

CO-RE, BTF и libbpf: возможность портирования без отдельных сборок на каждый хост

Скомпилировать программу на одном целевом хосте — отличный вариант, если вы собираетесь заниматься лишь исследовательскими задачами, но в продакшне это проблема. Принцип CO-RE — «Скомпилировал один раз, запустил повсюду» — решает эту проблему на уровне информации о типах BTF, что позволяет прямо во время загрузки приспособить заранее скомпилированный объект к различным ядрам. В большинстве современных дистрибутивов канонический BTF-файл публикуется по адресу /sys/kernel/btf/vmlinux. В вашем объекте eBPF содержится информация о перемещениях, где он ссылается на типы или поля ядра. После того, как вы загрузите эти сочетания,  libbpf разрешит эти перемещения в соответствии с BTF хоста, так что возможные отличия в структурной компоновке хоста не нарушат работу вашей программы. В рамках обычного рабочего потока, построенного на основе BTF, генерируется заголовок vmlinux.h, затем собираемый с опцией -O2 -g -target bpf. Таким образом, загрузчик располагает всей нужной ему информацией. Например:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
clang -O2 -g -target bpf -c prog.c -o prog.bpf.o

Libbpf также генерирует «скелеты» — маленькие заголовки на C, в которые обёртываются ваши контейнеры, программы и прикрепляемые элементы, благодаря чему загрузчик в пользовательском пространстве становится почти декларативным: открыть, загрузить, прикрепить, прочитать. На практике получается отлично: можно отправить один цельный артефакт, обеспечивающий надёжную наблюдаемость в ландшафте неоднородных ядер и при этом занимает минимум места при эксплуатации.

О верификаторе простым языком

Верификатор можно представить как механизм контроля безопасности, который приспосабливает к использованию в продакшне ваш собственный код, работающий внутри ядра. Он символически выполняет вашу программу, прослеживает происхождение указателей и доказывает, что все обращения в памяти происходят в пределах границ. Удостоверяется, что вы проверяете указатели перед разыменованием, что ваши циклы ограничены или размотаны, что те аргументы, с которыми вы вызываете вспомогательные функции, правильно типизированы, и что вы возвращаете значение, корректное именно для той точки перехвата, к которой прикрепились — XDP_PASSXDP_DROPXDP_REDIRECT или XDP_ABORTED в случае XDP. Также он проверяет в вашем объекте строку license, так как некоторые вспомогательные функции зарезервированы лишь для работы с программами, лицензируемыми как GPL. Если верификация пройдёт неудачно, запросите о ней подробный лог; он читается как беседа с придирчивым рецензентом, и по нему можно быстро выучить идиомы, принимаемые ядром.

Реальная польза от наблюдаемости, безопасности и налаживания сетевых взаимодействий

Что касается наблюдаемости, eBPF может отслеживать системные вызовы, открытие файлов, изменение учётных данных и вызов функций ядра; обогащать события родословной процессов, идентификацией cgroup и контейнеров, а также пространствами имён. Все эти данные потоком передаются в пользовательское пространство практически в режиме реального времени. Вы получаете подробную информацию, не пропатчивая приложений или изменения их конфигурации. Для целей безопасности BPF LSM закладывает решения разрешить/запретить прямо в предназначенные для этого точки перехвата в ядре, располагая при этом информацией об идентификаторах контейнеров и рабочих нагрузок, а не только о uid/gid. Есть такие инструменты как Tetragon, которые объединяют глубокую трассировку с соблюдением политик, благодаря чему удаётся заранее отследить и приостановить подозрительное поведение. Что касается сети, XDP обрабатывает ранние решения, принимаемые по быстрому пути — отбрасывание пакетов, переадресацию, в то время как TC применяет классификацию и оформляет стек. Вместе они обеспечивают работу CNI, основанных на eBPF, которые реализуют балансировку нагрузки на уровне сервисов, сетевые политики и даже координируют межузловое шифрование с более низкими и предсказуемыми задержками, чем при работе с длинными цепочками iptables.

Когда не стоит использовать eBPF

eBPF — мощный инструмент, но подходит для решения не всякой проблемы. Если ваша потребность удовлетворяется при помощи перехвата в пользовательском пространстве или библиотечного вызова, то не усложняйте. Если темп событий невелик и задержка не представляет проблем, то программа, работающая внутри ядра, может оказаться неоправданным вложением. Если вам требуется выполнять долгоиграющие или блокирующие задачи, не укладывающиеся в ограничения eBPF (программы, не относящиеся к конкретным типам, которые можно погружать в сон), перебрасывают эту логику в пользовательское пространство. Если же вы работаете с унаследованными ядрами, где нет заголовков или BTF, то вам потребуется обновиться, установить пакет BTF или передать минимальные данные BTF прежде, чем принцип CO-RE станет приносить вам пользу.

Подводные камни и отладка сложных случаев

Большинство проблем относятся к немногочисленным категориям. Если верификатор что-то отвергает, то это значит, что вы должны более явно проверять границы, допускать в потоке управления меньше таких путей, которые могут трактоваться неоднозначно, ограничить количество циклов или сделать их меньше. Избегайте арифметики указателей над недоверенными данными без проверок. Если в вашей системе нет BTF, то установите пакет BTF для ядра или отправьте в ядро минимальный BTF и регенерируйте vmlinux.h. Если вы упрётесь в предел доступных ресурсов на создание контейнеров, это может указывать на RLIMIT_MEMLOCK или на то, что контейнеры слишком крупные. Правильно подбирайте для них размеры, а для кэшей рассмотрите варианты с LRU. Что касается времени жизни прикреплённых элементов, предпочтительны BPF-ссылки, чтобы программы оставались прикреплёнными даже после выхода загрузчика. А если производительность кажется подозрительно низкой, убедитесь, что у вас активирована динамическая компиляция при помощи bpftool feature, и включите её перед бенчмаркингом.

Системный вызов bpf() и его оснастка

Хотя детали в библиотеках скрываются, всё проходит через системный вызов bpf(). Обычно он используется в libbpf или другой обёртке, и с его помощью создаются контейнеры, загружаются программы и прикрепляются приложения. В сравнительно старом коде трассировки также можно встретить perf_event_open(), соединяющий события производительности с другим кодом вашей программы. В современных программах для определения времени жизни прикреплённых элементов используются BPF-ссылки, а потоковая передача данных организуется через кольцевые буферы. Читать результаты просто: сначала потребляем события из буфера, а затем ищем нужную информацию в контейнере. При этом, поскольку программы и контейнеры можно закреплять в файловой системе bpffs по адресу /sys/fs/bpf, их легко обнаруживать и повторно использовать от процесса к процессу и от рестарта к рестарту. По мере роста ваших систем вызовы от BPF к BPF способствуют рефакторингу логики в функциях, а глобальные значения в .rodata или .bss помогают корректировать поведение во время загрузки, не требуя перекомпиляции.

Резюме

eBPF — это безопасный и высокопроизводительный механизм для выполнения пользовательской логики внутри ядра Linux. Вы компилируете небольшую программу; верификатор убеждается, что она безопасна; ядро её динамически компилирует, а вы прикрепляете её к событиям, чтобы менять поведение системы в режиме реального времени. В контейнерах хранится разделяемое состояние, в кольцевых буферах — потоковые события. В свою очередь, BTF и CO-RE обеспечивают портируемость, а libbpf плюс скелеты помогают держать загрузчики маленькими и надёжными. Обустраивая перехваты на этапах трассировки, сетевых взаимодействий и обеспечения безопасности — XDPTCkprobes/fentry и LSM – можно инструментировать приложения, не затрагивая их программного или конфигурационного кода и гарантировать точное соблюдение политик в богатом контексте. eBPF применяется от плоскостей данных Kubernetes до отслеживания системных вызовов и превентивной безопасности и уже успел превратиться из нишевого фокуса в мейнстримовую платформу. Далее изучаем eBPF и делаем небольшую демку, в рамках которой можно загружать, прикреплять, наблюдать, откреплять – пока не научитесь использовать эти паттерны с закрытыми глазами.