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

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

Для подолання цих бар'єрів мережеві інженери поєднують 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";

Завдяки цьому алгоритму пакети однієї криптографічної сесії стабільно обробляються на одному ядрі незалежно від змін IP-адреси чи портів клієнта. Початкові пакети рукостискання обробляються на тому ядрі, яке отримало апаратне переривання від мережевої карти, підтримуючи високу локальність кешів 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-шлюзах.