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


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


Если вы хотите узнать, как именно BPF помогает эффективно решать задачи защиты от DDoS атак, распределения нагрузки на серверы, реализации сетевого стека kubernetes, защиты систем от нападения, эффективной трассировки систем 24x7 прямо в проде и многие другие, то добро пожаловать под кат.


image


Типы программ и оглавление


Все существующие типы программ BPF регистрируются в файле include/uapi/linux/bpf.h ядра Linux. В следующих разделах я постарался сгруппировать их по логическим группам (звездочками отмечены технические ликбез-подразделы):



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


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


Коммит Автор Дата Тип программы
0975 Alexei\ Starovoitov 2014-09-26 BPF_PROG_TYPE_UNSPEC
ddd8 Alexei Starovoitov 2014-12-01 BPF_PROG_TYPE_SOCKET_FILTER
2541 Alexei Starovoitov 2015-03-25 BPF_PROG_TYPE_KPROBE
96be Daniel Borkmann 2015-03-01 BPF_PROG_TYPE_SCHED_CLS
94ca Daniel Borkmann 2015-03-20 BPF_PROG_TYPE_SCHED_ACT
98b5 Alexei Starovoitov 2016-04-06 BPF_PROG_TYPE_TRACEPOINT
6a77 Brenden Blanco 2016-07-19 BPF_PROG_TYPE_XDP
0515 Alexei Starovoitov 2016-09-01 BPF_PROG_TYPE_PERF_EVENT
0e33 Daniel Mack 2016-11-23 BPF_PROG_TYPE_CGROUP_SKB
6102 David Ahern 2016-12-01 BPF_PROG_TYPE_CGROUP_SOCK
3a0a Thomas Graf 2016-11-30 BPF_PROG_TYPE_LWT_IN
3a0a Thomas Graf 2016-11-30 BPF_PROG_TYPE_LWT_OUT
3a0a Thomas Graf 2016-11-30 BPF_PROG_TYPE_LWT_XMIT
4030 Lawrence Brakmo 2017-06-30 BPF_PROG_TYPE_SOCK_OPS
b005 John Fastabend 2017-08-15 BPF_PROG_TYPE_SK_SKB
ebc6 Roman Gushchin 2017-11-05 BPF_PROG_TYPE_CGROUP_DEVICE
4f73 John Fastabend 2018-03-18 BPF_PROG_TYPE_SK_MSG
c4f6 Alexei Starovoitov 2018-03-28 BPF_PROG_TYPE_RAW_TRACEPOINT
4fba Andrey Ignatov 2018-03-30 BPF_PROG_TYPE_CGROUP_SOCK_ADDR
004d Mathieu\ Xhonneux 2018-05-20 BPF_PROG_TYPE_LWT_SEG6LOCAL
f436 Sean Young 2018-05-27 BPF_PROG_TYPE_LIRC_MODE2
2dbb Martin KaFai Lau 2018-08-08 BPF_PROG_TYPE_SK_REUSEPORT
d58e Petar Penkov 2018-09-14 BPF_PROG_TYPE_FLOW_DISSECTOR
7b14 Andrey Ignatov 2019-02-27 BPF_PROG_TYPE_CGROUP_SYSCTL
9df1 Matt Mullins 2019-04-26 BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE
0d01 Stanislav\ Fomichev 2019-06-27 BPF_PROG_TYPE_CGROUP_SOCKOPT
f1b9 Alexei Starovoitov 2019-10-30 BPF_PROG_TYPE_TRACING
27ae Martin KaFai Lau 2020-01-08 BPF_PROG_TYPE_STRUCT_OPS
be87 Alexei Starovoitov 2020-01-20 BPF_PROG_TYPE_EXT
fc61 KP Singh 2020-03-29 BPF_PROG_TYPE_LSM
e9dd Jakub Sitnicki 2020-07-17 BPF_PROG_TYPE_SK_LOOKUP

Микроядро Linux


В 1992 году случился известный «спор» между Эндрю Танненбаумом (по-русски его бы звали Андрей Елочка) и Линусом Бенедиктом Торвальдсом (как он тогда подписывался). Танненбаум знатно набросил, начав тред под названием «LINUX is obsolete» про то, что Linux устарел (в 1992 году) потому, что он не микроядро. Я взял слово «спор» в кавычки потому, что, вообще говоря, Линус сразу согласился с Танненбаумом:


«Да, linux монолитен, и я согласен, что микроядра милее. Был бы заголовок менее спорным, я бы, наверное, согласился с большей частью сказанного вами. С теоретической (и эстетической) точки зрения linux проигрывает. Если бы прошлой весной ядро GNU было доступно, то я бы не потрудился даже начинать свой проект: но факт в том, что как оно не было готово тогда, так и не готово до сих пор. Linux побеждает, значительно опередив ядро GNU по очкам в игре "доступно сейчас"»


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


