Книга: BPF для мониторинга Linux
Назад: 2. Запуск программ BPF
Дальше: 4. Трассировка с помощью BPF

3. Карты BPF

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

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

Карты BPF — это хранилища ключей/значений, которые находятся в ядре. Доступ к ним может получить любая программа BPF, знающая о них. Программы, работающие в пространстве пользователя, также могут обращаться к картам с помощью файловых дескрипторов. Вы можете хранить в карте любые данные, если заранее правильно укажете их размер. Ядро обрабатывает ключи и значения как двоичные объекты и не заботится о том, что вы храните в карте.

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

Создание карт BPF

Самый простой способ создать карту BPF — использовать системный вызов bpf. Когда первым аргументом в вызове является BPF_MAP_CREATE, вы сообщаете ядру, что хотите создать новую карту. Этот вызов вернет идентификатор дескриптора файла, связанный с только что созданной картой. Второй аргумент в системном вызове — конфигурация данной карты:

union bpf_attr {

  struct {

    __u32 map_type;     /* одно из значений bpf_map_type */

    __u32 key_size;     /* размер ключей в байтах */

    __u32 value_size;   /* размер значений в байтах */

    __u32 max_entries;  /* максимальное количество записей в карте */

    __u32 map_flags;    /* флаги для модификации того, как создать карту */

  };

}

Третий аргумент в системном вызове — это размер атрибута конфигурации.

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

union bpf_attr my_map {

  .map_type    = BPF_MAP_TYPE_HASH,

  .key_size    = sizeof(int),

  .value_size  = sizeof(int),

  .max_entries = 100,

  .map_flags   = BPF_F_NO_PREALLOC,

};

 

int fd = bpf(BPF_MAP_CREATE, &my_map, sizeof(my_map));

Если вызов не удался, ядро возвращает значение -1. Это может произойти по одной из трех причин. Если один из атрибутов недействителен, ядро устанавливает для переменной errno значение EINVAL. Если пользователь, выполняющий операцию, не имеет достаточных привилегий, ядро устанавливает для переменной errno значение EPERM. Наконец, если для хранения карты недостаточно памяти, ядро устанавливает для переменной errno значение ENOMEM.

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

Соглашения ELF для создания карт BPF

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

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

int fd;

fd = bpf_create_map(BPF_MAP_TYPE_HASH, sizeof(int), sizeof(int), 100,

    BPF_F_NO_PREALOC);

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

struct bpf_map_def SEC("maps") my_map = {

      .type        = BPF_MAP_TYPE_HASH,

      .key_size    = sizeof(int),

      .value_size  = sizeof(int),

      .max_entries = 100,

      .map_flags   = BPF_F_NO_PREALLOC,

};

Когда вы определяете карту таким образом, вы применяете то, что называется атрибутом раздела, — в данном случае SEC("maps"). Этот макрос сообщает ядру, что данная структура является картой BPF и должна быть создана соответствующим образом.

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

fd = map_data[0].fd;

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

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

Работа с картами BPF

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

Обновление элементов в карте BPF

После создания любой карты вы, вероятно, захотите наполнить ее информацией. Для этой цели помощники ядра предоставляют функцию bpf_map_update_elem. Сигнатуры функции различаются в зависимости от того, загружаете вы ее из bpf/bpf_helpers.h в программе, работающей в ядре, или из tools/lib/bpf/bpf.h в программе, действующей в пространстве пользователя. Так происходит потому, что вы можете обращаться к картам напрямую, когда работаете в ядре, но ссылаетесь на них с помощью файловых дескрипторов, когда работаете в пространстве пользователя. Поведение функции тоже немного различается: код, работающий в ядре, может напрямую обращаться к карте в памяти и атомарно обновлять элементы на месте. Однако код, действующий в пользовательском пространстве, должен отправить сообщение ядру, которое копирует предоставленное значение перед обновлением карты, что делает операцию обновления неатомарной. Эта функция возвращает 0, когда операция завершается успешно, и отрицательное значение — в случае неудачи. При сбое в глобальную переменную errno записывается код сбоя. Позже в этой главе мы перечислим случаи сбоев и дадим их описание.

Функция bpf_map_update_elem в ядре принимает четыре аргумента. Первый — указатель на карту, которую мы уже определили. Второй — указатель на ключ, который хотим обновить. Поскольку ядро не знает тип обновляемого ключа, этот метод определяется как непрозрачный указатель типа void, что означает: можно передавать любые данные. Третий аргумент — значение, которое мы хотим установить. У этого аргумента та же семантика, что и у ключевого аргумента. В книге мы приведем несколько усложненных примеров применения непрозрачных указателей. В этой функции можете использовать четвертый аргумент, чтобы изменить способ обновления карты. Он может принимать три значения:

