Разработка → Захват пакетов в Linux на скорости десятки миллионов пакетов в секунду без использования сторонних библиотек

pavelodintsov 25 июня 2015 в 15:53 67,8k
Моя статья расскажет Вам как принять 10 миллионов пакетов в секунду без использования таких библиотек как Netmap, PF_RING, DPDK и прочие. Делать мы это будем силами обычного Линукс ядра версии 3.16 и некоторого количества кода на С и С++.



Сначала я хотел бы поделиться парой слов о том, как работает pcap — общеизвестный способ захвата пакетов. Он используется в таких популярных утилитах как iftop, tcpdump, arpwatch. Кроме этого, он отличается очень высокой нагрузкой на процессор.

Итак, Вы открыли им интерфейс и ждете пакетов от него используя обычный подход — bind/recv. Ядро в свою очередь получает данные из сетевой карты и сохраняет в пространстве ядра, после этого оно обнаруживает, что пользователь хочет получить его в юзер спейсе и передает через аргумент команды recv, адрес буфера куда эти данные положить. Ядро покорно копирует данные (уже второй раз!). Выходит довольно сложно, но это не все проблемы pcap.

Кроме этого, вспомним, что recv — это системный вызов и вызываем мы его на каждый пакет приходящий на интерфейс, системные вызовы обычно очень быстры, но скорости современных 10GE интерфейсов (до 14.6 миллионов вызовов секунду) приводят к тому, что даже легкий вызов становится очень затратным для системы исключительно по причине частоты вызовов.

Также стоит отметить, что у нас на сервере обычно более 2х логических ядер. И данные могут прилететь на любое их них! А приложение, которое принимает данные силами pcap использует одно ядро. Вот тут у нас включаются блокировки на стороне ядра и кардинально замедляют процесс захвата — теперь мы занимаемся не только копированием памяти/обработкой пакетов, а ждем освобождения блокировок, занятых другими ядрами. Поверьте, на блокировки может зачастую уйти до 90% процессорных ресурсов всего сервера.

Хороший списочек проблем? Итак, мы их все геройски попробуем решить!

Итак, для определенности зафиксируем, что мы работаем на зеркальных портах (что означает, что откуда-то извне сети нам приходит копия всего трафика определенного сервера). На них в свою очередь идет трафик — SYN флуд пакетами минимального размера на скорости 14.6 mpps/7.6GE.

Сетевые ixgbe, драйверы с SourceForge 4.1.1, Debian 8 Jessie. Конфигурация модуля: modprobe ixgbe RSS=8,8 (это важно!). Процессор у меня i7 3820, с 8ю логическими ядрами. Поэтому везде, где я использую 8 (в том числе в коде) Вы должны использовать то число ядер, которое есть у Вас.

Распределим прерывания по имеющимся ядрам


Обращаю внимание, что нам на порт прилетают пакеты, целевые MAC адреса которых не совпадают с MAC адресом нашей сетевой карты. В противном случае включится в работу TCP/IP стек Linux и машина подавится трафиком. Этот момент очень важен, мы сейчас обсуждаем только захват чужого трафика, а не обработку трафика, который предназначается данной машине (хотя для этого мой метод подходит с легкостью).

Теперь проверим, сколько трафика мы можем принять, если начнем слушать весь трафик.

Включаем promisc режим на сетевой карте:
ifconfig eth6 promisc


После этого в htop мы увидим очень неприятную картину — полную перегрузку одного из ядер:

1  [||||||||||||||||||||||||||||||||||||||||||||||||||||||||||100.0%]     
2  [                                                            0.0%]     
3  [                                                            0.0%]     
4  [                                                            0.0%]     
5  [                                                            0.0%]
6  [                                                            0.0%]
7  [                                                            0.0%]
8  [                                                            0.0%]


Для определения скорости на интерфейсе воспользуемся спец-скриптом pps.sh: gist.github.com/pavel-odintsov/bc287860335e872db9a5

Скорость на интерфейсе при этом довольно маленькая — 4 миллиона пакетов секунду:
bash /root/pps.sh eth6
TX eth6: 0 pkts/s RX eth6: 3882721 pkts/s
TX eth6: 0 pkts/s RX eth6: 3745027 pkts/s


Чтобы решить эту проблему и распределить нагрузку по всем логическим ядрам (у меня их 8) нужно запустить следующей скрипт: gist.github.com/pavel-odintsov/9b065f96900da40c5301 который распределит прерывания от всех 8 очередей сетевой карты по всем имеющимся логическим ядрам.

Прекрасно, скорость сразу вылетела до 12mpps (но это не захват, это лишь показатель того, что мы можем прочесть трафик на такой скорости из сети):
 bash /root/pps.sh eth6
TX eth6: 0 pkts/s RX eth6: 12528942 pkts/s
TX eth6: 0 pkts/s RX eth6: 12491898 pkts/s
TX eth6: 0 pkts/s RX eth6: 12554312 pkts/s


