В этой статье пойдет речь о том как работает TUN GSO, зачем нужен virtio_net_hdr, какие подводные камни встретились во время реализации и почему эта технология способна заметно снизить нагрузку на VPN-сервер. Статья будет полезна разработчикам VPN серверов и клиентов.
Основной язык проекта — Java, поэтому здесь и далее все примеры кода приводятся на Java. Синтаксис у неё C-подобный, так что опытному разработчику не составит труда переложить идею на свой язык.
Коротко об архитектуре
Мой VPN-сервер написан на Java. Основа сетевого стека — Java NIO, которая по сути является тонкой обёрткой над платформенными механизмами мультиплексирования ввода-вывода (epoll в Linux и kqueue в BSD/macOS). Модель работы — классический Reactor: несколько I/O-потоков ожидают события OP_ACCEPT, OP_READ и OP_WRITE. Аналогичный подход, например, используется в NGINX.
После получения события реактор читает данные из сокета и помещает их в очередь. Далее виртуальный поток (sslWorker) извлекает пакеты, выполняет обработку TLS через SSLEngine и перекладывает уже расшифрованные данные в следующую очередь. Оттуда они отправляются в /dev/net/tun. В обратном направлении путь практически симметричный: пакет читается из TUN-интерфейса, шифруется и отправляется клиенту.
На первый взгляд архитектура выглядит вполне эффективно. Однако узкое место появляется именно во взаимодействии с TUN-интерфейсом.
Обычный IP-пакет имеет размер порядка 1500 байт (точнее, ограничен MTU канала). Это означает, что когда через TUN проходит около 64 КБ данных одного TCP-потока, они приходят уже нарезанными примерно на 44 сегмента — и пользовательскому приложению достаётся не один большой пакет, а несколько десятков маленьких.
Для передачи этих 64 КБ потребуется примерно:
-
44 вызова
read()илиwrite(); -
44 перехода между пользовательским пространством и ядром (
user → kernel → user); -
44 копирования данных между пространствами памяти;
-
44 создания и обработки сетевых буферов (
skb) внутри ядра; -
44 прохода через обработчики сетевого стека.
Рис. 2. Одна и та же нагрузка без GSO и с TUN GSO: 44 системных вызова против одного.
Сама операция чтения или записи не кажется дорогой. Но когда счёт пакетов идёт на миллионы, накладные расходы начинают доминировать над полезной работой. Процессор всё больше времени тратит не на шифрование TLS или обработку сетевого трафика, а на обслуживание большого количества системных вызовов и переключений контекста.
Именно здесь возникает закономерный вопрос: а обязательно ли передавать через TUN каждый TCP-сегмент по отдельности? Может быть, эти операции можно как-то объединить?
Вариантов, по сути, два.
Первый — написать собственный модуль ядра и организовать обмен данными через разделяемую память, например с использованием кольцевых буферов. Такой подход позволяет добиться очень высокой производительности, но значительно усложняет разработку и поддержку проекта.
Второй вариант — использовать уже существующий механизм Linux, который называется TUN GSO (Generic Segmentation Offload). Именно его я и выбрал для своего проекта.
Оказалось, что информации по этой теме крайне мало. В русскоязычном интернете её практически нет, а в англоязычном большинство материалов ограничивается несколькими абзацами и рекомендацией «просто включить GSO». Чтобы разобраться, пришлось читать исходники драйвера TUN, документацию ядра, спецификацию VirtIO и код существующих VPN-проектов — тем же путём в своё время прошли, например, разработчики wireguard-go и Tailscale.
Что такое TUN GSO
Идея TUN GSO (Generic Segmentation Offload) сама по себе интуитивно понятна для любого программиста по сути это батчинг пакетов, в статье подробно будет рассмотрено только чтение, но запись работает аналогично и зеркально.
Вместо того чтобы вычитывать из TUN десятки уже сегментированных TCP-пакетов, приложение получает один большой IP-пакет размером до нескольких десятков килобайт. Дальше с ним есть два пути: нарезать его на сегменты по MSS уже в пользовательском пространстве — или, если клиент тоже умеет TUN GSO (например, работает под Linux), завернуть суперпакет в TLS и отправить как есть. Во втором случае сегментация на нашей стороне вообще не нужна, и достигается максимальная эффективность.
Однако сразу возникает вопрос.
Если в TUN приходит один большой пакет, откуда ядро узнает, на какие сегменты его нужно разбить? Какой размер MSS использовать? Где начинается транспортный заголовок? Нужно ли пересчитывать контрольные суммы? Это уже не тот IP-пакет, который можно просто отправить дальше по стеку.
Ответом является небольшой служебный заголовок, который передаётся перед каждым большим пакетом. Называется он virtio_net_hdr.
Несмотря на название, к виртуальным машинам он не имеет почти никакого отношения. Исторически этот заголовок появился в подсистеме VirtIO, но со временем Linux стал использовать его как универсальный формат описания offload-операций. Именно поэтому драйвер TUN тоже ожидает увидеть virtio_net_hdr, если включена поддержка GSO.
С точки зрения приложения пакет начинает выглядеть следующим образом:
Рис. 1. Структура суперпакета: 10-байтовый virtio_net_hdr, затем обычный IP-пакет.
Сам IP-пакет практически не меняется. Вся дополнительная информация находится в virtio_net_hdr: тип сегментации, размер будущих сегментов, параметры вычисления контрольных сумм и некоторые другие служебные поля.
Получив такой заголовок, приложение снимает первые 10 байт — дальше идёт обычный большой IP-пакет. Разбиением на MTU-сегменты на пути чтения занимается не ядро, а мы сами: своим сегментатором в пользовательском пространстве (а если клиент тоже понимает TUN GSO — суперпакет можно вообще не резать, завернуть в TLS и отправить как есть).
Ключевой выигрыш в том, что ядро перестаёт отдавать нам десятки уже нарезанных пакетов по одному. Вместо 44 системных вызовов приложение выполняет один, а сорок отдельных skb и сорок проходов по сетевому стеку внутри ядра просто не создаются.
Разбор структуры virtio_net_hdr
Заголовок занимает ровно 10 байт и стоит перед каждым пакетом — в том числе перед обычным, не-GSO. Появляется он, только если интерфейс создан с флагом IFF_VNET_HDR (в TUNSETIFF); размер заголовка фиксируется вызовом TUNSETVNETHDRSZ(10), чтобы ядро и приложение точно совпадали в разметке. Все многобайтовые поля ядро пишет в нативном порядке байт хоста — на x86-64 и ARM это little-endian (при необходимости порядок можно зафиксировать явно через TUNSETVNETLE).
|
Смещение |
Размер |
Поле |
Назначение |
|---|---|---|---|
|
0 |
1 |
|
битовая маска: нужно ли досчитывать контрольную сумму |
|
1 |
1 |
|
тип сегментации: NONE / TCPv4 / TCPv6 |
|
2 |
2 |
|
суммарная длина заголовков IP+TCP (подсказка, её надо проверять) |
|
4 |
2 |
|
MSS — сколько байт полезной нагрузки в каждом сегменте после нарезки |
|
6 |
2 |
|
смещение (от начала IP-пакета), с которого считается контрольная сумма |
|
8 |
2 |
|
куда положить контрольную сумму, относительно |
Обычный пакет несёт заголовок из одних нулей: gso_type = NONE, flags = 0.
Значения flags (битовая маска):
|
Константа |
Значение |
Смысл |
|---|---|---|
|
|
|
контрольная сумма частичная (посчитан только псевдозаголовок), получатель обязан её досчитать |
|
|
|
только на чтении: ядро говорит «сумма уже проверена, не пересчитывай» |
Значения gso_type (тип в младших битах, 0x80 — ECN-флаг поверх):
|
Константа |
Значение |
Смысл |
|---|---|---|
|
|
|
обычный пакет |
|
|
|
TCP/IPv4 суперпакет |
|
|
|
TCP/IPv6 суперпакет |
|
|
|
OR-флаг: в TCP был выставлен CWR (ECN) |
Пара тонкостей, на которых легко споткнуться:
-
Заголовок присутствует всегда и его длина фиксирована. Парсинг — это не «если есть заголовок», а просто «прочитать первые 10 байт, дальше
position = 10», и оттуда идёт обычный IP-пакет, с которым весь остальной код работает без изменений. -
ByteBufferв Java по умолчанию big-endian, а поля здесь little-endian — триu16-поля надо читать с явнымLITTLE_ENDIAN(или черезShort.reverseBytes), иначеgso_sizeпревратится в мусор.
В коде это вылилось в маленький allocation-free класс VnetHdr, который умеет только читать и писать эти 10 байт по абсолютному смещению в буфере. Всю раскладку из таблицы выше видно прямо в константах, а запись заголовка суперпакета — в одном методе:
public final class VnetHdr { /** Размер заголовка фиксирован — его же отдаём в TUNSETVNETHDRSZ(10). */ public static final int LEN = 10; // Смещения полей внутри заголовка. private static final int FLAGS_OFF = 0; // u8 private static final int GSO_TYPE_OFF = 1; // u8 private static final int HDR_LEN_OFF = 2; // u16 LE — длина заголовков IP+TCP (подсказка) private static final int GSO_SIZE_OFF = 4; // u16 LE — MSS private static final int CSUM_START_OFF = 6; // u16 LE — откуда начинается контрольная сумма (от IP) private static final int CSUM_OFFSET_OFF = 8; // u16 LE — поле суммы, относительно csum_start public static final int F_NEEDS_CSUM = 0x01; // частичная сумма — получатель досчитывает public static final int F_DATA_VALID = 0x02; // только на чтении: сумма уже проверена public static final int GSO_NONE = 0; public static final int GSO_TCPV4 = 1; public static final int GSO_TCPV6 = 4; public static final int GSO_ECN = 0x80; // OR-флаг поверх типа private VnetHdr() { } /** gso_type с вычтенным ECN-флагом. */ public static int gsoType(ByteBuffer b, int off) { return b.get(off + GSO_TYPE_OFF) & ~GSO_ECN & 0xFF; } public static int gsoSize(ByteBuffer b, int off) { return u16le(b, off + GSO_SIZE_OFF); } // Никакого ByteBuffer.getShort(): у него big-endian по умолчанию, а поля здесь little-endian. private static int u16le(ByteBuffer b, int off) { return (b.get(off) & 0xFF) | ((b.get(off + 1) & 0xFF) << 8); } private static void putU16le(ByteBuffer b, int off, int v) { b.put(off, (byte) (v & 0xFF)); b.put(off + 1, (byte) ((v >>> 8) & 0xFF)); } /** * Пишет заголовок суперпакета с флагом NEEDS_CSUM: ядро пересегментирует пакет по gsoSize * и досчитывает сумму каждого сегмента. csum_start = смещение TCP-заголовка (ipHdrLen), * csum_offset = 16 (поле суммы в TCP). В поле суммы TCP уже должна лежать частичная сумма * псевдозаголовка. */ public static void writeSuperpacketHeader(ByteBuffer b, int off, int gsoType, int gsoSize, int ipHdrLen, int tcpHdrLen) { b.put(off + FLAGS_OFF, (byte) F_NEEDS_CSUM); b.put(off + GSO_TYPE_OFF, (byte) gsoType); putU16le(b, off + HDR_LEN_OFF, ipHdrLen + tcpHdrLen); putU16le(b, off + GSO_SIZE_OFF, gsoSize); putU16le(b, off + CSUM_START_OFF, ipHdrLen); putU16le(b, off + CSUM_OFFSET_OFF, 16); }}
Чтение из TUN: три случая
Когда суперпакеты включены (TUNSETOFFLOAD(TUN_F_CSUM | TUN_F_TSO4 | TUN_F_TSO6)), после разбора заголовка возможны три ситуации. Диспетчеризация идёт по паре (gso_type, flags):
-
Случай 1 —
gso_type = NONE, flags = 0(илиDATA_VALID). Обычный пакет с корректными контрольными суммами. Пропускаем 10 байт и идём дальше по старому пути. В первой фазе внедрения (толькоIFF_VNET_HDR+TUN_F_CSUM, без TSO) это практически 100% трафика. -
Случай 2 —
gso_type = NONE, flags = NEEDS_CSUM. Пакет с частичной контрольной суммой: ядро посчитало только псевдозаголовок и ждёт, что сумму досчитают. Мы обязаны это сделать — наш реальный получатель находится на другом конце TLS-туннеля, а семантика частичной суммы действительна только между нами и нашим ядром. Отправить такой пакет как есть — это тихий дроп на стороне клиента. -
Случай 3 —
gso_type = TCPV4илиTCPV6. Тот самый суперпакет: IP- и TCP-заголовки нормальные, но полезная нагрузка TCP — до ~64 КБ, то есть больше MSS. Всегда приходит вместе сNEEDS_CSUM. Полеgso_size— это MSS, по которому надо нарезать (ядро взяло его из реального TCP-соединения, обычно 1460; доверяем ему, не вычисляем из MTU). Вот здесь и вступает в дело сегментатор.
Рис. 3. Три случая чтения из TUN — диспетчеризация по паре (gso_type, flags).
Одно правило стоит запомнить насмерть: семантика частичной контрольной суммы (NEEDS_CSUM) допустима только на границе между нашим процессом и нашим ядром. Всё, что пересекает любую другую границу — TLS-туннель до клиента, сегменты, которые мы нарезали сами, — обязано нести полностью и правильно посчитанные суммы. Любая пересборка в пользовательском пространстве означает полный пересчёт: частичная сумма из суперпакета считалась для другой длины и бесполезна.
Сегментатор
Теперь собственно нарезка. Сегментатор — чистая функция: на вход суперпакет, на выходе n готовых к отправке MTU-сегментов, каждый со своими, заново посчитанными контрольными суммами IP и TCP. Сам суперпакет не мутируется; сегменты берутся из пула буферов и по одному отдаются в sink.
Количество сегментов — это просто ceil(payload / gso_size), где последний сегмент может быть короче остальных:
@Overridepublic int segment(ByteBuffer superpacket, int gsoSize, Consumer<ByteBuffer> sink) { int ipOff = superpacket.position(); ByteBuffer ipHdrBuf = pool.get().clear(); ByteBuffer tcpHdrBuf = pool.get().clear(); try { IpTcpHdr hdr = IpTcpHdr.getIpTcpHeader(superpacket, ipOff, ipHdrBuf, tcpHdrBuf); int count = hdr.payloadLength() / gsoSize; int lastLen = hdr.payloadLength() % gsoSize; int total = count + (lastLen > 0 ? 1 : 0); for (int i = 0; i < count; i++) { emit(sink, superpacket, ipOff, hdr, i, gsoSize, gsoSize, i == total - 1); } if (lastLen > 0) { emit(sink, superpacket, ipOff, hdr, count, gsoSize, lastLen, true); } return total; } finally { pool.release(ipHdrBuf); pool.release(tcpHdrBuf); }}// Сегмент забирает sink, владение буфером передается коду отправки, который сам его освободитprivate void emit(Consumer<ByteBuffer> sink, ByteBuffer superpacket, int ipOff, IpTcpHdr hdr, int i, int mss, int chunk, boolean last) { sink.accept(fillSegment(superpacket, ipOff, hdr, i, mss, chunk, last));}
Вся работа — в fillSegment. Для каждого сегмента копируем шаблон заголовков IP+TCP и нужный кусок нагрузки, а потом правим поля, которые обязаны отличаться от сегмента к сегменту:
-
tcp.seq+=i * gso_size— с арифметикой по модулю 2³², то есть с учётом переполнения u32; -
FINиPSHоставляем только на последнем сегменте (иначе соединение закроется на первом же куске), остальные флаги копируем как есть. Отдельный случай с ECN — когда вgso_typeвыставленGSO_ECNиCWRдолжен остаться только на первом сегменте — в нашей конфигурации не встречается:TUN_F_TSO_ECNмы вTUNSETOFFLOADне передаём, поэтому суперпакеты с ECN-битом ядро не отдаёт, а сегментирует такие потоки само, но не сложно добавить при желании; -
для IPv4 —
id+=iиtot_len; для IPv6 —payload_len; -
контрольные суммы пересчитываем полностью: сначала заголовок IPv4, потом TCP (псевдозаголовок + заголовок + кусок данных).
/** * Собирает сегмент i: копирует шаблон заголовков IP+TCP и chunk-байтовый кусок нагрузки, * выставляет пер-сегментные поля (seq, id/tot_len или payload_len, FIN/PSH-на-последнем) * и пересчитывает контрольные суммы. mss — полный размер нагрузки сегмента * chunk — фактическая длина именно этого сегмента (== mss у всех, кроме, * возможно, последнего). */private ByteBuffer fillSegment(ByteBuffer superpacket, int ipOff, IpTcpHdr hdr, int i, int mss, int chunk, boolean last) { int ipHdrLen = hdr.ipHeaderLength(); int tcpOff = ipHdrLen; int hdrLen = hdr.headerLength(); int segEnd = hdrLen + chunk; ByteBuffer seg = pool.get().clear(); seg.put(0, hdr.ipHeader(), 0, ipHdrLen); seg.put(ipHdrLen, hdr.tcpHeader(), 0, hdr.tcpHeaderLength()); seg.put(hdrLen, superpacket, ipOff + hdrLen + i * mss, chunk); // Посегментные поля TCP (порты/ack/window/опции остаются как скопированы). setTcpSeq(seg, tcpOff, hdr.tcpSeq() + i * mss); int flags = last ? hdr.tcpFlags() : (hdr.tcpFlags() & ~(TCP_FIN | TCP_PSH)); setTcpFlags(seg, tcpOff, flags); // Посегментные поля IP + суммы (полный пересчёт; порядок: сначала поля, потом суммы). if (hdr.ipVersion() == 4) { setIpv4Id(seg, 0, hdr.ipv4Id() + i); setIpv4TotalLength(seg, 0, segEnd); InetChecksum.writeIpv4HeaderChecksum(seg, 0); InetChecksum.writeTransportChecksumV4(seg, 0, tcpOff, segEnd); } else { setIpv6PayloadLength(seg, 0, segEnd - ipHdrLen); // TCP-заголовок + chunk; у IPv6 нет суммы заголовка InetChecksum.writeTransportChecksumV6(seg, 0, tcpOff, segEnd); } seg.position(0).limit(segEnd); return seg;}
Порядок здесь важен: сначала выставляем все поля, и только потом считаем суммы — иначе посчитаем сумму по ещё не финализированному заголовку. И mss (полный размер сегмента, нужен для арифметики seq и смещений) намеренно отделён от chunk (фактическая длина именно этого сегмента).
Подводные камни
Их набралось несколько, и почти все — не про сегментацию как таковую, а про сопутствующие детали.
-
Контрольные суммы — это 90% всех проблем. Симптом всегда один и тот же: трафик «идёт»,
speedtestдаже что-то показывает, но соединения молча зависают. Если что-то не работает — проверяйте суммы первыми, до всего остального. -
Учёт пакетов. RED, токен-бакет шейпера и клиентские метрики должны считать суперпакет как
gso_segs = ceil(payload / gso_size)пакетов и его настоящую длину в байтах — а не как «1 пакет». Иначе RED дропает по 64 КБ за раз (это целое окно одного потока — гарантированный шторм ретрансмитов), шейпер недобирает раз в 40, а метрики врут. -
Little-endian заголовка. Про это уже сказано выше, но повторю: это первое, что ломается на ровном месте, если забыть про
order(LITTLE_ENDIAN). -
Номер ioctl
TUNSETOFFLOAD— легко ошибиться в одном байте. В Java нет хедеров из ядра линукс, поэтому магическое число хардкодим константой: в 32 бита упакованы четыре поля — направление обмена, размер аргумента, «тип» (буква'T', ею помечены все вызовы TUN) и в младшем байте номер самой команды. ДляTUNSETOFFLOAD = 0x400454D0по байтам это40(направление_IOW— «пишем в ядро») ·04(размер аргумента) ·54('T') ·D0(номер команды — 208).
Что показали замеры
Фича уже крутится на тестовом стенде (одно ядро, GraalVM, ZGC), а телеметрия снимается через Prometheus и Grafana. Сразу оговорюсь честно: чистого «до/после» по пропускной способности я здесь не привожу — для этого нужен отдельный прогон с выключенным TUN_GSO на ровно том же трафике, а полноценного baseline-снимка под рукой не оказалось. Зато хорошо видно то, ради чего всё и затевалось в первую очередь — амортизацию системных вызовов.
За сессию под смешанной нагрузкой (тот самый профиль на пару сотен клиентов, где перемешаны тяжёлые «торрентоподобные» потоки и лёгкий веб-сёрфинг) через download-путь прошло около 2,4 млн операций чтения из TUN. Один read() отдаёт ровно один пакет, так что это же и 2,4 млн прочитанных пакетов. Из них настоящими TCP-суперпакетами (случай 3), которые пришлось резать, оказались примерно 805 тысяч; остальные ~1,6 млн — обычные мелкие пакеты и одиночные ACK-и (случаи 1/2), их резать не нужно. И вот эти 805 тысяч суперпакетов сегментатор развернул в ~2,8 млн проводных пакетов. То есть на суперпакетах один read() в среднем сделал работу трёх с половиной, а на хвосте распределения — двух-трёх десятков. В пиковую минуту стенд читал из TUN около 2900 раз в секунду, отдавая наружу ~5900 сегментов; коэффициент амортизации — среднее число сегментов на один суперпакет — в моменте доходил до ~7,8.
|
Метрика (download-путь) |
Значение |
|---|---|
|
Прочитано пакетов из TUN (= вызовов |
~2,4 млн |
|
— из них суперпакетов (случай 3, резались) |
~805 тыс. |
|
— из них обычных пакетов (случаи 1/2) |
~1,6 млн |
|
Сегментов на выходе (из тех 805 тыс. суперпакетов) |
~2,8 млн |
|
Средний размер одного чтения из TUN (по всем пакетам) |
~2,3 КБ (p50 ~1,3 КБ, p99 ~15 КБ) |
|
Сегментов на суперпакет |
среднее 3,5; p99 ~24 |
|
Пик: чтений/с → сегментов/с |
~2 900 → ~5 900 |
|
Отброшено пакетов ( |
0 |
Рис. 4. Замеры со стенда: экономия системных вызовов и коэффициент амортизации.
Две вещи стоит проговорить честно.
Во-первых, среднее чтение из TUN здесь — всего пара килобайт, а не заветные 64. Это особенность нагрузки: в смешанном профиле много мелких пакетов, которым попросту нечего склеивать. На чистом bulk-потоке картина другая — самый крупный суперпакет, что стенд видел за всё время наблюдений, дорос до ~65 КБ, ровно до проектного потолка, и вот там амортизация выходит на те самые теоретические ~44 к одному.
Во-вторых, upload-путь (GRO-склейка) в этом прогоне почти не сработал: средняя запись в TUN — 360 байт, максимум упирался в MTU. Клиентский трафик в тесте состоял из мелких пакетов и одиночных ACK-ов, а их коалесер в первой версии намеренно не трогает — склеивать нечего. На реальном аплоаде с крупными загрузками здесь будет что показать, но честные цифры по нему я приведу уже отдельным замером.
И, пожалуй, самое приятное: ноль дропов на tun_gso_drops за миллионы пакетов. Значит, разбор заголовка и пересчёт контрольных сумм нигде не посыпались — а мы помним, что именно там живёт 90% всех багов GSO.
Итог: сколько системных вызовов мы сэкономили
Если свести всё к одной цифре, ради которой затевался TUN GSO, — это число системных вызовов на TUN-интерфейсе.
Посчитаем по той же сессии на стенде. На download-пути каждый проводной пакет без GSO — это отдельный read(). Всего таких пакетов за сессию набралось бы около 4,4 млн (≈1,6 млн обычных плюс ≈2,8 млн сегментов, которые ядро отдавало бы поштучно). С включённым GSO те же самые данные уместились в 2,4 млн чтений — те самые 1,6 млн обычных пакетов плюс 805 тыс. суперпакетов вместо 2,8 млн сегментов.
Разница — около 2 миллионов сэкономленных системных вызовов: download-путь похудел на ~45%, то есть переходов user ↔ kernel стало в 1,8 раза меньше. И это на «неудобном» смешанном трафике, где половина пакетов — мелочь, которую нечего укрупнять. На той доле, что реально сегментировалась, экономия честнее — в 3,5 раза (805 тыс. вызовов вместо 2,8 млн), а на насыщенном одиночном bulk-потоке, где суперпакет дорастает до 64 КБ, коэффициент
И это только сами сисколлы. За каждым сэкономленным read() стоит ещё и не созданный skb, не пройденный сетевой стек, не сделанная копия между пространствами памяти. И на нашей стороне тоже: один, а не сорок, проходов по всему конвейеру обработки пакета — поиск клиента-получателя по IP назначения, шейпер, постановка в очередь на отправку. Собственно, ради этой арифметики всё и затевалось.
ссылка на оригинал статьи https://habr.com/ru/articles/1058404/