передав 0, вы сообщите ядру, что хотите обновить элемент, если он существует, или что оно должно создать элемент в карте, если его не существует;

• передав 1, вы скажете ядру, что нужно создать элемент только тогда, когда его не существует;

• если вы передадите 2, ядро обновит элемент, только если он существует.

Эти значения определены как константы, которые вы можете использовать вместо того, чтобы запоминать семантику целых чисел. Значения: BPF_ANY для 0, BPF_NOEXIST для 1 и BPF_EXIST для 2.

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

int key, value, result;

key = 1, value = 1234;

result = bpf_map_update_elem(&my_map, &key, &value, BPF_ANY);

if (result == 0)

  printf("Map updated with new element\n");

else

  printf("Failed to update map with new value: %d (%s)\n",

      result, strerror(errno));

В этом примере мы используем strerror для описания ошибки, установленной в переменной errno. Больше об этой функции можете узнать на страницах справки, введя manstrerror.

Теперь посмотрим, какой результат мы получим при попытке создать элемент с тем же ключом:

int key, value, result;

key = 1, value = 5678;

 

result = bpf_map_update_elem(&my_map, &key, &value, BPF_NOEXIST);

if (result == 0)

  printf("Map updated with new element\n");

else

  printf("Failed to update map with new value: %d (%s)\n",

      result, strerror(errno));

Поскольку мы уже создали в карте элемент с ключом 1, результатом вызова bpf_map_update_elem станет -1, а значение errno будет равно EEXIST. Эта программа выведет на экран следующее:

Failed to update map with new value: -1 (File exists)

А теперь изменим программу, чтобы попытаться обновить элемент, которого еще не существует:

int key, value, result;

key = 1234, value = 5678;

 

result = bpf_map_update_elem(&my_map, &key, &value, BPF_EXIST);

if (result == 0)

  printf("Map updated with new element\n");

else

  printf("Failed to update map with new value: %d (%s)\n",

      result, strerror(errno));

При использовании флага BPF_EXIST результат этой операции снова будет равен -1. Ядро установит для переменной errno значение ENOENT, и программа выведет следующее:

Failed to update map with new value: -1 (No such file or directory)

Эти примеры показывают, как можно обновлять карты в программе ядра. Вы также можете обновить карты из программ пользовательского пространства. Помощники для этого похожи на те, которые мы только что видели, единственное отличие состоит в том, что для доступа к карте они используют файловый дескриптор, а не указатель на карту напрямую. Как вы помните, программы пользовательского пространства всегда получают доступ к картам с помощью файловых дескрипторов. Поэтому здесь в примерах аргумент my_map заменен глобальным идентификатором файлового дескриптора map_data[0].fd. Вот как выглядит оригинальный код в этом случае:

int key, value, result;

key = 1, value = 1234;

 

result = bpf_map_update_elem(map_data[0].fd, &key, &value, BPF_ANY);

if (result == 0)

  printf("Map updated with new element\n");

else

  printf("Failed to update map with new value: %d (%s)\n",

      result, strerror(errno));

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

Считывание элементов из карты BPF

Теперь, когда карта наполнена новыми элементами, мы можем читать их из других точек программы. API чтения станет понятен после изучения bpf_map_update_element.

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

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

int key, value, result; // будет хранить значение ожидаемого элемента

key = 1;

 

result = bpf_map_lookup_elem(&my_map, &key, &value);

if (result == 0)

  printf("Value read from the map: '%d'\n", value);

else

  printf("Failed to read value from the map: %d (%s)\n",

      result, strerror(errno));

Если бы ключ bpf_map_lookup_elem, который мы пытались прочитать, возвратил отрицательное число, он установил бы ошибку в переменной errno. Например, если бы мы не вставили значение до того, как пытались его прочитать, ядро вернуло бы ошибку not foundENOENT.

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

int key, value, result; // будет хранить значение ожидаемого элемента

key = 1;

 

result = bpf_map_lookup_elem(map_data[0].fd, &key, &value);

if (result == 0)

  printf("Value read from the map: '%d'\n", value);

else

  printf("Failed to read value from the map: %d (%s)\n",

      result, strerror(errno));