А нагрузка на ядра стабилизировалась:
1  [|||||                                                       7.4%]     
2  [|||||||                                                     9.7%]     
3  [||||||                                                      8.9%]    
4  [||                                                          2.8%]     
5  [|||                                                         4.1%]
6  [|||                                                         3.9%]
7  [|||                                                         4.1%]
8  [|||||                                                       7.8%]


Сразу обращаю внимание, что в тексте будут использоваться два примера кода, вот они:
AF_PACKET, AF_PACKET + FANOUT: gist.github.com/pavel-odintsov/c2154f7799325aed46ae
AF_PACKET RX_RING, AF_PACKET + RX_RING + FANOUT: gist.github.com/pavel-odintsov/15b7435e484134650f20

Это законченные приложения с максимальным уровнем оптимизаций. Промежуточные (заведомо более медленные версии кода) я не привожу — но все флажки для управления всеми оптимизациями выделены и объявлены в коде как bool — можете легко повторить мой путь у себя.

Первая попытка запуска AF_PACKET захвата без оптимизаций


Итак, запускаем приложение по захвату трафика силами AF_PACKET:
We process: 222048 pps
We process: 186315 pps


И нагрузка в потолок:
1  [||||||||||||||||||||||||||||||||||||||||||||||||||||||||   86.1%]     
2  [||||||||||||||||||||||||||||||||||||||||||||||||||||||     84.1%]     
3  [||||||||||||||||||||||||||||||||||||||||||||||||||||       79.8%]     
4  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||  88.3%]     
5  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||    83.7%]
6  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||  86.7%]
7  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||| 89.8%]
8  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||90.9%]


Причина тому, что ядро утонуло в блокировках, на которые тратит все процессорное время:
Samples: 303K of event 'cpu-clock', Event count (approx.): 53015222600
  59.57%  [kernel]        [k] _raw_spin_lock
   9.13%  [kernel]        [k] packet_rcv
   7.23%  [ixgbe]         [k] ixgbe_clean_rx_irq
   3.35%  [kernel]        [k] pvclock_clocksource_read
   2.76%  [kernel]        [k] __netif_receive_skb_core
   2.00%  [kernel]        [k] dev_gro_receive
   1.98%  [kernel]        [k] consume_skb
   1.94%  [kernel]        [k] build_skb
   1.42%  [kernel]        [k] kmem_cache_alloc
   1.39%  [kernel]        [k] kmem_cache_free
   0.93%  [kernel]        [k] inet_gro_receive
   0.89%  [kernel]        [k] __netdev_alloc_frag
   0.79%  [kernel]        [k] tcp_gro_receive


Оптимизация AF_PACKET захвата с помощью FANOUT


Итак, что же делать? Немного подумать :) Блокировки возникают тогда, когда несколько процессоров пытаются использовать один ресурс. В нашем случае это происходит из-за того, что у нас один сокет и его обслуживает одно приложение, что вынуждает остальные 8 логических процессоров стоять в постоянном ожидании.

Тут нам на помощь придет отличная функция — FANOUT, а если по-русски — разветвления. Для AF_PACKET мы можем запустить несколько (разумеется, наиболее оптимальным в нашем случае будет число процессов равное числу логических ядер). Кроме этого, мы можем задать алгоритм по которому данные будут распределяться по этим сокетам. Я выбрал режим PACKET_FANOUT_CPU, так как в моем случае данные очень равномерно распределяются по очередям сетевой карты и это, на мой взгляд, наименее ресурсоемкий вариант балансировки (хотя тут не ручаюсь — рекомендую посмотреть в коде ядра).

Корректируем в примере кода bool use_multiple_fanout_processes = true;

И снова запускаем приложение.

О чудо! 10 кратное ускорение:
We process: 2250709 pps
We process: 2234301 pps
We process: 2266138 pps

Процессоры, конечно, по прежнему загружены по полной:
1  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||92.6%]     
2  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||93.1%]     
3  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||93.2%]     
4  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||93.3%]     
5  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||93.1%]
6  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||93.7%]
7  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||93.7%]
8  [|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||93.2%]


Но карта perf top выглядит уже совершенно иначе — никаких локов больше нет:
Samples: 1M of event 'cpu-clock', Event count (approx.): 110166379815
  17.22%  [ixgbe]         [k] ixgbe_clean_rx_irq      
   7.07%  [kernel]        [k] pvclock_clocksource_read          
   6.04%  [kernel]        [k] __netif_receive_skb_core    
   4.88%  [kernel]        [k] build_skb     
   4.76%  [kernel]        [k] dev_gro_receive    
   4.28%  [kernel]        [k] kmem_cache_free 
   3.95%  [kernel]        [k] kmem_cache_alloc 
   3.04%  [kernel]        [k] packet_rcv 
   2.47%  [kernel]        [k] __netdev_alloc_frag 
   2.39%  [kernel]        [k] inet_gro_receive
   2.29%  [kernel]        [k] copy_user_generic_string
   2.11%  [kernel]        [k] tcp_gro_receive
   2.03%  [kernel]        [k] _raw_spin_unlock_irqrestore


