Привет, Хабр! Меня зовут Егор Карамышев, в YADRO я разрабатываю ПО для коммутаторов семейства KORNFELD. В статье расскажу о реализованной нами С++ обертке для управления сетевой подсистемой Linux на основе протокола Netlink и библиотеки libnl3. В некоторых случаях она позволила на порядок ускорить работу функций конфигурирования. Разберемся, почему мы решили отказаться от подхода с системными вызовами, а также посмотрим на результаты тестов производительности. 

Как мы раньше управляли сетевой подсистемой Linux

До недавнего времени в проекте для управления сетевыми интерфейсами, соседями, маршрутами и прочим в Linux мы использовали самописную функцию exec со следующим прототипом:

int exec(const std::string& cmd, std::string& result);

Она принимает на вход bash-команду, а в контексте управления сетью используется пакет iproute2.

exec использует функцию popen, в которую и передается команда с системным вызовом. Сама же popen последовательно вызывает:

  • pipe() для создания неименованного канала,

  • fork() для создания дочернего процесса,

  • exec() для запуска переданной команды.

Функция pclose ожидает завершение процесса и закрывает поток ввода-вывода, а ее код возврата позволяет вычислить возвращаемое значение функции exec.

Через параметр cmd мы можем передать bash-команду — например, ip link add Bridge type bridge для создания сетевого моста, в строке result сохраняется вывод результата для переданной команды.

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

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

Производительность — каждый вызов exec приводит к созданию процесса, переключению контекста, выполнению bash-команды и созданию Netlink-сокета.

Чистота кода — с помощью exec можно выполнить набор команд за один вывод, которые идут друг за другом и разделены «&&». В результате в коде появляются громоздкие конструкции с вызовами пакета iproute2.

Обработка ошибок — допустим, в наборе команд из нескольких вызовов iproute2 произошла ошибка. Но как узнать, при выполнении какой именно команды?

Тестирование — сложно тестировать вызывающий код с exec, так как мы не можем нормально сымитировать поведение сетевой подсистемы Linux. Впрочем, позже у нас появилась «мокнутая» функция. Для нее происходит проверка, что в exec передана определенная строка с командой, которая возвращает нужный нам результат — такой вариант тестов оставался не совсем оптимальным.

Схема работы текущего подхода
Схема работы текущего подхода

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

const std::string cmds = std::string("")
      + BASH_CMD + " -c \""
      + IP_CMD + " link add " + DOT1Q_BRIDGE_NAME + " up type bridge && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " mtu " + DOT1Q_BRIDGE_DEFAULT_MTU_STR + " && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " address " + gMacAddress.to_string() + " && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge vlan_filtering 1 && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge vlan_default_pvid 0 &&"
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge no_linklocal_learn 1";

std::string res;

int err = exec(cmds, res);
if (err)
    …

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

std::string res;
std::string cmd = "ip route get " + ipAddrStr + vrfName + " | sed -n 's/.*dev \\([^\\ ]*\\).*/\\1/p' | tr -d '\n'";
int ret = exec(cmd, res);

Сначала мы получаем маршрут по указанному IP-адресу и имени VRF через вызов ip route. C помощью команды sed убираем полученный вывод, оставляя только имя интерфейса, и наконец, убираем символ перевода строки, используя tr. Таким нехитрым способом мы получили имя сетевого интерфейса по IP-адресу в определенном VRF. Кстати, мне встречались функции, где анализ вывода iproute2 происходил прямо в вызывающем коде путем обработки результирующей строки.

Поэтому постепенно мы отказались от exec и начали создавать свою библиотеку для управления сетевой подсистемой. 

Как мы разрабатывали обертку

Мы уже поняли, чем нас не устраивает функция exec для управления сетевой подсистемой, и сформулировали требования к новой библиотеке:

  • Отказ от popen и вызовов iproute2, общение с ядром Linux напрямую через Netlink API.

  • Простой и понятный интерфейс, логически приближенный к CLI для пакета iproute2.

  • Сокрытие работы с сокетами и структурами Netlink.

  • Тестируемость как самой обертки, так и вызывающего ее кода, с помощью GTest и GMock.

  • Чистый плюсовый код (сейчас мы используем C++ 20).

С точки зрения функционала обертка должна:

  • иметь аналоги команд ip и bridge

  • уметь управлять интерфейсами, маршрутами, адресами, FDB-записями, VLAN и так далее.

Главное отличие нового подхода — взаимодействие процесса с ядром Linux напрямую. 

