BPF для самых маленьких, часть вторая: разнообразие типов программ BPF

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

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

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


0qchxjfo0secpjjyh715v9jrxks.jpeg

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

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

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

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

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


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

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

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

#! /usr/bin/env bpftrace

#include 
#include 

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 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.


Tail calls

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

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

pxpeabh0-l-8syzcrjkrqzrnpno.png

Дальше программа Э должна использовать функцию-помощник 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 Høiland-Jørgensen на Linux Plumbers 2020.

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

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

a2suiow2l8qdqbjlksuc8altlks.png

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

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

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


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

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

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

hwjjwnytq7j2nfoigw4ez86ped8.png

Прерывания в 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 вверх по сетевому стеку.

etam9golpvqnjh1ig9sbf7gexrw.png

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

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

b6hoe5lupy-posett2vp8mwproa.png

После создания 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):

z0bepcyuv2gvnzadqaxl0267q7q.png

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

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

wf-rsqlqjt2tj-a-ujyvx7cglt8.png

На картинке я попытался изобразить схему работы одного сокета типа 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. Например, наша программа выше будет преобразована следующим образом:

avkpwiumqyrbm0qvzn-9ecepyxi.png

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


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

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

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

#include 

__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):

ry67mkxbf2zes4jxkpz7mp-ucma.png

Другой простой пример — это 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. Оригинальн

© Habrahabr.ru