Как видите, мы заменили первый аргумент в bpf_map_lookup_elem идентификатором файлового дескриптора на карте. Поведение помощника такое же, как и в предыдущем примере.

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

Удаление элемента из карты BPF

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

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

int key, result;

key = 1;

 

result = bpf_map_delete_element(&my_map, &key);

if (result == 0)

  printf("Element deleted from the map\n");

else

  printf("Failed to delete element from the map: %d (%s)\n",

      result, strerror(errno));

Если элемента, который вы пытаетесь удалить, не существует, ядро возвращает отрицательное число. В этом случае оно также заполняет переменную errno ошибкой not foundENOENT.

В этом примере удаляется значение, если программа BPF выполняется в пространстве пользователя:

int key, result;

key = 1;

 

result = bpf_map_delete_element(map_data[0].fd, &key);

if (result == 0)

  printf("Element deleted from the map\n");

else

  printf("Failed to delete element from the map: %d (%s)\n",

      result, strerror(errno));

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

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

Перебор элементов в карте BPF

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

Этот помощник обеспечивает детерминированный способ перебора элементов в карте, но его поведение менее интуитивно понятно, чем поведение итераторов в большинстве языков программирования. Требуется три аргумента. Первый — это идентификатор файлового дескриптора карты, как и другие помощники пользовательского пространства, которые вы уже видели. Следующие два аргумента… ну, здесь все сложнее. Согласно официальной документации второй аргумент key — это идентификатор, который вы ищете, а третий, next_key, — следующий ключ в карте. Мы предпочитаем вызывать первый аргумент lookup_key — почему, станет ясно буквально через секунду. Когда вы вызываете данный помощник, BPF пытается найти в этой карте элемент с ключом, который вы передаете в качестве ключа поиска, затем устанавливает аргумент next_key со смежным ключом в карте. Поэтому, если вы хотите узнать, какой ключ следует за ключом 1, установите 1 в качестве ключа поиска, и, если у карты есть смежный с ним ключ, BPF задаст его в качестве значения для аргумента next_key.

Прежде чем смотреть, как bpf_map_get_next_key работает на реальном примере, добавим еще несколько элементов в нашу карту:

int new_key, new_value, it;

 

for (it = 2; it < 6 ; it++) {

  new_key = it;

  new_value = 1234 + it;

  bpf_map_update_elem(map_data[0].fd, &new_key, &new_value, BPF_NOEXIST);

}

Если вы хотите распечатать все значения, сохраненные в карте, можете использовать bpf_map_get_next_key с ключом поиска, которого в ней нет. Это заставляет BPF начать с начала карты:

int next_key, lookup_key;

lookup_key = -1;

 

while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0) {

  printf("The next key in the map is: '%d'\n", next_key);

  lookup_key = next_key;

}

Данный код выведет что-то вроде следующего:

The next key in the map is: '1'

The next key in the map is: '2'

The next key in the map is: '3'

The next key in the map is: '4'

The next key in the map is: '5'

Вы видите, что мы назначаем следующий ключ для lookup_key в конце цикла, таким образом, мы продолжаем перебирать карту, пока не дойдем до конца. Когда bpf_map_get_next_key достигает конца карты, возвращаемое значение становится отрицательным числом, а переменная errno устанавливается в ENOENT. Это прервет выполнение цикла.

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

Хитрости bpf_map_get_next_key на этом не заканчиваются — есть еще кое-что, о чем вам нужно знать. Многие языки программирования копируют значения, имеющиеся в карте, прежде чем выполнять итерации по ее элементам. Это предотвращает непредсказуемое поведение, если какой-то другой код в вашей программе захочет изменить карту. Это особенно опасно, если код удаляет элементы из карты. BPF не копирует значения в карте перед прохо­ждением по ним с помощью bpf_map_get_next_key. Если другая часть вашей программы удалит элемент из карты, пока вы проходите по значениям, bpf_map_get_next_key запустится заново, когда попытается найти следующее значение для ключа элемента, который был удален. Рассмотрим это на примере:

int next_key, lookup_key;

lookup_key = -1;

 

while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0) {

  printf("The next key in the map is: '%d'\n", next_key);

  if (next_key == 2) {

    printf("Deleting key '2'\n");

    bpf_map_delete_element(map_data[0].fd &next_key);

  }

  lookup_key = next_key;

}

Эта программа напечатает следующее:

The next key in the map is: '1'

The next key in the map is: '2'

Deleteing key '2'

The next key in the map is: '1'

The next key in the map is: '3'

The next key in the map is: '4'

