В этой статье я хочу рассказать вам о том, как мы обнаружили проблему, которая была вызвана неправильной конфигурацией пула соединений и как настраивать пулы HTTP-соединений в микросервисах GO. Проблема была скрыта в нашем API Gateway. Это сервис, который реализует модель фасада и предоставляет микросервисам единое окошко, обращенное наружу. Данная статья будет полезна тем кто занимается бэкенд-разработкой в Ozon — пишет микросервисы для личного кабинета продавца.
В упрощенном виде его работу API Gateway можно представить так:
-
Проверить аутентификацию и авторизацию с помощью HTTP-запроса в сервис аутентификации
-
Спроксировать запрос в нужный сервис, который определяем по пути и методу запроса пользователя
Иллюстрация работы API Gateway
Рост нагрузок и как следствие рост числа ошибок
На стороне API Gateway были обнаружены ошибки. Мы заглянули в логи за подробной информацией и обнаружили, что ошибки были похожи на таймауты обращения к сервизу аутентификацию:
{err_type: context.deadlineExceededError, err: context deadline exceeded} {err_type: *errors.errorString, err: context canceled}
Трейсы в Jaeger показали такую же ситуацию— мы не дожидались ответа от сервиса аутентификации за 2 секунды.
Для подтверждения своих слов, разработчики сервиса аутентификации скинул нам скриншот логов с подтверждением. В нем много ошибок об отмене запроса со стороны того, кто его инициировал.
Скриншот с множеством ошибок Cancelled by client
Итого:
-
Используемый нами сервис аутентификации стабильно отрабатывает за 200 миллисекунд.
-
Многие наши обращения к этому сервису таймаутят за 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 обращения к сервису аутентификации
Здесь вы можете увидеть значительное уменьшение квантиля 0,99 по времени обращения графика (синий цвет) — с 2-3 секунд до менее 300 миллисекунд. Я должен признать, что даже после этого мы иногда наблюдали таймауты при доступе к сервису аутентификации. Но теперь, по крайней мере, мы видели такие же таймауты на графиках от другого сервиса.
Зачем в Go такие настройки?
Теперь вы можете спросить: зачем делать такие настройки, которые нужно изменить позже? Задумывались ли создатели языка Go и библиотек о том, как его будут использовать на практике?
Думаю, да: настройки по умолчанию имеют смысл при работе с большим количеством хостов и не слишком большим количеством запросов. В этом случае значение MaxIdleConnsPerHost
защищает нас от того, что в случае большого количества запросов один из хостов исчерпывает оставшийся ресурс свободных подключений и не позволяет другой службе создать хотя бы одно долгосрочное подключение.
Непонятные таймауты в Go
Если вдруг вы сталкиваетесь с непонятными таймаутами, попробуйте следующее:
- Выявите метрики по скорости выполнения запросов к другим системным сервисам по HTTP.
- Если вы видите разницу в тайм-аутах клиента и сервера, проверяйте количество соединений TIME_WAIT.
- Если вы обнаружите несколько подключений в состоянии TIME_WAIT, весьма вероятно, что пул подключений настроен неправильно. Обратите внимание, что настройки Go по умолчанию не очень хорошо подходят для большого количества запросов на ограниченный набор услуг.
- Для хоста с неоправданно огромным количеством запросов к нему можно рассмотреть вариант подключения отдельного транспорта.
Заключение
Все чаще компании, занимающиеся разработкой распределенных систем, используют Go. При создании облачной распределенной системы вам может понадобиться поддержка различных конкретных функций в ваших сервизах – например, такие как транспортный протокол (например, HTTP, GRPC и т.д.), а также форматы кодирования сообщений для них, надежность RPC, ведение журнала,трассировка, метрики и профилизации, запросы прерывания, ограничение количества запросов, интеграция их в инфраструктуру и даже описания архитектуры. Благодаря своей простоте и отсутствию магии пакеты Go, такие как Стандартная библиотечная система, уже лучше подходят для разработки распределенной системы, чем использование полноценного фреймворк с большим количеством магии под капотом.