И снова здравствуйте!

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



Конфигурация Haproxy


Первая сложность заключалась в том, что опция maxconn у Haproxy бывает разной в зависимости от контекста:


По привычке я настроил только первый вариант (performance tuning). Вот что говорит об этой опции документация:
Sets the maximum per-process number of concurrent connections to <number>. It
is equivalent to the command-line argument "-n". Proxies will stop accepting
connections when this limit is reached.

Казалось бы – то, что нужно. Однако, когда я наткнулся на то, что новые соединения к прокси проходят не сразу, то стал более внимательно читать документацию, и там уже нашел второй параметр (bind options):
Limits the sockets to this number of concurrent connections. Extraneous
connections will remain in the system's backlog until a connection is
released. If unspecified, the limit will be the same as the frontend's maxconn.

Так-с, идем, значит, искать frontends maxconn:
Fix the maximum number of concurrent connections on a frontend

By default, this value is set to 2000.

Отлично, то, что нужно. Добавляем в конфигурацию:

global
  daemon
  maxconn 524288

...

defaults
  mode http
  maxconn 524288

Следующий затык был в том, что Haproxy однопоточен. Я очень привык к модели в Nginx, поэтому этот нюанс меня всегда удручал. Но отчаиваться не стоит – Вилли (Willy Tarreau – разработчик Haproxy) понимал, что делал, поэтому добавил опцию – nbproc.

Однако прямо в документации сказано:
USING MULTIPLE PROCESSES
IS HARDER TO DEBUG AND IS REALLY DISCOURAGED.
Эта опция действительно может принести головную боль в случаях, если вам нужно:

  • ограничивать количество запросов/соединений к серверам (так как у вас уже будет не один процесс с одним счетчиком, а много процессов, и у каждого счетчик свой);
  • собирать статистику из сокета управления Haproxy;
  • включать/отключать бэкенды через управляющий сокет;
  • … возможно что-то еще. ?\_(?)_/?

Тем не менее боги даровали нам многоядерные процессоры, поэтому хотелось бы их использовать по максимуму. В моем случае было по четыре ядра в двух физических ядрах. Для Haproxy я выделил первое ядро, и выглядело это следующим образом:

  nbproc 4
  cpu-map 1 0
  cpu-map 2 1
  cpu-map 3 2
  cpu-map 4 3

С помощью cpu-map мы привязываем процессы Haproxy к определенному ядру. Планировщику OS больше не нужно думать, где бы запланировать работу Haproxy, тем самым сохраняя content switch в холоде, а cpu кэш – в тепле.

Буферов бывает много, но не в нашем случае


  • tune.bufsize – в нашем случае бустить его не пришлось, но если у вас бывают ошибки с кодом 400 (Bad Request), то, возможно, это ваш случай.
  • tune.http.cookielen – если раздаете пользователям большие «печеньки», то, во избежание их повреждения во время передачи по сети, может иметь смысл поднять и этот буфер.
  • tune.http.maxhdr – еще один возможный источник 400-х кодов ответов в случае, если у вас передается очень много заголовков.

Теперь рассмотрим более низкоуровневые штуки


tune.rcvbuf.client / tune.rcvbuf.server, tune.sndbuf.client / tune.sndbuf.server – в документации сказано следующее:
It should normally never be set, and the default size (0) lets the kernel autotune this value depending on the amount of available memory.

Но для меня явное лучше неявного, поэтому я зафорсил значения этих опций, чтобы быть уверенным в завтрашнем дне.

И еще один параметр, не относящийся к буферам, но достаточно важный – tune.maxaccept.
Sets the maximum number of consecutive connections a process may accept in a
row before switching to other work. In single process mode, higher numbers
give better performance at high connection rates. However in multi-process
modes, keeping a bit of fairness between processes generally is better to
increase performance.

В нашем случае генерируется довольно много запросов к прокси, поэтому я поднял это значение, чтобы за раз принимать больше запросов. Тем не менее, как и говорится в документации, стоит потестировать, чтобы в многопоточном режиме нагрузка была максимально равномерно распределена между процессами.

Все параметры вместе:

  tune.bufsize 16384
  tune.http.cookielen 63
  tune.http.maxhdr 101
  tune.maxaccept 256

  tune.rcvbuf.client 33554432
  tune.rcvbuf.server 33554432

  tune.sndbuf.client 33554432
  tune.sndbuf.server 33554432

Чего много не бывает, так это таймаутов. Что бы мы без них делали?


  • timeout connect – время на установление соединения с бэкендом. Если связь с бэкендом не очень, то лучше отключить его по этому таймауту, пока сеть не придет в норму.
  • timeout client – таймаут на передачу первых байт данных. Хорошо помогает отключать тех, кто делает запросы “про запас”.