Реализуется это при помощи нового типа программ BPF: BPF_PROG_TYPE_STRUCT_OPS. Пользовательский интерфейс довольно запутанный, Daniel Borkman даже предлагал его поменять, добавив новый тип объектов BPF — модули, но не срослось.


Этот механизм не позволяет переписать произвольную структуру, просто написав код BPF. Чтобы иметь возможность переписывать какую-то конкретную структуру, нужна поддержка со стороны ядра. В качестве примера и первого приложения была добавлена возможность реализовывать на BPF структуру tcp_congestion_ops, которая определяет какой алгоритм использовать для TCP congestion control. Были добавлены и два примера — реализации DCTCP и CUBIC на BPF.


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


Программы BPF для трассировки и сбора информации


Почти сразу после появления нового BPF известный товарищ Brendan Gregg, которого мы в дальнейшем будем называть просто БГ, рассмотрел в нем огромный потенциал для трассировки систем под управлением Linux и начал использовать его на практике. В результате появилось с сотню утилит bcc, скриптов для bpftrace, книжка «BPF Performance Tools», несколько стартапов, бизнес которых основан на применении BPF, и т.п. В компаниях Facebook и Netflix, в которой сейчас работает БГ, на каждом боевом сервере работают десятки трассировочных программ BPF, 24x7. BPF это легко позволяет — запуск трассировки под BPF сравнительно дешевый.


Почему БГ так сильно воодушевился? Давайте посмотрим. BPF позволяет написать и подцепить программу произвольной сложности на следующие события:


  • вызов и возврат (почти) любой функций ядра Linux
  • запуск конкретной инструкции в ядре или в пользовательском коде
  • на любой tracepoint в ядре
  • любому событию perf, software и hardware

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


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


#! /usr/bin/env bpftrace

#include <linux/skbuff.h>
#include <linux/ip.h>

k:icmp_echo {
    $skb = (struct sk_buff *) arg0;
    $iphdr = (struct iphdr *) ($skb->head + $skb->network_header);
    @pingstats[ntop($iphdr->saddr), ntop($iphdr->daddr)]++;
}

Эта программа из нескольких строчек строит статистику о том, кто пингует наш хост. Мы подцепляемся к kprobe на входе в функцию icmp_echo, которая вызывается на приход ICMPv4 пакета типа echo request. Ее первый аргумент, arg0 в нашей программе, — это указатель на sk_buff, описывающий пакет. Из этой структуры мы достаем IP адреса и увеличиваем соответствующий счетчик в словаре @pingstats. Все, теперь у нас есть полная статистика о том, кто и как часто пинговал наши IP адреса! Раньше для написания такой программы вам пришлось бы писать модуль ядра, регистрировать в нем обработчик kprobe, а также придумывать механизм взаимодействия с user space, чтобы хранить и читать статистику.


Перечислим типы программ BPF, используемые для tracing:


  • BPF_PROG_TYPE_KPROBE: позволяет подцепить программу BPF к kprobe, kretprobe, uprobe или uretprobe. Это позволяет подцепиться к вызову или возврату из любой функции ядра, любому адресу внутри ядра (т.е. к конкретным инструкциям внутри функций), а также любому адресу внутри пользовательского кода, например, к функциям любой разделяемой библиотеки.
  • BPF_PROG_TYPE_PERF_EVENT: позволяет подцепить программу BPF к любому событию perf.
  • BPF_PROG_TYPE_TRACEPOINT: позволяет подцепить программу BPF к любому tracepoint. Зачем это нужно, если мы можем делать это при помощи kprobes? Дело в том, что tracepoints — это стабильный API ядра (что означает, что при попытке удаления/изменения tracepoint на автора патча будет оказываться сильное психологическое давление) и значит утилиты, созданные на основе tracepoints могут считаться более стабильными (требовать меньше поддержки в будущем).
  • BPF_PROG_TYPE_RAW_TRACEPOINT: при вызове tracepoints аргументы для обработчика конструируются в момент выполнения и на это тратится некоторое количество ресурсов. При помощи raw tracepoints можно запускать программы BPF без создания «удобных» и переносимых аргументов, что позволяет оптимизировать время выполнения для тех программ, которым это важно
  • BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE: tracepoints с доступным на запись контекстом (см. здесь)
  • BPF_PROG_TYPE_TRACING: новый тип программ, которые можно подключать в нескольких вариантах: к tracepoints, входам и выходам из функций, к функциям, которые позволяют переписывать возвращаемое значение (список таких функций можно получить так: sudo cat /sys/kernel/debug/error_injection/list), а также для создания «итераторов». Отличительной особенностью этого типа программ является то, что использование BTF при их загрузке — обязательно.

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


Linux Security Modules


В ядре Linux повсюду раскиданы хуки (security hooks), которые проверяются при взаимодействии с разными объектами, например, при создании и изменении инодов, процессов и т.п. Механизм модулей безопасности Linux (Linux Security Modules или LSM) позволяет писать системы наподобие SELinux, AppArmor, и т.д., использующие эти хуки.


