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-серверами гораздо легче и при этом дешевле, что немаловажно при закупке оборудования.