Очень часто Nginx используют для распределения трафика между несколькими экземплярами серверов приложений. Эти серверы иногда сбоят, а если их много, то сбоят постоянно. Ведь одни выключены для обслуживания, вторые перегружены запросами, третьи просто на минуточку перезапускаются для обновления и вот-вот вернутся. Ну, и сеть, конечно, не безупречна, наверняка дело в ней, всегда дело в ней. Одна из задач Nginx — скрыть за собой весь этот хаос и без ошибок показывать пользователю его любимых котиков и заодно страничку интернет-магазина.

Но ведь прокси-сервер не знает, что происходит вокруг. Как же быть? Частично помогают повторные запросы. Но они не всегда безопасны, довольно грубо конфигурируются и очень легко приводят к перегрузке всей системы. Если бэкенд сбоит в течение какого-то времени, его нужно как-то автоматически отключать, а не тратить время на безуспешные попытки.

В бесплатной версии Nginx наблюдает за ошибками при работе с бэкенд-серверами и при их появлении отстреливает сервер на несколько секунд от нагрузки. Такая схема неплохо работает, если на другой стороне сервис просто не слушает порт, и прокси-сервер быстро получает отказ в подключении. Но вот если удалённый сервер выключен, или ужасная сеть теряет ваши пакеты — всё становится не так радужно.

Коммерческая версия Nginx предлагает решение в виде проверок работоспособности из модуля ngx_http_upstream_hc_module: периодически запрашивается какой-то URL (или просто пытается открыть порт), и, если тот или иной бэкенд не отвечает, его временно исключают из рабочего списка.

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

Представьте, что у вас есть приложение, которое легко может устроить out-of-memory event, если к нему подключится много клиентов. Вы знаете его пределы и, например, не хотите пускать больше ста пользователей за раз. Если нет возможности организовать очередь запросов, то даже при небольших всплесках активности пользователей не избежать 502-х ошибок в сторону клиента или даже OOM на серверах с приложением.

Третье существенное ограничение бесплатной версии Nginx — отсутствие встроенного способа получения вменяемой статистики по распределению трафика по бэкендам.

Что же делать, если коммерческая подписка по каким-то причинам не подходит, а в остальном вас Nginx полностью устраивает?

Несколько лет назад я работал на одну крупную российскую компанию и стоял перед этим вопросом. Дополнительно меня тяготило, что под Nginx у нас были существующие и довольно сложные конфигурации сайтов, бездумно трогать которые было совершенно небезопасно. Тогда в мою голову пришло решение, о котором хочу сегодня рассказать, — разделить задачи конфигурации бизнесовой логики сайта с его десятками locations, headers и прочих радостей и задачу управления распределением нагрузки между серверами. С первой задачей Nginx справляется на отлично, а вот второе прекрасно умеет решать очень известный на рынке продукт — Haproxy.

Здравствуй, старый друг

Что же умеет Haproxy в бесплатной своей версии из того, что нам пригодится:

  • Низкое потребление памяти и CPU, что нам очень даже нужно, ведь Nginx мы договорились оставить.

  • Tcp-streams, HTTP, HTTP/2 (в том числе и в сторону бэкендов) и скоро будет HTTP/3 (QUIC).

  • Распределение трафика между сотнями серверов с десятком алгоритмов балансировки и гибкой логикой выбора набора используемых бэкендов.

  • Замечательные health-check’и из коробки, в том числе с понижением нагрузки на сервера при их перегрузке.

  • Лимиты на частоту и количество соединений, а также очередь входящих запросов.

  • Возможность динамически формировать список серверов, управлять ими через unix domain socket, собирать оттуда счётчики, если нужно.

  • Умилительная, но очень-очень полезная html-страничка статистики, которая вызывает ностальгические воспоминания своим почти неизменным за последние 20 лет дизайном, но скрывает могучие возможности по управлению балансировкой.

Почему не Envoy?

Очень субъективный ответ: Envoy прекрасен, но чуть более требователен к ресурсам, а они при объединении двух компонентов становятся очень важны. Как мне кажется, идеальное место применения Envoy — динамически настраиваемый sidecar proxy в облачном контейнере, что тоже интересно, но всё-таки вне темы этой статьи. Повторюсь: не претендую на истину в последней инстанции и буду рад узнать что-нибудь новое в комментариях.

Собираем пазл

При наличии незагруженных ядер и достаточного количества памяти Nginx и Haproxy отлично уживаются на одном хосте, общаясь друг с другом через unix domain или tcp-сокеты. Что выбрать — зависит от вашего профиля нагрузки. Подробный тест будет позже, а пока я сформулирую общие правила:

  • Если ваш сайт генерирует страницы большого объёма и одновременных клиентских сессий мало (например, десятки), то лучше будут работать unix-sockets. Но так как unix-сокеты — это фактически просто файлы, придётся подумать о том, как организовать структуру хранения, и о правах. Кроме того, в unix-сокеты относительно сложно быстро заглянуть без риска пролить клиентский трафик. 

  • Если же у вас много клиентов, а ответы серверов обычно небольшие (например, десятки килобайт), то tcp покажет лучшие результаты по задержке. Он намного гибче в использовании. Вы можете запустить tcpdump на lo-интерфейсе и вживую увидеть всё, что там творится. А можете какой-нибудь умный фильтр между прокси-серверами поставить, который интересный для вас трафик куда-нибудь перенаправит, отфильтрует или модифицирует. При необходимости Haproxy можно будет перенести на другую машину без особых проблем.