Пару лет назад в гугле задумались над созданием механизма, позволяющего собирать информацию и быстро реагировать на возможные атаки. Подробнее о том, почему нужно было добавлять новые API можно послушать в докладе Kernel Runtime Security Instrumentation с конференции LSS-NA 2019. В результате появился новый тип программ BPF, BPF_PROG_TYPE_LSM, название которого подсказывает, что теперь программу BPF можно подключать к любому LSM хуку. Это позволяет создавать кастомные системы для аудита и немедленного реагирования, причем, так как мы в мире BPF, это безопасно и дешево и не требует загрузки модулей и т.п.


В процессе разработки этого типа программ появилось несколько интересных фич, например, особый подтип программ BPF, которые могут засыпать. Эта функциональность нужна при анализе пользовательских данных, которые могут быть не подгружены в память на момент запуска программы BPF. Еще одна особенность заключается в том, что программы LSM нужно иногда погружать в момент загрузки системы. Делается это посредством user mode helper, так как для загрузки программ BPF требуется libbpf, а тянуть функциональность libbpf в ядро мантейнеры не хотят.


Краткую историю создания KRSI и небольшой туториал можно посмотреть в этом недавнем докладе KP Singh, автора KRSI, на BPF Summit.


Динамическое расширение программ BPF


Tail calls


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


Интерфейс у tail calls следующий. Допустим, в зависимости от контекста, программа Э хочет вызвать одну из программ — Ю или Ы. Для этого при загрузке создается специальный мап типа BPF_MAP_TYPE_PROG_ARRAY, в котором значениями по индексу являются программы BPF (из пространства пользователя мы пихаем в этот мап соответствующие файловые дескрипторы):



Дальше программа Э должна использовать функцию-помощник bpf_tail_call. Например, чтобы вызвать программу Ы, программа Э должна сказать bpf_tail_call(&map, ctx, 1), где ctx — это указатель на контекст, который мы хотим передать следующей программе. После прыжка, реализованного как long jump, новая программа запускается со старым стеком. Количество прыжков ограничено 32, так что мы гарантированно завершаем работу, даже если программа вызывает саму себя в цикле.


Следует также сказать, что начиная с ядра 5.1 лимит на количество инструкций был увеличен до миллиона и, если потребуется, будет еще увеличен в будущем. Это не отменяет механизм tail calls, так как мы все еще получаем пользу от раздельной компиляции и верификации программ.


В некоторых случаях, однако, tail calls добавляют головной боли. Например, что сделает bpf_tail_call, если индекс указывает на несуществующую программу? Правильный ответ — ничего, просто провалится на следующую инструкцию.


XDP Features

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


Еще один пример, где tail calls усложняют жизнь — это XDP features. Когда мы пишем программу на XDP, мы знаем, какие из «фич» XDP она хочет использовать. Сетевое устройство, к которому мы ее подсоединяем, знает какое множество «фич» оно предоставляет. В простейшем случае мы легко можем проверить, является ли множество предоставляемых фич надмножеством требуемых и, соответственно, позволить или не позволить подсоединить программу. Но если наша программа использует tail calls, то мы не можем больше с уверенностью сказать, что за множество фич нам нужно. Эту задачку в ядре пока так и не решили, но, скорее, из-за того, что она из разряда «неуловимых Джо» — Джо никто не может поймать не потому, что он такой хитрый, а потому, что его никто не ищет.


Динамическая подмена подпрограмм


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


Этот механизм используется, например, в xdp-dispatcher — механизме, который позволяет подцеплять несколько программ XDP на один интерфейс. Для этого создается «главная» программа с функциями-заглушками, а когда мы хотим подцепить одну или несколько XDP программ, функции-заглушки заменяются на эти программы. См. доклад Multiple XDP programs on a single interface—status and next steps от Toke Hoiland-Jorgensen на Linux Plumbers 2020.


LIRC: Linux Infrared Remote Control


Если у вас есть инфракрасный приемник, то вы можете использовать программы BPF типа BPF_PROG_TYPE_LIRC_MODE2 для декодирования сигнала. На lwn есть отличный туториал от Sean Young, автора данного типа программ, о том, как это можно использовать на практике.


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



Возникает вопрос, а зачем это нужно, если мы можем декодировать эти события в пространстве пользователя при помощи lirc? Но Sean Young говорит, что использование BPF это, скорее, попытка упростить API: теперь все IR устройства могут обрабатываться из userspace как будто бы ядро раскодировало сигнал (и это правда).


Программы BPF для сетевого программирования


Классический BPF создавался сетевыми инженерами из Berkeley Labs, новый BPF создавался сетевыми инженерами Linux, и поэтому не удивительно, что значительная часть типов программ BPF являются программами для работы с сетями.


