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

Хотя системная опция Linux SO_REUSEPORT позволяет нескольким независимым сокетам связываться с одинаковой комбинацией IP-адреса и порта, стандартный механизм ядра опирается на статическое вычисление 4-компонентного хеша (IP источника, порт источника, IP назначения, порт назначения). В высоконагруженных защищенных сетях этот подход создает критические архитектурные проблемы: нарушается непрерывность сессий при переключении сетей мобильными клиентами, возникает неоптимальный доступ к памяти между узлами NUMA (Non-Uniform Memory Access) и происходит вымывание кэша L3 между физическими разъемами процессоров.

Для устранения этих ограничений инженеры сетевого стека Linux применяют связку SO_REUSEPORT с классификаторами сокетов eBPF (BPF_PROG_TYPE_SK_REUSEPORT). Выполняя логику программируемой маршрутизации непосредственно на этапе поиска сокета в ядре, система обеспечивает детерминированную привязку сессий, распределение нагрузки без блокировок и строгую локализацию данных в узлах NUMA.

Ограничения масштабирования стандартного хеширования SO_REUSEPORT

В стандартном ядре Linux входящие датаграммы UDP, соответствующие группе SO_REUSEPORT, распределяются между сокетами по алгоритму reciprocal_scale(skb_get_hash(skb), num_sockets). Эта схема отлично подходит для веб-трафика без сохранения состояния, однако в защищенных туннелях VPN она порождает три серьезных дефекта:

Входящая датаграмма UDP
       │
       ▼
┌─────────────────────────────────┐
│ Хеширование ядра по 4 полям     │
│ (src_ip, src_port, dst_ip, dst) │
└──────────────┬──────────────────┘
       │
       ├─────────────────┬─────────────────┐
       ▼                 ▼                 ▼
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│ Сокет 0      │  │ Сокет 1      │  │ Сокет 2      │
│ Ядро 0 (NUMA0)│ │ Ядро 4 (NUMA0)│ │ Ядро 8 (NUMA1)│
└──────────────┘  └──────────────┘  └──────┬───────┘
                                           │  <--- Задержка шины UPI между узлами
                                           ▼       (Сброс линий кэша L3)
                                    Удаленный кэш L3
  1. Роуминг клиентов и смена портов NAT: Мобильные клиенты VPN часто переключаются между сетями Wi-Fi и сотовой связью, а операторские шлюзы NAT регулярно меняют внешний порт источника. При любом изменении IP или порта 4-компонентный хеш немедленно направляет пакеты активной криптографической сессии на другой рабочий поток. Новый поток не содержит ключей сессии в локальном кэше, что провоцирует межпоточную синхронизацию либо повторное рукопожатие.
  2. Задержка межпроцессорной памяти NUMA: На двухсокетных серверах сетевой адаптер PCIe аппаратно подключен к конкретному разъему процессора (узел NUMA 0). Если стандартный хеш ядра передает пакет рабочему потоку на узле NUMA 1, каждая операция с памятью передается через межпроцессорную шину Ultra Path Interconnect (UPI). Это увеличивает задержку обработки пакета с 800 наносекунд до более чем 3,2 микросекунды.
  3. Неравномерное распределение нагрузки: Хеширование носит вероятностный характер. При одновременном попадании нескольких высокоскоростных потоков на одно и то же ядро оно загружается на 100%, вызывая отбрасывание пакетов, в то время как соседние ядра остаются недогруженными.

Архитектура программируемой диспетчеризации: BPF_PROG_TYPE_SK_REUSEPORT

Тип программ BPF_PROG_TYPE_SK_REUSEPORT совместно с картой BPF_MAP_TYPE_REUSEPORT_SOCKARRAY позволяет заменить стандартное статическое хеширование ядра на собственную логику маршрутизации.

Входящая зашифрованная датаграмма
       │
       ▼
┌────────────────────────────────────────────────────────┐
│ Классификатор eBPF: BPF_PROG_TYPE_SK_REUSEPORT         │
│ - Анализирует полезную нагрузку UDP (индекс сессии)   │
│ - Считывает smp_processor_id() для привязки к NUMA     │
│ - Вызывает вспомогательную функцию                     │
│   bpf_sk_select_reuseport()                            │
└──────────────────────────┬─────────────────────────────┘
                           │
                           ▼
          ┌──────────────────────────────────┐
          │ BPF_MAP_TYPE_REUSEPORT_SOCKARRAY │
          └────────────────┬─────────────────┘
                           │
       ┌───────────────────┴───────────────────┐
       ▼                                       ▼
┌──────────────┐                        ┌──────────────┐
│ Рабочее ядро 0│ (Локальный узел NUMA 0)│ Рабочее ядро 1│ (Локальный узел NUMA 0)
│ Дескриптор 0 │                        │ Дескриптор 1 │
└──────────────┘                        └──────────────┘

Когда входящий пакет попадает в процедуру сопоставления сокетов UDP, ядро вызывает подключенную программу eBPF до помещения пакета в какую-либо очередь. Программа получает контекст struct sk_reuseport_md:

  • skb->data и skb->data_end: Прямые указатели на начало заголовков и данных пакета.
  • sk: Сокет-кандидат.
  • ip_protocol: Сетевой транспортный протокол (UDP или TCP).
  • bind_inany: Флаг связывания сокета.