The next key in the map is: '5'

Такое поведение не очень интуитивно понятно, имейте это в виду, когда используете bpf_map_get_next_key.

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

Поиск и удаление элементов

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

int key, value, result, it;

key = 1;

 

for (it = 0; it < 2; it++) {

  result = bpf_map_lookup_and_delete_element(map_data[0].fd, &key, &value);

  if (result == 0)

    printf("Value read from the map: '%d'\n", value);

  else

    printf("Failed to read value from the map: %d (%s)\n",

        result, strerror(errno));

}

В этом примере мы пытаемся извлечь один и тот же элемент из карты дважды. На первой итерации код будет печатать значение элемента в карте. Однако, поскольку мы используем bpf_map_lookup_and_delete_element, эта итерация также удалит элемент из карты. Во второй раз, когда цикл попытается извлечь элемент, код завершится ошибкой и заполнит переменную errno ошибкой not foundENOENT.

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

Конкурентный доступ к элементам карты

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

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

Первое, что нам нужно сделать для работы со спин-блокировками, — создать элемент, доступ к которому мы хотим заблокировать, а затем добавить семафор:

struct concurrent_element {

  struct bpf_spin_lock semaphore;

  int count;

}

Мы будем хранить эту структуру в нашей карте BPF и использовать семафор внутри элемента, чтобы предотвратить нежелательный доступ к нему. Теперь мы можем объявить карту, которая будет содержать эти элементы. Она должна быть аннотирована с помощью BPF Type Format (BTF), чтобы верификатор знал, как интерпретировать структуру. Формат типа позволяет ядру и другим инструментам глубже понять структуры данных BPF, добавляя отладочную информацию в двоичные объекты. Поскольку этот код будет работать внутри ядра, мы можем задействовать макросы ядра, которые предоставляет libbpf, для аннотирования карты с конкурентным доступом:

struct bpf_map_def SEC("maps") concurrent_map = {

      .type        = BPF_MAP_TYPE_HASH,

      .key_size    = sizeof(int),

      .value_size  = sizeof(struct concurrent_element),

      .max_entries = 100,

};

 

BPF_ANNOTATE_KV_PAIR(concurrent_map, int, struct concurrent_element);

В рамках программы BPF мы можем использовать два помощника блокировки для защиты от состояния гонки. Несмотря на то что семафор закрыт, наша программа гарантированно сможет безопасно изменять значение элемента (позже):

int bpf_program(struct pt_regs *ctx) {

  int key = 0;

  struct concurrent_element init_value = {};

  struct concurrent_element *read_value;

 

  bpf_map_create_elem(&concurrent_map, &key, &init_value, BPF_NOEXIST);

 

  read_value = bpf_map_lookup_elem(&concurrent_map, &key);

  bpf_spin_lock(&read_value->semaphore);

  read_value->count += 100;

  bpf_spin_unlock(&read_value->semaphore);

}

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

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

6713.png

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

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

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

Типы карт BPF