Мы начнем рассказ с двух самых «универсальных» типов — программ XDP и программ подсистемы управления трафиком Linux — и продолжим более узко-специализированными программами, многие из которых создавались под конкретную задачу/расширение функциональности Linux.


Сетевой стек Linux: прибытие пакета


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


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



Прерывания в Linux обрабатываются в два этапа — top half и bottom half. Первый этап — это, собственно, обработчик прерывания (top half), задача которого подтвердить получение прерывания и запланировать отложенную задачу (bottom half), которая будет выполнена позднее в контексте softirq или даже потока ядра. Из соображений производительности, все сетевые bottom halves, соответствующие входящим пакетам, обрабатываются в специальной softirq под названием NET_RX.


В softirq драйвер определяет границы памяти, в которой находится пакет и создает экземпляр структуры struct sk_buff. Структура sk_buff, акроним от socket buffer, — это одна из двух центральных структур в сетевом стеке Linux. В первом приближении каждому сетевому пакету в Linux соответствует экземпляр структуры sk_buff. Структура содержит множество полей, описывающих пакет: указатели на начало и конец данных, указатели на заголовки различных уровней, устройство ввода-вывода, метаданные и т.д., и т.п. Драйвер заполняет некоторые из полей, а остальные заполняются в процессе движения sk_buff вверх по сетевому стеку.



Здесь мы видим структуру данных и некоторые из полей. Например, head и end указывают на начало и конец доступной памяти, data и tail указывают на начало и конец данных, net_header и transport_hdr указывают на заголовки третьего и четвертого уровня, соответственно, и т.п. Указатель data будет передвигаться вправо в процессе продвижения пакета по стеку — для разных уровней стека слово «данные» означает разные куски пакета.


Дальше драйвер при помощи функции netif_receive_skb передает пакет дальше по стеку и возвращается к своим драйверным делам. Что происходит с пакетом в стеке дальше? Мне нравится вот эта картинка из Netfilter wiki:



После создания sk_buff и доставки в стек, пакет попадает в систему контроля трафика (ingress qdisc на картинке), а после начинает путешествие по уровням стека, делая остановки у хуков netfiler. В конце концов, пакет (sk_buff) будет либо доставлен приложению, либо отправлен дальше — в устройство вывода или в небытие.


Однако, вернемся немного назад и посмотрим на предыдущую картинку внимательнее (вы можете кликнуть на нее). В первом узле после start, начала обработки в softirq, мы видим слова eBPF и XDP и подозрительные стрелочки, ведущие в обход стека...


Сети на стероидах — Express Data Path


Создание структуры sk_buff — это довольно дорогое удовольствие и во многих ситуациях для того, чтобы принять решение о судьбе пакета нам не нужно ничего, кроме заголовков или метки VLAN и т.п. С появлением программ BPF типа XDP (Express Data Path) стало возможным решать судьбу пакета без создания sk_buff.


Программы XDP подсоединяются к конкретному сетевому устройству и, как мы видели на картинке выше, запускаются сразу после того, как пакет был доставлен в RAM и драйвер о нем узнал. В качестве контекста программам предоставляется виртуальная структура struct xdp_md, содержащая указатели на начало и конец пакета, а также некоторую дополнительную информацию, которая нам сейчас не интересна. Основываясь на содержании контекста — данных и метаданных конкретного пакета — программа XDP может дропнуть пакет (XDP_DROP), отправить его назад на то же устройство, с которого пакет пришел (XDP_TX), перенаправить его на другое устройство (XDP_REDIRECT), а также пропустить вверх по стеку (XDP_PASS):



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


Одной из замечательных особенностей XDP является возможность перенаправлять пакеты прямо в пространство пользователя при помощи сокетов типа AF_XDP, причем, если драйвер это поддерживает, то можно делать zero copy. Эта функциональность позволяет получать производительность, сравнимую с DPDK, но при этом полностью находящуюся в рамках ядра:



На картинке я попытался изобразить схему работы одного сокета типа AF_XDP: каждому сокету AF_XDP соответствует ровно одна очередь устройства (rx queue), отмеченная на рисунке зелененьким. Программа XDP, работающая на устройстве смотрит на входящие пакеты и пересылает нужные прямо в сокет. Остальные пакеты, как из зелененькой очереди, так и из остальных, могут отправляться в сетевой стек. (Вообще говоря, это тоже требует поддержки драйвера, так как нам нужно сконфигурировать сетевую карточку так, что интересующие нас пакеты приходят на одну и ту же очередь. Например, если мы хотим получать UDP пакеты с портом доставки 65784 в сокет типа AF_XDP, посаженный на очередь 13, то мы можем, например, настроить карточку так: ethtool -N flow-type udp4 dst-port 65784 action 13.)


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


