И снова здравствуйте!
В прошлый раз мы рассказывали о выборе инструмента в 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 – таймаут на передачу первых байт данных. Хорошо помогает отключать тех, кто делает запросы “про запас”.
- пытаемся из пула взять свободное установленное соединение;
- если не вышло, запускаем в горутине установку нового соединения;
- проверяем пул еще раз;
- если в пуле нашлось свободное — берем его, а новое складываем в пул, если нет — используем новое.
Уже поняли, в чем соль?
Если клиент установил новое соединение, но не воспользовался им, то спустя пять секунд сервер его закрывает, и дело с концом. Клиент же отлавливает это только тогда, когда уже достает соединение из пула и пытается им воспользоваться. Стоит иметь это ввиду.
- 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, чтобы ему и нашей операционной системе было комфортно вместе, и железа хватало на всех.
До встречи!
Автор: undying