Документация Linux (https://oreil.ly/XfoqK) определяет карты как общие структуры данных, где можно хранить различные типы данных. За прошедшие годы разработчики ядра добавили много специализированных структур данных, более эффективных в конкретных случаях. В этом разделе рассматриваются все типы карт и способы их использования.

Карты хеш-таблиц

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

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

#define IPV4_FAMILY 1

struct ip_key {

  union {

    __u32 v4_addr;

    __u8 v6_addr[16];

  };

__u8 family;

};

 

struct bpf_map_def SEC("maps") counters = {

      .type        = BPF_MAP_TYPE_HASH,

      .key_size    = sizeof(struct ip_key),

      .value_size  = sizeof(uint64_t),

      .max_entries = 100,

      .map_flags   = BPF_F_NO_PREALLOC

};

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

Напишем небольшой фрагмент кода, который обновляет эти счетчики в ядре:

uint64_t update_counter(uint32_t ipv4) {

  uint64_t value;

  struct ip_key key = {};

  key.v4_addr = ip4;

  key.family = IPV4_FAMILY;

 

  bpf_map_lookup_elem(counters, &key, &value);

  (*value) += 1;

}

Данная функция берет IP-адрес, извлеченный из сетевого пакета, и выполняет поиск карты с помощью составного ключа, который мы объявляем. В этом случае предполагаем, что ранее инициализировали счетчик с нулевым значением, в противном случае вызов bpf_map_lookup_elem вернет отрицательное число.

Карты массивов

Карты массива были вторым типом карт BPF, добавленных в ядро. Они определены с типом BPF_MAP_TYPE_ARRAY. Когда вы инициализируете карту массива, все ее элементы предварительно распределяются в памяти и устанавливаются в ноль. Поскольку эти карты поддерживаются определенным количеством элементов, ключи являются индексами в массиве и их размер должен составлять ровно 4 байта.

Недостатком использования карт массива является то, что содержащиеся в них элементы нельзя удалить и вы не можете уменьшить массив. Если попытаетесь применить map_delete_elem в карте массива, вызов не удастся и в результате вы получите ошибку EINVAL.

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

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

Карты программных массивов

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

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

Рассмотрим подробный пример, чтобы понять, как лучше применять этот тип карты:

struct bpf_map_def SEC("maps") programs = {

  .type = BPF_MAP_TYPE_PROG_ARRAY,

  .key_size = 4,

  .value_size = 4,

  .max_entries = 1024,

};

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

int key = 1;

struct bpf_insn prog[] = {

  BPF_MOV64_IMM(BPF_REG_0, 0), // присваиваем r0 = 0

  BPF_EXIT_INSN(),  // возвращаем r0

};

 

prog_fd = bpf_prog_load(BPF_PROG_TYPE_KPROBE, prog, sizeof(prog), "GPL");

bpf_map_update_elem(&programs, &key, &prog_fd, BPF_ANY);

Следует объявить программу, на которую мы собираемся переключиться. В этом случае пишем программу BPF, единственная цель которой — вернуть 0. Мы используем bpf_prog_load, чтобы загрузить ее в ядро, а затем добавляем идентификатор ее файлового дескриптора в карту нашей программы.

Теперь, когда наша программа сохранена, мы можем написать другую программу BPF, к которой первая будет переходить, то есть вызывать ее из себя. BPF-программы могут переходить к другим программам, только если они одного типа, в этом случае мы присоединяем программу к трассировке kprobe (это рассматривалось в главе 2):

SEC("kprobe/seccomp_phase1")

int bpf_kprobe_program(struct pt_regs *ctx) {

  int key = 1;

  /* отправка в следующую программу BPF */

  bpf_tail_call(ctx, &programs, &key);

 

  /* попадаем сюда, когда дескриптора программы нет в карте */

  char fmt[] = "missing program in prog_array map\n";

  bpf_trace_printk(fmt, sizeof(fmt));

  return 0;

}

С bpf_tail_call и BPF_MAP_TYPE_PROG_ARRAY можно связать до 32 вложенных вызовов. Это строгое ограничение для предотвращения появления бесконечных циклов и исчерпания памяти.

Карты массивов событий производительности

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

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

struct data_t {

  u32 pid;

  char program_name[16];

};

Теперь нам нужно создать карту, которая будет отправлять события в пространство пользователя:

struct bpf_map_def SEC("maps") events = {

  .type        = BPF_MAP_TYPE_PERF_EVENT_ARRAY,

  .key_size    = sizeof(int),

  .value_size  = sizeof(u32),

  .max_entries = 2,

};

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

SEC("kprobe/sys_exec")

int bpf_capture_exec(struct pt_regs *ctx) {

  data_t data;

  // bpf_get_current_pid_tgid возвращает идентификатор текущего процесса

  data.pid = bpf_get_current_pid_tgid() >> 32;

  // bpf_get_current_comm загружает текущее имя программы

  bpf_get_current_comm(&data.program_name, sizeof(data.program_name));

  bpf_perf_event_output(ctx, &events, 0, &data, sizeof(data));

  return 0;

}

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

В главе 4 обсуждается более продвинутое использование этих типов карт и приводятся примеры работы программ в пространстве пользователя.

Хеш-карты для каждого процессора

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

Карты массивов для каждого процессора

Такие карты также являются улучшенной версией BPF_MAP_TYPE_ARRAY. Они определены с типом BPF_MAP_TYPE_PERCPU_ARRAY. При выделении одной из этих карт (как и предыдущей) каждый ЦП видит собственную изолированную версию карты, что делает ее намного более эффективной для высокопроизводительных поисков и объединения данных.

Карты трассировки стека

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

Карты массива контрольной группы

Карты такого типа хранят ссылки на контрольные группы. Карты массива контрольной группы определяются с типом BPF_MAP_TYPE_CGROUP_ARRAY. По сути, их поведение аналогично поведению BPF_MAP_TYPE_PROG_ARRAY, но они хранят идентификаторы файловых дескрипторов, которые указывают на контрольные группы.

Эта карта полезна, когда вы хотите поделиться ссылками контрольной группы (cgroup) между картами BPF для контроля трафика, отладки и тестирования. Рассмотрим, как заполнить эту карту. Начнем с ее определения:

struct bpf_map_def SEC("maps") cgroups_map = {

  .type        = BPF_MAP_TYPE_CGROUP_ARRAY,

  .key_size    = sizeof(uint32_t),

  .value_size  = sizeof(uint32_t),

  .max_entries = 1,

};

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

int cgroup_fd, key = 0;

cgroup_fd = open("/sys/fs/cgroup/cpu/docker/cpu.shares", O_RDONLY);

 

bpf_update_elem(&cgroups_map, &key, &cgroup_fd, 0);

Хеш-карты LRU и хеш-карты отдельных процессоров

Карты этих двух типов являются картами хеш-таблиц подобно тем, которые вы видели ранее, но они также реализуют внутренний кэш LRU. LRU очень редко используется в последнее время, что означает: если карта заполнена, она будет удалять элементы, которые применяются нечасто, чтобы освободить место для новых элементов. Поэтому вы можете использовать эти карты для вставки элементов, превышающих максимальный предел, если не возражаете против потери элементов, не используемых в последнее время. Они определяются с типами BPF_MAP_TYPE_LRU_HASH и BPF_MAP_TYPE_LRU_PERCPU_HASH.

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

Карты LPM Trie

Древовидные карты LPM — это тип карт, которые используют поиск по самому длинному префиксу (LPM) для поиска элементов в карте. LPM — это алгоритм, который выбирает элемент дерева, совпадающий с самым длинным ключом поиска из любого другого соответствия в дереве. Этот алгоритм применяется в маршрутизаторах и других устройствах, которые хранят таблицы пересылки трафика для сопоставления IP-адресов с конкретными маршрутами. Карты определены с типом BPF_MAP_TYPE_LPM_TRIE.

Эти карты требуют, чтобы размеры их ключей были кратны восьми и находились в диапазоне от 8 до 2048. Если вы не хотите реализовывать собственный ключ, ядро предоставляет структуру, которую можно использовать для этого. Она называется bpf_lpm_trie_key.

В следующем примере мы добавляем два маршрута пересылки в карту и пытаемся сопоставить IP-адрес с правильным маршрутом. Сначала нам нужно создать карту:

struct bpf_map_def SEC("maps") routing_map = {

  .type = BPF_MAP_TYPE_LPM_TRIE,

  .key_size = 8,

  .value_size = sizeof(uint64_t),

  .max_entries = 10000,

  .map_flags = BPF_F_NO_PREALLOC,

};

Мы собираемся заполнить ее тремя маршрутами для передачи: 192.168.0.0/16, 192.168.0.0/24 и 192.168.1.0/24:

uint64_t value_1 = 1;

struct bpf_lpm_trie_key route_1 = {.data = {192, 168, 0, 0}, .prefixlen = 16};

uint64_t value_2 = 2;

struct bpf_lpm_trie_key route_2 = {.data = {192, 168, 0, 0}, .prefixlen = 24};

uint64_t value_3 = 3;

struct bpf_lpm_trie_key route_3 = {.data = {192, 168, 1, 0}, .prefixlen = 24};

 

bpf_map_update_elem(&routing_map, &route_1, &value_1, BPF_ANY);

bpf_map_update_elem(&routing_map, &route_2, &value_2, BPF_ANY);

bpf_map_update_elem(&routing_map, &route_3, &value_3, BPF_ANY);

Теперь мы используем ту же структуру ключей, чтобы найти правильное соответствие для IP 192.168.1.1/32:

uint64_t result;

struct bpf_lpm_trie_key lookup = {.data = {192, 168, 1, 1}, .prefixlen = 32};

 

int ret = bpf_map_lookup_elem(&routing_map, &lookup, &result);

if (ret == 0)

  printf("Value read from the map: '%d'\n", result);

В этом примере и 192.168.0.0/24, и 192.168.1.0/24 могут соответствовать IP-адресу поиска, поскольку маска 16 (см. в коде) покрывает оба диапазона. Но, поскольку эта карта использует алгоритм LPM, результатом будет значение для ключа 192.168.1.0/24.

Массив карт и хеш-карт

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

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

Карты устройств

Этот специализированный тип карт хранит ссылки на сетевые устройства. Карты определены с типом BPF_MAP_TYPE_DEVMAP. Они полезны для сетевых приложений, которые хотят манипулировать трафиком на уровне ядра. Вы можете создать виртуальную карту портов, которые указывают на определенные сетевые устройства, а затем перенаправить пакеты с помощью помощника bpf_redirect_map.

Карты процессоров

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

Карты открытого сокета

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

Карты массива и хеша сокета

BPF_MAP_TYPE_SOCKMAP и BPF_MAP_TYPE_SOCKHASH — это две специализированные карты, в которых хранятся ссылки на открытые сокеты в ядре. Как и предыдущие карты, эти типы карт используются вместе с помощником bpf_redirect_map для пересылки буферов сокетов из текущей программы XDP в другой сокет.

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

Карты сохранения сgroup и сохранения по ЦПУ

Эти два типа карт были введены, чтобы помочь работать с программами BPF, прикрепленными к контрольной группе. Как говорилось в главе 2, можно присоединять программы BPF к контрольным группам и отсоединять их, а также изолировать их среду выполнения от конкретных контрольных групп с помощью BPF_PROG_TYPE_CGROUP_SKB. Эти две карты определены с типами BPF_MAP_TYPE_CGROUP_STORAGE и BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE.

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

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

Основное различие между этими двумя типами карт, как вы видели ранее на подобных картах, заключается в том, что BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE хранит разные хеш-таблицы для каждого процессора.

Карты переиспользования сокетного порта

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

Карты очередей

Карты очередей для хранения элементов используют принцип «первым пришел — первым вышел» (FIFO). Они определены с типом BPF_MAP_TYPE_QUEUE. FIFO означает, что, когда вы выбираете элемент из карты, это будет элемент, который находился в ней дольше всех.

Помощники для карт bpf работают предсказуемо для этой структуры данных. Когда вы используете bpf_map_lookup_elem, карта всегда ищет самый старый элемент. Когда применяете bpf_map_update_elem, она всегда добавляет элемент в конец очереди, поэтому вам нужно прочитать остальные элементы в карте, прежде чем вы сможете получить этот элемент. Вы также можете задействовать помощник bpf_map_lookup_and_delete для выбора более старого элемента и удаления его из карты атомарно. Данная карта не поддерживает помощники bpf_map_delete_elem и bpf_map_get_next_key. Если вы попытаетесь использовать их, они не сработают и в результате для переменной errno будет установлено значение EINVAL.

Что еще следует сказать об этих типах карт: они не используют ключи карт для поиска, а размер ключа при инициализации карт всегда должен быть равен 0. Когда вы помещаете элементы в эти карты, ключ должен иметь нулевое значение.

Посмотрим на пример использования карты такого типа:

struct bpf_map_def SEC("maps") queue_map = {

  .type = BPF_MAP_TYPE_QUEUE,

  .key_size = 0,

  .value_size = sizeof(int),

  .max_entries = 100,

  .map_flags = 0,

};

Поместим несколько элементов в карту и получим их в том же порядке, в котором вставляли:

int i;

for (i = 0; i < 5; i++)

  bpf_map_update_elem(&queue_map, NULL, &i, BPF_ANY);

 

int value;

for (i = 0; i < 5; i++) {

  bpf_map_lookup_and_delete(&queue_map, NULL, &value);

  printf("Value read from the map: '%d'\n", value);

}

Эта программа выведет следующее:

Value read from the map: '0'

Value read from the map: '1'

Value read from the map: '2'

Value read from the map: '3'

Value read from the map: '4'

Если мы попытаемся извлечь новый элемент из карты, bpf_map_lookup_and_delete вернет отрицательное число, а для переменной errno будет задано значение ENOENT.

Карты стека

Карты стеков для хранения элементов используют хранилище, организованное по принципу «последним пришел — первым вышел» (LIFO). Они определены с типом BPF_MAP_TYPE_STACK. LIFO означает: выбирая элемент из карты, вы получите элемент, добавленный в карту последним.

Помощники карты bpf также работают предсказуемо для этой структуры данных. Когда вы берете bpf_map_lookup_elem, карта всегда ищет самый новый элемент. Когда применяете bpf_map_update_elem, она всегда добавляет элемент в верхнюю часть стека, поэтому и выбирает его первым. Вы также можете задействовать помощник bpf_map_lookup_and_delete, чтобы получить самый новый элемент и удалить его из карты атомарным способом. Эта карта не поддерживает помощники bpf_map_delete_elem и bpf_map_get_next_key. Если вы попытаетесь использовать их, они не сработают и в результате для переменной errno будет установлено значение EINVAL.

Рассмотрим пример применения карты:

struct bpf_map_def SEC("maps") stack_map = {

  .type = BPF_MAP_TYPE_STACK,

  .key_size = 0,

  .value_size = sizeof(int),

  .max_entries = 100,

  .map_flags = 0,

};

Поместим несколько элементов в эту карту и получим их в том же порядке, в котором вставили:

int i;

for (i = 0; i < 5; i++)

  bpf_map_update_elem(&stack_map, NULL, &i, BPF_ANY);

 

int value;

for (i = 0; i < 5; i++) {

  bpf_map_lookup_and_delete(&stack_map, NULL, &value);

  printf("Value read from the map: '%d'\n", value);

}

Программа выведет следующее:

Value read from the map: '4'

Value read from the map: '3'

Value read from the map: '2'

Value read from the map: '1'

Value read from the map: '0'

Если мы попытаемся извлечь новый элемент из карты, bpf_map_lookup_and_delete вернет отрицательное число, а для переменной errno будет задано значение ENOENT.

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

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

Виртуальная файловая система BPF

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

Каталог по умолчанию, в котором BPF ожидает найти виртуальную файловую систему, — это /sys/fs/bpf. Некоторые дистрибутивы Linux не монтируют ее по умолчанию, поскольку не предполагают, что ядро поддерживает BPF. Вы можете смонтировать ее самостоятельно, используя команду mount:

# mount -t bpf /sys/fs/bpf /sys/fs/bpf

Как и любая другая файловая иерархия, постоянные объекты BPF в файловой системе идентифицируются путями. Вы можете организовать эти пути любым способом, который имеет смысл для ваших программ. Например, если хотите, чтобы программы делились между собой определенной картой с информацией об IP, можете сохранить ее в /sys/fs/bpf/shared/ips. Как упоминалось ранее, есть два типа объектов, которые вы можете сохранить в этой файловой системе: карты BPF и полные программы BPF. Оба они идентифицируются дескрипторами файлов, поэтому интерфейс для работы с ними одинаков. Управлять этими объектами можно только системным вызовом bpf. Хотя ядро предоставляет высокоуровневые помощники, которые помогут взаимодействовать с ними, вы не сможете открыть файлы с помощью системного вызова open.

BPF_PIN_FD — это команда для сохранения объектов BPF в файловой системе. После успешного выполнения команды объект будет располагаться в ней по указанному вами пути. Если команда не выполняется, она возвращает отрицательное число и глобальная переменная errno получает код ошибки.

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

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

Во-первых, напишем программу, которая создает карту, заполняет ее несколькими элементами и сохраняет в файловой системе:

static const char * file_path = "/sys/fs/bpf/my_array";

 

int main(int argc, char **argv) {

  int key, value, fd, added, pinned;

 

  fd = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(int), sizeof(int), 100, 0);

  if (fd < 0) {

    printf("Failed to create map: %d (%s)\n", fd, strerror(errno));

    return -1;

  }

 

  key = 1, value = 1234;

  added = bpf_map_update_elem(fd, &key, &value, BPF_ANY);

  if (added < 0) {

    printf("Failed to update map: %d (%s)\n", added, strerror(errno));

    return -1;

  }

 

  pinned = bpf_obj_pin(fd, file_path);

  if (pinned < 0) {

    printf("Failed to pin map to the file system: %d (%s)\n",

        pinned, strerror(errno));

    return -1;

  }

 

  return 0;

}

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

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

ls -la /sys/fs/bpf

total 0

drwxrwxrwt 2 root  root  0 Nov 24 13:56 .

drwxr-xr-x 9 root  root  0 Nov 24 09:29 ..

-rw------- 1 david david 0 Nov 24 13:56 my_map

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

static const char * file_path = "/sys/fs/bpf/my_array";

 

int main(int argc, char **argv) {

  int fd, key, value, result;

 

  fd = bpf_obj_get(file_path);

  if (fd < 0) {

    printf("Failed to fetch the map: %d (%s)\n", fd, strerror(errno));

    return -1;

  }

 

  key = 1;

  result = bpf_map_lookup_elem(fd, &key, &value);

  if (result < 0) {

    printf("Failed to read value from the map: %d (%s)\n",

        result, strerror(errno));

    return -1;

  }

 

  printf("Value read from the map: '%d'\n", value);

  return 0;

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

Резюме

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

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

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

Назад: 2. Запуск программ BPF
Дальше: 4. Трассировка с помощью BPF