Кулстори про HTTP клиент в Go
В Go есть штатный HTTP клиент, у которого есть возможность держать пул соединений к серверам. Так случилась одна интересная история, в которой принял участие вышеописанный таймаут и пул соединений в HTTP клиенте. Однажды разработчик пожаловался, что у него периодически бывают 408 ошибки от прокси. Мы заглянули в код клиента и увидели там такую логику:

  • пытаемся из пула взять свободное установленное соединение;
  • если не вышло, запускаем в горутине установку нового соединения;
  • проверяем пул еще раз;
  • если в пуле нашлось свободное — берем его, а новое складываем в пул, если нет — используем новое.

Уже поняли, в чем соль?

Если клиент установил новое соединение, но не воспользовался им, то спустя пять секунд сервер его закрывает, и дело с концом. Клиент же отлавливает это только тогда, когда уже достает соединение из пула и пытается им воспользоваться. Стоит иметь это ввиду.

  • timeout server – максимальное время ожидания ответа от сервера.
  • timeout client-fin/timeout server-fin – здесь мы защищаемся от полузакрытых соединений, чтобы не копить их в таблице операционной системы.
  • timeout http-request – один из самых годных таймаутов. Позволяет отрубать медленных клиентов, которые не могут оформить HTTP запрос в отведенное для них время.
  • timeout http-keep-alive – конкретно в нашем случае, если keep-alive соединение висит без запросов больше 50 секунд, то, скорее всего, что-то пошло не так, и соединение можно прикрыть, освободив тем самым память для чего-то нового, светлого.

Все таймауты вместе:

defaults
  mode http
  maxconn 524288

  timeout connect 5s
  timeout client 10s
  timeout server 120s

  timeout client-fin 1s
  timeout server-fin 1s

  timeout http-request 10s
  timeout http-keep-alive 50s

Логирование. Почему так сложно?


Как я уже писал раньше, чаще всего в своих решениях я использую Nginx, поэтому избалован его синтаксисом и простотой модификации форматов логов. Особенно мне нравилась киллер фича – форматировать логи в виде json, чтобы потом парсить их любой стандартной библиотекой.

Что же у нас есть в Haproxy? Такая возможность тоже есть, только писать можно исключительно в syslog, и синтаксис конфигурации чуть более завернутый.
Сразу приведу пример конфигурации с комментариями:

# выносим все, что касается ошибок или событий, в отдельный лог (по аналогии с 
# error.log в nginx)
log 127.0.0.1:2514 len 8192 local1 notice emerg

# здесь у нас что-то вроде access.log
log 127.0.0.1:2514 len 8192 local7 info

Особую боль доставляют такие моменты:
  • короткие имена переменных, а особенно их комбинации вроде %HU или %fp
  • формат нельзя разбивать на несколько строк, поэтому приходится писать портянку в одну строку. трудно добавлять/удалять новые/не нужные элементы
  • чтобы некоторые переменные заработали, их нужно явно объявлять через capture request header

В итоге, чтобы получить что-то интересное, приходится иметь вот такую портянку:

log-format '{"status":"%ST","bytes_read":"%B","bytes_uploaded":"%U","hostname":"%H","method":"%HM","request_uri":"%HU","handshake_time":"%Th","request_idle_time":"%Ti","request_time":"%TR","response_time":"%Tr","timestamp":"%Ts","client_ip":"%ci","client_port":"%cp","frontend_port":"%fp","http_request":"%r","ssl_ciphers":"%sslc","ssl_version":"%sslv","date_time":"%t","http_host":"%[capture.req.hdr(0)]","http_referer":"%[capture.req.hdr(1)]","http_user_agent":"%[capture.req.hdr(2)]"}'

Ну и, казалось бы, мелочи, но приятные


Выше я описывал формат лога, но не все так просто. Чтобы залогировать некоторые элементы в нем, такие как:

  • http_host,
  • http_referer,
  • http_user_agent,

нужно сперва захватить эти данные из запроса (capture) и поместить в массив захваченных значений.

Вот пример:

capture request header Host len 32
capture request header Referer len 128
capture request header User-Agent len 128

В результате мы теперь можем обращаться к нужным для нас элементам таким образом:
%[capture.req.hdr(N)], где N – порядковый номер определения capture группы.
В вышеприведенном примере заголовок Host будет под номером 0, а User-Agent – под номером 2.

У Haproxy есть особенность: он резолвит DNS адреса бэкендов при запуске и, если не может разрезолвить какой-то из адресов, падает смертью храбрых.

