Пробы и ошибки при выборе HTTP Reverse Proxy

    Всем привет!

    Сегодня мы хотим рассказать о том, как команда сервиса бронирования отелей Ostrovok.ru решала проблему роста микросервиса, задачей которого является обмен информацией с нашими поставщиками. О своем опыте рассказывает undying, DevOps Team Lead в Ostrovok.ru.


    Сначала микросервис был мал и выполнял следующие функции:

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

    Однако время шло, сервис рос вместе с количеством партнеров и запросов к ним.

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

    В итоге нам предстояло решить следующие задачи:

    • желательно иметь несколько фиксированных внешних IP адресов, чтобы можно было предоставлять их партнерам для добавления их в белые списки,
    • иметь единый пул соединений ко всем поставщикам, чтобы при масштабировании нашего микросервиса количество соединений оставалось минимальным,
    • терминировать SSL и держать keepalive в одном месте, тем самым снижая нагрузку для самих партнеров.

    Долго думать не стали и сразу задались вопросом, что выбрать: Nginx или Haproxy.
    Сперва маятник качнулся в сторону Nginx, так как большую часть проблем, связанных с HTTP/HTTPS, я решал с его помощью и всегда оставался доволен результатом.

    Схема была простой: делался запрос в наш новый Proxy Server на Nginx с доменом вида <partner_tag>.domain.local, в Nginx был map, где <partner_tag> соответствовал адресу партнера. Из map брался адрес и делался proxy_pass на этот адрес.

    Вот пример map, которым мы парсим домен и выбираем апстрим из списка:

    ### берем префикс из имени домена: <tag>.domain.local
    map $http_host $upstream_prefix {
      default 0;
      "~^([^\.]+)\." $1;
    }
    
    ### выбираем нужный адрес по префиксу
    map $upstream_prefix $upstream_address {
      include snippet.d/upstreams_map;
      default http://127.0.0.1:8080;
    }
    
    ### выставляем переменную upstream_host исходя из переменной upstream_address
    map $upstream_address $upstream_host {
      default 0;
      "~^https?://([^:]+)" $1;
    }
    

    А вот как выглядит “snippet.d/upstreams_map”:
    “one” “http://one.domain.net”;
    “two” “https://two.domain.org”;
    

    Тут у нас сам server{}:

    server {
      listen 80;
    
      location / {
        proxy_http_version 1.1;
        proxy_pass $upstream_address$request_uri;
        proxy_set_header Host $upstream_host;
    
        proxy_set_header X-Forwarded-For "";
        proxy_set_header X-Forwarded-Port "";
        proxy_set_header X-Forwarded-Proto "";
      }
    }
    
    # service for error handling and logging
    server {
      listen 127.0.0.1:8080;
    
      location / {
        return 400;
      }
    
      location /ngx_status/ {
        stub_status;
      }
    }
    

    Все классно, все работает. Можно на этом закончить статью, если бы не один нюанс.

    При использовании proxy_pass прямиком на нужный адрес запрос идет, как правило, по протоколу HTTP/1.0 без keepalive и закрывается сразу после завершения ответа. Даже если мы выставим proxy_http_version 1.1, без апстрима ничего не изменится (proxy_http_version).

    Что делать? Первая мысль – завести всех поставщиков в апстримы, где в качестве server будет нужный нам адрес поставщика, а в map держать "tag" "upstream_name".

    Добавляем еще один map для парсинга схемы:

    ### берем префикс из имени домена: <tag>.domain.local
    map $http_host $upstream_prefix {
      default 0;
      "~^([^\.]+)\." $1;
    }
    
    ### выбираем нужный адрес по префиксу
    map $upstream_prefix $upstream_address {
      include snippet.d/upstreams_map;
      default http://127.0.0.1:8080;
    }
    
    ### выставляем переменную upstream_host исходя из переменной upstream_address
    map $upstream_address $upstream_host {
      default 0;
      "~^https?://([^:]+)" $1;
    }
    
    ### добавляем парсинг схемы, чтобы к кому надо ходить по https, а к кому надо, но не очень - по http
    map $upstream_address $upstream_scheme {
      default "http://";
      "~(https?://)" $1;
    }
    

    И создаем upstreams с именами тегов:
        upstream one {
          keepalive 64;
          server one.domain.com;
        }
        upstream two {
          keepalive 64;
          server two.domain.net;
        }
    

    Сам сервер немного видоизменяем, чтобы учитывать схему и вместо адреса использовать имя апстрима:

    server {
      listen 80;
    
      location / {
        proxy_http_version 1.1;
        proxy_pass $upstream_scheme$upstream_prefix$request_uri;
        proxy_set_header Host $upstream_host;
    
        proxy_set_header X-Forwarded-For "";
        proxy_set_header X-Forwarded-Port "";
        proxy_set_header X-Forwarded-Proto "";
      }
    }
    
    # service for error handling and logging
    server {
      listen 127.0.0.1:8080;
    
      location / {
        return 400;
      }
    
      location /ngx_status/ {
        stub_status;
      }
    }
    

    Отлично. Решение работает, добавляем в каждый апстрим директиву keepalive, выставляем proxy_http_version 1.1, – теперь у нас есть пул соединений, и все работает как надо.

    На этот раз точно можно заканчивать статью и идти пить чай. Или нет?

    Ведь пока мы пьем чай, у кого-то из поставщиков может под тем же доменом измениться IP адрес или группа адресов (привет, Амазон), тем самым один из поставщиков может отвалиться в самый разгар нашего чаепития.

    Ну что же, как быть? Есть у Nginx интересный нюанс: во время reload он может отрезолвить сервера внутри upstream в новые адреса и пустить трафик на них. В целом, тоже решение. Закидываем в cron reload nginx раз в 5 минут и продолжаем пить чай.

    Но все же это показалось мне так себе решением, поэтому я стал косо посматривать в сторону Haproxy.

    У Haproxy есть возможность указать dns resolvers и настроить dns cache. Тем самым Haproxy будет сам обновлять dns cache, если записи в нем истекли, и заменять адреса для апстримов в том случае, если они изменились.

    Отлично! Теперь осталось дело за настройками.

    Вот краткий пример конфигурации для Haproxy:

    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
    
    resolvers dns
      hold valid 1s
      timeout retry 100ms
      nameserver dns1 1.1.1.1:53
    
    backend one
      http-request set-header Host one.domain.com
      server one--one.domain.com one.domain.com:80 resolvers dns check
    
    backend two
      http-request set-header Host two.domain.net
      server two--two.domain.net two.domain.net:443 resolvers dns check ssl verify none check-sni two.domain.net sni str(two.domain.net)
    

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

    У меня уже был map из Nginx с форматом "tag" "upstream", поэтому я решил взять его за основу, парсить и генерировать на основании этих значений haproxy backend.

    #! /usr/bin/env bash
    
    haproxy_backend_map_file=./root/etc/haproxy/snippet.d/name_domain_map
    haproxy_backends_file=./root/etc/haproxy/99_backends.cfg
    nginx_map_file=./nginx_map
    
    
    while getopts 'n:b:m:' OPT;do
      case ${OPT} in
        n)
          nginx_map_file=${OPTARG}
          ;;
        b)
          haproxy_backends_file=${OPTARG}
          ;;
        m)
          haproxy_backend_map_file=${OPTARG}
          ;;
        *)
          echo 'Usage: ${0} -n [nginx_map_file] -b [haproxy_backends_file] -m [haproxy_backend_map_file]'
          exit
      esac
    done
    
    
    function write_backend(){
      local tag=$1
      local domain=$2
      local port=$3
    
      local server_options="resolvers dns check"
    
      [ -n "${4}" ] && local ssl_options="ssl verify none check-sni ${domain} sni str(${domain})"
      [ -n "${4}" ] && server_options+=" ${ssl_options}"
    
    cat >> ${haproxy_backends_file} <<EOF
    
    backend ${tag}
      http-request set-header Host ${domain}
      server ${tag}--${domain} ${domain}:${port} ${server_options}
    
    EOF
    }
    
    
    :> ${haproxy_backends_file}
    :> ${haproxy_backend_map_file}
    
    
    while read tag addr;do
      tag=${tag//\"/}
    
      [ -z "${tag:0}" ] && continue
      [ "${tag:0:1}" == "#" ] && continue
    
      IFS=":" read scheme domain port <<<${addr//;}
      unset IFS
    
      domain=${domain//\/}
    
      case ${scheme} in
        http)
          port=${port:-80}
          write_backend ${tag} ${domain} ${port}
          ;;
        https)
          port=${port:-443}
          write_backend ${tag} ${domain} ${port} 1
      esac
    done < <(sort -V ${nginx_map_file})
    

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

    На сегодня, пожалуй, все. Данная статья относится скорее к вводной и была посвящена проблеме выбора решения и его интеграции в текущее окружение.

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

    Всем спасибо за внимание, до встречи!
    • +28
    • 7,1k
    • 7
    Ostrovok.ru
    106,00
    Компания
    Поделиться публикацией

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

      +3
      У Haproxy есть возможность указать dns resolvers и настроить dns cache.

      У Nginx тоже есть (https://www.nginx.com/resources/wiki/modules/domain_resolve/)

        +3

        Можно без стороннего модуля

          +3
          Да, можно. Но при использовании этой функции с блоком upstream нужен nginx с коммерческой подпиской.
          #resolve
            0
            Решали проблему смены IP на домене следующим образом:
            resolver 127.0.0.1;
            set $upstream "upstream.example.com";
            proxy_pass $scheme://$upstream;
              0
              Да, метод рабочий.
              Но есть у него минус. Т.к. proxy_pass работает вне блока upstream, а «напрямую», то нет keepalive соединения, а значит на каждый запрос создается новое TCP соединение, что при высокой нагрузке может добавить лишнего времени.
        –1
        image
          –1
          Я как то решил изучить GoLang и написал небольшой reverseproxy. С моими задачами он справляется – дает доступ ~10 сайтом http, https. Посмотрите в этом направлении.

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

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