В tcp-режиме стоит поговорить про адресацию. Классическое решение: Haproxy слушает какие-то порты на 127.0.0.1, куда подключается Nginx. Но здесь есть несколько подводных камней.

  1. Довольно скоро при большом пользовательском трафике и множестве haproxy-бэкендов вы столкнётесь с исчерпанием эфемерных портов. С этим можно бороться, увеличив их количество, но вы в любом случае будете ограничены теоретическим пределом около 65 000.

  2. Если у вас на Haproxy множество конфигураций, которые слушают разные порты, то всё это очень быстро превратится в полнейший бардак, где живут 127.0.0.1:8000, 127.0.0.1:8080, 127.0.0.1:8888 и ещё десяток разных 8xxx, и кто из них за что отвечает, без чтения конфигурации понять будет невозможно. Если вы разбираете ретроспективные логи, то даже при наличии репозитория с конфигурацией всё будет совсем невесело.

Для себя я нашёл вот такое решение. Для начала вспомним про ipv6: во-первых, он у вас есть, во-вторых, сетевой стек в ядре сейчас под него очень хорошо оптимизирован. В ipv6, как и в ipv4, есть диапазон так называемых частных адресов, которые вы можете использовать по своему усмотрению — это fd00::/8. Вероятнее всего, вы сможете выбрать какую-то сеть из этого диапазона для ваших внутренних нужд, требования RFC на этот счёт описаны тут. Я предположу, что это будет fd00::/64. Также я предположу, что ваш сайт доступен снаружи под ipv4 адресом 192.0.2.1 и ещё каким-то ipv6. В таком случае мы можем добавить на lo-интерфейс адрес fd00::192.0.2.1/128 (есть такая нотация у ipv6-адресов, она будет интерпретироваться как fd00::c000:201) и его же использовать в Haproxy.

Если сайт требует больше одного haproxy-бэкенда — не беда. Ipv4 адрес занимает лишь младшие 32 бита, а у нас есть ещё 2^32 вариантов: [fd00::1.192.0.2.1]:80, [fd00::2.192.0.2.1]:80, [fd00::1234:5678.192.0.2.1]:80. Никаких больше проблем с эфемерными портами, а по адресу сразу всем понятно (даже вашим парсерам логов), к какому сайту он относится. В примерах я буду придерживаться именно этого подхода.

Важное дополнение про tcp и Nginx: Nginx фактически не способен работать с бэкендами через HTTP/2 и выше (кроме gRPC-бэкендов). Также он по умолчанию общается через HTTP/1.0. У этого протокола есть две важные для нас особенности:

  • На каждый запрос обязательно открывается отдельное соединение, что очень дорого, если мы работаем поверх tcp даже на одном хосте.

  • Не предполагается использование заголовка Host, что может привести к исключительно интересным расследованиям «угадай, кто тебе ответил» в дальнейшем.

Поэтому мы сразу договоримся, что во все прокси-секции Nginx, даже при использовании unix-socket, мы будем добавлять:

location @upstream {
  proxy_http_version 1.1;
  proxy_set_header "Connection" "";
  proxy_set_header Host $http_host;
  ...
}

Для примера предположим, что у вас есть какая-то гипотетическая четырёхъядерная система, которая не бывает загружена более, чем на 200% (два ядра). Сервера приложений живут где-то ещё, а на системе крутится Nginx с примерно таким файлом конфигурации (пропускаю всё, что не относится к теме, в том числе SSL, HTTP/2 и прочее):

worker_processes auto;
http {
  upstream servers {
    server 10.0.0.1:80;
    server 10.0.0.2:80;
    server 10.0.0.3:80;
  }
  server {
    listen 192.0.2.1:80;
    location / {
      proxy_pass http://servers/;
    }
  }
}

Создаём конфигурацию Haproxy. Я убрал отсюда всё неважное для нас сейчас и  уверен, что, установив Haproxy к себе в систему, вы найдёте там готовый /etc/haproxy/haproxy.cfg, в котором для начала нужно лишь добавить или заменить секции frontend и backend.

global
  chroot /var/lib/haproxy
  user haproxy
  group haproxy
  daemon
  # Start two threads...
  nbthread 2
  # And bind them to the 2nd and the 3rd CPUs
  cpu-map 1/all 2-3

defaults
  # By default Haproxy works in TCP-mode
  mode  http
  # Log only HTTP requests
  option  dontlognull
  # It isup to you what timeouts to set
  timeout connect 5s
  timeout client  5s
  timeout server  5s

frontend fe_default
  # Fancy ipv6 loopback-address
  bind [fd00::192.0.2.1]:8000
  # By all means use servers from be_default backend
  default_backend be_default

backend be_default
  # Declaring the servers: names, addresses and ports
  server server1 10.0.0.1:80
  server server2 10.0.0.2:80
  server server3 10.0.0.3:80

Обязательно добавляем на lo-интерфейс нужный ipv6-адрес. Временно это можно сделать через sudo ip -6 address add fd00::192.0.2.1/128 dev lo, а как сохранить его навсегда — зависит от вашего дистрибутива. Перезагружаем конфигурацию Haproxy (обычно sudo systemctl reload haproxy) и возвращаемся к Nginx:

# We've bound Haproxy to the cores #2 and #3
# So, Nginx will use #0 and #1
worker_processes 2;
worker_cpu_affinity 0001 0010;