Схема работы с сетевой подсистемой с помощью обертки над Netlink API
Схема работы с сетевой подсистемой с помощью обертки над Netlink API

Мы проанализировали уже готовые решения, но не нашли обертку на C++, которая соответствовала бы нашим требованиям: либо нас не устраивал скудный функционал, либо решения напрямую использовали низкоуровневый Netlink API, что усложнило бы их доработку. В проекте активно использовался пакет библиотек libnl3 для прослушивания обновлений от ядра об изменении сетевой конфигурации. Он предоставляет API для интерфейсов ядра Linux на основе протокола Netlink.

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

Для тех, кто хочет глубже погрузиться в работу Netlink и отслеживание состояния сетевой подсистемы, рекомендую статью моего коллеги: «Как отслеживать состояние сетевых интерфейсов на Linux с помощью netlink».

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

Главное достоинство libnl3 — высокоуровневый Netlink API, который избавляет нас от дополнительной работы с сокетами и Netlink-сообщениями. Поэтому библиотека является подходящим инструментом для реализации внутренней логики нашей С++ обертки.

Для создания нужного функционала обертки мы взяли две библиотеки из набора libnl3: libnl-core и libnl-route. Первая содержит функции управления Netlink-сокетами и кешем, а также формирования и обработки Netlink-сообщений. Вторая предоставляет структуры данных, которые отражают сетевые сущности, функции по их настройке и применению конфигурации к системе.

Хочешь работать над созданием высокопроизводительных коммутаторов для дата-центров? Присоединяйся к нашей команде:

→ C++ инженер-программист
→ Инженер-программист на Python
→ Инженер группы аналитики и комплексного тестирования
→ Инженер по нагрузочному тестированию

Рассмотрим подробнее пример из libnl-route со структурой rtnl_link для работы с сетевыми интерфейсами. Установка ее полей осуществляется через set-методы — например, rtnl_link_set_mtu (установка MTU) и rtnl_link_set_master (установка индекса master-интерфейса) и так далее. Также в структуре есть аналогичные get-методы. Таких функций много, при этом есть специфичные атрибуты и методы, выбор которых зависит от типа сетевого интерфейса. Например, для vlan-интерфейса мы можем установить vlan ID и дополнительные флаги, а для vrf-интерфейса — идентификатор таблицы маршрутизации.

Перечислю другие структуры, которые есть в libnl-route:

  • rtnl_addr — адреса, 

  • rtnl_neigh — соседи, 

  • rtnl_neightbl — таблицы соседей,

  • rtnl_route — маршруты, 

  • rtnl_rule — правила маршрутизации, 

  • rtnl_tc — контроль трафика.

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

Например, так выглядит минимальный код для установки IP-адреса на интерфейс с помощью чистого libnl3:

int setIpAddress(const std::string& ifName, const std::string& ipAddress)
{
   nl_sock* sock = nl_socket_alloc();
   int err = nl_connect(sock, NETLINK_ROUTE);
   if (err)
       return err;

   rtnl_addr* addrPtr = rtnl_addr_alloc();
   if (!addrPtr)
       return -NLE_NOMEM;

   int ifIndex = static_cast<int>(if_nametoindex(ifName.c_str()));
   if (!ifIndex)
       return -NLE_NODEV;

   rtnl_addr_set_ifindex(addrPtr, ifIndex);

   nl_addr* nlAddrPtr = nullptr;
   err = nl_addr_parse(ipAddress.c_str(), AF_UNSPEC, &nlAddrPtr);
   if (err)
       return err;

   err = rtnl_addr_set_local(addrPtr, nlAddrPtr);
   if (err)
       return err;

   err = rtnl_addr_add(sock, addrPtr, NLM_F_CREATE | NLM_F_EXCL);

   nl_addr_put(nlAddrPtr);
   rtnl_addr_put(addrPtr);
   nl_socket_free(sock);

   return err;
}

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

int setIpAddress(const std::string& ifName, const std::string& ipAddress)
{
   auto addrInfo = AddrInfo(ifName, ipAddress)
       .setScope(GLOBAL)
       ...
       .setValidLifeTime(50000);

   return add(addrInfo);
}

Так появился набор классов с постфиксом Info, которые объединяют поля сетевых сущностей так же, как в утилитах ip link, ip route, ip address и так далее. Например, LinkInfo объединяет как общие для всех типов настройки интерфейсов, так и специфические для определенного типа — например, vlan, bridge и другие, которые мы исключили из-за множества разных полей:

struct LinkInfo
{
    LinkInfo() = default;
    explicit LinkInfo(const std::string& linkName);
  explicit LinkInfo(const std::string& linkName, const std::string& linkType);
    ...
    std::optional<std::string> name;
    std::optional<uint32_t> index;
    std::optional<std::string> type;
    std::optional<std::string> linkLayerAddr;
    std::optional<uint32_t> mtu;
    ...
    std::optional<bool> isAdminUp;
    ...
    LinkInfo& setName(const std::string& value);
    LinkInfo& setType(const std::string& value);
    LinkInfo& setLinkLayerAddr(const std::string& value);
    LinkInfo& setMtu(const uint32_t value);
    ...
    LinkInfo& setAdmin(const bool enabled);
}; 

Однако этого кода достаточно, чтобы понять основные принципы работы класса LinkInfo и ему подобных. Все поля этих структур имеют тип std::optional. Для их установки с помощью удобного механизма цепочки вызовов используются set-методы.

В случае с LinkInfo ключевое поле — это name. По нему мы можем однозначно идентифицировать интерфейс в системе. Остальные поля опциональны: индекс, MTU, MAC-адрес, тип и так далее. Для структуры FdbInfo ключевые поля — MAC-адрес и имя интерфейса, а дополнительные — адрес назначения, имя сетевого моста, VLAN ID, различные флаги и поля для управления VxLAN. 

Каждой Info-сущности соответствует своя Netlink-обертка — например, LinkNetlink или RouteNetlink. Обертки наследуются от соответствующего интерфейса, который предоставляет определенный набор функций: add, del, set, get и другие. В эти функции и будет передаваться Info-примитив.

Создать обертку можно с помощью фабрики, которая отвечает за управление сокетами — так мы не плодим много Netlink-сокетов одного и того же вида:

struct NetlinkFactory final: public INetlinkFactory
{
    NetlinkFactory();
    virtual ~NetlinkFactory() override = default;
    std::shared_ptr<ILinkNetlink> createLinkNetlink() override;
    std::shared_ptr<IRouteNetlink> createRouteNetlink() override;
    ...
private:
    std::shared_ptr<ILinkNetlink> m_linkNetlink;
    std::shared_ptr<IRouteNetlink> m_routeNetlink;
    ...
    std::unordered_map<int, std::shared_ptr<nl_sock>> m_producerSocketMap;
}

Упрощенно представить взаимодействие пользователя с созданной библиотекой можно с помощью диаграммы последовательности: 

Типовое использование разработанной библиотеки в нашем проекте можно описать так:

  1. При инициализации сетевого сервиса создается фабрика.

  2. С ее помощью создаются нужные сервису Netlink-обертки.

  3. Сервис может подписаться на изменения в базах данных Redis и на события в ядре — при изменении соответствующих таблиц с конфигурацией или обновлении информации в ядре вызываются функции-обработчики, которые и используют Netlink API-обертку.

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

Тестирование обертки и вызывающего кода

Тестирование кода, связанного с оберткой, обычно разбито на две части: тестирование самой библиотеки и использующего ее кода, в котором мы применяем «мокнутые» объекты.

Добавление нового функционала в библиотеку всегда сопровождается написанием модульных и интеграционных тестов. В первой категории тестов мы заполняем структуры Info-примитивов и проверяем корректность их преобразования к типам из библиотеки libnl3.

А обобщенный тестовый сценарий для интеграционных тестов выглядит так:

  1. Заполнить Info-примитив.

  2. Вызвать метод Netlink-обертки, чтобы отправить Netlink сообщение ядру и проверить код возврата.

  3. Получить данные из сетевой подсистемы Linux и сверить их с отправленными.

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

sudo unshare --net build/tests/kfnetlink_tests --gtest_output=xml:junit-report.xml

Абстрактный пример для тестирования кода, который использует методы нашей обертки. Мы добавляем в тестируемый класс «мокнутый» объект, а в тесте создаем Info-примитив, настраиваем ожидание и вызываем нужный метод:

class IpMgrTest : public ::testing::Test
{
    std::unique_ptr<IpMgr> m_ipMgr;
    std::shared_ptr<MockAddrNetlink> m_mockAddrNetlink;
    ...
    IpMgrTest() : m_mockAddrNetlink(std::make_shared<StrictMock<MockAddrNetlink>>())
        , (m_ipMgr(m_mockAddrNetlink))
    {}
}

