История блужданий по документации Haproxy, или на что стоит обратить внимание при его конфигурации

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

    В прошлый раз мы рассказывали о выборе инструмента в 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, чтобы ему и нашей операционной системе было комфортно вместе, и железа хватало на всех.

    До встречи!
    • +24
    • 3,9k
    • 3
    Ostrovok.ru
    106,00
    Компания
    Поделиться публикацией

    Комментарии 3

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

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

          Ещё интересное поведение, если ip бэкенда поменялся в процессе работы, то haproxy об этом не узнает до релоада. А вы будете думать, что у вас бэк сломан.

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

          Самое читаемое