На данный момент есть два «классических» способа использования XDP, которые работают на отлично: защита от DDoS и балансировка нагрузки. Каждый пакет, который приходит в Facebook, проходит через load balancer katran, написанный на XDP, Cloudfare использует XDP как основу для защиты от DDoS и для load balancing, cilium также использует XDP для обоих случаев, и т.п. Также идут разговоры и R&D на тему того, как скрестить XDP и P4 для того, чтобы создавать эффективные сетевые решения, программируемые на языках высокого уровня (для машин под управлением мифического NPU — Networking Processing Unit).


Про XDP на самом деле можно сказать очень много, но это не входит в рамки этой статьи — это входит в рамки нескольких статей, которые я планирую написать позже. Тем, кому хочется поиграться с XDP, советую посмотреть на XDP Tutorial, а для тех, кто в школе учил немецкий, — на статью пользователя kozlyuk на хабре.


Структура struct __sk_buff


Перед тем как смотреть на остальные типы сетевых программ BPF, нам нужно объяснить одну техническую деталь. Как мы уже знаем, у каждого пакета в Linux есть соответствующая структура sk_buff, которая создается в момент прихода пакета на интерфейс и затем постепенно заполняется и передается на уровни выше. Она содержит поля типа len — длина пакета, network_header — указатель на заголовок уровня L3, dev — указатель типа struct net_device на устройство ввода или вывода, и так далее.


Так как сетевые программы работают с пакетами, а пакет — это sk_buff (кроме XDP, в случае которого sk_buff еще не создан), было бы разумно, если бы большинство сетевых программ BPF получали в качестве контекста указатель на sk_buff. Однако, все не так просто — по соображениям безопасности мы не хотим давать программам BPF доступ ко всей структуре и поэтому им выдается указатель на виртуальную структуру struct __sk_buff:


struct __sk_buff {
    __u32 len;
    __u32 pkt_type;
    __u32 mark;
    __u32 queue_mapping;
    __u32 protocol;
    __u32 vlan_present;
    ...
};

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


int bpf_prog(struct __sk_buf *ctx)
{
    __u32 len = ctx->len;
    __u32 type = ctx->pkt_type;
    ...
}

но при загрузке в ядро Verifier проверяет правомерность доступа (разные типы программ имеют разный уровень доступа) и подменяет инструкции так, что они указывают на настоящий sk_buff. Например, наша программа выше будет преобразована следующим образом:



Заметьте, что Verifier привел типы. В частности, для pkt_type, битового поля длины 3, Verifier подчистил все, не относящиеся к делу биты.


Как сгенерить такой листинг

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


Берем исходный файл skbuff.c с бессмысленной программой (но мы должны использовать поля как-то, чтобы компилятор их не отоптимизировал):


#include <linux/bpf.h>

__attribute__((section("socket/test")))
int bpf_prog(struct __sk_buff *ctx)
{
    __u32 len = ctx->len;
    __u32 type = ctx->pkt_type;
    return len + type;
}

Компилируем:


clang -target bpf -O2 skbuff.c -o skbuff.o -c

Загружаем (заметьте, кстати, что это одна из немногих типов программ, которые можно загрузить без рута):


mkdir mnt
sudo mount -t bpf none ./mnt
bpftool prog load ./skbuff.o ./mnt/xxx

Посмотрим на исходник:


$ llvm-objdump -D ./skbuff.o --section socket/test
  0:    61 12 00 00 00 00 00 00 r2 = *(u32 *)(r1 + 0)
  1:    61 10 04 00 00 00 00 00 r0 = *(u32 *)(r1 + 4)
  2:    0f 20 00 00 00 00 00 00 r0 += r2
  3:    95 00 00 00 00 00 00 00 exit

Посмотрим на то, что загрузилось:


$ sudo bpftool prog dump xlated pinned ./mnt/xxx
   0: (61) r2 = *(u32 *)(r1 +104)
   1: (71) r0 = *(u8 *)(r1 +120)
   2: (54) w0 &= 7
   3: (0f) r0 += r2
   4: (95) exit

Управление трафиком в Linux


Если мы вернемся к схеме сетевого стека Linux, то увидим, что сразу после того, как для входящего пакета был создан sk_buff, он проходит через узел ingress qdisc. Аналогично, и на самом деле, более важно, что egress qdisc — это последнее, что происходит с пакетом перед отправкой с устройства, и происходит это до/после всех возможных хуков netfilter.


Qdisc здесь расшифровывается как queueing discipline и является точкой входа в систему контроля трафиком в Linux — Traffic Control (TC). Для исходящего трафика egress qdisc определяет алгоритм работы планировщика очередей сетевого устройства — выставляет приоритеты и задержки при отправке пакетов, дропает лишние. Для входящего трафика, исторически и из-за его природы, возможности более ограниченные.


Кьюдисциплины бывают двух типов — classful и classless — поддерживающие классификацию и нет. Вторые — более простые, так как алгоритм классификации в них встроен. Например, дефолтный egress qdisc, pfifo_fast, организует приоритетную очередь на основе поля TOS пакетов IPv4 и IPv6 (подробнее см. lartc 9.2):