Вызывая функцию ядра bpf_sk_select_reuseport(md, &reuseport_map, &key, flags), код eBPF указывает точный индекс сокета в массиве карты, которому передается пакет.

Реализация в ядре: Маршрутизация по индексу получателя WireGuard

В защищенных туннелях WireGuard каждый зашифрованный пакет данных несет 32-битное поле receiver_index со смещением 4 байта в полезной нагрузке UDP. Это значение однозначно идентифицирует сессию пира.

Следующая программа eBPF демонстрирует извлечение индекса сессии и выбор целевого процессорного ядра с сохранением привязки к NUMA:

#include <linux/bpf.h>
#include <linux/filter.h>
#include <bpf/bpf_helpers.h>

#define WG_DATA_PACKET_TYPE 4
#define NUMA_NODE_CORES 16

struct {
    __uint(type, BPF_MAP_TYPE_REUSEPORT_SOCKARRAY);
    __uint(max_entries, 64);
    __type(key, __u32);
    __type(value, __u32);
} reuseport_sock_map SEC(".maps");

SEC("sk_reuseport")
int select_vpn_socket(struct sk_reuseport_md *ctx)
{
    void *data = ctx->data;
    void *data_end = ctx->data_end;
    
    /* Проверка границ памяти: минимальный размер нагрузки UDP */
    if (data + 8 > data_end)
        return SK_PASS;
        
    __u8 packet_type = *(__u8 *)data;
    __u32 target_socket_index = 0;
    
    if (packet_type == WG_DATA_PACKET_TYPE) {
        /* Извлечение 32-битного receiver_index со смещением 4 байта */
        __u32 receiver_index = *(__u32 *)(data + 4);
        
        /* Сохранение стабильной привязки к рабочему ядру */
        target_socket_index = receiver_index % NUMA_NODE_CORES;
    } else {
        /* Пакеты рукопожатия направляются текущему ядру процессора */
        __u32 current_cpu = bpf_get_smp_processor_id();
        target_socket_index = current_cpu % NUMA_NODE_CORES;
    }
    
    /* Выбор сокета без блокировок */
    int ret = bpf_sk_select_reuseport(ctx, &reuseport_sock_map, &target_socket_index, 0);
    if (ret == 0)
        return SK_PASS;
        
    return SK_DROP;
}

char _license[] SEC("license") = "GPL";

Благодаря этой логике все пакеты одной криптографической сессии непрерывно обрабатываются на одном ядре независимо от переключения сетей клиентом. Пакеты инициализации сессии обрабатываются на том ядре, которое приняло аппаратное прерывание сетевой карты, что сохраняет данные в горячем кэше L1/L2.

Подключение из пространства пользователя через SO_ATTACH_REUSEPORT_EBPF

Для запуска диспетчеризации сокеты создаются и регистрируются в массиве eBPF с помощью стандартных вызовов POSIX:

int setup_reuseport_cluster(int epoll_fd, int bpf_prog_fd, int sock_map_fd)
{
    int num_cores = 16;
    
    for (int i = 0; i < num_cores; i++) {
        int sfd = socket(AF_INET, SOCK_DGRAM, 0);
        
        int opt = 1;
        setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
        
        struct sockaddr_in addr = {
            .sin_family = AF_INET,
            .sin_port = htons(51820),
            .sin_addr.s_addr = htonl(INADDR_ANY)
        };
        bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
        
        /* Сохранение дескриптора сокета в карту sockarray */
        __u32 key = i;
        bpf_map_update_elem(sock_map_fd, &key, &sfd, BPF_ANY);
        
        /* Привязка программы eBPF к первому сокету группы */
        if (i == 0) {
            setsockopt(sfd, SOL_SOCKET, SO_ATTACH_REUSEPORT_EBPF, 
                       &bpf_prog_fd, sizeof(bpf_prog_fd));
        }
    }
    return 0;
}

После привязки к первому сокету ядро автоматически использует логику eBPF для всех сокетов в группе SO_REUSEPORT.

Интеграция в архитектуру RiceGuard

В распределенной инфраструктуре Anycast система RiceGuard применяет BPF_PROG_TYPE_SK_REUSEPORT для исключения потерь производительности на уровне процессорных сокетов:

  • Устранение задержек NUMA: Рабочие пулы RiceGuard жестко закреплены за ядрами процессора, физически связанными с контроллером PCIe высокоскоростного адаптера 100G. Дешифрование и коммутация пакетов происходят исключительно в пределах локального кэша L3, снижая задержку более чем на 60%.
  • Бесшовный роуминг мобильных устройств: При смене базовых станций смартфоном классификатор eBPF сопоставляет входящий идентификатор сессии и направляет пакет активному потоку без перезапуска сессионного шифрования.
  • Безостановочное обновление шлюзов: При обновлении серверного кода новые сокеты регистрируются в карте REUSEPORT_SOCKARRAY, в то время как старые процессы плавно завершают активные сессии без потери пакетов.

Интеграция SO_REUSEPORT с программируемой диспетчеризацией eBPF обеспечивает максимальную масштабируемость и строгую изоляцию пользовательских потоков в современных VPN-шлюзах.