Я думаю, феномен Linux восхитителен, потому что он прочно стоит на той основе, которую предоставил Unix. Linux кажется одним из самых здоровых прямых деривативов Unix.
Деннис M. Ритчи, из интервью на LinuxFocus.org, 1999
Сетевые устройства усложняются, и в них удобно использовать ОС общего назначения, такие как Linux.
Но сетевой стек ОС, реализованный в ядре, и путь от драйвера к прикладному уровню . То есть в высокопроизводительных сетевых устройствах, например маршрутизаторах, DLP-системах и прочих, обычный стек Linux и многих других ОС использовать уже невозможно.
Подобная проблема существует не только в Linux. Многие ОС предоставляют универсальный сетевой стек и пользовательский интерфейс на основе сокетов. Задача стеков — не столько высокая производительность, сколько обеспечение универсальности и совместимости, а также поддержка разнообразных сетевых протоколов и устройств.
Чтобы исключить из работы медленное ядро, было предложено работать с адаптерами напрямую в пространстве пользователя. Эту технику называют kernel bypass — «обход ядра».
Сетевая подсистема Linux медленная во многом потому, что пакеты копируются минимум два раза до того, как их начнет обрабатывать приложение.
Сначала пакет копируется из DMA-области памяти в буфер ядра, затем оттуда в буфер пользователя. На одно копирование требуется от 500 до 2000 тактов процессора. Мало того, копирования в дальнейшем приводят к промахам кэша CPU, что еще сильнее замедляет работу. Поэтому множественные копирования небольших по размеру пакетов приводят к снижению производительности.
Чтобы этого избежать, в сторонних решениях используется отображение DMA (direct memory access, прямой доступ к памяти) в пространство пользователя, что позволяет не прибегать к копированию. Но это требует поддержки сетевой картой и ее драйвером. Также возможно отображение буфера ядра в пространство пользователя, то есть одно копирование.
Основные преимущества работы в пространстве пользователя:
• Упрощение разработки.
• Исключение частого копирования данных из пространства ядра в пространство пользователя и обратно.
• Возможность использовать все средства, предоставляемые ОС на прикладном уровне.
• Возможность перенести часть вычислений на ускорители, не разрабатывая для этого сопряжения между драйверами.
• Простота смены алгоритмов обработки.
• Потоковая обработка данных взамен управляемой прерываниями, как в ядре.
Мы познакомимся с механизмами обработки высокоскоростного трафика, начиная с TUN/TAP-интерфейсов, которые используются для туннелирования, а также встроенных механизмов zero-copy и заканчивая различными вариантами In-Kernel FastPath, ускоряющими обработку пакетов. Кроме того, мы немного коснемся фреймворков DPDK, PF_RING и Snabb.
Вдобавок мы поверхностно рассмотрим модель WSD, с которой пользователь может работать в ОС Windows через интерфейс WinSock, но без подключения всего стека протоколов.
Также затронем программно-аппаратные комплексы, такие как SmartNIC, или умные сетевые карты, и язык программирования P4.
Рис. 23.1. Интерфейс TUN/TAP
TUN/TAP — это интерфейс операционной системы для создания сетевых интерфейсов, управляемых из пространства пользователя. Он изображен на рис. 23.1. Обычно он используется для реализации VPN. Такие пакеты, как OpenVPN, l2tpns и OpenSSH, в режиме туннелирования используют данный интерфейс.
TUN/TAP реализован в Linux, FreeBSD, OpenBSD и некоторых других BSD-системах, а также в Solaris. Интерфейс предоставляет виртуальные сетевые устройства — TUN и TAP. Эти устройства реализованы в ядре и позволяют приложениям создавать новые сетевые интерфейсы. В Linux их реализация содержится в модуле ядра, который называется tun.
Создаваемые интерфейсы можно рассматривать как Ethernet-устройства «точка-точка», пригодные для отправки и приема пакетов напрямую из пользовательского пространства.
Администрировать TUN/TAP-устройства возможно, используя команду ip tuntap.
Рис. 23.2. Типичная реализация VPN
В Linux типичная реализация VPN использует TAP-устройство, то есть создает виртуальные интерфейсы Ethernet. Показанный на рис. 23.2 TAP-интерфейс с настроенным IP представляет собой точку подключения к виртуальной сети.
Все пакеты с адресами, соответствующими этой сети, отправляются в данный интерфейс. Если же он настроен как шлюз по умолчанию, через него будет отправлен весь трафик.
Алгоритм работы такого VPN следующий:
1. На интерфейсе tap0 настроен IP-адрес. Поэтому всякий раз, когда ядро отправляет пакет на tap0, он передается связанному приложению.
2. Приложение шифрует, сжимает и отправляет его получателю, используя TCP или UDP через физический интерфейс.
3. На стороне приемника приложение получает данные из сети по физическому интерфейсу, затем распаковывает и расшифровывает полученные данные и записывает пакеты в TAP-устройство.
4. Ядро обрабатывает пакет так, как будто он пришел с реального физического интерфейса, то есть из сети, и в результате отправляет его нужному приложению. Например, серверу, который слушает порт.
Так организуется канал, частью которого является приложение VPN.
Основное различие — это работа на разных уровнях OSI, как показано на рис. 23.3.
TUN — интерфейс сетевого уровня 3. Он работает с IP-дейтаграммами. Это интерфейс «точка-точка».
TAP — интерфейс канального уровня 2. По умолчанию он работает с Ethernet-кадрами. Иными словами — это виртуальный Ethernet-адаптер.
Рис. 23.3. TUN и TAP в сетевом стеке
TAP-интерфейс по умолчанию имеет тип ARPHRD_ETHER, а TUN — ARPHRD_NONE. Другие константы типа устройства определены в linux/if_arp.h.
Сначала необходимо открыть файл /dev/net/tun — управляющий интерфейс. Через его дескриптор будет получено новое виртуальное сетевое устройство. Для создания сетевых устройств или для подключения к сетевым устройствам, не принадлежащим данному пользователю, требуется привилегия CAP_NET_ADMIN.
Вызов, запрашивающий виртуальный сетевой интерфейс, — TUNSETIFF. Его параметром является уже знакомая нам структура ifreq. Если поле ifr_name не пусто, вызов ioctl создает либо новый виртуальный сетевой интерфейс с выбранным именем, либо открывает существующий.
Если поле ifr_name пусто, будет создан новый интерфейс, имя для которого сгенерировано автоматически, как tunXX или tapXX, и сохранено в этом же поле.
Если при создании TUN-интерфейса установить опцию IFF_NO_PI, то есть NO Packet Information, в поле ifr_flags заголовок будет отсутствовать, а версия IP будет выводиться из номера версии IP в пакете.
В случае установки опции IFF_NO_PI TUN-интерфейс будет отбрасывать все не IPv4- или IPv6-пакеты, фильтруя их по первому байту, с ошибкой EINVAL.
В случае же, если опция не установлена, появляется возможность отправлять IP-пакеты версии, указанной пользователем.
Некоторые полезные ioctl:
• TUNSETIFF — запросить создание нового устройства или установить параметры существующего. Это один из главных ioctl. В поле ifr_flags задается тип интерфейса и некоторые другие флаги. Тип может быть следующим:
• IFF_TUN — создать TUN-интерфейс.
• IFF_TAP — создать TAP-интерфейс.
• TUNGETIFF — получить структуру ifreq, содержащую имя и флаги устройства.
• TUNSETDEBUG — включить или выключить отладку. Будет работать, если ядро скомпилировано с флагом #define TUN_DEBUG.
• TUNSETPERSIST — установить или сбросить TUN_PERSIST.
• TUNSETOWNER — установить uid владельца устройства.
• TUNSETGROUP — установить gid владельца устройства.
• TUNGETFEATURES — возвращает флаги, поддерживаемые интерфейсом.
• TUNSETOFFLOAD — позволяет выполнять некоторые сетевые вычисления в пользовательском пространстве так, будто они выполняются сетевым адаптером:
• TUN_F_CSUM — передавать пакеты без контрольной суммы. В этом случае ее должен рассчитывать пользовательский код. Лучше устанавливать всегда, чтобы не получать случайных ошибок на старых версиях ядер.
• TUN_F_TSO4 — TSO или TCP Segmentation Offload для пакетов IPv4. При включении часть обработки может выполняться пользовательским кодом.
• TUN_F_TSO6 — TSO или TCP Segmentation Offload для пакетов IPv6.
• TUN_F_TSO_ECN — пользовательский код может выполнять TSO с битами ECN, то есть без явного уведомления о перегрузке.
• TUN_F_USO4 и TUN_F_USO6 — разгрузка сегментации UDP для пакетов IPv4 и IPv6 соответственно.
• TUN_F_UFO — пользовательский код может обрабатывать пакеты UFO, или UDP Fragmentation Offload, то есть собирать дейтаграммы из фрагментов. Опция устарела и не рекомендуется к использованию.
• TUNSETIFINDEX — установить индекс виртуального интерфейса.
• TUNGETSNDBUF — получить текущий буфер отправки.
• TUNSETSNDBUF — установить текущий буфер отправки.
• SIOCSIFHWADDR — установить «аппаратный адрес» интерфейса, причем это можно сделать даже на работающем устройстве.
• SIOCGIFHWADDR — получить «аппаратный адрес» интерфейса.
• TUNGETVNETHDRSZ — получить размер заголовка, используемого при установке TUN_VNET_HDR.
• TUNSETVNETHDRSZ — задать размер заголовка, используемого при установке TUN_VNET_HDR. В качестве параметра используется структура virtio_net_hdr_mrg_rxbuf.
• TUNSETQUEUE — управляет добавлением очередей. Будет работать, только если при создании устройства был установлен флаг IFF_MULTI_QUEUE. Флаги в ifr_flags, которые управляют поведением:
• IFF_ATTACH_QUEUE — добавить очередь.
• IFF_DETACH_QUEUE — удалить очередь.
• TUNSETCARRIER — установить состояние несущей интерфейса.
• TUNGETDEVNETNS — получить дескриптор сетевого пространства имен, которому принадлежит интерфейс.
Флаги, которые устанавливаются в ifreq.flags при вызове TUNSETIFF:
• IFF_MULTI_QUEUE — позволяет открывать tun- или tap-устройство через TUNSETIFF несколько раз, указывая одно и то же имя. В результате через разные дескрипторы несколько разных потоков смогут обрабатывать данные параллельно, используя одно устройство, которое будет теперь содержать несколько очередей кадров или пакетов.
• IFF_TUN_EXCL — гарантирует создание нового устройства, а не открытие существующего постоянного. Вернет EBUSY, если устройство существует.
• IFF_NO_CARRIER — не устанавливать флаг CARRIER интерфейса. Потом возможно установить через вызов TUNSETCARRIER.
• IFF_NOFILTER — фильтр Ethernet-адресов не установлен. Флаг только для чтения.
• IFF_ONE_QUEUE — устарел. В современных системах он всегда включен.
• IFF_PERSIST — позволяет устройству продолжать существовать, даже когда последний связанный дескриптор закрыт. Доступен только для чтения. Чтобы установить или убрать этот флаг, необходимо открыть устройство, выключить опцию через ioctl TUNSETPERSIST и после этого закрыть устройство.
• IFF_VNET_HDR — включает управление GSO, или Generic Segmentation Offload. Добавляет структуру virtio_net_hdr для указания GSO и контрольной суммы. GSO предполагает, что некоторые функции обработки пакетов будут вынесены из ядра. В данном случае они будут выполняться приложениями.
• IFF_NAPI и IFF_NAPI_FRAGS — использовать NAPI вызовы. Это при выполнении GRO может увеличивать производительность.
В TUN приложение должно отправлять IP-пакеты, и читать оно будет тоже IP-пакеты.
По умолчанию каждый пакет в TUN-интерфейсе предваряется заголовком:
struct tun_pi
{
// 2 байта флагов.
__u16 flags;
// 2 байта — код протокола из списка EtherType.
__be16 proto;
};
В Linux структура определена в /usr/include/linux/if_tun.h.
Ее атрибуты:
• flags — различные флаги. В Linux определен только один флаг: TUN_PKT_STRIP. Он устанавливается ядром, если длина предоставленного буфера недостаточна и часть данных была отброшена.
• proto — EtherType-код протокола в сетевом порядке байтов. Обычно ETH_P_IP или ETH_P_IPV6.
Отключить это поведение возможно опцией IFF_NO_PI, задаваемой в поле ifr_flags.
Обмен выполняется «пользовательскими» Ethernet-кадрами в случае, если TAP-устройство имеет тип Ethernet. Тип можно изменить, используя вызов TUNSETLINK. Например, сделать виртуальным Infiniband либо ATM-адаптером:
ioctl(dev_fd, TUNSETLINK, ARPHRD_ATM);
Изменения будут несущественными, но команда ip addr покажет тип интерфейса link/atm.
Собственно, для TUN-интерфейса можно сделать то же самое. Интерфейс должен быть выключен, иначе возникнет ошибка EBUSY.
Специфичные для TAP-интерфейсов ioctl:
• TUNSETTXFILTER — установить или сбросить фильтр Ethernet-адресов уровня драйвера. Принимает структуру tun_filter.
• TUNATTACHFILTER — установить фильтр BPF. Фильтр может быть только один.
• TUNDETACHFILTER — сбросить фильтр BPF.
• TUNSETFILTEREBPF — установить фильтр eBPF на TAP-устройство.
• TUNSETSTEERINGEBPF — установить eBPF-программу для выбора очереди от TAP интерфейса к приложению. Необходимо, если требуется привязать некоторый трафик, например, к потоку в приложении.
Структура фильтра:
struct tun_filter
{
// Может иметь значение только TUN_FLT_ALLMULTI.
__u16 flags;
// Количество адресов.
__u16 count;
// Адреса интерфейсов.
__u8 addr[0][ETH_ALEN];
};
Флаг может иметь только одно значение: TUN_FLT_ALLMULTI — принимать все multicast-пакеты.
Реализуем набор классов и приложение, работающее как с TUN-, так и с TAP-интерфейсами. Каждый тип интерфейса будет обрабатываться своим классом, а контроллер позволит их создавать и удалять.
Также контроллер задает имя, если оно было передано, в описывающую интерфейс структуру:
class TunTapController
{
public:
// Размер MAC-адреса.
static constexpr auto mac_size = 6;
public:
Tap open_tap(const std::optional<std::string> &dev_name = std::nullopt)
const
{
return std::move(internal_open<Tap>(dev_name));
}
Tun open_tun(const std::optional<std::string> &dev_name = std::nullopt)
const
{
return std::move(internal_open<Tun>(dev_name));
}
private:
template<class T>
T internal_open(const std::optional<std::string> &dev_name) const
{
// Открыть управляющее интерфейсами устройство.
int dev_fd = open("/dev/net/tun", O_RDWR);
if (dev_fd < 0)
{
throw std::system_error(errno, std::system_category(),
"Opening interface");
}
ifreq ifr = {0};
// Если имя задано, скопировать его в ifr.
if (dev_name.has_value())
{
if (dev_name->size() >= IFNAMSIZ)
{
throw std::logic_error("Incorrect name size");
}
std::copy(dev_name->begin(), dev_name->end(), ifr.ifr_name);
}
// Остальное сделает конструктор класса конкретного интерфейса.
return T(dev_fd, ifr);
}
};
Реальный ioctl TUNSETIFF будут выполнять конструкторы сетевых интерфейсов. Для создания TUN-интерфейса устанавливается флаг IFF_TUN. Кроме того, для TUN-интерфейса добавляются опции:
class Tun : public TunTapNetworkInterface
{
public:
Tun(int ni_desc, ifreq &ifr) :
TunTapNetworkInterface::TunTapNetworkInterface(ni_desc)
{
// Это — TUN.
// Сейчас флаг IFF_NO_PI устанавливается безусловно.
ifr.ifr_flags = IFF_TUN | IFF_NO_PI;
if (!set_if_features(ifr))
{
throw std::system_error(errno, std::system_category(),
"Can't set interface features");
}
// Запросить интерфейс.
perform_ioctl(*this, TUNSETIFF, &ifr, "TUNSETIFF ioctl failed");
}
Tun(Tun &&) = default;
std::string type() const override { return "tun"; }
Tun &operator=(const Tun &) = delete;
Tun &operator=(Tun &&other) = default;
protected:
bool set_if_features(ifreq &ifr) noexcept
{
unsigned int features = 0;
// Получить возможности, которые поддерживает интерфейс.
if (-1 == ioctl(*this, TUNGETFEATURES, &features))
{
return false;
}
// Проверка и установка флага, реального эффекта данный флаг не имеет.
if (features & IFF_ONE_QUEUE)
{
ifr.ifr_flags |= IFF_ONE_QUEUE;
}
return true;
}
};
Функция perform_ioctl() выполняет некоторые ioctl, проверяет результат и генерирует исключение в случае ошибки:
int perform_ioctl(int fd, const int call_id, void *result,
const std::string &msg)
{
const auto ioctl_res = ioctl(fd, call_id, result);
if (-1 == ioctl_res)
{
throw std::system_error(errno, std::system_category(), msg);
}
return ioctl_res;
}
В конструкторе TAP-интерфейса вызывается ioctl TUNSETIFF с флагом IFF_TAP:
class Tap : public TunTapNetworkInterface
{
public:
Tap(int ni_desc, ifreq &ifr) :
TunTapNetworkInterface::TunTapNetworkInterface(ni_desc)
{
// Это — TAP.
ifr.ifr_flags = IFF_TAP;
perform_ioctl(*this, TUNSETIFF, &ifr, "TUNSETIFF ioctl failed");
}
Tap(Tap &&) = default;
const std::string type() const override { return "tap"; }
Tap& operator=(Tap &&) = default;
Tap &operator=(const Tap &) = delete;
public:
// MAC можно установить только для TAP-интерфейса.
void set_hw_addr(const uint8_t hw_addr[TunTapNetworkInterface::mac_size])
{
// Сначала нужно получить структуру, описывающую интерфейс.
ifreq ifr = get_iff();
std::copy(hw_addr, hw_addr + TunTapNetworkInterface::mac_size,
&ifr.ifr_hwaddr.sa_data[0]);
// Новый сокет.
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
throw std::system_error(errno, std::generic_category(),
"Can't create socket");
}
// Установка адреса только для Ethernet.
ifr.ifr_hwaddr.sa_family = ARPHRD_ETHER;
try
{
// Вызов ioctl для установки адреса.
perform_ioctl(sock, SIOCSIFHWADDR, &ifr,
"Can't set interface address");
}
catch (const std::exception &e)
{
std::cerr << e.what() << std::endl;
close(sock);
throw;
}
close(sock);
}
};
Базовый класс не представляет особого интереса, за исключением некоторых общих методов, таких как получение и установка имени сетевого интерфейса, а также методов его преобразования:
class TunTapNetworkInterface
{
public:
static const auto mac_size = 6;
public:
explicit TunTapNetworkInterface(int ni_desc) : fd_(ni_desc) {}
TunTapNetworkInterface(TunTapNetworkInterface &&other)
{
fd_ = other.reset();
}
TunTapNetworkInterface& operator=(TunTapNetworkInterface &&other)
{
fd_ = other.reset();
return *this;
}
virtual ~TunTapNetworkInterface()
{
if (-1 != fd_) close(fd_);
}
virtual const std::string type() const = 0;
public:
// Интерфейс не копируемый, поэтому конструктор
// и операторы присваивания (в наследниках) удаляются.
// Два разных объекта могут содержать только разные интерфейсы.
// С разными именами, дескрипторами.
TunTapNetworkInterface(const TunTapNetworkInterface &) = delete;
Сброс и преобразование к типу дескриптора, то есть к int:
public:
// Преобразование в дескриптор.
operator int() const { return fd_; }
// После сброса дескриптора объект больше им не владеет и не закроет
// его при разрушении.
int reset()
{
const int fd = fd_;
fd_ = -1;
return fd;
}
Методы получения и установки имени сетевого интерфейса:
std::string get_name() const
{
// Получение имени данного TUN/TAP-интерфейса.
auto ifr{std::move(get_iff())};
return ifr.ifr_name;
}
std::string set_name(const std::string &new_name)
{
if (new_name.size() >= IFNAMSIZ)
{
throw std::logic_error("Incorrect name size");
}
// Чтобы выполнить ioctl, необходим обычный сокет.
// Дескриптор TUN/TAP не подходит.
int sock = socket(PF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
throw std::system_error(errno, std::system_category(),
"Opening socket");
}
// Получить текущее имя данного интерфейса.
const auto old_name = std::move(get_name());
ifreq ifr = {0};
// Старое имя.
std::copy(old_name.begin(), old_name.end(), ifr.ifr_name);
// Новое имя.
std::copy(new_name.begin(), new_name.end(), ifr.ifr_newname);
try
{
perform_ioctl(sock, SIOCSIFNAME, &ifr, "SIOCSIFNAME failed");
}
catch(...)
{
close(sock);
throw;
}
close(sock);
// Перезапросить новое имя.
return get_name();
}
Метод для установки типа интерфейса и служебный метод, необходимый, чтобы получать свойства интерфейса:
void set_interface_type(unsigned int type = ARPHRD_INFINIBAND)
{
// Пример установки типа интерфейса.
perform_ioctl(fd_, TUNSETLINK, &type, "TUNSETLINK ioctl failed");
}
protected:
ifreq get_iff() const
{
// Служебный метод для получения структуры описания интерфейса.
ifreq ifr = {0};
perform_ioctl(fd_, TUNGETIFF, &ifr, "TUNGETIFF ioctl failed");
return ifr;
}
private:
int fd_;
};
Теперь посмотрим, как с этим работать. Сначала для получения интерфейсов создадим контроллер. Используя его, мы получаем объект для нужного интерфейса.
Обмен данными выполняется выполняется с помощью функций обычного файлового ввода-вывода:
• write() — отправляет кадр или пакет на виртуальный сетевой интерфейс.
• read() — получает кадр или пакет от виртуального сетевого интерфейса.
• select() — работает так же, как и для обычного дескриптора сокета, что подробно будет описано в книге 2.
Пример создания TAP-интерфейса, а также работы с ним реализован прямо в функции main():
int main(int argc, const char * const argv[])
{
// Буфер для приема данных.
constexpr auto buffer_size = 1600;
// Контроллер, управляющий созданием интерфейсов.
const TunTapController controller;
try
{
// Открыть новый TAP-интерфейс.
Tap tt_iface{std::move((argc > 1) ?
controller.open_tap(std::string(argv[1])) :
controller.open_tap())};
auto &&dev_name = std::move(tt_iface.get_name());
std::cout
<< "Device " << dev_name << " was opened." << std::endl;
std::vector<char> buf(buffer_size);
int bytes_count;
// Обратите внимание, что MAC для TAP-интерфейса должен начинаться
// с 0x12.
uint8_t mac_addr[] = {0x12, 0x10, 0x20, 0x30, 0x40, 0x50};
// Установить новый адрес.
tt_iface.set_hw_addr(mac_addr);
// Чтение данных из созданного интерфейса.
while (true)
{
std::cout << "Waiting for data..." << std::endl;
const bytes_count = read(tt_iface, &buf[0], buf.size());
std::cout
<< "Read "
<< bytes_count << " bytes "
<< "from " << dev_name << "." << std::endl;
}
}
catch (const std::exception &e) { ... ; }
return EXIT_SUCCESS;
}
Когда программа закрывает последний файловый дескриптор, связанный с интерфейсом TUN/TAP, система уничтожает интерфейс и все связанные маршруты, если для интерфейса не была включена опция TUN_PERSIST.
Теперь запустим пример:
➭ sudo build/bin/b01-ch23-tun-example
Назначим интерфейсу адрес, включим его и запустим ping:
➭ sudo ip a add 10.20.30.40 dev tap0
➭ sudo ip link set up tap0
➭ ping 10.20.30.40
PING 10.20.30.40 (10.20.30.40) 56(84) bytes of data.
64 bytes from 10.20.30.40: icmp_seq=1 ttl=64 time=0.092 ms
...
Видим, что данные пошли:
➭ sudo build/bin/b01-ch23-tun-example
Device tap0 was opened.
Waiting for data...
Read 94 bytes from tap0.
Waiting for data...
Read 66 bytes from tap0.
...
Read 74 bytes from tap0.
Waiting for data...
Широко распространенные пакеты, такие как OpenVPN и Wireguard, строятся вокруг виртуальных сетевых интерфейсов. Чтобы эти пакеты работали в других ОС, их разработчики поставляют драйверы, реализующие интерерфейсы.
Например, OpenVPN впервые добавили такой драйвер в ОС Windows.
Для MacOS также реализован драйвер от OpenVPN. Также в MacOS есть достаточно и драйвер tuntaposx, который более не поддерживается и сейчас является частью проекта Tunnelblick — MacOS-интерфейса OpenVPN.
Существует даже отдельный драйвер TUN/TAP для Solaris.
OpenVPN позволяет создавать и настраивать TUN/TAP-интерфейс через параметр mktun: openvpn --mktun.
Для установки параметров TUN/TAP в ОС Windows предлагается использовать Netsh:
netsh interface ip set address my-tap static 10.3.0.1 255.255.255.0
Работа с драйвером TUN/TAP в ОС Windows отличается. В частности, разными ioctl, как по составу, так и по кодам. Поэтому существуют кросс-платформенные библиотеки, унифицирующие интерфейс:
• — относительно старая библиотека, реализованная на C. Поддерживает Linux, BSD-системы, MacOS X, ОС Windows.
• — библиотека C#, поддерживающая основные операции с TUN/TAP-интерфейсами на Linux и Windows.
• — API для Erlang. Работает на FreeBSD, Linux и MacOS X. На MacOS X требует драйвер tuntaposx.
• поддерживает Linux и MacOS X.
Вообще библиотеки, поддерживающие TUN/TAP, реализованы для многих языков.
В Python работа с TUN/TAP-интерфейсами реализована в пакете Scapy. Пакет Scapy подробнее будет рассмотрен в главе 25 этой книги и в книге 3.
Работа с TUN/TAP в Scapy реализована в модуле layers/tuntap.py полностью на Python, без использования модулей на C. Интерфейс TUN/TAP требует файлового ввода-вывода и вызова ioctl, то есть работать с ним легко можно почти на любом распространенном языке программирования.
Дальнейшие действия выполняются из консоли Scapy. Создание интерфейса:
>>> t = TunTapInterface('tun1')
Естественно, должен быть загружен модуль tun. Далее необходимо поднять интерфейс и выполнить назначение адресов через команду ip либо ifconfig, как было показано выше. Интерфейс Scapy этого сделать не позволяет.
Теперь можно работать с интерфейсом из Scapy. Например, создать ICMP-ответчик, который будет возвращать ICMP REPLY, обеспечивая работу ping:
>>> am = t.am(ICMPEcho_am)
>>> am()
am — это объект класса AnsweringMachine, возвращенный методом am() класса scapy.SimpleSocket, от которого и унаследован класс TunTapInterface.
Внимание! Требуется Scapy версии 2.5 и выше, так как в более ранних версиях указанная функциональность отсутствует.
Простой сторонний пакет позволяет создавать устройства TUN/TAP и работать с ними как в Linux, так и в ОС Windows:
from tuntap import TunTap
# Создать TUN.
tun = TunTap(nic_type='Tun', nic_name='tun0')
print(tun.name, tun.ip, tun.mask)
# Создать TAP.
tap = TunTap(nic_type='Tap', nic_name='tap0')
# Установить параметры.
tap.config(ip='192.168.1.10', mask='255.255.255.0', gateway='192.168.1.254')
print(tap.mac)
# Обмен данными.
buf = tun.read(10)
tun.write(buf)
# Закрыть.
tap.close()
tun.close()
Мы не рекомендуем данный пакет к использованию: он не развивается и к тому же не очень грамотно реализован. Например, удаление интерфейсов в нем выполняется не через вызов API, а через запуск команды ip tuntap delete в Linux, а установка адреса в ОС Windows — через запуск netsh interface ip set.
Тем не менее пакет интересен тем, что он прост, и в классе WinTap — реализации для ОС Windows — хорошо видно, как производится работа с Windows-драйвером.
Виден набор ioctl:
class WinTap(Tap):
def __init__(self, nic_type):
super().__init__(nic_type)
# Id устройства для управления драйвером.
self.component_id = 'tap0901'
# Ветка реестра, где будет производиться его поиск.
self.adapter_key = (
r'SYSTEM\CurrentControlSet\Control\Class'
r'\{4D36E972-E325-11CE-BFC1-08002BE10318}'
)
# Набор API для Windows-драйвера.
self.TAP_ioctl_GET_MAC = self._TAP_CONTROL_CODE(1, 0)
self.TAP_ioctl_GET_VERSION = self._TAP_CONTROL_CODE(2, 0)
self.TAP_ioctl_GET_MTU = self._TAP_CONTROL_CODE(3, 0)
self.TAP_ioctl_GET_INFO = self._TAP_CONTROL_CODE(4, 0)
self.TAP_ioctl_CONFIG_POINT_TO_POINT = self._TAP_CONTROL_CODE(5, 0)
self.TAP_ioctl_SET_MEDIA_STATUS = self._TAP_CONTROL_CODE(6, 0)
self.TAP_ioctl_CONFIG_DHCP_MASQ = self._TAP_CONTROL_CODE(7, 0)
self.TAP_ioctl_GET_LOG_LINE = self._TAP_CONTROL_CODE(8, 0)
self.TAP_ioctl_CONFIG_DHCP_SET_OPT = self._TAP_CONTROL_CODE(9, 0)
self.TAP_ioctl_CONFIG_TUN = self._TAP_CONTROL_CODE(10, 0)
...
self.buffer = win32file.AllocateReadBuffer(2000)
def _TAP_CONTROL_CODE(self,request, method):
return self._CTL_CODE(34, request, method, 0)
def _CTL_CODE(self,device_type, function, method, access):
return (device_type << 16) | (access << 14) | (function << 2) |
method;
Для создания устройства в реестре выполняется поиск его GUID, который затем передается функции CreateFile():
def _get_device_guid(self):
# Далее открывается ветка, чтобы найти требуемое устройство
with reg.OpenKey(reg.HKEY_LOCAL_MACHINE, self.adapter_key) \
as adapters:
try:
for i in range(10000):
key_name = reg.EnumKey(adapters, i)
with reg.OpenKey(adapters, key_name) as adapter:
try:
component_id = reg.QueryValueEx(adapter,
'ComponentId')[0]
if component_id == self.component_id:
regid = reg.QueryValueEx(adapter,
'NetCfgInstanceId')[0]
return regid
...
def create(self):
guid = self._get_device_guid()
# Создание устройства.
self.handle = win32file.CreateFile('\\\\.\\Global\\%s.tap' % guid,
win32file.GENERIC_READ | win32file.GENERIC_WRITE, 0,
None, win32file.OPEN_EXISTING,
win32file.FILE_ATTRIBUTE_SYSTEM | win32file.FILE_FLAG_OVERLAPPED,
None
)
if self.handle:
return self
else:
return None
Управление осуществляется путем вызовов к драйверу с использованием функции DeviceIoControl():
def config(self,ip,mask,gateway="0.0.0.0"):
self.ip = ip
self.mask = mask
self.gateway = gateway
try:
code = b'\x01\x00\x00\x00'
result =
win32file.DeviceIoControl(self.handle,
self.TAP_ioctl_SET_MEDIA_STATUS,
code, 512, None)
...
Чтение и запись в ОС Windows также производятся через файловый API: функции ReadFile() и WriteFile().
Часть API была рассмотрена в главах, посвященных ОС Windows; что касается прочих API, они подробно описаны как в MSDN, так и в литературе по разработке под ОС Windows.
Виртуальные интерфейсы возможно использовать для реализации практически любых протоколов на уровне пользователя без самостоятельной реализации модулей ядра.
Механизм zero-copy, опции которого были рассмотрены в главе 8, позволяет обойтись без копирования данных между пространством пользователя и ядра, выделяя буфер, доступный как из ядра, так и из пространства пользователя.
О том, что в буфер, общий между ядром и пользовательским пространством, разрешено записать данные, ядро уведомляет через очередь ошибок сокета:
const int sock_fd{socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)};
if (!sock_fd) return EXIT_FAILURE;
int one = 1;
// Процесс должен сначала сигнализировать о включении режима.
if (setsockopt(sock_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
throw std::system_error(errno, std::system_category(), "setsockopt");
...
std::string buf{"test data"};
auto c_i = socket_wrapper::get_client_info("localhost", "12345");
// Подключиться к абоненту.
if (-1 == connect(sock_fd, c_i->ai_addr, c_i->ai_addrlen))
{
throw std::system_error(errno, std::system_category(), "connect");
}
// Отправить данные, задействовав механизм zero-copy.
if (send(sock_fd, buf.data(), buf.size(), MSG_ZEROCOPY) != buf.size())
throw std::system_error(errno, std::system_category(), "send");
// Инициализировать структуру дескриптором сокета.
pollfd pfd = {.fd = sock_fd, .events = 0};
// Опрос, poll() будет ожидать событий.
if (poll(&pfd, 1, -1) != 1 || !(pfd.revents & POLLERR))
throw std::system_error(errno, std::system_category(), "poll");
Внимание! Процесс не должен немедленно перезаписывать буфер после возврата из системного вызова. Это может привести к повреждению потока данных программы.
Функцию poll() мы изучим в книге 2. Сейчас достаточно знать, что она будет ожидать на сокете новые данные или ошибку. На практике эффективнее не ждать уведомлений, а выполнять чтение, не блокируясь, на каждую пару вызовов sendmsg().
Примем сообщение, которое содержит уведомления о разрешении записи:
// Буфер для вспомогательных данных.
std::array<char, CMSG_SPACE(sizeof(uint32_t))> ancil_data_buff;
// Буфер для обычных данных — предыдущая строка.
iovec iov[1] = { { buf.data(), buf.size() } };
msghdr msg =
{
.msg_name = nullptr, .msg_namelen = 0,
.msg_iov = iov, .msg_iovlen = 1,
.msg_control = &ancil_data_buff[0],
.msg_controllen = ancil_data_buff.size(),
.msg_flags = 0
};
// Принять сообщение.
if (-1 == recvmsg(sock_fd, &msg, MSG_ERRQUEUE))
throw std::system_error(errno, std::system_category(), "recvmsg");
// Обработать уведомления.
auto [data, info] = read_notification(&msg);
std::cout << "Data: " << data << ", Info: " << info << std::endl;
Обработаем уведомления:
std::pair<uint32_t, uint32_t> read_notification(struct msghdr *msg)
{
const cmsghdr *cm = CMSG_FIRSTHDR(msg);
if (cm->cmsg_level != SOL_IP && cm->cmsg_type != IP_RECVERR)
throw std::system_error(errno, std::generic_category(), "cmsg");
// Структура уведомления.
const sock_extended_err *serr =
reinterpret_cast<const sock_extended_err *> CMSG_DATA(cm);
// Необходимо проверить код ошибки и то, что уведомление пришло
// от ZeroCopy.
if (serr->ee_errno != 0 || serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)
throw std::system_error(errno, std::system_category(), "recvmsg");
return std::make_pair(serr->ee_data, serr->info);
}
Поля [ee_info, ee_data] содержат 32-битный диапазон в буфере, куда можно безопасно записывать данные.
Внимание! Уведомление может поступить до того, как данные будут полностью переданы. То есть уведомление о завершении работы с буфером не является уведомлением о завершении передачи.
Другая опция — TCP_ZEROCOPY_RECEIVE — не отправляет и не принимает управляющую информацию, но для удобства опишем работу с ней.
Эта опция используется только в getsockopt() и принимает указатель на структуру tcp_zerocopy_receive:
#include <linux/tcp.h>
struct tcp_zerocopy_receive
{
// Адрес мэппинга, созданного с помощью mmap().
__u64 address;
// Количество байтов данных, которые будут записаны в мэппинг.
// После чтения в буфер сюда ядро запишет количество прочитанных байтов.
__u32 length;
// Объем данных, который нужно вычитать, чтобы вернуться к режиму
// копирования. Устанавливается ядром.
__u32 recv_skip_hint;
// Количество байтов в очереди чтения. Устанавливается ядром.
__u32 inq;
// Код ошибки сокета, устанавливаемый ядром.
__s32 err;
// Адрес буфера для копирующего I/O.
__u64 copybuf_address;
// Размер буфера. Устанавливается пользователем. Изменяется ядром.
__s32 copybuf_len;
// Флаги, устанавливаемые пользователем.
__u32 flags;
};
При возврате из функции длина буфера устанавливается равной количеству байтов, фактически сопоставленных с мэппингом. Сопоставлены они до тех пор, пока диапазон не будет отменен с помощью munmap() или они не будут заменены другим вызовом getsockopt().
Проблема с данным механизмом в том, что он может работать не всегда и существует для того, чтобы уведомить ядро, что при обмене данными с приложением можно обойтись без копирования. Но ядро все еще может копировать данные, если адаптер не поддерживает разгрузку, выделенный буфер слишком маленький или неправильно выровнен, используется туннелирование и т.п.
API тоже выглядит несколько искусственным, поэтому, если требуется высокая производительность, как вариант, следует использовать XDP, DPDK или иную подобную технологию, описанную далее. Это более зрелые решения, которые предоставляют больше возможностей и позволяют полностью избежать копирования.
Если требуется сложная обработка и быстрая обработка данных в условиях высоких нагрузок, можно использовать подход kernel bypass — исключения стека ядра.
Реализуется данный подход несколькими разными технологиями.
Это технология, которая позволяет пересылать пакеты без дополнительной обработки в ядре, что значительно повышает скорость и удобнее, чем ZeroCopy.
В Linux данная технология реализуется фреймворком XDP — eXpress Data Path, показанным на рис. 23.4.
Рис. 23.4. Схема работы XDP
Фреймворк может обрабатывать пакеты в двух режимах:
• Обработка данных в ядре программой, интерпретируемой eBPF. В таком случае трафик вообще не передается в пространство пользователя. Но программированием уровня ядра это не является, так как BPF выполняет скрипты безопасно.
• Передача в пространство пользователя через сокеты семейства AF_XDP.
О BPF очень кратко упомянуто в конце главы 13. В случае его использования данные перехватываются на очень раннем этапе обработки — еще в драйвере сетевого интерфейса, то есть сразу после обработки прерывания и до выделения памяти, выполняемого сетевым стеком. После чего обработку выполняет программа eBPF. Она может изменить данные пакета и вернуть код, определяющий следующие действия над пакетом:
• XDP_ABORTED — удалить пакет с исключением точки трассировки. По сути, то же, что XDP_DROP.
• XDP_PASS — отправить пакет в сетевой стек.
• XDP_DROP — отбросить пакет. Это действие выполняется по умолчанию, если код некорректен.
• XDP_TX — отправить пакет через тот же адаптер, с которого он был принят.
• XDP_REDIRECT — перенаправить пакет на другой сетевой адаптер или сокет AF_XDP.
XDP может отбросить порядка 26 миллионов пакетов в секунду на один процессор.
Обработка данных с использованием XDP невероятно гибкая. Например, разработчик может выполнить быструю проверку и препроцессинг принятого пакета в ядре без написания своего модуля, и если пакет его устраивает, отправить его приложению через сокет AF_XDP, при необходимости убрав лишние уровни протоколов.
Если же требуется более сложная обработка, например сборка IP-пакета из фрагментов, программа для eBPF может отправить пакет обратно в стек.
Из-за того что область DMA обычно доступна только для чтения, препроцессинг в ядре потребует копирования пакета.
Особенность сокетов AF_XDP в том, что помимо необработанных данных, которые вообще не попадают в сетевой стек, в них реализована возможность обмена данными между ядром и пространством пользователя без копирования, что дает возможность реализовывать очень высокопроизводительные сетевые приложения.
Чтобы XDP мог выполняться, от драйвера требуется возможность установки RX-хука. Такую возможность поддерживают как минимум драйверы Mellanox mlx4 и mlx5, Cavium — thunder и qede, Broadcom — bnxt, Intel ixgbe и драйвер virtio-net.
От сетевого адаптера требуется по крайней мере возможность обработки пакетов в несколько очередей.
Ко всему прочему в XDP реализовано выполнение программы eBPF на контроллере сетевого интерфейса. И хотя это требует поддержки от сетевого адаптера, эта функциональность работает во многих устройствах от Intel, Mellanox и Netronome.
Если выполнение на адаптере не поддерживается, XDP просто будет использовать общую реализацию, выполняя код eBPF на процессоре.
Сокет AF_XDP создается обычным вызовом socket(). С каждым таким сокетом связаны два кольца: кольцо RX, в которое сокет получает данные, и кольцо TX, с помощью которого отправляются пакеты. Эти кольца регистрируются и измеряются с помощью опций XDP_RX_RING и XDP_TX_RING.
Кольца содержат дескрипторы, которые указывают на буфер данных в области памяти, называемой UMEM. RX и TX могут совместно использовать один и тот же UMEM, поэтому пакет не требуется копировать между ними. Если же пакет нужно сохранить, например, для повторной передачи, достаточно изменить указывающий на него дескриптор, не копируя данные.
Для упрощения работы с XDP существует библиотека Libbpf. Она содержит вспомогательные функции в tools/lib/bpf/xsk.h для работы с AF_XDP-сокетами, позволяя выполнять следующие действия:
• настройку AF_XDP-сокета;
• безопасный и быстрый доступ к кольцам XDP в плоскости данных.
XDP реализован не только в Linux. В 2022 году для Windows, поставляемую по лицензии MIT. Microsoft сотрудничает с другими компаниями и добавляет поддержку XDP в протокол MsQuic.
Еще более высокую производительность обеспечивают фреймворки, которые работают с оборудованием напрямую. Часто, когда говорят о kernel bypass, имеют в виду именно такой подход.
Таких фреймворков несколько:
• — Linux API для быстрого захвата пакетов. Строго говоря, это не совсем kernel bypass, скорее исключение лишних копирований данных пакета.
• — фреймворк, изначально способный работать «на железе» без ОС. Реализует kernel bypass для ОС Linux, ОС FreeBSD и в будущем ОС Windows.
• — фреймворк, который включает модули ядра, позволяющие выполнить kernel bypass и обработку пакетов в пользовательском пространстве.
• — хорошо развитый фреймворк для FreeBSD и Linux. Использует модули ядра для реализации kernel bypass и несколько патчей на драйверы. Работа выполняется через устройство /dev/netmap. API — обычные сокеты, предоставляемые LibC.
• — фреймворк на Lua. Реализовать его позволяет Linux User I/O. Развивается не очень активно.
• — фреймворк в ОС Windows, который дает возможность использовать сокеты поверх RDMA, не включая большую часть стека ОС.
Очень кратко рассмотрим некоторые из названных фреймворков.
Для отправки или получения пакетов PACKET_MMAP предоставляет кольцевой буфер, отображаемый в пространство пользователя. Размер буфера настраиваемый.
Используется данный фреймворк для работы с сокетами семейства AF_PACKET, которые не выполняют обработку данных стеком ядра, и можно считать, что это минималистичный фреймворк для kernel bypass.
Чтобы читать пакеты, нужно просто их дождаться, а отправить несколько пакетов можно за один системный вызов. Это обеспечивает максимальную пропускную способность. Использование общего буфера между ядром и пользователем также позволяет минимизировать количество копий пакетов.
PACKET_MMAP можно использовать для повышения производительности процесса захвата данных на высоких скоростях относительно скорости процессора, если драйвер сетевой карты поддерживает NAPI или агрегацию прерываний. Кроме того, можно привязать ядра процессора к сетевым адаптерам для оптимизации.
DPDK, или Data Plane Development Kit, — проект с открытым по BSD-лицензии исходным кодом, управляемый Linux Foundation. Изначально создавался компанией Intel для работы в режиме «прямо на железе». Передача данных в DPDK показана на рис. 23.5.
Рис. 23.5. Передача данных в DPDK
Фреймворк работает на x86, ARM и PowerPC, обеспечивает поддержку приложений Linux и FreeBSD. В настоящее время осуществляется .
В состав DPDK входят:
• Драйверы для опроса контроллера сетевого интерфейса. На данный момент они базируются на .
• Набор библиотек для обработки пакетов.
Драйверы контроллера сетевого интерфейса, предоставляемые DPDK, оптимизированы следующим образом:
• В диспетчере очередей реализованы неблокирующие очереди.
• Диспетчер буферов использует предварительно выделенные буферы фиксированного размера.
• Менеджер памяти выделяет пулы объектов в памяти и использует кольцевые буферы для хранения свободных объектов, что гарантирует равномерное распределение объектов по всем каналам DRAM.
• Драйверы режима опроса работают без асинхронных уведомлений, что снижает накладные расходы.
Интересной особенностью DPDK является наличие возможности производить вычисления, связанные с сетевыми данными, на графических адаптерах и тензорных процессорах NVIDIA, что многократно повышает его вычислительные мощности.
Для конкретных аппаратных и программных сред фреймворк предлагает библиотеки, которые предоставляют «уровень абстракции среды», или EAL — Environment Abstraction Layer.
Этот уровень предоставляет стандартный API для библиотек, доступных аппаратных ускорителей, различных элементов оборудования и операционной системы. Если для конкретной платформы существует EAL, разработчики могут просто связывать приложения с библиотекой для создания приложений.
EAL также предоставляет дополнительные сервисы, в том числе привязку ко времени, общий доступ к шине, функции трассировки и отладки, операции с сигналами. С помощью библиотек DPDK можно реализовать в пользовательском пространстве модель с низкими накладными расходами: конвейерную, управляемую событиями или гибридную.
— модуль ядра Linux и фреймворк пользовательского пространства, который позволяет обрабатывать пакеты с высокой скоростью, обеспечивая при этом согласованный API для приложений.
На рис. 23.6 показано, как передаются данные в PF_RING.
Рис. 23.6. Передача данных в PF_RING
В отличие от DPDK, вместо ввода-вывода в пространстве пользователя используются модули ядра, позволяющие драйверу направлять пакет от сетевого адаптера напрямую к PF_RING. Режим работы PF_RING определяется при загрузке модуля через параметр transparent:
➭ sudo insmod pf_ring.ko transparent_mode = 0
Параметр может принимать следующие значения:
• 0 — пакеты отправляются в PF_RING через стандартные механизмы ядра.
• 1 — пакеты отправляются непосредственно драйвером адаптера в PF_RING. Они по-прежнему распространяются на другие компоненты ядра.
• 2 — чистый kernel bypass.
Если значение параметра равно нулю, оба пакета отправляются в PF_RING и остальным компонентам ядра. Все драйверы поддерживают этот режим. В обычном режиме производится опрос сетевых адаптеров с помощью Linux NAPI. В свою очередь, NAPI копирует пакеты с адаптера в кольцевой буфер PF_RING, а затем приложение пользовательского уровня считывает пакеты из кольца.
В этом сценарии есть два опроса: приложение и NAPI, что приводит к трате времени процессора на опрос. Но преимущество в том, что PF_RING может распределять входящие пакеты параллельно по нескольким кольцам, а значит, и приложениям.
В режиме 1 обработка ускоряется, так как пакеты копируются драйвером сетевого адаптера без прохождения обычного пути ядра. Этот вариант используют, только если драйвер сетевой карты поддерживает PF_RING.
В режиме 2 пакеты отправляются непосредственно драйвером адаптера в PF_RING. Они не передаются ядру и его компонентам, чтобы не замедлять их захват. Этот режим самый быстрый: после копирования в PF_RING пакеты отбрасываются сразу после их обработки.
На рис. 23.7 показана модульная архитектура PF_RING, где помимо основных модулей ядра видны дополнительные компоненты.
Рис. 23.7. Архитектура PF_RING
Дополнительные модули:
• , — фреймворк обработки пакетов, который позволяет обрабатывать пакеты со скоростью линии 1–10 Гбит/с как на прием, так и на передачу при любом размере пакета. Реализует операции без копирования между процессами и виртуальными машинами KVM. По сути — преемник , предлагающий единый и согласованный API. Инструмент платный, но существует бесплатная версия, которая обрабатывает ограниченное количество пакетов и работает только на адаптерах Intel.
• Модули карт на базе ПЛИС — добавляет поддержку многих вендоров: Accolade, Exablaze, Endace, Fiberblaze, Inveatech, Mellanox, Myricom/CSPI, Napatech, Netcope и др.
• Модуль стека — позволяет вводить пакеты в сетевой стек Linux.
• Модуль Timeline — позволяет беспрепятственно извлекать трафик из дампов с помощью PF_RING API.
• Модуль Sysdig — фиксирует системные события, используя подсистему sysdig.
Данный фреймворк реализован на Lua и представляет собой концептуальный интерес. Snabb позволяет создавать высокопроизводительные конвейеры обработки данных, как, например, изображенный на рис. 23.8. Его основой являются каналы обмена данными и приложения или модули, посредством каналов соединяемые в сеть для обмена пакетами:
Рис. 23.8. Конвейер обработки данных
Каждое приложение получает пакеты из входных каналов, обрабатывает и передает через выходные каналы такой структуры:
// Структура, описывающая канал.
struct link
{
// Буфер канала, содержащий блок пакетов.
struct packet *packets[256];
// Позиции кольцевого курсора.
int read, write;
};
Пакет в Snabb — это просто буфер в памяти, как правило, содержащий Ethernet-кадры:
// Пакет данных, которыми обмениваются приложения.
struct packet
{
// Полезные данные.
unsigned char payload[10240];
// Длина буфера пакета.
uint16_t length;
};
Приложение может делать что угодно, главное, чтобы оно могло принимать и передавать пакеты.
В действительности пакеты не создаются и не уничтожаются при каждом обмене. Всегда имеется буфер из предварительно созданных пакетов. Буферы для пакетов берутся из набора свободных буферов и переиспользуются.
В Snabb реализовано уже порядка 30 подобных приложений. Среди них: интерфейсы ввода-вывода для сетевой карты или виртуальных машин, коммутатор Ethernet, маршрутизатор, брандмауэр, мост из сетевого стека Linux в сеть Snabb, работа с PCAP-форматом и прочие.
Когда требуются еще не реализованные функции обработки пакетов, разработчики Snabb пишут новые приложения. Большая часть кода приложений реализуется на Lua.
Не весь фреймворк реализован на Lua. Например, управление разделяемой памятью реализовано на C, некоторые функции в ядре также являются обертками над вызовами функций из LibC.
Приложения должны поддерживать следующий интерфейс:
{
input = { ... }, -- Таблица именованных входных каналов.
output = { ... }, -- Таблица именованных выходных каналов.
pull = <function>, -- Функция для забора новых пакетов из системы.
-- Функция для дальнейшей отправки существующих пакетов.
push = <function>
}
Сеть приложений выполняется внутри цикла обработки событий. На каждой итерации цикл получает блок пакетов от источников ввода-вывода, а затем направляет их по сети к конечным пунктам назначения.
Приложения компилируются LuaJIT, поэтому выполняются быстро. Параллельно как независимые процессы могут быть запущены несколько сетей приложений. Между ними можно передавать данные, используя приложения, выполняющие межпроцессный ввод-вывод.
Рассмотрим код приложения, сохраняющего пакеты в формате PCAP.
Сначала объявляется модуль, реализующий приложение, тип его видимости и модули, требуемые для его работы:
-- Вызов модуля. Функция Lua, которая создает загружаемый модуль из кода,
-- определенного в этом файле.
module(..., package.seeall)
-- Загрузка различных модулей.
local ffi = require("ffi")
-- Ядро Snabb: библиотека, описание канала, описание приложения.
local app = require("core.app")
local lib = require("core.lib")
local link = require("core.link")
-- Библиотека, реализующая формат PCAP.
local pcap = require("lib.pcap.pcap")
-- Пакетный фильтр.
local pf = require("pf")
Tap = {}
Следующий шаг — определение параметров конфигурации:
local tap_config_params = {
-- Имя файла, в который записываются пакеты.
filename = { required=true },
-- Режим: truncate для усечения файла после открытия,
-- append для его дополнения.
mode = { default = "truncate" },
-- Будут захвачены только пакеты, соответствующие фильтру.
filter = { },
-- Записывать только каждый N-й пакет, который прошел фильтр.
sample = { default=1 },
}
Теперь реализуются функции приложения, в том числе описанные ранее. Первая из них — конструктор «приложения»:
-- Конструктор будет выполняться при создании объекта приложения.
function Tap:new(conf)
-- Проверяет arg на соответствие спецификации в config.
-- Возвращает новую таблицу, содержащую параметры из arg,
-- и значения по умолчанию вместо пропущенных.
-- В случае появления неизвестных ключей генерирует ошибку.
local o = lib.parse(conf, tap_config_params)
-- Получить режим для open() по имени режима.
local mode = assert(({truncate='w+b', append='a+b'})[o.mode])
-- Открыть файл.
o.file = assert(io.open(o.filename, mode))
-- Если файл имеет нулевой размер, записать основной заголовок PCAP.
if o.file:seek() == 0 then pcap.write_file_header(o.file) end
-- Скомпилировать фильтр, если был задан.
if o.filter then o.filter = pf.compile_filter(o.filter) end
o.n = o.sample — 1
return setmetatable(o, {__index = Tap})
end
Вторая — обработчик, который выполняется, когда модулю переданы данные:
-- Будет выполняться каждый раз, когда кто-то передает модулю пакет.
function Tap:push ()
-- n должен храниться в объекте, чтобы не теряться между вызовами push().
local n = self.n
-- Цикл обхода каналов.
while not link.empty(self.input.input) do
-- Получить данные из канала (точнее — указатель на данные канала).
local p = link.receive(self.input.input)
-- Применение фильтра.
if not self.filter or self.filter(p.data, p.length) then
n = n + 1
-- Проверка номера пакета на соответствие записываемым.
if n == self.sample then
n = 0
-- Формирование заголовка PCAP и запись пакета
-- в открытый файл.
pcap.write_record(self.file, p.data, p.length)
end
end
-- Дальнейшая отправка пакета, если были подключены выходные каналы.
-- Если дальнейшая отправка не предусмотрена, пакет нужно освободить
-- через вызов packet.free(p).
link.transmit(self.output.output, p)
end
self.n = n
end
Приложение может содержать несколько модулей, один из которых является главным. Выше показан лишь простейший вариант.
Snabb-программа — упакованная сеть приложений. Она похожа на физический кластер сетевых устройств, соединенных между собой каналами Ethernet. Проектировать Snabb-программы можно по такому же принципу, как физические сети.
Модули, реализующие программы, содержат запускаемую фреймворком функцию run(), в которой могут обрабатывать параметры командной строки:
function run (parameters)
if not (#parameters == 2) then
print("Usage: example_replay <pcap-file> <interface>")
main.exit(1)
end
local pcap_file = parameters[1]
local interface = parameters[2]
Программа скрывает от пользователя структуру данной сети и компилируется в утилиту, которую можно запустить из командной строки. Таким образом пользователь может работать с «обычными утилитами», например tcpdump или netcat, и не знать, что они реализованы поверх Snabb.
Для реализации множества программ в одном исполняемом файле Snabb использует тот же прием, что и BusyBox: он ведет себя по-разному в зависимости от имени, по которому пользователь его вызывает.
При компиляции Snabb Switch вы получаете один исполняемый файл, поддерживающий все доступные программы.
Программу можно запустить, вызвав ее через snabb myprogram, или скопировать snabb в /usr/local/bin/myprogram и затем просто запустить myprogram.
Вместо копирования обычно делают символические ссылки.
Вот некоторые из уже реализованных Snabb-программ:
• VPWS — Virtual Private Wire Service, VPN уровня 2.
• Packetblaster — генератор пакетов для проведения нагрузочного тестирования сети. Позволяет создавать поток в несколько сотен гигабит в секунду, при этом почти не загружает процессор.
• Snabb NFV — сеть виртуальных машин поверх QEMU/KVM для обработки трафика на скорости порядка 100 Гбит/с.
• Firehose — адаптер для вызова из C-библиотеки функции для обработки трафика. Это дает возможность интегрировать пользовательский код на C в Snabb без знания Lua и процесса работы фреймворка. Модуль работает достаточно эффективно.
• LISPER — организует виртуальную Ethernet-сеть поверх сети IPv6.
Процесс работы программы типичен:
1. Получение данных из какого-либо источника, загружаемого динамически.
2. Обработка данных сетью из приложений, которая и является сутью программы.
3. Отправка данных в сеть и сохранение дампа в файл.
Взаимодействие с сетью может производиться посредством:
• raw-сокетов;
• интерфейсов TUN/TAP;
• драйверов сетевых адаптеров Mellanox, реализованных как приложения Snabb;
• драйверов сетевых адаптеров Intel 82599, также реализованных в Snabb.
Как ни странно, драйверы также реализуются на Lua и работают вне пространства ядра. Код их достаточно объемен, и здесь его приводить нет смысла. Он доступен в репозитории проекта Snabb.
Windows Sockets Direct, или WSD, — интерфейс, реализованный в ОС Windows, который позволяет приложениям через WinSock использовать SAN, то есть сети с RDMA — удаленным прямым доступом к памяти.
Сети SAN работают поверх специального оборудования и таких физических сетей, как InfiniBand, Gigabit Ethernet, Fibre Channel и подобных, обеспечивающих немаршрутизируемый протокол с надежной и упорядоченной доставкой.
Оборудование передает данные из физической памяти одного узла на другой, не задействуя центральный процессор и промежуточную память, поэтому SAN имеют производительность в 2–2,5 раза выше по сравнению с обычными сетями. Это позволяет применять их в центрах обработки данных, например, для соединения серверов приложений и серверов баз данных.
В SAN все функции, обычно реализованные в TCP/IP, выполняет оборудование, поэтому стек протоколов ОС не используется.
WSD реализует SDP — , который является альтернативой TCP.
SAN реализуется через прокси-драйвер, позволяющий приложениям обходить компоненты ядра, что минимизирует количество системных вызовов.
Сравнение модели WSD и традиционной сокетной модели показано на рис. 23.9.
Рис. 23.9. Традиционная и WSD-модели
Доступ к SAN WSD обеспечивается с помощью программного коммутатора, направляющего сетевую активность SAN провайдеру WinSock SAN. Поэтому WSD позволяет любому приложению на базе WinSock, использующему стек TCP/IP, работать с SAN и RDMA без модификации.
Необходимо создать новый сокет, передав WSASocket() структуру WSASTARTUPINFO, заполненную провайдером WSD. DLL провайдера SAN экспортирует одну функцию инициализации — WSPStartupEx(), которая заполняет адресами функций таблицу диспетчеризации провайдера.
Для эффективной работы с такими «сокетами» используется асинхронный WinSock API и специальные функции, например WSPRdmaRead() и WSPRdmaWrite(), вместо обычных функций получения и отправки данных.
Существует расширенный набор функций, специфичных для SAN, которые можно получить через вызов функции WSAIoctl() с параметром SIO_GET_EXTENSION_FUNCTION_POINTER. Она вызовет функцию WSPIoctl() провайдера SAN.
Эти функции позволяют использовать WSD более эффективно. Например, функция WSPRegisterMemory() регистрирует массив буферов для передачи данных сокетом, а функции WSPRdmaRead() и WSPRdmaWrite() служат для прямой передачи данных через буфер RDMA.
Расширенные функции объявлены в Ws2san.h и описаны в разделе «».
SAN предоставляет драйвер мини-порта NDIS для взаимодействия стека TCP/IP с оборудованием SAN. А WSP отображает аппаратные регистры SAN в память пользовательского режима, что дает возможность управлять оборудованием, минуя компоненты режима ядра. Именно это позволяет без изменения переносить приложения, использующие стек TCP/IP.
Если существует потребность в еще более высокой производительности и скорости обработки потока, следует применять специализированные решения, которые выполняют обработку независимо от CPU. Как правило, такие решения имеют специально разработанную аппаратуру и относятся к устройствам DPU — Data Processing Unit. Они либо содержат массив высокопроизводительных процессоров, либо частично реализованы на ПЛИС. Решения имеют доступ к достаточно большому объему высокоскоростной памяти, то есть представляют собой полноценный специализированный компьютер. Реализован он, как правило, в форм-факторе платы, подключаемой через высокоскоростные интерфейсы, например PCI Express.
Для таких устройств пишутся свои драйверы и нередко набор приложений для управления, получения статистики и т.д.
DPU, используемые для обработки сетевого трафика, называются SmartNIC — «умные» сетевые карты. По сути, они представляют собой программируемый ускоритель, на который может быть записано программное обеспечение, специфичное для выполняемой задачи.
Такие адаптеры выполняют следующие функции:
• Обеспечивают безопасность с помощью встроенного шифрования.
• Реализуют популярные протоколы безопасности, такие как TLS, IPSec, MAC Security.
• На аппаратном уровне выполняют протоколы доступа к данным, такие как RoCE, GPUDirect Storage, NVM Express и TCP.
• Поддерживают ЦОД с виртуализацией ввода-вывода, виртуальной коммутацией и маршрутизацией.
• Ускоряют сетевые стеки, в том числе программно-определяемые на пользовательском уровне.
Ведущие компании в данной области — Napatech и Myricom, но свои решения уже предлагают Nvidia, Xilinx, Intel, а также активно создаются новые стартапы, например линейка NVIDIA ConnectX, ориентированная на сети центров обработки данных. Решения этой линейки должны обеспечивать RDMA — удаленный прямой доступ к памяти.
Наиболее мощный адаптер ConnectX-7 имеет пропускную способность 400 Гбит/с. Адаптер может работать с Ethernet и Infiniband. Он поддерживает выполнение IPSec и TLS с аппаратным ускорением используемых в них функций, аппаратное ускорение хеширования SHA-2, распаковки и сжатия по алгоритму deflate и регулярных выражений. Ориентировочная цена такой «сетевой карты» порядка $1500.
Зачастую программирование описанных выше устройств выполняется на языке P4 — Programming Protocol-Independent Packet Processors. Это предметно-ориентированный язык для независимого от протокола описания процесса обработки пакета в плоскости данных. Принцип программирования на нем показан на рис. 23.9. При этом программа на P4 работает в плоскости управления, но может управлять объектами только в плоскости данных, то есть язык дает возможность управлять таблицами маршрутизации, регистрами устройства, обрабатывать пакеты и т.п. Операции производятся над такими объектами, как заголовки, парсеры заголовков, описывающие корректные данные, таблицы, метаданные.
Производители аппаратуры предоставляют SDK, в которую входит среда, включающая описание архитектуры и компилятор P4 для устройства.
Пользователь реализует программу на P4 для конкретной архитектуры. Компилятор генерирует конфигурацию плоскости данных, которая описывает логику пересылки и API для управления состоянием объектов плоскости данных из плоскости управления.
Рис. 23.10. Программирование на языке P4
Синтаксис языка C-подобный. Например, описание заголовка на P4 выглядит следующим образом:
header Ethernet_h {
bit<48> dstAddr;
bit<48> srcAddr;
bit<16> etherType;
}
Парсер может выглядеть так:
parser top(packet_in b, out Parsed_packet p) {
state start {
// Выделить Ethernet-заголовок.
b.extract(p.ethernet);
transition select(p.ethernet.etherType) {
// EtherType 800 ->
// новое состояние конечного автомата — parse_ipv4.
16w0x0800 : parse_ipv4;
// Перейти к разбору IPv6.
16w0x86DD : parse_ipv6;
}
}
// Разобрать IPv4-пакет.
state parse_ipv4 {
b.extract(p.ip.ipv4);
transition accept;
}
// Разбор IPv6-заголовка.
state parse_ipv6 {
b.extract(p.ip.ipv6);
// Принять пакет.
transition accept;
}
}
Описание адаптеров и языка P4 приведено как пример направления, в котором могут развиваться сетевые технологии. Сейчас это скорее «передний край», и большинству разработчиков он не понадобится, а погружение в тему потребует серьезного изучения материала. Но для интересующихся читателей спецификация языка и возможность для взаимодействия с его комьюнити доступны на сайте .
Стек операционных систем постоянно развивается и оптимизируется, например, в него включаются такие механизмы, как Zero Copy в Linux, позволяющие обойти копирование данных между ядром и пользовательским пространством. Однако он все равно остается недостаточно быстрым, чтобы обрабатывать потоки данных, которые циркулируют в современных высокопроизводительных сетях. Для этого используются оптимизированные стеки и прямая работа с адаптерами в обход ядра ОС, то есть механизм kernel bypass.
Существует несколько вариантов реализации подхода kernel bypass: TUN/TAP-интерфейсы, In-Kernel FastPath в Linux, специальные фреймворки.
TUN/TAP частично полагается на стек ядра и нужен для реализации виртуальных сетевых интерфейсов, которые управляются из пользовательского пространства. Различные программы, такие как OpenVPN, l2tpns и OpenSSH, используют данный интерфейс для туннелирования.
TUN является интерфейсом сетевого уровня, который работает с IP-пакетами. TAP — интерфейс канального уровня, который обрабатывает Ethernet-кадры.
In-Kernel FastPath — это подход, который предполагает максимальное исключение стека ядра для увеличения производительности. В Linux он реализуется через фреймворк XDP, предполагающий обработку данных в ядре программой, интерпретируемой с помощью eBPF.
Трафик передается в ядро напрямую, без копирования из пространства пользователя. Пересылка пакета в пользовательское пространство при необходимости осуществляется через сокеты семейства AF_XDP.
Для разных платформ существуют разные фреймворки, например PACKET_MMAP в Linux, кросс-платформенные Netlink, DPDK и PF_RING.
PACKET_MMAP — нечто среднее между полноценными фреймворками и механизмом zero-copy, остальные же фреймворки предоставляют достаточно обширный набор библиотек и драйверов.
Фреймворк DPDK представляет собой набор библиотек в пользовательском пространстве и оптимизированные драйверы для опроса контроллера сетевого интерфейса. Особенностью DPDK является работа с пулами объектов в памяти и использование кольцевых буферов для хранения свободных объектов, что гарантирует равномерное распределение данных по всем каналам DRAM.
PF_RING — модуль ядра Linux и фреймворк пользовательского пространства, который позволяет обрабатывать пакеты с высокой скоростью, обеспечивая при этом согласованный API для приложений. PF_RING может распределять входящие пакеты параллельно по нескольким кольцам, а значит и приложениям, тем самым ускоряя обработку данных.
Интересным развитием концепции является фреймворк Snabb, реализованный на Lua, который позволяет создавать высокопроизводительные конвейеры. Он производит обработку данных в пользовательском пространстве. Даже некоторые драйверы физических адаптеров реализованы в пространстве пользователя.
В ОС Windows реализацией данного подхода можно считать WSD, позволяющую работать с RDMA через интерфейс WinSock2, но без обработки TCP/IP-стеком.
Когда требуется еще более высокая производительность, например, в узкоспециализированных сетевых устройствах, таких как SmartNIC, применяются специализированные решения, которые выполняют обработку вообще независимо от CPU. Такие решения либо содержат массив высокопроизводительных процессоров, либо частично реализованы на ПЛИС и часто имеют доступ к большому объему высокоскоростной памяти. Их программирование тоже постепенно стандартизируется. Сейчас для этого используется предметно-ориентированный язык P4.
1. Для чего нужны интерфейсы TUN и TAP?
2. В чем различие между TUN- и TAP-интерфейсами?
3. С помощью каких ioctl-вызовов создаются TUN- и TAP-интерфейсы?
4. Поддерживаются ли TUN/TAP-интерфейсы только в Linux или в других ОС тоже? Если да, то в каких?
5. Какие библиотеки существуют для работы с TUN/TAP? Какими преимуществами они обладают по сравнению с системным интерфейсом?
6. Как воспользоваться TUN/TAP в Python? Какие пакеты, предлагающие подобную функциональность, существуют?
7. За счет чего механизм Zero-copy в Linux позволяет обойтись без копирования данных между пространством пользователя и пространством ядра?
8. Может ли процесс, использующий Zero-copy в Linux, перезаписать буфер, переданный вызову send() сразу после завершения вызова? Почему? Если не может, как процесс должен поступить?
9. Какие существуют варианты реализации подхода kernel bypass?
10. Что такое In-Kernel FastPath и как он может ускорить обработку пакетов в ядре Linux?
11. Какие фреймворки используются для обработки высокоскоростного сетевого трафика?
12. В каких случаях может быть предпочтительнее использовать API, отличный от стандартного POSIX, например XDP или DPDK?
13. Как работает фреймворк XDP в Linux?
14. Существует ли XDP в ОС Windows?
15. Для чего нужна библиотека LibBPF?
16. С каким семейством сокетов может работать фреймворк PACKET_MMAP?
17. За счет чего DPDK обеспечивает высокую пропускную способность и низкую задержку при обработке сетевых пакетов?
18. В чем основное отличие PF_RING от других высокоскоростных фреймворков?
19. Для чего нужен модуль ZeroCopy во фреймворке PF_RING?
20. Чем интересен фреймворк Snabb?
21. Каковы основные компоненты фреймворка Snabb?
22. Какие технологии можно использовать для ускорения обработки сетевого трафика на уровне ядра операционной системы?
23. Что такое Windows Sockets Direct?
24. Что представляет собой RDMA?
25. Для чего используются функции WSPRdmaRead() и WSPRdmaWrite() в SAN?
26. Какие возможности открывает WSP для управления оборудованием SAN из пользовательского режима?
27. Что требуется для переноса WinSock-приложений, использующих стек TCP/IP, для работы с SAN?
28. Что такое SmartNIC и чем его возможности отличаются от обычных сетевых карт?
29. Для чего требуется и где используется язык P4?
30. Из чего состоит P4-программа и как она загружается на устройство?
31. Дополните пример работы с TUN/TAP-интерфейсами заданием своего МАС-адреса на создаваемом интерфейсе и заданием нужного TUN/TAP-индекса интерфейса, например 10.
32. Напишите программу, которая будет фильтровать входящие пакеты на TUN-интерфейсе по IP-адресу и пропускать только пакеты с определенными адресами.
)