Другой простой пример — это qdisc noqueue, который отправляет пакет немедленно, если устройство не занято, а в противном случае дропает его.


Classful qdiscs являются строительными блоками для более изощренных систем. Они позволяют подразделять трафик на классы и к каждому из классов применять свой собственный алгоритм или подсоединять другие qdiscs. Таким образом, мы можем выстраивать деревья разбора произвольной сложности. Построение такого дерева не определяет как именно трафик распределяется по классам. Для того, чтобы деревья зажили активной сетевой жизнью, необходимы еще два строительных блока: классификаторы (classifiers) и действия (actions). Классификаторы позволяют программировать алгоритмы, по которым пакет отправляется в тот или оной класс, а действия позволяют решить, что делать с пакетом дальше, есть фильтр нашел совпадение. Примеры классификаторов: u32, flower и т.п. Примеры действий: drop (дропнуть пакет), reclassify (отправить на повторную проверку, например, после того, как мы отрезали VLAN tag), и т.п.


Итого, при помощи qdiscs и классов мы можем выстраивать деревья, а при помощи классификаторов и действий вдыхать в них жизнь. Но зачем нам выстраивать сложные деревья при помощи разных типов qdiscs и классов, если мы можем написать произвольную логику на C в рамках одной программы? Для этого в ядро были добавлены два типа программ BPF, BPF_PROG_TYPE_SCHED_CLS и BPF_PROG_TYPE_SCHED_ACT, позволяющие писать классификаторы и действия, соответственно. На практике второй из этих типов не нужен, так как существует специальный qdisc под названием clsact, который можно ставить как на egress, так и на ingress, и подключать к ней программы BPF типа BPF_PROG_TYPE_SCHED_CLS с флагом direct action. Этот флаг позволяет классификатору — программе BPF — возвращать значения actions, т.е. принимать окончательное решение о судьбе пакета в рамках только лишь одной программы.


Если вам интересно посмотреть на практическое применение BPF TC, то главные ресурсы — это BPF Reference Guide от Daniel Borkman и исходники cilium — самого мощного CNI для kubernetes, который теперь используется в Alibaba и Google.


Откуда есть пошел BPF


Оригинальной задачей классического BPF была фильтрация пакетов — программа BPF подсоединялась к сокету и запускалась для каждого пакета, проходящего через него. Так как eBPF разрабатывался как усовершенствованная версия cBPF, не удивительно, что первый поддерживаемый тип программ eBPF повторял функциональность cBPF. А именно, программу BPF типа BPF_PROG_TYPE_SOCKET_FILTER можно подсоединить к любому открытому сокету при помощи опции SO_ATTACH_BPF. Интересной особенностью этого типа, является то, что ее может использовать обычный процесс без привилегий CAP_SYS_ADMIN.


С новым BPF мы можем подсоединять программу типа BPF_PROG_TYPE_SOCKET_FILTER не только к сокетам, давайте кратко посмотрим на все применения:


  • Как уже было сказано, аналогично классическому BPF, программа BPF, подсоединенная к сокету вызывается для каждого следующего куска данных, пришедшего на сокет (для каждого sk_buff) и может делать только следующее: обрезать данные, в том числе до нуля (так, что пакет не будет доставлен). Обычно это используется для разнообразных снифферов, которые создают RAW сокет и при помощи прикрепленной программы получают только интересующие их пакеты, может быть обрезанные только до хедеров. Некоторые более интересные варианты использования программ типа BPF_PROG_TYPE_SOCKET_FILTER с сокетами можно найти в докладе Evil eBPF In-Depth с DEFCONF 27.
  • У сокетов типа AF_PACKET есть замечательная опция под названием PACKET_FANOUT, которая позволяет объединить несколько сокетов в группу и распределять нагрузку между ними. Это нужно, например, для реализации систем типа DPI. Про использование этой опции можно почитать на хабре в статье Захват пакетов в Linux на скорости десятки миллионов пакетов в секунду. В 2015 к fanout была добавлена опция PACKET_FANOUT_DATA, которая позволяет распределять нагрузку между сокетами при помощи программы BPF.
  • В 2007 году был написал модуль xt_bpf подсистемы netfilter. В качестве аргумента он принимал классическую программу BPF, которая решала судьбу пакета. В 2016 была добавлена поддержка eBPF — теперь судьбу пакета может решать и eBPF программа типа BPF_PROG_TYPE_SOCKET_FILTER.
  • Фильтры можно подсоединять не только к сокетам, но, например, к tun устройствам, что, например, позволяет делать более эффективную фильтрацию пакетов, направляющихся в VM. См. TUNSETFILTEREBPF.
  • Для тех же tun устройств при помощи программ BPF можно выбирать в какую очередь направится пакет. См. TUNSETSTEERINGEBPF.
  • Kernel Connection Multiplexor позволяет создавать пул TCP сокетов и поверх них создавать пул из datagram сокетов (см. lwn, kcm). Поток байтов конкретного TCP соединения парсится при помощи обязательной BPF программы типа BPF_PROG_TYPE_SOCKET_FILTER, подсоединенной к специальному сокету типа AF_KCM при помощи SIOCKCMATTACH (см. случайный пример, который я загуглил).
  • Наконец, программы типа BPF_PROG_TYPE_SOCKET_FILTER можно присоединять к сокетам при помощи SO_ATTACH_REUSEPORT_EBPF, см. статью Perfect locality and three epic SystemTap scripts.

