Nginx и HAProxy имеют опции для балансировки нагрузки и умеют организовывать отказоустойчивость. Т.е при выходе одного из трех бакендов он просто перестанет посылать запросы к нему и распределит нагрузку между двумя оставшимися. Примеры в этой статье будут приведены для nginx, но теория является общей для всех балансировщиков HTTP.

upstream backends_poll {
   server 10.31.1.17 weight=100 max_fails=3 fail_timeout=10 slow_start=75 ;
   server 10.31.1.18 weight=100;
   server 10.31.1.19 weight=100;

   least_conn;
}

server {
   ...
   location / {
      proxy_connect_timeout 3s;
      proxy_next_upstream error timeout invalid_header http_502;
      proxy_next_upstream_timeout 3s;
      proxy_next_upstream_tries 1;
      ...
   }
  
}

Это отлично работает, если на том конце сервер включен и доступен по сети, но сервис "упал". Nginx быстро получит через ICMP ответ "connection refused" и перекинет запрос на другой бакенд без видимой для пользователя ошибки.

Но что произойдет при аппаратном выключении сервера 10.32.1.19, если адрес сервера с nginx - 10.32.1.4/24, т.е. находится в том же сегменте сети, что и бакенд?

Сперва nginx будет пытаться соединиться до наступления proxy_connect_timeout, а потом перекинет запрос на другой бакенд. Запрос, конечно, не выдаст пользователю никакой ошибки, но работать он будет медленно: proxy_connect_timeout + время обработки, например 3+0.009 = 3.009s. Три секунды для отдачи html-станички - это величина неприемлемая и мешающая пользователям. Это проблема номер раз.

Она актуальна как при авариях до "выключения железки", так и при сетевых проблемах, и в ряде случаев при деплое с перезапуском виртуальных машин (есть и такие сервисы, обычно это делается в реалиях суровой ИБ).

Проблема реальна, т.к. при трех бакендах страдает треть соединений (грубо), а, например, при одновременной обработке хотя бы трех тысяч запросов - это уже тысяча запросов пользователей/поисковиков/api-клиентов тормозит на одну секунду. Мобильные клиенты вовсе могут не дождаться долгого ответа из-за непостоянства в 3G/4G передаче данных.

Nginx умеет запоминать бакенд, выдавший ошибку и потом пробно отправляет на него запрос, как в нашем примере на одну минуту. За минуту - из ARP кеша выпала запись о MAC-адресе недоступного сервера и вся история повторяется, но еще и с созданием дополнительного трафика во внутреней сети. Оно будет повторяться периодически, пока сервер не вернется в сеть. Это проблема номер два.

Что меняет маршрутизатор?

Маршрутизатор имеет на себе ARP-таблицу и берет на себя функцию возвращать ICMP-ответы до тех пор, пока сервер-получатель не вернется в сеть, а грамотно настроеннный маршрутизатор сразу даст ICMP-ответ в сторону nginx о том, что host unreachable. Для nginx - ICMP ответ host unreachable имеет такой же эфект, как и connection refused. Выдать ответ маршрутизатор может очень быстро - гораздо быстрее трех секунд. Это снижает время переброса запроса на другой бакенд с величины proxy_connect_timeout до времени обработки ICMP-ответа.

Маршрутизатор может отдавать этот ответ до тех пор, пока сервер не вернеться в сеть и не начнет отзыватся на ARP. Если все строить на уровне l2/l3, как в этом описании, то придеться снизить таймаут ARP-таблицы маршрутизатора и искуственно поднять объем трафика, хоть и совсем не намного - 1 пакет на 1 сервер за время таймаута. Т.е. если у вас стоит таймаут ARP-таблицы в 1 секунду, а в сегменте 8 серверов, то это будет 8 запросов и 8 ответов за секунду. Всего arp-16 пакетов не создадут проблем заполнения канала. Очень хорошо, что маршрутизатор запрашивает arpу 1 раз, а не на каждый пакет.

Такая схема, конечно же, имеет минусы, потому что нагружает CPU-маршрутизатора постоянными действиями с ARP-таблицей.

В некоторых сервисах мы применяем /32 маршрутизацию и каналы точка-точка до бакенд-серверов. Это позволяет на уровне L3 (используя OSPF), искуственно выводить бакенды из работы и включать их назад без reload-а nginx-ов. Последнее важно в проектах с большим числом keep-alive соединений или websocket подключений - в них reload nginx-а приведет к тому, что новые соединения будут обрабатываться новыми процессами, а старые процессы не завершатся до тех пор, пока не завершатся WS:// или keep-alive соединения.

В итоге

Обе проблемы (и начальный тормоз части запросов и периодические просадки при попытках подключится к упавшему backend-у) решаемы и легче всего они решаются на уровне построения сети Вашего Production. Особенно важно то, что таким нехитрым разделением на подсети - балансировщиков и бакендов - поднимается качество измеримое в секундах ответа в моменты аварий. Падения одного бакенда становятся не так заметны пользователю, а при /32 маршрутизации к бакендам и вовсе не привлекают внимания пользователей к разовой задержке в ответе.

Дополнительные плюсы

1) Очевидно, что подсеть с frontend-серверами (балансировщиками) пропускает через себя гораздо больше, чем сеть с backend-серверами. Это объясняется тем, что frontend отдают еще и статику, а backend работают только с динамикой (которой по объемам трафика в большей части проектов меньше). Разделив одну сеть на две подсети, можно четко контролировать узкие места и управлять каналами и пропускной способностью, исходя из задачи - объем трафика vs горячая замена backend.

2) Так же есть отдельная задача в сети backend-серверов - работа с серверами баз данных (СУБД). Сеть до СУБД, в отличие от сети, по которой проходит http/fastcgi трафик, должна соответствовать горазо более высоким требованиям к Latency и потерям пакетов (хорошая показательная величина - "количество потерянных пакетов за год"). В ответственом проекте связь между СУБД и коммутатором должна резервироваться на двух уровнях: L2 и L7. На уровне L2 это может быть самый обычный Etherchannel, главное, чтобы он был корректно настроен, а на уровне L7 это может быть балансировщик нагрузки между базами данных (pgpool для postgresql, mongos для mongodb, и т.д.).

Разделение сетей frontend (nginx) и backend позволяет решить задачу качества сети между СУБД и Backend-серверами гораздо легче и при этом дешевле, что немаловажно при закупке оборудования.