Кроме этого, у сокетов (хотя я не уверен про AF_PACKET) есть возможность задать буфер приема, SO_RCVBUF, но на моем тест-стенде это не дало никаких результатов.

Оптимизация AF_PACKET захвата с помощью RX_RING — кольцевого буфера


Что же делать и почему все равно медленно? Ответ тут в функции build_skb, она означает, что внутри ядра по-прежнему производится два копирования памяти!

Теперь попробуем разобраться с выделением памяти за счет использования RX_RING.

И ура 4 MPPS вершина взята!!!
We process: 3582498 pps
We process: 3757254 pps
We process: 3669876 pps
We process: 3757254 pps
We process: 3815506 pps
We process: 3873758 pps


Такой прирост скорости был обеспечен тем, что копирование памяти из буфера сетевой карты теперь производится лишь однажды. И при передачей из пространства ядра в пространство пользователя повторное копирование не производится. Это обеспечивается за счет общего буфера выделенного в ядре и пропущенного в user space.

Также меняется подход с работой — нам теперь нельзя висеть и слушать когда придет пакет (помните — это оверхед!), теперь с помощью вызова poll мы можем ожидать сигнала, когда будет заполнен целый блок! И после этого начинать его обработку.

Оптимизация AF_PACKET захвата с помощью RX_RING силами FANOUT


Но все равно у нас проблемы с блокировками! Как же их победить? Старым методом — включить FANOUT и выделить блок памяти для каждого потока-обработчика!
Samples: 778K of event 'cpu-clock', Event count (approx.): 87039903833
  74.26%  [kernel]       [k] _raw_spin_lock
   4.55%  [ixgbe]        [k] ixgbe_clean_rx_irq
   3.18%  [kernel]       [k] tpacket_rcv
   2.50%  [kernel]       [k] pvclock_clocksource_read
   1.78%  [kernel]       [k] __netif_receive_skb_core
   1.55%  [kernel]       [k] sock_def_readable
   1.20%  [kernel]       [k] build_skb
   1.19%  [kernel]       [k] dev_gro_receive
   0.95%  [kernel]       [k] kmem_cache_free
   0.93%  [kernel]       [k] kmem_cache_alloc
   0.60%  [kernel]       [k] inet_gro_receive
   0.57%  [kernel]       [k] kfree_skb
   0.52%  [kernel]       [k] tcp_gro_receive
   0.52%  [kernel]       [k] __netdev_alloc_frag


Итак, подключаем FANOUT режим для RX_RING версии!

УРА! РЕКОРД!!! 9 MPPS!!!
We process: 9611580 pps
We process: 8912556 pps
We process: 8941682 pps
We process: 8854304 pps
We process: 8912556 pps
We process: 8941682 pps
We process: 8883430 pps
We process: 8825178 pps


perf top:
Samples: 224K of event 'cpu-clock', Event count (approx.): 42501395417
  21.79%  [ixgbe]              [k] ixgbe_clean_rx_irq
   9.96%  [kernel]             [k] tpacket_rcv
   6.58%  [kernel]             [k] pvclock_clocksource_read
   5.88%  [kernel]             [k] __netif_receive_skb_core
   4.99%  [kernel]             [k] memcpy
   4.91%  [kernel]             [k] dev_gro_receive
   4.55%  [kernel]             [k] build_skb
   3.10%  [kernel]             [k] kmem_cache_alloc
   3.09%  [kernel]             [k] kmem_cache_free
   2.63%  [kernel]             [k] prb_fill_curr_block.isra.57


К слову, справедливости ради обновление на ядро 4.0.0 ветки особенного ускорения не дало. Скорость держалась в тех же пределах. Но нагрузка на ядра упала значительно!
1  [|||||||||||||||||||||||||||||||||||||                       55.1%]     
2  [|||||||||||||||||||||||||||||||||||                         52.5%]     
3  [||||||||||||||||||||||||||||||||||||||||||                  62.5%]     
4  [||||||||||||||||||||||||||||||||||||||||||                  62.5%]     
5  [|||||||||||||||||||||||||||||||||||||||                     57.7%]
6  [||||||||||||||||||||||||||||||||                            47.7%]
7  [|||||||||||||||||||||||||||||||||||||||                     55.9%]
8  [|||||||||||||||||||||||||||||||||||||||||                   61.4%]


В заключение хотелось бы добавить, что Linux является просто потрясающей платформой для анализа трафика даже в окружении, где нельзя собрать какой-либо специализированный модуль ядра. Это очень и очень радует. Есть надежда, что в уже ближайших версиях ядра можно будет обрабатывать 10GE на полноценной wire-speed в 14.6 миллионов/пакетов секунды используя процессор на 1800 мегагерц :)

Рекомендуемые к прочтению материалы:
www.kernel.org/doc/Documentation/networking/packet_mmap.txt
man7.org/linux/man-pages/man7/packet.7.html
Проголосовать:
+111
Сохранить: