Как настраивать пулы HTTP-соединений

Как настраивать пулы HTTP-соединений

В этой статье я хочу рассказать вам о том, как мы обнаружили проблему, которая была вызвана неправильной конфигурацией пула соединений и как настраивать пулы HTTP-соединений в микросервисах GO. Проблема была скрыта в нашем API Gateway. Это сервис, который реализует модель фасада и предоставляет микросервисам единое окошко, обращенное наружу. Данная статья будет полезна тем кто занимается бэкенд-разработкой в Ozon — пишет микросервисы для личного кабинета продавца.

В упрощенном виде его работу API Gateway можно представить так:

  1. Проверить аутентификацию и авторизацию с помощью HTTP-запроса в сервис аутентификации

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

Иллюстрация работы API Gateway

Иллюстрация работы API Gateway

Рост нагрузок и как следствие рост числа ошибок

На стороне API Gateway были обнаружены ошибки. Мы заглянули в логи за подробной информацией и обнаружили, что ошибки были похожи на таймауты обращения к сервизу аутентификацию:

{err_type: context.deadlineExceededError, err: context deadline exceeded} {err_type: *errors.errorString, err: context canceled}

Трейсы в Jaeger показали такую же ситуацию— мы не дожидались ответа от сервиса аутентификации за 2 секунды.

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

Скриншот с множеством ошибок Cancelled by client

Скриншот с множеством ошибок Cancelled by client

Итого:

  1. Используемый нами сервис аутентификации стабильно отрабатывает за 200 миллисекунд.

  2. Многие наши обращения к этому сервису таймаутят за 2 секунды.

В чем причина проблемы?

Один из авторов нашего API Gatcheway отметил, что давно заметил странную особенность сервиса – он внезапно начинает открывать множество подключений к удаленным портам. После запуска команды из-под контейнера, вы увидите:

$ ss -natp state time-wait | awk '{print $4}' | sort -nr | uniq -c | sort -nr | head

1053 10.20.49.117:80
1030 10.20.49.92:80
1016 10.20.49.91:80
1014 10.20.54.129:80
1013 10.20.53.213:80
1008 10.20.53.173:80
969 10.20.53.172:80

Эта команда показывает количество сокетов TCP в состоянии TIME_WAIT до различных удаленных портов. Короче говоря, состояние TIME_WAIT — это де-факто соединение, закрытое клиентом. Linux предотвращает повторное использование этих пар, когда это возможно, в течение 60 секунд для защиты от старых пакетов, мешающих вновь установленному TCP-соединению.

Нам важна другая сторона вопроса. Само существование TCP-соединения означает, что соединение установлено и закрыто. Если такая ситуация возникнет массово, мы будем иметь дело с накладными расходами на разрешение DNS и настройку подключения. В результате время HTTP-запроса может увеличиться. Пулы подключений помогают избежать этой проблемы. Go использует для этой цели абстракцию http.Transport.

А именно, в этом месте мы подходим к истокам проблемы. Все запросы, которые нам поступали, мы обрабатывали с помощью http-DefaultTransport. Его параметры:

var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext,
    ForceAttemptHTTP2:     true,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

Среди перечисленных выше параметров к настройке пула соединений имеют отношения два:

  • MaxIdleConns — число соединений, которое разрешается иметь в состоянии Idle (т.е. открытых TCP-соединений, которые в данный момент не используются);

  • IdleConnTimeout — время, через которое закрываются такие неактивные соединения.

Но в DefaultTransport совсем не упомянут другой ключевой параметр — MaxIdleConnsPerHost. В его обязанности входит контроль за количеством активных TCP-соединений, которые можно установить на один хост. 

При этом если MaxIdleConnsPerHost не указан, тогда используется значение по умолчанию:

const DefaultMaxIdleConnsPerHost = 2

В результате мы столкнулись с проблемой, которая была связана с использованием http.DefaultTransport для всех запросов.

А теперь представьте себе, что нам нужно установить 10 подключаний одновременно перед службой регистрации. В дальнейшем для 8 из них TCP/IP-соединения будут открыты и сразу же закрыты из-за ограничения MaxIdleConnsPerHost. А если это будет повторяться постоянно и в больших объемах, то мы будем вынуждены увеличить свои накладные расходы на один HTTP-запрос, поскольку для этого потребуется новое соединение. Поэтому возрастает риск возникновения тайм-аутов.

Решение проблемы

Мы решили эту проблему следующим образом:

  • Назначен отдельный транспорт для служб аутентификации и авторизации, чтобы транспорт для службы аутентификации не вытеснял другие службы.
  • Мы полностью использовали выделенный пул — мы сделали значение MaxIdleConnsPerHost соответствующим значению MaxIdleConns:
func createOneHostTransport() *http.Transport {
    result := http.DefaultTransport.(*http.Transport).Clone()

    result.MaxIdleConnsPerHost = result.MaxIdleConns

    return result
}

График response time обращения к сервису аутентификации

График response time обращения к сервису аутентификации

Здесь вы можете увидеть значительное уменьшение квантиля 0,99 по времени обращения графика (синий цвет) — с 2-3 секунд до менее 300 миллисекунд. Я должен признать, что даже после этого мы иногда наблюдали таймауты при доступе к сервису аутентификации. Но теперь, по крайней мере, мы видели такие же таймауты на графиках от другого сервиса.

Зачем в Go такие настройки?

Теперь вы можете спросить: зачем делать такие настройки, которые нужно изменить позже? Задумывались ли создатели языка Go и библиотек о том, как его будут использовать на практике?

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

Непонятные таймауты в Go

Если вдруг вы сталкиваетесь с непонятными таймаутами, попробуйте следующее:

  1. Выявите метрики по скорости выполнения запросов к другим системным сервисам по HTTP. 
  2. Если вы видите разницу в тайм-аутах клиента и сервера, проверяйте количество соединений TIME_WAIT.
  3. Если вы обнаружите несколько подключений в состоянии TIME_WAIT, весьма вероятно, что пул подключений настроен неправильно. Обратите внимание, что настройки Go по умолчанию не очень хорошо подходят для большого количества запросов на ограниченный набор услуг.
  4. Для хоста с неоправданно огромным количеством запросов к нему можно рассмотреть вариант подключения отдельного транспорта.

Заключение

Все чаще компании, занимающиеся разработкой распределенных систем, используют Go. При создании облачной распределенной системы вам может понадобиться поддержка различных конкретных функций в ваших сервизах – например, такие как транспортный протокол (например, HTTP, GRPC и т.д.), а также форматы кодирования сообщений для них, надежность RPC, ведение журнала,трассировка, метрики и профилизации, запросы прерывания, ограничение количества запросов, интеграция их в инфраструктуру и даже описания архитектуры. Благодаря своей простоте и отсутствию магии пакеты Go, такие как Стандартная библиотечная система, уже лучше подходят для разработки распределенной системы, чем использование полноценного фреймворк с большим количеством магии под капотом.

 

 

 

 

 

  

Click to rate this post!
[Total: 1 Average: 5]

Leave a reply:

Your email address will not be published.