«Рассекатель потоков» и flower


Функция ядра __skb_flow_dissect, довольно объемная, реализует в ядре Linux flow dissector — функциональность по вытаскиванию мета-данных из пакета. Она используется в нескольких местах ядра, в частности, одним из популярных ingress фильтров системы контроля трафиком Linux, flower.


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


BPF и контрольные группы


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


Программы типа BPF_PROG_TYPE_CGROUP_SKB позволяют подсоединять фильтры BPF ко всему трафику приходящему (ingress) или выходящему (egress) из конкретной группы. Если программа возвращает 1, то пакет пропускается, если 0, то дропается. Если группа входит в иерархию, то к пакетам также применяются фильтры из родительской группы. Таким образом мы можем фильтровать трафик для контейнеров, виртуалок, и т.п. Пример: как писать и подсоединять фильры BPF к сервисам systemd.


Помимо фильтрации, программы BPF могут применяться для управления логикой создания и управления ресурсов. Например, программы BPF типа BPF_PROG_TYPE_CGROUP_SOCK позволяют влиять на процесс создания и удаления сокетов, подправляя нужные поля в структуре struct sock. Оригинально этот тип был добавлен для управления полем sk_bound_dev_if структуры сокета, позволяющем привязать сокет к конкретному сетевому устройству. Этот же тип программы может присоединяться в момент системного вызова bind(2) и разрешать/запрещать определенные значения параметров или собирать информацию для дальнейшей обработки.


Для управления разными вопросами, касающимися адресации, существуют программы BPF типа BPF_PROG_TYPE_CGROUP_SOCK_ADDR. Изначально они позволяли подсоединяться к bind и подправлять IP адреса и порты, к которым программа байндилась (конкретный use case был такой: процессы внутри одной cgroup должны использовать один конкретный адрес из нескольких, доступных на сервере, см. коммит). В дальнейшем была добавлена возможность подсоединять программы такого типа к системным вызовам connect, getpeername, getsockname, sendmsg и recvmsg. Как иллюстрацию о том, зачем это нужно, можно посмотреть доклад о том, как cilium избавлялись от iptables в k8s.


Программы типа BPF_PROG_TYPE_CGROUP_SOCKOPT позволяют управлять поведением системного вызова setsockopt.


Программы типа BPF_PROG_TYPE_CGROUP_DEVICE позволяют для cgroupv2 реализовать контроллер, аналогичный контроллеру device в cgroupsv1.


Программы типа BPF_PROG_TYPE_CGROUP_SYSCTL позволяют подсоединяться к конкретным sysctl и разрешать, запрешать, записывать поведение процессов, работающих в конкретной cgroup и пытающихся поменять настройки системы.


Кроме всего сказанного выше все эти программы могут использоваться как источник информации и собирать интересную и красивую статистику о жизни процессов.


BPF и сокеты


Программы типа BPF_PROG_TYPE_SK_SKB позволяют перенаправлять пакеты между сокетами. При этом подсоединяются они интересным способом: создается специальный мап типа SOCKMAP, к которому подсоединяется программа. Дальше, в этот мап можно помещать один и более сокетов и тогда, когда у этих сокетов случается recvmsg, то вызывается программа BPF, которая может перенаправить данный sk_buff на другой сокет. Этот тип программ, оригинально разработанные Isovalent для построения их CNI cilium для k8s, нашли свое применение и в компании Cloudfare, см. статью SOCKMAP — TCP splicing of the future.


Добавленные вскоре после BPF_PROG_TYPE_SK_SKB, программы типа BPF_PROG_TYPE_SK_MSG нужны для того, чтобы запускаться на каждый sendmsg или sendpage и позволяющий анализировать данные, пересылаемые через сокет и реализовывать файерволы уровня L7 — пакеты могут быть либо пропущены дальше, либо отброшены. Аналогично с предыдущим типом программ BPF_PROG_TYPE_SK_SKB, программы данного типа подсоединяются к мапу типа sockmap и сокеты активируются при помощи добавления в этот мап.


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


Интересный тип программ BPF_PROG_TYPE_SK_LOOKUP позволяет выбирать сокет для доставки пакета в тот момент, когда он уже попал в транспортный уровень. Два оригинальных примера использования: пересылать на один и тот же сокет пакеты, приходящие на диапазон IP адресов, пересылать в один сокет пакеты, приходящие на определенный адрес, вне зависимости от порта. Программы этого типа подсоединяются к сетевым namespaces.


