Сучасні протоколи криптографічних тунелів, зокрема WireGuard, RiceGuard, IPsec-over-UDP та QUIC, практично повсюдно покладаються на датаграми UDP як на базовий транспортний контейнер. Хоча UDP забезпечує суттєву перевагу завдяки відсутності складної підтримки станів та спрощеній маршрутизації крізь глобальні мережі WAN, експлуатація тунелів на базі стандартного UDP стикається з серйозним бар'єром під час роботи на швидкостях у десятки гігабітів: витратами на обробку кожного окремого пакета (per-packet overhead).

У класичній мережевій моделі ядра Linux кожна датаграма розміром 1420 або 1500 байтів, яка проходить через віртуальний інтерфейс TUN/TAP або сокет, вимагає виділення структури буфера (struct sk_buff), перевірки правил фільтрації міжмережевого екрана, маршрутизації в таблиці FIB та генерації апаратного переривання мережевої плати. Щоб повністю завантажити канал 10 Гбіт/с, сервер має передавати понад 820 000 пакетів щосекунди. За швидкостей 25 Гбіт/с або 40 Гбіт/с потік перевищує 3 мільйони пакетів на секунду (Mpps), змушуючи ядра процесора витрачати левову частку часу на службові процедури операційної системи замість шифрування даних.

Щоб подолати цей бар'єр, у ядро Linux було інтегровано механізми узагальненого розвантаження сегментації UDP (UDP_SEGMENT / UDP GSO) та узагальненого розвантаження прийому UDP (UDP_GRO). Працюючи злагоджено, ці підсистеми об'єднують десятки дрібних пакетів у єдині 64-кілобайтні «суперпакети», що дозволяє магістральним VPN-шлюзам масштабувати пропускну здатність на сучасній серверній інфраструктурі.

Витрати на обробку кожного пакета в зашифрованих тунелях UDP

Щоб зрозуміти масштаби неефективності традиційного підходу, простежимо шлях передавання вихідного пакета у звичайному шлюзі VPN без апаратного розвантаження:

[Традиційне надсилання UDP: 45 ізольованих проходжень стека]
Програма VPN ──> Шифрування 45 пакетів (по 1420 байтів)
                        │
                        ▼
       45 викликів sendmsg() або 1 виклик sendmmsg(45)
                        │
┌───────────────────────┴───────────────────────┐
│ Мережевий стек Linux:                         │
│ • Виділення 45 окремих структур sk_buff       │
│ • 45 перевірок у таблицях Netfilter/conntrack │
│ • 45 рішень маршрутизації FIB                 │
│ • 45 операцій постановки в чергу qdisc        │
│ • 45 оновлень кільцевого буфера дескрипторів  │
└───────────────────────┬───────────────────────┘
                        │
                        ▼
         45 фізичних фреймів у дроті

Навіть коли програмне забезпечення об'єднує системні виклики через sendmmsg(2) чи recvmmsg(2), операційна система все одно змушена створювати та супроводжувати незалежні екземпляри sk_buff на кожному рівні мережевого стека. Втрата актуальності процесорного кешу, атомарні операції над лічильниками посилань на сторінки пам'яті та блокування в чергах qdisc забирають до 70% загального часу процесора на граничних маршрутизаторах.

Сучасні векторні інструкції AVX-512 та ARM Neon здатні шифрувати потоки ChaCha20-Poly1305 або AES-256-GCM зі швидкістю понад 40 Гбіт/с на одне ядро. Головною перешкодою для високої продуктивності є не обчислювальна складність шифрування, а подрібнений облік пакетів у ядрі.

Механізм UDP GSO: створення суперпакетів розміром 64 КБ

Підсистема UDP GSO, яка з'явилася в Linux 4.18, надає програмі змогу передавати ядру суцільний масив даних обсягом до 65 535 байтів (64 КБ) за одну дію із сокетом. При цьому система виконує фізичну сегментацію на найнижчому можливому рівні.

Замість надсилання 45 окремих блоків по 1420 байтів шлюз компонує безперервну послідовність із 45 інкапсульованих пакетів. Разом із цим масивом програма додає службове повідомлення (cmsg), яке визначає розмір кожного фрагмента за допомогою параметра UDP_SEGMENT:

[Обробка UDP GSO та відкладена сегментація]
Шлюз VPN: Формує буфер 64 КБ (45 пакетів WireGuard по 1420 байтів)
     │
     ▼ sendmsg() із параметром SOL_UDP / UDP_SEGMENT (розмір = 1420)
┌─────────────────────────────────────────────────────────────┐
│ Ядро Linux: Обробляє весь блок 64 КБ як єдиний sk_buff      │
│ • 1 перевірка правил Netfilter                              │
│ • 1 пошук маршруту FIB                                      │
│ • 1 операція черги qdisc                                    │
└──────────────────────────────┬──────────────────────────────┘
                               │
            ┌──────────────────┴──────────────────┐
            ▼                                     ▼
   [Апаратне розвантаження USO]          [Програмний конвеєр GSO]
   Мережева плата нарізає 45 кадрів      Ядро розбиває суперпакет
   безпосередньо на рівні ASIC           перед викликом dev_queue_xmit

Оскільки блок даних залишається цілісним суперпакетом упродовж усього проходження крізь ядро, система виконує фільтрацію, маршрутизацію та керування чергами лише один раз замість сорока п'яти.

Якщо мережева карта підтримує апаратну сегментацію UDP (USO), ядро надсилає весь масив безпосередньо в кільце передавання адаптера, доручаючи розділення фреймів апаратному контролеру. Якщо ж апаратне розвантаження відсутнє, програмний рівень ядра розділяє суперпакет на фінальній стадії (dev_queue_xmit) перед передачею контролеру.

Механізм UDP GRO: об'єднання вхідних потоків за допомогою Frag_List

У той час як GSO оптимізує вихідний потік (TX), магістральні шлюзи стикаються з не менш інтенсивним навантаженням на вході (RX). Механізм UDP GRO (UDP_GRO), доданий у Linux 5.0, оптимізує приймання даних.

Коли потік кадрів UDP надходить у цикл опитування NAPI мережевого драйвера, підсистема GRO аналізує отримані пакети: перевіряються IP-адреси відправника й отримувача, порти UDP та довжина корисних даних.

Замість багаторазового переривання ядра та частих перемикань контексту до простору користувача, GRO збирає однакові датаграми у зв'язний список покажчиків frag_list провідної структури sk_buff:

[Конвеєр вхідної агрегації пакетів UDP GRO]
Фізична лінія: 45 вхідних зашифрованих пакетів
                     │
                     ▼
          Цикл опитування NAPI драйвера адаптера
                     │
                     ▼ виклик napi_gro_receive()
┌─────────────────────────────────────────────────────────────┐
│ Підсистема GRO: Аналіз 5-tuple та довжини корисного вантажу │
│ Перший фрейм формує базовий sk_buff                         │
│ Наступні 44 пакети приєднуються до skb_shinfo(skb)->frag_list│
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼ Проходить стек отримання як 1 sk_buff
                       Рівень сокетів UDP
                              │
                              ▼ recvmsg() із параметром UDP_GRO
                 Служба VPN у просторі користувача:
            Отримання єдиного блоку 64 КБ за 1 системний виклик

Завдяки об'єднанню пакетів на рівні драйвера операційна система позбавляється до 95% проміжних операцій із пам'яттю. Якщо програма активує опцію сокета UDP_GRO, єдиний виклик recvmsg(2) повертає весь масив обсягом 64 КБ разом із даними про межі фрагментів.

Практична реалізація: конфігурація сокетів UDP GSO та GRO

У прикладі мовою C продемонстровано налаштування сокета для приймання пакетів через GRO та надсилання великих блоків через GSO:

#include <netinet/in.h>
#include <netinet/udp.h>
#include <sys/socket.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

#define SEGMENT_SIZE 1420
#define NUM_SEGMENTS 45
#define BUFFER_SIZE  (SEGMENT_SIZE * NUM_SEGMENTS)

/* Налаштування сокета UDP з увімкненою агрегацією пакетів GRO */
int setup_vpn_socket(int port)
{
    int fd = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0);
    int gro_enable = 1;

    /* Активація UDP GRO на приймальному сокеті */
    if (setsockopt(fd, SOL_UDP, UDP_GRO, &gro_enable, sizeof(gro_enable)) < 0) {
        perror("Помилка setsockopt UDP_GRO");
    }

    struct sockaddr_in sin = {
        .sin_family = AF_INET,
        .sin_port = htons(port),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(fd, (struct sockaddr *)&sin, sizeof(sin));
    return fd;
}

/* Надсилання зібраного суперпакета 64 КБ через механізм UDP GSO */
ssize_t send_gso_superpacket(int fd, const char *payload, size_t total_len, 
                            struct sockaddr_in *dst)
{
    struct msghdr msg = {0};
    struct iovec iov = {
        .iov_base = (void *)payload,
        .iov_len = total_len
    };
    
    char cbuf[CMSG_SPACE(sizeof(uint16_t))];
    memset(cbuf, 0, sizeof(cbuf));

    msg.msg_name = dst;
    msg.msg_namelen = sizeof(*dst);
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = cbuf;
    msg.msg_controllen = sizeof(cbuf);

    /* Додавання службового заголовка UDP_SEGMENT із розміром сегмента */
    struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
    cmsg->cmsg_level = SOL_UDP;
    cmsg->cmsg_type = UDP_SEGMENT;
    cmsg->cmsg_len = CMSG_LEN(sizeof(uint16_t));
    
    uint16_t gso_size = SEGMENT_SIZE;
    memcpy(CMSG_DATA(cmsg), &gso_size, sizeof(gso_size));

    return sendmsg(fd, &msg, 0);
}

Така побудова дає можливість обробляти великі агреговані блоки безпосередньо через мережевий стек, мінімізуючи накладні витрати на кожен пакет.

Порівняльний аналіз результатів випробувань

Під час тестування під навантаженням на серверах Intel Xeon Platinum 8480+ (112 ядер) із мережевими платами Mellanox ConnectX-6 Dx 100GbE під керуванням ядра Linux 6.6 LTS було зафіксовано такі показники:

Показник продуктивності Традиційний стек UDP (recvmmsg/sendmmsg) Стек з UDP GSO та UDP_GRO Коефіцієнт зростання
Пропускна здатність на ядро 2,14 Гбіт/с 18,72 Гбіт/с Зростання у 8,7 раза
Кількість пакетів у ядрі 1 880 000 pps 215 000 супер-pps Зменшення на 88,5%
Циклів CPU на один гігабайт 4,82 ГГц-сек 0,58 ГГц-сек Ефективніше у 8,3 раза
Частота переривань ядра 142 000 / сек 16 500 / сек Зменшення на 88,3%
Коливання затримки (99th Jitter) 3,84 мс 0,42 мс Стабільніше у 9,1 раза

Перенесення сегментації на апаратний рівень і групування кадрів у циклі NAPI вивільняють процесорні потужності, необхідні для шифрування трафіку.

Практичне розгортання у вузлах Anycast RiceGuard

Мережева інфраструктура RiceGuard застосовує механізми UDP GSO та GRO на граничних серверах Anycast для обробки інтенсивних потоків зашифрованих даних:

  • Стабільна передача на максимальній швидкості: Граничні вузли Anycast у мережі RiceGuard стабільно витримують навантаження 40 Гбіт/с на сервер за помірного завантаження процесора, забезпечуючи високу продуктивність для тисяч одночасних тунелів.
  • Зниження мікрозатримок і джитера: Усунення перевантаження черг під час диспетчеризації мільйонів дрібних пакетів забезпечує мінімальні коливання затримки для чутливих до швидкості протоколів (IP-телефонія, відеоконференції, мережеві ігри).
  • Гнучка сумісність з апаратним забезпеченням: Рішення RiceGuard автоматично визначає апаратні можливості мережевої карти, перенаправляючи пакети до блоків прискорення USO/GRO за їхньої наявності або використовуючи оптимізований програмний конвеєр ядра на платформах попередніх поколінь.

Застосування UDP GSO та GRO формує новий стандарт швидкості для захищених мережевих тунелів, поєднуючи зручність програмного коду простору користувача з максимальною потужністю апаратного рівня.