В нашем случае это не очень удобно, так как бэкендов много, мы ими не управляем, и лучше получить 503 от Haproxy, чем весь прокси-сервер откажется стартовать из-за одного поставщика. Помогает нам в этом следующая опция: init-addr.

Строка, взятая прямиком из документации, позволяет нам пройтись по всем доступным методам резолва адреса и, в случае фейла, просто отложить это дело на потом и пойти дальше:

default-server init-addr last,libc,none

Ну и напоследок – мое любимое: выбор бэкенда.
Синтаксис конфигурации выбора бэкенда у Haproxy всем знаком:

use_backend <backend1_name> if <condition1>
use_backend <backend2_name> if <condition2>

default-backend <backend3>

Но, право слово, это как-то не очень. У меня уже описаны все бэкенды автоматизированным путем (см. предыдущую статью), можно было бы и здесь генерировать use_backend, дурное дело — не хитрое, но не захотелось. В итоге нашелся другой путь:

  capture request header Host len 32
  capture request header Referer len 128
  capture request header User-Agent len 128

  # выставляем переменную host_present если запрос пришел с заголовком Host
  acl host_present hdr(host) -m len gt 0

  # вырезаем из заголовка префикс, который идентичен имени бэкенда
  use_backend %[req.hdr(host),lower,field(1,'.')] if host_present

  # а если с заголовками не срослось, то отдаем ошибку
  default_backend default

backend default
  mode http
  server no_server 127.0.0.1:65535

Таким образом, мы стандартизировали имена бэкендов и урлы, по которым к ним можно сходить.

Ну а теперь компиляция из вышеприведенных примеров в один файл:

Полная версия конфигурации
  global
    daemon
    maxconn 524288
    nbproc 4
    cpu-map 1 0
    cpu-map 2 1
    cpu-map 3 2
    cpu-map 4 3

    tune.bufsize 16384
    tune.comp.maxlevel 1
    tune.http.cookielen 63
    tune.http.maxhdr 101
    tune.maxaccept 256

    tune.rcvbuf.client 33554432
    tune.rcvbuf.server 33554432

    tune.sndbuf.client 33554432
    tune.sndbuf.server 33554432

    stats socket /run/haproxy.sock mode 600 level admin
    log /dev/stdout local0 debug


  defaults
    mode http
    maxconn 524288

    timeout connect 5s
    timeout client 10s
    timeout server 120s

    timeout client-fin 1s
    timeout server-fin 1s

    timeout http-request 10s
    timeout http-keep-alive 50s

    default-server init-addr last,libc,none

    log 127.0.0.1:2514 len 8192 local1 notice emerg
    log 127.0.0.1:2514 len 8192 local7 info
    log-format '{"status":"%ST","bytes_read":"%B","bytes_uploaded":"%U","hostname":"%H","method":"%HM","request_uri":"%HU","handshake_time":"%Th","request_idle_time":"%Ti","request_time":"%TR","response_time":"%Tr","timestamp":"%Ts","client_ip":"%ci","client_port":"%cp","frontend_port":"%fp","http_request":"%r","ssl_ciphers":"%sslc","ssl_version":"%sslv","date_time":"%t","http_host":"%[capture.req.hdr(0)]","http_referer":"%[capture.req.hdr(1)]","http_user_agent":"%[capture.req.hdr(2)]"}'


  frontend http
    bind *:80

    http-request del-header X-Forwarded-For
    http-request del-header X-Forwarded-Port
    http-request del-header X-Forwarded-Proto

    capture request header Host len 32
    capture request header Referer len 128
    capture request header User-Agent len 128

    acl host_present hdr(host) -m len gt 0
    use_backend %[req.hdr(host),lower,field(1,'.')] if host_present

    default_backend default


  backend default
    mode http
    server no_server 127.0.0.1:65535

  resolvers dns
    hold valid 1s
    timeout retry 100ms
    nameserver dns1 127.0.0.1:53
  


Спасибо тем, кто дочитал до конца. Тем не менее это еще не все. В следующий раз рассмотрим уже более низкоуровневые штуки, касающиеся оптимизации самой системы, в которой трудится Haproxy, чтобы ему и нашей операционной системе было комфортно вместе, и железа хватало на всех.

До встречи!

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


  1. SAAE
    07.02.2019 02:44

    В закладки!


  1. khanid
    07.02.2019 11:12

    У Haproxy есть особенность: он резолвит DNS адреса бэкендов при запуске и, если не может разрезолвить какой-то из адресов, падает смертью храбрых.

    Это не единственная особенность такого характера. Например, если нет ни одного acl для хоста, но этот хост присутствует в use_backend. Освещения этого момента в документации я не помню. После такого варианта конфига haproxy тоже падает при старте.