http {
  upstream haproxy {
    # We need a zone for the workers to share the upstream state
    zone haproxy 64K;
    # Haproxy's local address
    server [fc00::192.0.2.1]:8000 max_fails=0;
    # Always have hundred connections ready
    keepalive 100;
  }

  server {
    listen 192.0.2.1:80;

    location / {
      proxy_pass http://haproxy/;
      # Proxy buffering is better be turned on,
      # unless you have huge responses
      proxy_buffering on;
      # Turning on keepalive
      proxy_http_version 1.1;
      proxy_set_header "Connection" "";
      # Always add the port if it differs from 80/443
      proxy_set_header Host $http_host:$server_port;
      # Not necessarily, but ease debugging a lot
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Real-IP $remote_addr;
    }
  }
}

Обновляем конфигурацию Nginx: sudo systemctl reload nginx. При определённой доле везения на этом всё должно заработать.

Крутим ручки настройки

Итак, у нас есть рабочий бутерброд из двух обратных прокси. Но в таком виде он ничем не лучше, чем просто Nginx. Даже заметно хуже, так как обрабатывает трафик дважды. Давайте начнём с чего-то простого и полезного.

Мы хотим знать, живы ли сервера приложений на серверах. Для этого мы можем поправить бэкенд-секцию в Haproxy вот так:

backend be_default
  default-server check
  server server1 10.0.0.1:80
  server server2 10.0.0.2:80
  server server3 10.0.0.3:80 check-ssl port 443 verify required

Опция default-server применяется по умолчанию для всех серверов секции. Сервер вправе переопределить что-то для себя. В данном случае мы просим Haproxy каждые две секунды пытаться открыть tcp-соединение на адрес и 80-й порт каждого из серверов. Кроме последнего (server3), там он пытается открыть TLS-соединение на порт 443 того же сервера, проверяя при этом корректность сертификата кроме поля subject. Если трижды подряд не получится это сделать, сервер будет временно отключён от нагрузки. Но попытки при этом не прекратятся, но когда две подряд увенчаются успехом, нагрузка будет возвращена. Все эти тайминги и количество попыток конфигурируется опциями inter, fall и rise. Порт и даже адрес хоста, куда отправляются проверки, можно задать опциями addr и port.

Если мы хотим убедиться, что сервер приложений гарантированно работает, можно использовать проверку HTTP-запросом. Я покажу пример с использованием GET-запроса, который должен вернуть определённую строку (healthy), но часто просто используется HEAD-запрос на URL "/". Проверок можно указать несколько, например, на разные порты, тогда все они должны быть успешны.

backend be_default
  option httpchk
  http-check send meth GET uri /health ver HTTP/1.1 hdr host localhost
  http-check expect rstring healthy
  default-server check
  server server1 10.0.0.1:80
  server server2 10.0.0.2:80
  server server3 10.0.0.3:80

А теперь самое интересное. Дополнительно к обычным проверкам доступности Haproxy предлагает Agent check. Подразумевается, что где-то есть приложение-агент: одно на всех или индивидуальное на каждом узле. Это приложение знает о состоянии бэкендов и может регулировать нагрузку на них. Оно должно слушать tcp-порт и при подключении отвечать одной строкой со статусом бэкенда. Haproxy подключается к приложению, может опционально передать туда какую-то строку, например, имя проверяемого сервера, вычитывает и разбирает ответ, меняет статус или вес бэкенда. Звучит сложнее, чем это есть на самом деле. Давайте разберём пример:

backend be_default
  option httpchk
  http-check send meth GET uri /health ver HTTP/1.1 hdr host localhost
  http-check expect rstring healthy
  default-server check weight 100 agent-check agent-addr 10.0.0.4 agent-port 10000 agent-inter 5s
  server server1 10.0.0.1:80 agent-send “server1\n“
  server server2 10.0.0.2:80 agent-send “server2\n“
  server server3 10.0.0.3:80 agent-send “server3\n“

В общем для всех указанных серверов параметре default-server появились две новые группы опций:

  • Вес weight: запросы от клиентов распределяются среди серверов-бэкендов согласно запрошенному типу балансировки (параметр balance). По умолчанию это roundrobin, то есть честное распределение среди всех живых бэкендов пропорционально их весу. Иными словами, если у одного сервера вес 100, а у другого — 2, то второй будет получать в 50 раз меньше трафика, чем первый. По умолчанию вес равен 1, мы меняем его на 100.

  • Настройки agent check: адрес, порт и интервал — как часто ходить по указанным реквизитам.

Каждые 5 секунд для каждого сервера Haproxy открывает порт 10.0.0.4:10000 и пишет туда заданную строку, например, server1 + перевод строки. Сервис с той стороны отвечает одной строчкой, которая заканчивается переводом строки. В этой строчке может быть несколько токенов, разделённых пробелом, табуляцией или запятыми. Подробнее, что там может быть, описано в документации. После этого tcp-сокет закрывается. Приведу несколько примеров ответов агента:

  • drain — убрать нагрузку сервера и перевести его в MAINT-статус;

  • ready — вернуть сервер к READY-статусу и подать нагрузку;

  • 50% maxconn:30 — вес сервера должен быть установлен в 50% от заданного при конфигурации, а максимальное количество одновременных клиентских запросов, которые он может обслужить, не может быть больше 30. Именно ради этих 50% мы и устанавливали вес, так как 50% от одного — это 0, а на сервера с нулевым весом нагрузка не подаётся (по сути, это DRAIN-статус). Будьте аккуратны, играя с процентами, и не делайте вес сервера меньше 100.

Пример программы на Go, которая меняет вес сервера в зависимости от загруженности его CPU, есть тут.

Надеюсь, что вы почувствовали всю мощь, которую открывает перед вами agent check.

Наблюдаем и управляем

Итак, у нас всё как-то работает, но пока не видно, как именно. У Haproxy накопилось множество различных инструментов интроспекции, но я хочу поговорить про следующие три:

  • веб-страничка статистики;

  • встроенный Prometeus exporter;

  • CLI.

Первые два варианта мы можем элегантно сконфигурировать вместе вот в такой бэкенд-секции:

frontend stats
  bind 127.0.0.1:8080
  mode http
  http-request use-service prometheus-exporter if { path /metrics }
  stats enable
  stats uri /stats
  stats refresh 30s

Убедитесь, что поддержка Prometeus скомпилирована в вашей сборке Haproxy: haproxy -vv | grep USE_PROMEX=1.

Если всё в порядке, применяем новые настройки через sudo systemctl reload haproxy и идём браузером на адрес сервера http://ваш-сервер:8080/stats.

Можно ещё добавить SSL, ACL, аутентификацию и повесить на внешний интерфейс для удобства. Но это не самое интересное. Самое интересное — это режим администратора (stats admin if <cond>). Он позволит прямо из браузера управлять бэкендами — как отдельными серверами, так и целыми секциями: включать и отключать проверки, снимать нагрузку и творить прочую магию.

С CLI тоже всё довольно просто. В секции global у вас должен быть сконфигурирован unix-socket для доступа к статистике и не только, а также указан таймаут — иначе он будет лишь 10 секунд. Обратите внимание на уровень доступа: если вы хотите управлять бэкендами, вам может понадобиться level admin. В других случаях достаточно level operator, который позволяет получать информацию о происходящем в системе и сбрасывать счётчики.

global
  stats socket /run/haproxy/admin.sock mode 600 level admin
  stats socket /run/haproxy/stats.sock mode 660 level operator
  stats timeout 1m

Несколько примеров того, как с этим сокетом работать:

  • Получить массу информации о статусе, счётчиках, состоянии секций и серверов:
    echo "show info; show stat; show table" | socat /run/haproxy/stats.sock stdio.

  • Сбросить счётчики всех секций и серверов:
    echo "clear counters" | socat /run/haproxy/stats.sock stdio.

  • Если вы сконфигурировали socket-файл с параметром level admin, вы сможете также управлять серверами и секциями. Например, вот так мы отключаем один из ранее сконфигурированных серверов и добавляем новый. Учтите только, что динамически добавленный сервер не переживёт перезагрузки конфигурации haproxy и исчезнет. В остальном — всё будет прекрасно работать:
    echo "disable server be_default/server3; add server be_default/server4 10.0.0.5:80 enabled check; enable health be_default/server4" | socat /run/haproxy/admin.sock stdio.

Возможностей очень много, и я отошлю вас к документации, чтобы поискать там ещё что-нибудь полезное. Буду рад, если в комментариях вы расскажете, что удалось найти.

Измеряем производительность

Самая грустная глава — и не только потому, что последняя. Дело в том, что каждый слой прокси-серверов замедляет ответы бэкендов. Особенно это заметно на маленьких и быстрых ответах.

Также Haproxy будет кушать CPU и память: немного, но данные нужно где-то хранить, а HTTP-протокол придётся ещё раз декодировать и обратно закодировать. Ещё есть всеми любимые переходы из пространства пользователя в пространство ядра и обратно для передачи данных даже через loopback-интерфейс.

Именно поэтому в разделе о конфигурации я говорил о многоядерных системах, загруженных условно наполовину. Если вы возьмёте одноядерную машину и просто запустите там Haproxy в дополнение к Nginx, то тайминги вашего сервиса увеличатся почти в два раза (для быстрых страниц и под 100%-й нагрузкой). Иногда, как ни странно, это даже помогает, сглаживая пики пользовательских запросов.

Хорошая новость: если свободные CPU-ресурсы и память у вас есть, то большинство клиентов этих нескольких добавленных миллисекунд не заметят никогда, потому что:

  • Интернет огромный в плане расстояний, и редко кто-то будет жить вплотную к вашему дата-центру (ну разве что админы, которые там и живут).

  • Клиенты очень часто сидят на Wi-Fi или сотовой сети, а там такие задержки и такой джиттер, что... ну, вы понимаете.

  • Маленькая статика и котики у вас наверняка на CDN (на CDN же, правда?), а локальный NodeJS или Tomcat всё равно отвечает за сотни миллисекунд (иначе несерьёзно как-то).

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

Описание стенда
  • Три самых дешёвых виртуальных машины в одном из дата-центров облачного провайдера. На каждой из них один неторопливый Intel CPU и гигабайт оперативной памяти. Плюс одна чуть подороже с Intel Premium CPU (что бы это ни значило, но он реально быстрее раза в два) и тоже гигабайтом RAM под прокси-сервер. Диски заявлены, как SSD.

  • Какая-то виртуальная внутренняя сеть на 2,5 Гб, почему-то работающая только с ipv4.

  • Ubuntu 22.04, штатный Nginx=1.18.0-6ubuntu14.4 и нештатный, но свежий Haproxy=2.8.3-1ppa1~jammy.

  • Конфигурационные файлы Nginx — тут, а Haproxy — тут.

  • Для тестирования использовался wrk=4.1.0-3build1

  • Трафик и нагрузку на память мы создавали вот этой программой с гордым названием Generator, которая довольно быстро работает, но легко доводит несчастную виртуалку до OOM при нескольких сотнях одновременных клиентов.

  • На виртуальной машине, которая будет играть роль прокси, к systemd-конфам Nginx, Haproxy и Generator был добавлен параметр CPUQuota=33%, чтобы минимизировать конкуренцию за CPU. Двух- и более ядерные виртуальные машины оказались как-то неприлично дороги.

  • Из sysctl-тюнинга ничего не делал, кроме увеличения net.core.netdev_max_backlog, net.core.somaxconn и net.ipv4.tcp_max_syn_backlogдо 1024.

  • И ещё я не использовал TLS и HTTP/2, так как речь совсем не про них.

Всё! Сцена нарисована, артисты расставлены, давайте измерять.

Акт 1. Генератор трафика на прокси-сервере

Все три участника: Nginx, Haproxy и Generator — запущены на одном сервере. Ядро одно, но через cgroups каждому выделено лишь 33%. Тестирование проводится с соседнего сервера.

Цели тестирования:

  • Показать вносимые проксированием задержки, минимизируя влияние сети на трафик между прокси и сервером приложений.

  • Определить различия поведения unix sockets и tcp sockets для обмена данными между Nginx и Haproxy.

Для тестирования мы будем запрашивать Generator выдать нам ответы размером 1, 30 и 100 КБ (без учёта заголовков). Wrk будет последовательно загружать систему в течение 30 секунд сначала 1 подключением, затем 10, 100, 250, 500, 750 и, наконец, 1 000.

Пример командной строки запуска wrk:

wrk -t4 -c500 -d30s http://proxy/generate/102400 --latency --timeout 30s

Оценивать будем медиану времени ответа:

Рисунок 1. Сравнение таймингов на ответе в 1 КБ
Рисунок 1. Сравнение таймингов на ответе в 1 КБ
Рисунок 2. Сравнение таймингов на ответе в 30 КБ
Рисунок 2. Сравнение таймингов на ответе в 30 КБ
Рисунок 3. Сравнение таймингов на ответе в 100 КБ
Рисунок 3. Сравнение таймингов на ответе в 100 КБ

Что мы видим на этих графиках:

  1. Проксирование — это всегда некоторое ухудшение таймингов. Это очевидно, но сейчас вы видите насколько — сотни микросекунд на единичных запросах. При большом количестве пользователей паразитная задержка кратно растёт, так как ресурсы CPU остались всё те же, и каждый запрос разбивается на большее количество тайм-слайсов, растянутых во времени.

  2. Unix-сокеты — штука очень интересная. На потоках малого размера они только всё портят, но если нужно передавать большие файлы, то отсутствие необходимости дополнительного перехода kernel/user space становится их убойным преимуществом. Посмотрите на стремительный рост таймингов в tcp-варианте подключения на рисунке 3.

  3. Непонятно, каким образом, но на больших файлах и множестве клиентов Nginx показал лучшее время ответа, чем прямое подключение к приложению генератору трафика. Я повторял тест — всё так же. Если у кого-то есть какие-то мысли на этот счёт, то поделитесь, пожалуйста, в комментариях.

Акт 2. Отпавший бэкенд

Сейчас мы будем имитировать падение одного из двух серверов под балансерами. Просто останавливать сервис — неинтересно, так как прокси получают RST-ответ и быстро соображают, что нужно переотправить запрос на другой сервер. Мы сделаем хитрее: будем тихо отфильтровывать все приходящие от прокси-сервера на один из серверов-бэкендов пакеты: iptables -I INPUT -s proxy-server-ip -j DROP.

Но есть и ещё кое-что. Параметры запросов подобраны так, что один сервер-бэкенд при запросе ответов размера в 100 КБ и выше может выдержать примерно 100 одновременных клиентских сессий. После этого память на сервере заканчивается, процесс умирает и перезапускается. Когда живы оба бэкенда, запросы равномерно распределяются между ними, и всё работает относительно хорошо. Но если вся нагрузка ляжет на одного... Посмотрим, что будет.

Цель тестирования — определить, получаем ли мы какую-то пользу от наличия второго реверс-прокси в случае непредвиденного отключения сервера-бэкенда.

Замеряем производительность решения и наличие ошибок по методике из предыдущего акта. Здесь возможны следующие варианты:

  • Оба бэкенда живы.

  • Один бэкенд недоступен — решение, построенное только на Nginx с настройками по умолчанию.

  • Аналогичный вариант, но с ограничением max_conns=100 для бэкендов.

  • Один бэкенд недоступен, но вторым слоем дежурит Haproxy, где установлено ограничение в 100 одновременных клиентских сессий на один сервер. На Nginx ограничений нет.

На графиках я не показываю медиану ответа, если процент ошибок за сессию превышает 20%, так как wrk считает задержку для всех кодов ответа в одной куче.

Рис 4. Сравнение медианных таймингов при одном недоступном бекенде и ответе в 1Кб
Рисунок 4. Сравнение медианных таймингов при одном недоступном бэкенде и ответе в 1 КБ
Рисунок 5. Ошибки при одном недоступном бэкенде и ответе в 1 КБ
Рисунок 5. Ошибки при одном недоступном бэкенде и ответе в 1 КБ

На маленьких ответах наш бэкенд способен справиться и с тысячей одновременных запросов в одиночку. Nginx при этом показывает лишь небольшое проседание по таймингам, заметное на десятке одновременных клиентов. Ожидаемые 60% потерь для Nginx с лимитом в 100 соединений на бэкенд, начиная с 250 одновременных клиентов. Комбинация с Haproxy ведёт себя стабильно и без ошибок, показывая чуть большие тайминги.

30 КБ вариант я пропущу для экономии времени. Он показал что-то промежуточное между 1 КБ и 100 КБ. Увеличиваем размер ответа сразу до 100 КБ.

Рисунок 6. Сравнение медианных таймингов при одном недоступном бэкенде и ответе в 100Кб
Рисунок 6. Сравнение медианных таймингов при одном недоступном бэкенде и ответе в 100Кб
Рисунок 7. Ошибки при одном недоступном бэкенде и ответе в 100 КБ
Рисунок 7. Ошибки при одном недоступном бэкенде и ответе в 100 КБ

Становится интереснее. Бэкендам уже не хватает памяти, чтобы обслужить 500 одновременных соединений. Все варианты, кроме гибрида с Haproxy, показывают огромное количество ошибок на тысяче одновременных клиентов. Тайминги Nginx улетают вверх. Гибрид с Haproxy стабилен, и задержки его растут линейно с количеством пользователей.

Давайте ещё увеличим размер ответа до 1 МБ.

Рисунок 8. Сравнение медианных таймингов при одном недоступном бэкенде и ответе в 1Мб
Рисунок 8. Сравнение медианных таймингов при одном недоступном бэкенде и ответе в 1Мб
Рисунок 9. Ошибки при одном недоступном бэкенде и ответе в 1Мб
Рисунок 9. Ошибки при одном недоступном бэкенде и ответе в 1Мб

Начиная с 250 одновременных клиентов, в живых остался только гибрид с Haproxy. Всё так же без ошибок, всё так же линейно растут задержки от числа клиентов.

Думаю, что победа парной работы двух реверс-прокси в данном примере очевидна.

Акт 3. Реальный пользователь

Напоследок посмотрим, видна ли разница в таймингах у пользователя, находящегося примерно в 12 миллисекундах от дата-центра, где размещён наш стенд.

Рисунок 10. Медианные тайминги ответов в 1 КБ на удалённом клиенте
Рисунок 10. Медианные тайминги ответов в 1 КБ на удалённом клиенте
Рисунок 11. Медианные тайминги ответов в 100 КБ на удалённом клиенте
Рисунок 11. Медианные тайминги ответов в 100 КБ на удалённом клиенте

Как видите, время ответа действительно немного подрастает. Но насколько это критично для ваших клиентов — решать вам.

Заключение

Ну что же, мы попробовали преодолеть ограничения одного бесплатного инструмента с помощью другого бесплатного инструмента, и всё получилось. Решение не бесплатное, но цена известна, и она в CPU, памяти и таймингах. Взамен же вы получаете большую гибкость и надёжность всей вашей системы.

Если вам интересна эта тема и вы хотите больше узнать о применении Haproxy для распределения относительно больших объёмов трафика, пишите в комментариях. Буду рад поделиться ещё чем-нибудь интересным.

Спасибо, что осилили!

Комментарии (32)


  1. hyperwolf
    16.11.2023 11:31
    +1

    А почему не попробовали tengine? Это форк nginx от alicloud, и он умеет многие фичи nginx+, в том числе мониторить живость апстримов.


    1. GreyTomcat Автор
      16.11.2023 11:31
      +3

      Это уже вопрос доверия к кодовой базе и её сопровождению. Все проблемы с тем же haproxy (там был сложный период большого рефакторинга, после которого появился рабочий nbthreads) мейнтейнеры решали в пределах недели после баг репорта. Один раз даже сообщил о баге в пятницу вечером, а в субботу уже был патч. И это без платного контракта.
      Были ли у вас прецеденты решения проблем с авторами tengine? Есть ли какая-то статистика?


      1. hyperwolf
        16.11.2023 11:31
        +1

        С 2016 года до сих пор проект жив и развивается, исходники есть на гитхабе, никто не мешает собрать самому. А так, у меня не было каких-то проблем или багов ни с nginx, ни с tengine.


    1. karabanov
      16.11.2023 11:31
      +4

      https://angie.software/ выглядит привлекательнее tengine, как мне кажется, да и API у него имеется.

      Кстати интервью с разработчиками сегодня опубликовали: "Интервью с Валентином Бартеневым: как бывшие сотрудники Nginx разрабатывают отечественный веб-сервер Angie"


  1. markowww
    16.11.2023 11:31
    +1

    Я бы такое сделал на Nginx + Consul + Consul Template. Это позволит еще и онлайн управлять списком апстримов.


    1. GreyTomcat Автор
      16.11.2023 11:31
      +2

      Мы примерно так и поступили, но со списком бекендов в haproxy. Это позволило отделить динамическое управление бекендами от конфигурации собственно сайта в Nginx.


  1. MMik
    16.11.2023 11:31
    +4

    В сети 127.0.0.1/8 около 16млн. адресов, IPv6 вам не был нужен. Адреса навешивать на lo интерфейс не обязательно, просто биндитесь к 127.0.0.2, 127.0.0.3 и пр., и заносите их в /etc/hosts, если нужно.


    1. GreyTomcat Автор
      16.11.2023 11:31
      +2

      Тут есть интересный момент. Так как на lo-интерфейсе висит адрес 127.0.0.1 (с маской /8), то при подключении к 127.0.0.2, например, будет образовано соединение между 127.0.0.1:клиентский_порт и 127.0.0.2:серверный _порт. Что все равно ограничивает доступное количество клиентских портов. С отдельным /128 адресом на lo-интерфейсе такого уже не произойдет, так как соединение будет с того же адреса.

      Ну и бардак с адресами, как я писал, в случае 127.0.0.2 и т.д. будет редкостный. Ipv6 позволяет лучше все упорядочить.


  1. ExH
    16.11.2023 11:31
    +3

    Посмотрите на angie, это форк nginx с большим количеством полезных доделок. Статистика, к примеру


    1. GreyTomcat Автор
      16.11.2023 11:31
      +2

      Чуть выше отвечал про tengine – форки интересные есть, но очень много вопросов возникает при "промышленном" использовании:
      * А если протестированная под nginx конфигурация под angie поведет себя как-то иначе?
      * А тестировали ли они свой проект под теми нагрузками, которые нас интересуют? А вытянет сто бекендов, а тысячу?
      * А как там с аудитом кода на безопасность?
      * А как быстро ответят на вопрос о проблеме и починят найденный баг?
      * А будет ли проект поддерживаться через год-другой?
      Если брать широко используемые инструменты всё становится несколько проще. Ну или уже писать своё, если есть ресурсы.


      1. VBart
        16.11.2023 11:31
        +8

        Большая часть вопросов должна отпасть, если учесть, что Angie разрабатывает та же команда, что до этого 10 лет разрабатывала nginx.


      1. ExH
        16.11.2023 11:31
        +3

        @VBart Уже ответил. Уверен что если для Вас действительно важны все эти вопросы, то парни из Angie с удовольствием продадут вам платную поддержку. Как я понимаю, весь смысл Angie в том, чтобы можно было покупать платную поддержку "nginx" в России. Плюс появление тех фич, которые F5 не хотят делать по причине конкуренции с их платными фичами. Соответственно те, кто платит за Angie, опосредованно платят за разработку открытой версии. Можете присоединиться =)


  1. ufoton
    16.11.2023 11:31
    +1

    nginx и haproxy надо местами поменять и всё будет хорошо.


    1. GreyTomcat Автор
      16.11.2023 11:31
      +1

      А что, конкретно, вы считаете улучшится?


      1. ufoton
        16.11.2023 11:31
        +1

        исчезнет двойное проксирование. Улучшится логика работы. Увеличится гибкость.


    1. magic2k
      16.11.2023 11:31
      +1

      это было бы бессмысленно. Тогда уж haproxy убрать, если в нем нет нужды.


      1. ufoton
        16.11.2023 11:31
        +1

        ты точно читал причину появления haproxy?


        1. magic2k
          16.11.2023 11:31
          +4

          прочитал еще раз, и не по диагонали. Я думал что haproxy первым стоял, а там nginx первым, еще и на том же хосте. Мне бы такое в голову не пришло, это использование haproxy не по назначению. Поддерживаю - поменять их местами, а еще лучше nginx с этого хоста убрать (или вообще его убрать, он же только проксирует?), если нагрузка такая значительная.

          PS ТЫ пиши в следующий раз нормально, чтобы было понятно что ты имеешь в виду.


          1. ufoton
            16.11.2023 11:31
            +1

            У меня сложилось впечатление, что nginx там ещё что то делает. Типа статику раздаёт. Если это не так то...


            1. GreyTomcat Автор
              16.11.2023 11:31
              +3

              Всё перепуталось :)
              * Начальный вариант: есть хост с nginx который работает с бекендами на других хостах. На Nginx довольно сложные конфиги с кучей locations, рерайтов, редиректов, зависимостей от заголовков и прочих фронтендных радостей. Там же секции upstream со всеми бекендами. Это все, конечно, можно попотеть и перенести на haproxy. Но потом это все еще и придется поддерживать. А, к сожалению, "читабельность" сложных конфигураций у haproxy значительно ниже, чем у nginx.
              * Конечный вариант: nginx с прежними сложными конфигами и теми же владельцами, но с upstream, по сути из одной строчки, смотрящей на haproxy, который живет на том же хосте, что и nginx. Конечные бекенды по прежнему удаленные и о них знает только haproxy.
              По итогу мы можем править бизнес-логику на nginx и логику распределения на haproxy. Каждый продукт занимается своим делом.


              1. ufoton
                16.11.2023 11:31
                +1

                не читабельность конфигов haproxy слишком преувеличена.
                А так вы увеличили сложность через уменьшение надёжности.


  1. BasilioCat
    16.11.2023 11:31
    +1

    Кто-нибудь уже предложил использовать kubernetes вместо RHEL 5 для комплексного решения проблем? Сервисы на ipvs явно производительнее haproxy, есть хэлсчеки и все что надо для счастья, внешний nginx на ingress controller и внутренние - с костылями дополнительной логикой.
    Angie, к слову, вынес в коммерческую версию всё ровно то, что есть в Nginx Plus


  1. vesper-bot
    16.11.2023 11:31

    А почему такой взлет ошибок на 1МБ ответе в режиме "все бэкенды доступны"? Работа без кэша, что ли?


    1. GreyTomcat Автор
      16.11.2023 11:31

      Где-то между 100 и 200 одновременными подключениями на хост при генерируемом ответе в 1мб на хосте-сервере кончается память, OOM убивает сервис и systemd его перезапускает.


  1. Pono
    16.11.2023 11:31

    Максим, а можете, пожалуйста, немножко развернуть мотивацию такого бутерброда? В предисловии к статье я вижу три пункта:

    Коммерческая версия Nginx предлагает решение в виде проверок работоспособности из модуля ngx_http_upstream_hc_module: периодически запрашивается какой-то URL (или просто пытается открыть порт), и, если тот или иной бэкенд не отвечает, его временно исключают из рабочего списка.

    tengine предоставляет модуль активных хелсчеков: https://github.com/alibaba/tengine/tree/master/modules/ngx_http_upstream_check_module

    ограничение на количество одновременных запросов к бэкенду, а точнее — помещение запросов, превышающих лимиты, в очередь для последующей обработки.

    Это решается и в опенсорсной версии в ngx_http_limit_req_module: limit_req zone=... burst=... без ключа nodelay.

    Вы знаете его пределы и, например, не хотите пускать больше ста пользователей за раз

    Если же подразумевалось, что инстансы nginx должны иметь синхронизированные счетчики - то не проще ли не решать это вовсе, а воспользоваться рейтлимитером на стороне приложения? Тем более, что известны его пределы. Причем в качестве примера приведено вообще единомоментное количество пользователей, а не rps - то есть вообще другой домен, который с ограничением rps связан слабо.

    отсутствие встроенного способа получения вменяемой статистики по распределению трафика по бэкендам

    Это же решается с https://github.com/vozlt/nginx-module-vts

    Т.е. вижу что, но не могу понять зачем


    1. karabanov
      16.11.2023 11:31
      +1

      VTS тоже не совсем настоящие метрики экспортирует, к примеру данные о запросе можно получить только после того, как он будет завершён, а сколько запросов обрабатывается в данный момент непонятно. Это почти тоже самое, что и логи парсить...

      У Angie есть модуль prometheus который позволяет экспортировать более точные метрики.


      1. Pono
        16.11.2023 11:31

        Спасибо за уточнение. В ту же копилку можно добавить и ngx_http_reqstat_module от того же tengine, да. Что я пытался сказать - что есть способы достать из nginx метрики. И чего я не могу понять из статьи - так это мотивации делать то, что описано


    1. GreyTomcat Автор
      16.11.2023 11:31
      +2

      Спасибо за интересный вопрос, на "почему не XXX" всегда сложно отвечать, так как вариантов много, они все замечательные и можно долго спорить :) Пройдемся по пунктам:

      * Проверки работоспособности бекендов. На мой взгляд, tengine, как и любой другой форк, это уже не совсем nginx. Можно доверять проекту и использовать его, но кодовая база –  иная и развиваемая в интересах одного конкретного клиента. В нашем случае – это было важно. О причинах я писал в комментариях выше (поддержка, аудит кода, совместимость, и т.д.). Впрочем, agent_check и возможности динамического изменения весов в tengine кажется все равно нет (выкручусь я). Или есть?

      * Одновременные запросы к бекенду. limit_req прекрасен, но он все таки, как вы верно заметили про RPS, а не про одновременное количество запросов, которое сервер выдержит. К примеру, если сервер отвечает за 10 мс, но умирает по памяти на 101 одновременном клиенте, то при 200 запросах в секунду мы можем как выжить, так и не выжить – как повезет. С другой стороны, если мы гарантируем, что больше 100 запросов на сервер не будут переданы еодиномоментно – 10000 RPS вполне могут быть обслужены. Всё зависит от сценария и иметь обе возможности в доступности – вдвойне замечательно.

      * "синхронизированные счетчики". Я, извините, не очен понял – это вопрос про agent_check и веса? Или про встроенные в haproxy распределенно-синхронизируемые счетчики?

      * Про VTS уже за меня ответили – haproxy все-таки тут более открыт для анализа. Да я и говорил "отсутствие встроенного способа получения вменяемой статистики", т.е. внешние существуют, но на них придется завязываться, а тот же VTS даже сейчас в версии 0.2.2, что нисколько не умаляет его заслуг, но несколько лишает уверенности.

      А теперь попробую ответить на "зачем". Главный плюс, который мы получили от такого бутерброда, если убрать в сторону тактические преймущества конкретных продуктов, – разделение зон ответственности. Мы растащили собственно работу с бекендами и бизнес-логику сайтов. В результате, все тот же nginx можно было легко заменить на любой другой подходящий прокси-сервис, если того требовала задача (и мы так делали), а доставка трафика от бекендов при этом никак не менялась. И наоборот – различные конфигурации бекендов для различных локаций никак не отражались на конфигурации nginx, что очень упрощало и делало более безопасной его настройку.


  1. ugenk
    16.11.2023 11:31
    +1

    Да, хороший бутерброд, работает. Использовали такой лет пять, сейчас перешли на кубернетсовский ингресс nginx


  1. GhOsT_MZ
    16.11.2023 11:31

    Вероятно, я не совсем корректно понял введение в статью, но тем не менее, почему nginx+haproxy? Почему бы не оставить просто haproxy, если он так же может проксировать? Также, было бы не плохо добавить в графики результаты тестов с одним лишь haproxy.


    1. GreyTomcat Автор
      16.11.2023 11:31

      Для сложной бизнес логики haproxy сложно конфигурировать и этому придется учить людей, тогда как конфиги nginx умеют делать в том, или ином виде многие. Можно разделить зоны ответственности и взять лучшее от двух продуктов.

      Вот об этом хотелось рассказать и о том, чего это будет стоить. А лобовых сравнений nginx и haproxy по производительности довольно много. Спойлер: они зависят от того, кто и как тестирует :) Но, в целом, если усреднить, результаты близки и выбирать нужно по каким-то другим факторам.


      1. ufoton
        16.11.2023 11:31

        Если бизнес логика вынесена на уровень проксирования то апач вам в руки.