При разработке высоконагруженых сетевых приложений возникает необходимость в балансировке нагрузки.
Популярным инструментом L7 балансировки является Nginx. Он позволяет кешировать ответы, выбирать различные стратегии и даже скриптить на LUA.
Несмотря на все прелести Nginx, если:
- Не нужно работать с HTTP(s).
- Нужно выжать из сети максимум.
- Нет необходимости что либо кешировать - за балансером чистые API - сервера с динамикой.
Может возникнуть вопрос: а зачем нужен Nginx? Зачем тратить ресурсы на балансировку на L7, не проще ли просто пробросить SYN-пакет? (L4 Direct Routing).
Layer 4 balancing или как балансировали в древности
Популярным инструментом проброски пакетов был IPVS. Он выполнял задачи балансировки через тоннель и Direct Routing.
В первом случае, для каждого соединения устанавливался TCP-канал и пакет от пользователя шел на балансер, потом на миньон, а потом в обратном порядке.
В этой схеме видна главная проблема: в обратную сторону данные идут вначале на балансер, а потом к пользователю (Nginx работает точно так же). Выполняется ненужная работа, учитывая тот факт, что к пользователю, обычно, идет больше данных, то такое поведение приводит к некоторой потере производительности.
Такого недостатка лишен (но наделен новыми) метод балансировки под названием Direct Routing. Схематично он выглядит следующим образом:
В случае с Direct Routing обратные пакеты идут напрямую к пользователю, минуя балансер. Очевидно, что экономятся как ресурсы балансера, так и сетевые. Под экономией сетевых ресурсов, подразумевается не столько экономия трафика, ведь обычная практика - соединить сервера в отдельную сетку и не аккаунтить трафик, а тот факт, что даже переброска через балансер - это потеря миллисекунд.
Данный метод накладывает определенные ограничения:
- Датацентр, где расположена инфраструктура должен позволять спуфить локальные адреса. В схеме выше, каждый миньон должен отправлять обратно пакеты от имени IP 10.10.0.1, который закреплен за балансером.
- Балансер ничего не знает о состоянии миньонов. Следовательно, стратегии Least Conn и Least Time не реализуемы «из коробки». В одной из последующих статей я попробую реализовать их и показать результат.
Here Comes NFTables
Несколько лет назад, в Linux начали активно продвигать NFTables, как замену IPTables, ArpTables, EBTables и всех остальных [a-z]{1,}Tables. В момент, когда у нас в Adramе, возникла необходимость выжать из сети каждую миллисекунду ответа, я решил вытащить шашку и поискать — а может быть, ipTables научился делать iphash форвардинг и на нем можно накостылить быструю балансировку. Тут я и наткнулся на nftables, который умеет и не только это, а iptables всё еще не умеет даже этого.
После нескольких дней разбирательств, я наконец-то смог завести в тестовой лаборатории Direct Routing и Channel Routing через NFTables а так же пробенчить их в сравнении с nginx.
Итак, тестовая лаборатория. Имеем 5 машин:
- nft-router - роутер, выполняет задачу связи клиента и подсети AppServer. На нем 2 сетевых карты: 192.168.56.254 - смотрит на сеть аппсервера, 192.168.97.254 - смотрит на клиентов. Включен ip_forward и прописаны все маршруты.
- nft-client: клиент, с которого будет гоняться ab, ip 192.168.97.2
- nft-balancer: балансер. Имеет два IP: 192.168.56.4, к которому обращаются клиенты и 192.168.13.1, из подсетки миньонов.
- nft-minion-a и nft-minion-b: миньоны ипы: 192.168.56.2, 192.168.56.3 и 192.168.13.2 и 192.168.13.3 (я пробовал и через одну сеть и через разные балансировать). В тестах остановился на том, что миньоны имеют «внешние» ипы — в подсети 192.168.56.0/24
У всех интерфейсов MTU 1500.
Direct Routing
Настройки NFTables на балансере:
table ip raw {
chain input {
type filter hook prerouting priority -300; policy accept;
tcp dport http ip daddr set jhash tcp sport mod 2 map { 0: 192.168.56.2, 1: 192.168.56.3 }
}
}
Создается raw цепочка, на хуке прероутинг, с приоритетом -300.
Если приходит пакет с целевым адресом http, то в зависимости от исходного порта (сделано для тестирования с одной машины, в реальности нужен ip saddr), выбирается либо 56.2 либо 56.3 и устанавливается как целевой адрес в пакете, а потом отправляется далее по маршрутам. Грубо говоря для четных портов 56.2, для нечетных, соответственно, 56.3 (на самом деле нет, ибо для четных/нечетных хешей, но проще понимать именно так). После установки целевого IP, пакет уходит обратно в сеть. Никакого NAT не происходит, на миньоны пакет приходит с исходным IP клиента, а не балансера, что важно для Direct Routing.
Настройки NFT на миньонах:
table ip raw {
chain output {
type filter hook output priority -300; policy accept;
tcp sport http ip saddr set 192.168.56.4
}
}
Cоздается raw output hook с приоритетом -300 (здесь очень важен приоритет, на более высоких нужный менглинг не будет работать для reply-пакетов).
Весь исходящий трафик с http порта подписывается 56.4 (айпи балансера) и отправляется прямиком клиенту, в обход балансера.
Чтоб проверить будет ли корректно всё работать, я завел клиента в другую сеть и пустил через маршрутизатор.
Так же я отключил arp_filter, rp_filter (чтоб работал спуфинг) и включил ip_forward как на балансере так и на роутере.
Для бенчей, в случае NFT, использован Nginx + php7.2-FPM через unix socket на каждом миньоне. На балансере не было ничего.
В случае с Nginx использован: nginx на балансере и php7.2-FPM через TCP на миньонах. В итоге, я балансировал не веб-сервера за балансером, а сразу FPM-ки (что будет более честно по отношению к nginx, и больше соответствовать реальной жизни).
Для NFT использовался только стратегия hash (в таблице — nft dr), для nginx: hash (ngx eq) и least conn (ngx lc)
Было сделано несколько тестов.
- Маленький быстрый скрипт (small).
<?php system('hostname');
- Скрипт со случайной задержкой (rand).
<?php usleep(mt_rand(100000,200000)); echo "ok";
- Скрипт с отправкой большого объема данных (size).
<?php $size=$_GET['size']; $file='/tmp/'.$size; if (!file_exists($file)) { $dummy=""; exec ("dd if=/dev/urandom of=$file bs=$size count=1 2>&1",$dummy); } fpassthru (fopen($file,'rb'));
Были использованы следующие размеры:
512,1440,1460,1480,1500,2048,65535,655350 байт.
Перед тестами, я прогрел файлики со статикой, на каждом миньоне.
Тестировал ab, три раза каждый тест:
#!/bin/bash
function do_test()
{
rep=$3
for i in $(seq $rep)
do
echo "testing $2 # $i"
echo "$2 pass $i" >> $2
ab $1 >> $2
echo "--------------------------" >> $2
done
}
do_test " -n 5000 -c 100 http://192.168.56.4:80/rand.php" "ngx_eq_test_rand" 3
do_test " -n 10000 -c 100 http://192.168.56.4:80/" "ngx_eq_test_small" 3
size=512
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
size=1440
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
size=1460
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
size=1480
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
size=1500
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
size=2048
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
size=65535
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
size=655350
do_test " -n 10000 -c 100 http://192.168.56.4:80/size.php?size=$size" "ngx_eq_test_size_$size" 3
Изначально, планировал привести время теста, миллисекунды и остальное, в итоге остановился на RPS — они репрезентативны и коррелируют с временными показателями.
Получили следующие результаты:
Тест Size — колонки — размер отдаваемых данных.
Как видно, nft direct routing выигрывает с огромным отрывом.
Я рассчитывал на несколько другие результаты, связанные с размером ethernet фрейма, но корреляции не обнаружилось. Возможно, 512 body не влазит в 1500 MTU, хотя, сомневаюсь, тест small — будет показательным.
Заметил, что на больших объемах (650к) nginx уменьшает отрыв. Возможно, это как-то связано с буферами и TCP Windows size.
Результат rand теста. Показывает как справляется least conn в условиях разной скорости выполнения скриптов на разных миньонах.
Удивительно, но nginx hash отработал быстрее, нежели least conn, и только в финальном проходе least conn немного вырвался вперед, что не претендует на статистическую значимость.
Цифры проходов сильно отличаются за счет того, что уходит сразу 100 потоков, а FPM-ок со старта грузится около 10. К третьему проходу они успели нафоркаться — что показывает применимость стратегий при берстах.
NFT ожидаемо проиграл этот тест. Nginx хорошо оптимизирует взаимодействие с FPMами в таких ситуациях.
small test
nft незначительно выигрывает по RPS, least conn опять в аутсайдерах.
Кстати, в этом тесте видно, что выдается 400-500RPS, хотя, на тесте с отправкой 512 байт было за 1500 — похоже, system сжирает эту тысячу.
Выводы
NFT хорошо себя показал в ситуации оптимизации равномерных нагрузок: когда отдается много данных, а время работы приложения детерминировано и ресурсов кластера хватает на отработку входящего потока без ухода в штопор.
В ситуации, когда нагрузка по каждому запросу хаотична и невозможно равномерно сбалансировать нагрузку серверов примитивным остатком от деления хеша, то NFT будет проигрывать.
Автор: AlexKMK