Пока правил на интерфейсе десяток, ядро справляется с классификацией незаметно для процессора. Но граница сети, где сходятся клиентские подсети, туннели и серверные сегменты, превращает набор фильтров в узкое место: каждый входящий пакет заново прогоняется по всей цепочке условий, и на десятках гигабит эта работа начинает стоить реальных ядер. Linux дает несколько способов разложить потоки по классам быстро, и разница между неудачной и продуманной схемой измеряется не процентами, а разами. Ниже разобрана цена одного правила на пакете, ранние отсечки по состоянию соединений, хеш-наборы nftables, классификатор tc flower с аппаратным offload и eBPF-программы с картами. Читатель получит готовые команды и числовые прикидки, по которым видно, где прячется просадка и что с ней делать.

Цена классификации на каждом пакете и откуда берется просадка скорости

Классификация выполняется на каждом пакете, а не один раз на соединение, поэтому ее стоимость умножается на скорость канала. Типичный кадр Ethernet с заголовком, преамбулой и межкадровым зазором занимает 84 байта, отсюда 1 гигабит в секунду дает около 1,49 миллиона пакетов в секунду, а 10 гигабит почти 14,9 миллиона. На такой скорости бюджет на пакет равен примерно 67 наносекундам на ядро, и линейный перебор правил способен съесть его целиком: сравнение условия стоит порядка нескольких наносекунд, а сто правил дают сотни наносекунд на пакет. Разбор завершается на первом совпадении, поэтому порядок правил влияет на итог сильнее их количества. Расчет для наглядности: цепочка из 180 однотипных правил при 14,9 миллиона пакетов в секунду заставляет ядро выполнить 2,68 миллиарда сравнений ежесекундно, тогда как один поиск в хеш-таблице укладывается в те же 14,9 миллиона операций. Первым делом стоит взглянуть на текущий масштаб бедствия:

nft list ruleset | wc -l
tc -s filter show dev eth0 ingress

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

Ранние отсечки по состоянию соединений и порядок правил в nftables

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

nft add table inet cls
nft add chain inet cls input { type filter hook input priority 0 \; policy accept \; }
nft insert rule inet cls input ct state established,related counter accept

Таблица и цепочка создаются один раз, правило вставляется на первое место и считает пакеты, прошедшие по отсечке. Цепочки вызываются по номерам приоритетов, и в таблице inet их может быть несколько: цепочка с приоритетом -300 у prerouting отработает раньше цепочки с нулем у input, поэтому тяжелую разборку иногда делят на два этапа, сперва грубая отсечка мусора до маршрутизации, затем тонкая классификация ближе к доставке. Дальше цепочку пересобирают по частоте срабатываний, двигая горячие условия вверх. Порядок работы удобен такой:

  1. выгрузить действующий набор правил в файл командой nft list ruleset > ruleset.nft;
  2. собрать статистику по счетчикам counter и выделить условия с наибольшим числом пакетов;
  3. перенести горячие условия в начало цепочки в порядке убывания частоты;
  4. применить файл заново командой nft -f ruleset.nft и сверить состояние через nft list chain inet cls input.

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

Хеш-наборы nftables вместо длинных цепочек однотипных правил

Механизм наборов хранит элементы внутри ядра в хеш-таблицах и красно-черных деревьях, поэтому поиск по набору почти не дорожает с ростом числа записей. Двести правил вида запретить адрес превращаются в один набор и одно условие принадлежности. Анонимные наборы из фигурных скобок прямо в правиле удобны для пары значений, однако они привязаны к правилу и не обновляются. Именованные наборы объявляются отдельно, живут независимо и пополняются на лету; имя занимает до 16 символов, а тип элемента задается словом ipv4_addr, ipv6_addr, ether_addr, inet_service, mark или ifname. Для диапазонов адресов включают флаг interval, а опция auto-merge склеивает перекрывающиеся подсети в одну запись. У набора есть таймауты на элементы, ограничение size и политика хранения performance по умолчанию либо memory. С версии 0.9.5 каждому элементу доступен персональный счетчик, а с версии 0.9.7 на ядре 5.10 и новее набору можно дать комментарий. Черный список собирается так:

nft add set inet cls blackhole { type ipv4_addr \; flags interval \; auto-merge \; counter \; }
nft add element inet cls blackhole { 203.0.113.0/24, 198.51.100.0/24, 192.0.2.55 }
nft add rule inet cls input ip saddr @blackhole drop
nft get element inet cls blackhole { 203.0.113.10 }

Три подсети и один адрес, которые в линейной схеме заняли бы четыре правила, теперь стоят одного поиска, а команда get element мгновенно отвечает, есть ли адрес в наборе. Временные блокировки оформляют таймаутом, например запись 192.0.2.55 timeout 1h исчезнет из набора сама. Порты классифицируются тем же способом:

nft add set inet cls mgmt_ports { type inet_service \; }
nft add element inet cls mgmt_ports { 22, 5900, 8006 }
nft add rule inet cls input tcp dport @mgmt_ports ip saddr 10.8.0.0/16 accept

Доступ к служебным портам открыт одной строкой, и новый номер добавляется без пересборки всего набора правил.

Классификатор tc flower с точными масками и классами трафика

Фильтр flower разбирает пакеты по ключам нескольких уровней: адреса и маски MAC, номера VLAN, протокол, поля TOS и TTL, префиксы адресов, порты одиночные и диапазонами, флаги TCP и даже состояние соединения через ct_state, где значение +trk+est выделяет установленные потоки. Уровни зависят друг от друга: порты принимаются только при заданном ip_proto tcp, udp или sctp, а типы ICMP требуют соответствующего протокола. Одна маска работает на один приоритет, поэтому значения с разными масками разносят по разным prio, иначе ядро откажет при добавлении фильтра. Пример ниже делит канал HTB на три класса: голосовой UDP и пакеты с меткой EF уходят в первый, абонентская подсеть во второй, остальное в третий по умолчанию:

tc qdisc replace dev eth0 root handle 1: htb default 30
tc class add dev eth0 parent 1: classid 1:10 htb rate 300mbit ceil 900mbit prio 0
tc class add dev eth0 parent 1: classid 1:20 htb rate 400mbit ceil 900mbit prio 1
tc class add dev eth0 parent 1: classid 1:30 htb rate 200mbit ceil 900mbit prio 2
tc filter add dev eth0 parent 1: protocol ip flower ip_proto udp dst_port 5000-5020 classid 1:10
tc filter add dev eth0 parent 1: protocol ip flower ip_tos 0xb8/0xfc classid 1:10
tc filter add dev eth0 parent 1: protocol ip flower src_ip 10.20.0.0/16 classid 1:20

Значение 0xb8 соответствует метке EF, сдвинутой на два бита в поле TOS, а маска 0xfc отсекает два младших бита ECN, чтобы они не мешали совпадению. Диапазон портов 5000-5020 проверяется одним условием, без отдельных правил на каждый номер. Фильтры перебираются по приоритетам, поэтому часто срабатывающие условия ставят на младшие номера prio, а широкие маски объединяют, если логика классификации позволяет.

Аппаратный offload классификации на сетевой карте

Следующая ступень экономии переносит разбор пакетов с процессора на карту. Современные чипы хранят flower-фильтры в собственных таблицах потоков и маркируют пакеты еще до передачи ядру, поэтому классификация перестает стоить процессорные такты вовсе. Возможность включают на интерфейсе, поднимают ingress-очередь и добавляют правило с флагом skip_sw, который запрещает программную обработку: если карта не справится, команда честно вернет ошибку. Обратный флаг skip_hw закрепляет правило за программой ядра, а параметр verbose выводит подробности при неудачном offload:

ethtool -K eth0 hw-tc-offload on
tc qdisc replace dev eth0 handle ffff: ingress
tc filter add dev eth0 ingress protocol ip flower skip_sw ip_proto udp dst_port 5000-5020 action skbedit priority 5

Действие skbedit priority проставляет пакету приоритет, по которому его дальше разбирают уже программные классификаторы. Принятый картой фильтр виден в выводе tc filter show по пометке in_hw. У аппаратного пути есть пределы: число правил в таблице потоков и поддерживаемые маски зависят от чипа, обычно речь о тысячах записей, поэтому в карту offloadят самые горячие правила, а редкие оставляют ядру. На канале 10 гигабит такой перенос снимает с процессора всю описанную выше работу с 14,9 миллиона пакетов в секунду, и ядра освобождаются под сам трафик.

eBPF программы и карты для нестандартных условий классификации

Когда условия не влезают в словарь готовых фильтров, на сцену выходит eBPF. Программа прикрепляется к точке tc и возвращает вердикт напрямую, флаг da отключает цепочку действий, а данные живут в картах: поиск в хеш-карте стоит одинаково при сотнях и сотнях тысяч записей. Младший уровень, XDP, разбирает пакеты в драйвере еще до создания sk_buff, и это самый ранний перехват в стеке. Объект компилируют из кода на C, загружают в точку входа и обновляют карту на лету утилитой bpftool, перезагрузка правил не нужна:

clang -O2 -target bpf -c classifier.c -o classifier.o
tc qdisc replace dev eth0 clsact
tc filter replace dev eth0 ingress bpf da obj classifier.o sec classifier
bpftool map update pinned /sys/fs/bpf/cls_v4 key 0a 14 00 02 value 02
bpftool map dump pinned /sys/fs/bpf/cls_v4

Первая строка собирает скомпилированный объект, вторая поднимает общий qdisc clsact с точками ingress и egress, третья загружает программу, четвертая добавляет адрес 10.20.0.2 в ключ карты и метку класса 2 в значение, пятая печатает содержимое карты для проверки. Загруженные программы и карты видны через bpftool prog show и bpftool map show. Такой подход окупается, когда правила зависят от содержимого приложения или требуют собственной математики, в простых случаях хватает наборов и flower.

Измерение результата и типичные ошибки при оптимизации

Любую перестройку классификации сверяют с цифрами до и после. До правок снимают базу: утилита perf stat с событиями cycles и instructions на загруженном канале покажет расход тактов на ядро, а perf top подсветит горячие функции разбора правил вроде nft_do_chain в макушке профиля. После правок те же цифры снимают на том же трафике, и разница станет видна в счетчиках фильтров. Замеры на пустом канале обесценивают всю работу: классификация без реального потока не тратит ничего, и выигрыш окажется невидим. Вторая типичная ошибка - две разные маски в одном приоритете у flower: ядро отказывает при добавлении второго фильтра, и правило приходится переносить на другой prio. Третья ошибка, забытые счетчики: без counter в правилах невозможно понять, срабатывают ли новые отсечки, и оптимизация превращается в гадание. Профиль до оптимизации на реальном канале выглядит характерно, и по нему видно, куда уходит время:

74.20%  nft_do_chain_inet
 8.10%  flower_classify
 3.40%  cls_bpf_classify

Здесь три четверти тактов сгорают в переборе правил nftables, а классификаторы tc почти не видны, потому что весь трафик пока проходит через фаервол. Контрольная сводка после правок собирается тремя командами:

perf stat -e cycles,instructions -a sleep 10
tc -s class show dev eth0
nft list chain inet cls input

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