TEST_F(IntfTest, AddIPv6)
{
    auto addrInfo = AddrInfo("Ethernet1", "2001:db8:85a3::a2e:3:34/64")
        .setFamily(AF_INET6);

    EXPECT_CALL(*m_mockAddrNetlink, add(addrInfo))
        .Times(1)
        .WillOnce(Return(NLE_SUCCESS));
    ...
    ASSERT_TRUE(m_ipMgr->addIpAddress("Ethernet1", "2001:db8:85a3::a2e:3:34/64");
}

Результаты и бенчмарки

Перейдем к самому интересному — к результатам. Приведу пример создания и настройки моста для сравнения с предыдущим вариантом (под спойлером): 

Скрытый текст
const std::string cmds = std::string("")
      + BASH_CMD + " -c \""
      + IP_CMD + " link add " + DOT1Q_BRIDGE_NAME + " up type bridge && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " mtu " + DOT1Q_BRIDGE_DEFAULT_MTU_STR + " && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " address " + gMacAddress.to_string() + " && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge vlan_filtering 1 && "
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge vlan_default_pvid 0 &&"
      + IP_CMD + " link set " + DOT1Q_BRIDGE_NAME + " type bridge no_linklocal_learn 1";

std::string res;

int err = exec(cmds, res);
if (err)
    …
LinkInfo bridgeLinkInfo(DOT1Q_BRIDGE_NAME, "bridge");

int err = m_linkNetlink->add(bridgeLinkInfo);
if (err)
    ...

bridgeLinkInfo.setAdmin(true)
    .setLinkLayerAddr(macAddress)
    .setMtu(DOT1Q_BRIDGE_DEFAULT_MTU)
    .setBridgeVlanFiltering(true)
    .setBridgeVlanDefaultPvid(0)
    .setBridgeLinkLocalLearn(false);

err = m_linkNetlink->set(bridgeLinkInfo);
if (err)
    ...

Новый код стал чище. В примере конечный пользователь обертки получил высокоуровневый интерфейс управления с понятными структурами и полями, не требующий вмешательства в работу протокола Netlink.

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

LinkInfo linkInfo("Ethernet1");
int err = m_linkNetlink->get(linkInfo);
if (err)
	...
if (linkInfo.isAdminUp.has_value() && linkInfo.isAdminUp.value())
{
	std::cout << "Ethernet1 is UP" << std::endl;
}

Перейдем к оценке производительности созданной обертки. Для этого мы сравним время ее работы с exec, std::system, функциями, написанными с помощью libnl3, а также подходом с «сырым» Netlink, реализованном на языке C, как это сделано в iproute2. Рассмотрим пример простейшего сетевого сценария с созданием виртуального интерфейса. 

Для функций exec и system не нужно создавать дополнительные объекты — их можно вызывать напрямую. Функция с «сырым» Netlink будет в «C-шном» стиле, при этом для нее, функций с использованием libnl3 и реализованной обертки будут созданы специальные классы. Они позволят не создавать сокеты и объекты на каждом вызове наших функций.

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

Код в качестве примера показывает, как выглядит класс для управления dummy-интерфейсом, который реализован с помощью libnl3. В конструкторе один раз происходит инициализация сокета, после чего он используется во всех функциях: 

class LibnlDummyManager
{

using LinkPtr =std::unique_ptr<rtnl_link, decltype(&rtnl_link_put)>;
using SocketPtr = std::unique_ptr<nl_sock, decltype(&nl_socket_free)>;

public:
   LibnlDummyManager() : m_socket(nl_socket_alloc(), nl_socket_free)
   {
       if (!m_socket)
           throw std::runtime_error("Failed to allocate memory to Netlink socket");

       if(int err = nl_connect(m_socket.get(), NETLINK_ROUTE); err)
           throw std::runtime_error("Failed to connect Netlink socket: " + std::string(nl_geterror(err)));
   }

   int createDummy(const std::string& dummyName)
   {
       LinkPtr linkPtr(rtnl_link_alloc(), rtnl_link_put);

       rtnl_link_set_name(linkPtr.get(), dummyName);
       rtnl_link_set_type(linkPtr.get(), "dummy");

       return rtnl_link_add(m_socket.get(), linkPtr.get(), NLM_F_REQUEST | NLM_F_CREATE | NLM_F_EXCL | NLM_F_ACK);
   }

   int setDummyUp(const std::string& dummyName)
   {
       LinkPtr oldLinkPtr(rtnl_link_alloc(), rtnl_link_put);
       LinkPtr newLinkPtr(rtnl_link_alloc(), rtnl_link_put);

       rtnl_link_set_name(oldLinkPtr.get(), dummyName.c_str());

       rtnl_link_set_name(newLinkPtr.get(), dummyName.c_str());
       rtnl_link_set_flags(newLinkPtr.get(), IFF_UP);

       return rtnl_link_change(m_socket.get(), oldLinkPtr.get(), newLinkPtr.get(), 0);
   }

private:
   SocketPtr m_socket;
};

Аналогичен по структуре и класс WrapperDummyManager: он добавляет еще один уровень абстракции с помощью нашей обертки, а в конструктор передается экземпляр Netlink-обертки, заранее созданный фабрикой.

class WrapperDummyManager
{
public:
    WrapperDummyManager(std::shared_ptr<ILinkNetlink> linkNetlink)
        : m_linkNetlink(linkNetlink)
    {}

    int createDummy(const std::string& dummyName)
    {
        return m_linkNetlink->add(kfnl::LinkInfo(dummyName, "dummy"));
    }

    int setDummyUp(const std::string& dummyName)
    {
        return m_linkNetlink->set(kfnl::LinkInfo(dummyName).setAdmin(true));
    }

private:
    std::shared_ptr<ILinkNetlink> m_linkNetlink;
};
Другие функции для создания dummy-интерфейса:
int execCreateDummy()
{
    std::string res;
    return exec("ip link add dummy0 type dummy", res);
}
int systemCreateDummy()
{
    return std::system("ip link add dummy0 type dummy");
}
class RawDummyManager
{
public:
   int createDummy()
   {
       struct nlmsghdr *hdr = NULL;
       struct ifinfomsg *ifinfo = NULL;
       struct rtattr *rta = NULL;

       char msg_buffer[BUFFER_SIZE] = {0};
       char if_name [] = "dummy0";
       char if_type [] = "dummy";

       sa.nl_family = AF_NETLINK;

       hdr = (struct nlmsghdr *)msg_buffer;
       hdr->nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
       hdr->nlmsg_type = RTM_NEWLINK;
       hdr->nlmsg_flags = NLM_F_REQUEST | NLM_F_CREATE | NLM_F_EXCL | NLM_F_ACK;
          
       ifinfo = (struct ifinfomsg *)NLMSG_DATA(hdr);
       ifinfo->ifi_family = AF_UNSPEC;
       ifinfo->ifi_type = 0;
       ifinfo->ifi_index = 0;
       ifinfo->ifi_flags = 0;

       rta = (struct rtattr *)((char *)hdr + NLMSG_ALIGN(hdr->nlmsg_len));
       rta->rta_type = IFLA_IFNAME;
       rta->rta_len = RTA_LENGTH(strlen(if_name));
       memcpy(RTA_DATA(rta), if_name, strlen(if_name));
       hdr->nlmsg_len = NLMSG_ALIGN(hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len);

       rta = (struct rtattr *)(((char *)hdr) + NLMSG_ALIGN(hdr->nlmsg_len));
       rta->rta_type = IFLA_LINKINFO;
       rta->rta_len = RTA_LENGTH(0);

       struct rtattr *type_attr = (struct rtattr *)((char *)rta + RTA_ALIGN(sizeof(struct rtattr)));
       type_attr->rta_type = IFLA_INFO_KIND;
       type_attr->rta_len = RTA_LENGTH(strlen(if_type));
       memcpy(RTA_DATA(type_attr), if_type, strlen(if_type));

       rta->rta_len = RTA_LENGTH(RTA_ALIGN(type_attr->rta_len));
       hdr->nlmsg_len = NLMSG_ALIGN(hdr->nlmsg_len) + RTA_ALIGN(rta->rta_len);

       struct sockaddr_nl kernel_sa = {0};
       kernel_sa.nl_family = AF_NETLINK;
       kernel_sa.nl_pid = 0;

       struct iovec iov = {
           .iov_base = msg_buffer,
           .iov_len = hdr->nlmsg_len
       };

       struct msghdr msg = {
           .msg_name = &kernel_sa,
           .msg_namelen = sizeof(kernel_sa),
           .msg_iov = &iov,
           .msg_iovlen = 1,
           .msg_control = NULL,
           .msg_controllen = 0,
           .msg_flags = 0
       };

       if (int ret = sendmsg(m_fd, &msg, 0); ret < 0)
       {
           return ret;
       }

       memset(msg_buffer, 0, BUFFER_SIZE);

       if (int ret = recv(m_fd, msg_buffer, BUFFER_SIZE, 0); ret < 0)
       {
           return ret;
       }

       hdr = (struct nlmsghdr *)msg_buffer;

       if (hdr->nlmsg_type == NLMSG_ERROR)
       {
           struct nlmsgerr *err = (struct nlmsgerr *)NLMSG_DATA(hdr);
           if (err->error != 0)
           {
               return -err->error;
           }
       }

       return 0;
   }

   RawDummyManager()
   {
       m_fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
       if (m_fd < 0)
       {
           throw std::runtime_error("Failed to create Netlink socket");
       }

       sa.nl_family = AF_NETLINK;
       if (bind(m_fd, (sockaddr*)&sa, sizeof(sa)) < 0)
       {
           throw std::runtime_error("Failed to connect Netlink socket");
       }
   }

   ~RawDummyManager()
   {
       if (m_fd != -1)
           close(m_fd);
   }

private:
   int m_fd = -1;
   struct sockaddr_nl sa = {0};
};

Для измерения времени выполнения мы использовали Google Benchmark. Запуск тестов производился на компьютере с процессором Intel Core i5-1235U под управлением Ubuntu 22.04:

Run on (12 X 4400 MHz CPU s)
CPU Caches:
  L1 Data 48 KiB (x6)
  L1 Instruction 32 KiB (x6)
  L2 Unified 1280 KiB (x6)
  L3 Unified 12288 KiB (x1)
Load Average: 0.91, 0.92, 0.92

Для функций выше мы получили такие показатели по времени выполнения:

Benchmark 

Time

 CPU

Iterations

exec

1592 us

102 us

7438

std::system

1552 us

73.3 us

9222

С++ обертка

186 us

185 us

3812

libnl3

168 us

167 us

3854

сырой Netlink

152 us

151 us

4759

Замеры времени для выставления административного состояния виртуального интерфейса: 

Benchmark

Time

CPU

Iterations

exec

1228 us

70.3 us

9639

std::system

1210 us

52.4 us

12827

С++ обертка

63.2 us

61.6 us

11277

libnl3

46.0 us

44.0 us

16314

сырой Netlink

36.1 us

34.6 us

19284

Функции std::system и exec выполняются за соизмеримое время, хотя в обоих случаях std::system была немного быстрее. Самое лучшее время у функции с сырым Netlink, на 10–16 микросекунд медленнее работает функция с libnl3. Немного хуже результаты функций нашей обертки — они выполняются на 17–18 микросекунд медленнее, чем функции libnl3. Именно столько нам пришлось заплатить за еще один уровень абстракции, цепочку вызовов, а также за «плюсовость» и тестируемость нашего кода.

Соберем библиотеку и код для замеров времени добавления виртуального интерфейса с флагом оптимизации O2. Теперь разница между оберткой и функциями libnl3 практически не заметна. Наш проект собирается именно с этим уровнем оптимизации, а это значит, что использование C++ обертки вместо сырого Netlink и libnl3 не только решает все наши задачи, но и не сильно проигрывает в производительности.

Benchmark

Time

CPU

Iterations

exec

1502 us

88.7 us

7839

std::system

1490 us

70.2 us

10324

С++ обертка

156 us

155 us

4397

libnl3

154 us

154 us

4531

сырой Netlink  

144 us

143 us

5105

А по сравнению с функциями exec и std::system, которые используют системные вызовы, скорость обертки значительно выше (с оптимизацией и без): для операции создания интерфейса наша функция работает примерно в 9 раз быстрее вызова через exec, а для установки административного состояния — в 19 раз!

Заключение

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

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

Буду рад ответить на ваши вопросы в комментариях.

Если хотите больше узнать о разработке коммутаторов KORNFELD, читайте эти материалы:

Комментарии (2)


  1. outlingo
    17.11.2025 14:34

    почему мы решили отказаться от подхода с системными вызовами

    Только вы не "отказались от системных вызовов" - вы отказались от вызовов программ через exec. Что существенно, поскольку socket/send/recv/write/read - это самые что ни на есть системные вызовы.

    Ну и на самом деле вызывать "bash -c ... && ... && ..." - это ну не то чтобы "вне добра и зла", но как минимум не самая здравая идея


  1. victor_1212
    17.11.2025 14:34

    Егор, команды управления конфигурацией выполняются сравнительно редко, какой смысл в их глубокой оптимизации? Что это дает кроме морального удовлетворения?