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

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

mbqxehqjq4oabybefapvvbpqadg.png
Сначала микросервис был мал и выполнял следующие функции:
— принять запрос от локального сервиса;
— сделать запрос партнеру;
— нормализовать ответ;
— вернуть результат вопрошающему сервису.

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

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

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


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

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

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

### берем префикс из имени домена: .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 для парсинга схемы:

### берем префикс из имени домена: .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} < ${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, какие метрики оказалось полезно мониторить и что точно стоит оптимизировать в системе, чтобы выжать максимум производительности из серверов.

Всем спасибо за внимание, до встречи!

© Habrahabr.ru