Наконец, еще один тип программы, позволяющий динамически менять параметры TCP соединения — это BPF_PROG_TYPE_SOCK_OPS. Он подсоединяется к cgroupv2, но в отличие от кажущегося похожим типа программ BPF_PROG_TYPE_CGROUP_SOCKOPT, программы данного типа запускаются в момент выполнения программы, т.е., в момент прихода пакетов и изменения параметров. Пример использования можно посмотреть в статье от автора данной фичи, в которой он показывает как оптимизировать параметры TCP соединения, если оба собеседника находятся в одном датацентре.


LWT: туннелирование в один клик


Грубо говоря, туннелирование позволяет передавать трафик между двумя не связанными друг с другом сегментами сети так, как будто бы они были связаны. Обычно это происходит при помощи инкапсуляции. Например, мы можем соединить две не связанные друг с другом IPv4-сети при помощи IPv6-сети, или подсоединить домашний компьютер к офисной сети по VPN, ну и так далее.


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



Для того, чтобы туннелировать пакет в Linux, исторически необходимо было создать специальный виртуальный интерфейс: ip link add name ipip0 type ipip... и т.п. В 2015 году в Linux появилась возможность привязывать правила инкапсуляции прямо к маршрутам. Это оказывается сильно дешевле, но, конечно, не всегда применимо — иногда хочется настраивать параметры отдельных сетевых устройств.


Однако, через год, в 2016 году, разработчики добавили возможность реализовывать любую логику! А именно, в качестве правил стало можно указывать программы BPF трех следующих типов:



Здесь input вызывается для входящих пакетов, output — для выходящих, а xmit — прямо перед отправкой пакета на устройство. В качестве контекста программам выдается указатель на struct __sk_buff, на основе которого они могут его пропустить (BPF_OK), дропнуть (BPF_DROP), отправить на повторный поиск маршрута (BPF_REDIRECT) или, наконец, направить на другое устройство вывода (BPF_DROP). Кроме этого, программы типа xmit могут менять содержимое пакета — добавлять и дропать заголовки, переписывать данные.


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


ip route add 10.0.0.0/24 encap bpf xmit obj <prog.o> section <section> dev <dev>

где <prog.o> — ваш BPF ELF, <section> — секция с вашей программой в этом объектнике.


В 2018 году был добавлен новый тип программ, BPF_PROG_TYPE_LWT_SEG6LOCAL, который позволяет запускать программы в качестве функции туннеля типа seg6local, подробнее см. Using SRv6.


Программы невидимого фронта


Остался только один не рассмотренный нами тип программ BPF: BPF_PROG_TYPE_UNSPEC. Этот тип программы, как следует из названия, используется в качестве дефолтного/неопределенного типа. Загрузить программу такого типа в ядро при помощи системного вызова bpf(2) не получится.


Однако, программы этого типа являются одними из самых используемых на практике! И с вероятностью 99% вы тоже их использовали, может даже сегодня. Дело в том, что классический BPF в Linux теперь всегда транслируется в программу типа BPF_PROG_TYPE_UNSPEC на новом BPF, а это значит, что когда вы, например, используете tcpdump или wireshark на Linux, вы запускаете такую программу.


Арест, заключение и ссылки


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


В следующих статьях мы начнем более детально знакомиться с конкретными приложениями и начнем со статьи (или, скорее, нескольких) про трассировку Linux — программах BPF для работы с kprobes, tracepoints и perf events, а также доступных средствах разработки и готовых решениях — libbpf, bcc и bpftrace.


Ссылки


Предыдущие статьи этого цикла


  1. BPF для самых маленьких, часть нулевая: classic BPF
  2. BPF для самых маленьких, часть первая: extended BPF

2,5 книжки


  • Brendan Gregg, «BPF Performance Tools». БГ распознал потенциал BPF в деле трассировки Linux сразу после его появления и данная книжка описывает результаты работы последних пяти лет — отладочные утилиты из пакета BCC, около сотни которых было написано специально для этой книжки. Книжка является отличным справочным дополнением по BPF к следующей.
  • Brendan Gregg, «Systems Performance: Enterprise and the Cloud, 2nd Edition (2020)». Еще не вышедшее второе издание знаменитой «Systems Performance». Главные изменения: добавлен материал по BPF, выкинут материал по Solaris, а сам БГ стал на пять лет опытнее. Если книжка «BPF Performance Tools» отвечает на вопрос «как?», то эта книжка отвечает на вопрос «почему?»
  • David Calavera and Lorenzo Fontana, «Linux Observability with BPF». Эта книжка мне не понравилась. Авторы явно торопились написать Первую Книгу про BPF, но ничего нового, по сравнению с уже существующими на тот момент блогами, не написали.

Online-ресурсы, статьи и блоги


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