Ruby AWS SDK и сетевые соединения

Вопросом переиспользует ли AWS SDK TCP-соединения я задавался уже давно. Официальная документация об этом молчит. Но это важно понимать, ведь накладные расходы на открытие соединения влияют на время отправки HTTP-запроса. И если AWS SDK это не поддерживает, тогда приложению придется переиспользовать клиентов, а не создавать каждый раз на лету.

Аналогичная ситуация и с потокобезопасностью. Многопоточные сервера, такие как Puma и Sidekiq, популярны поэтому вопрос не праздный.

И вот наконец у меня дошли руки разобраться самому.

TL;DR: AWS SDK эффективно использует TCP-соединения. Делая запросы клиент не тратит время каждый раз на открытие нового соединения и переиспользует уже созданное. Также, соединение может быть переиспользовано другим клиентом в том же Ruby-процессе. AWS SDK работает с соединениями потокобезопасно, поэтому совместим с многопоточными серверами.

Проверим это на примере клиента к DynamoDB (NoSQL база данных от Amazon).

Тестируем клиента к DynamoDB

В тесте мы получим список таблиц DynamoDB и проверим какие соединения установятся с сервером AWS.

Выберем конкретный регион, например us-west-2, и определим конкретный IP-адрес сервера:

$ dig dynamodb.us-west-2.amazonaws.com

; <<>> DiG 9.10.6 <<>> dynamodb.us-west-2.amazonaws.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 43723
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;dynamodb.us-west-2.amazonaws.com. IN	A

;; ANSWER SECTION:
dynamodb.us-west-2.amazonaws.com. 1953 IN A	52.94.29.68

;; Query time: 2 msec
;; SERVER: 188.190.254.254#53(188.190.254.254)
;; WHEN: Sun Jan 02 18:13:00 EET 2022
;; MSG SIZE  rcvd: 77

Получаем IP-адрес 52.94.29.68.

Список соединений поможет получить команда netstat:

$ netstat -an -ptcp | grep 52.94.29.68

Опция -ptcp означает отфильтровать только TCP-соединения (и игнорировать UDP). Это специфика netstat в MacOS. В Linux опции отличаются, поэтому -ptcp заменяется на -t.

Теперь сделаем запрос к DynamoDB. Для этого устанавливаем gem aws-sdk-dynamodb и задаем credentials к аккаунту AWS. Далее запускаем irb и настраиваем AWS SDK:

require 'aws-sdk-dynamodb'

ENV['AWS_ACCESS_KEY_ID'] = '...'
ENV['AWS_SECRET_ACCESS_KEY'] = '...'
ENV['AWS_REGION'] = 'us-west-2'

Тест #1. Делаем последовательные запросы

Проверим, создастся ли новое соединение если сделать повторный запрос. Для этого в уже открытой и настроенной сессии irb выполним код:

client = Aws::DynamoDB::Client.new
client.list_tables

И тут же в соседней вкладке терминала выполняем следующую shell-команду:

$ netstat -an -ptcp | grep 52.94.29.68
tcp4       0      0  192.168.1.12.56015     52.94.29.68.443        ESTABLISHED

В результате видим, что установилось одно соединение с сервером AWS с IP-адресом 52.94.29.68. Локально используется случайный порт 56015, которым соединения и различаются.

Возвращаемся ко вкладке с irb и повторяем запрос к DynamoDB. Нужно сделать это быстро - интервал между запросами не должен превышать 5 секунд (поведение AWS SDK по-умолчанию):

client.list_tables

Проверим соединения:

$ netstat -an -ptcp | grep 52.94.29.68
tcp4       0      0  192.168.1.12.56015     52.94.29.68.443        ESTABLISHED

Видим в результате соединение с тем же самым локальным портом. Вывод - повторный запрос к AWS переиспользует уже открытое соединение.

Тест #2. Делаем запросы с задержкой

Выполним те же действия, но подождем перед вторым запросом - секунд 10, например:

client = Aws::DynamoDB::Client.new
client.list_tables

Установилось соединение:

$ netstat -an -ptcp | grep 52.94.29.68
tcp4       0      0  192.168.1.12.56214     52.94.29.68.443        ESTABLISHED

Ждем 10 секунд и повторяем запрос:

client.list_tables

В результате создано два соединения:

$ netstat -an -ptcp | grep 52.94.29.68
tcp4       0      0  192.168.1.12.56268     52.94.29.68.443        ESTABLISHED
tcp4       0      0  192.168.1.12.56214     52.94.29.68.443        TIME_WAIT

Соединения различаются локальными портами - 56268 и 56214. Соединение с портом 56214 - это то, что создалось при первом запросе. И оно в состоянии TIME_WAIT. Это промежуточное состояние соединения при закрытии. В нашем случае соединение было закрыто на стороне клиента.

Вывод - повторный запрос после некоторого ожидания создает новое соединение.

Тест #3. Создаем несколько клиентов

Давайте проверим, как ведет себя AWS SDK когда созданы два клиента. Приведет ли это к открытию новых соединений или AWS SDK умеет экономить и переиспользовать соединения открытые другим клиентом?

Создадим первый клиент и сделаем запрос:

client = Aws::DynamoDB::Client.new(http_idle_timeout: 60)
client.list_tables

В результате открывается соединение

$ netstat -an -ptcp | grep 52.94.29.68
tcp4       0      0  192.168.1.12.56424     52.94.29.68.443        ESTABLISHED

Создаем новый клиент и делаем еще один запрос (надо успеть это сделать за 60 секунд):

client = Aws::DynamoDB::Client.new(http_idle_timeout: 60)
client.list_tables

В результате открыто единственное соединение:

$ netstat -an -ptcp | grep 52.94.29.68
tcp4       0      0  192.168.1.12.56424     52.94.29.68.443        ESTABLISHED

Мы видим, что используется тот же локальный порт 56424. Это значит, что второй клиент использовал соединение открытое первым клиентом при первом запросе.

Вывод - клиенты используют соединения сообща и создание нового клиента не приводит к созданию новых соединений.

Seahorse

Давайте посмотрим как это реализовано в AWS SDK.

Работа с HTTP вынесена в библиотеку seahorse в gem‘е aws-sdk-core. По-умолчанию для отправки запроса используется класс Seahorse::Client::NetHttp::Handler (source).

Обратим внимание на метод session, который возвращает HTTP-клиента. (Net::HTTP из стандартной библиотеки Ruby):

def session(config, req, &block)
  pool_for(config).session_for(req.endpoint) do |http|
    # ...
    yield(http)
  end
end

config - это настройки заданные при инстанцировании клиента (или дефолтные значения (документация)).

AWS SDK создает pool HTTP-клиентов для каждой конфигурации настроек. В таком pool‘е для каждого сервиса AWS поддерживается список HTTP-клиентов (под-pool). Так как у каждого сервиса отдельный поддомен - сервисы различают по хосту из URL (req.endpoint).

Pool соединений реализован в классе Seahorse::Client::NetHttp::ConnectionPool (source). Pool‘ы соединений - это “статическое поле” (instance variable) объекта ConnectionPool и представляет собой Hash:

class ConnectionPool
  # ...
  @pools_mutex = Mutex.new
  @pools = {}
  # ...
end

Для потокобезопасности работа с pool‘ом оборачивается в мьютекс (@pools_mutex). Таким образом, в многопоточном коде клиент работает корректно.

Например, в методе self.for возвращается конкретный pool для указанных настроек (options):

class << self
  def for options = {}
    options = pool_options(options)
    @pools_mutex.synchronize do
      @pools[options] ||= new(options)
    end
  end
end

Рассмотрим, как организован pool соединений. Соединения хранятся в Hash, где ключи - это URL, а значение - массивы HTTP-клиентов. Работа с @pool аналогично обернута в мьютекс (@pool_mutex):

def initialize(options = {})
  # ...
  @pool_mutex = Mutex.new
  @pool = {}
end

Как упоминалось выше, метод session_for принимает аргументом URL (endpoint) и возвращает HTTP-клиента из pool‘а. Если упростить, то метод работает следующим образом (source):

def session_for(endpoint, &block)
  # attempt to recycle an already open session
  @pool_mutex.synchronize do
    if @pool.key?(endpoint)
      session = @pool[endpoint].shift
    end
  end

  session ||= start_session(endpoint)

  yield(session)

  # No error raised? Good, check the session into the pool.
  @pool_mutex.synchronize do
    @pool[endpoint] = [] unless @pool.key?(endpoint)
    @pool[endpoint] << session
  end

  nil
end

Если в pool (@pool) уже добавлено соединение для сервиса (сервис определяется endpoint-ом), то используем его (session) и удаляем из pool‘а методом Array#shift. Иначе создаем новый HTTP-клиент вызывая хелпер-метод start_session. Далее передаем клиента в блок, в котором отправят HTTP-запрос в AWS и обработают ответ. Далее возвращаем клиента опять в pool. Обратите внимание, что работа с @pool закрыта мьютексом @pool_mutex.

Как настроить pool соединений

В тесте ранее упоминалась опция http_idle_timeout. В принципе, это единственная доступная настройка pool‘а.

Из документации:

The number of seconds a connection is allowed to sit idle before it is considered stale. Stale connections are closed and removed from the pool before making a request.

Опция http_idle_timeout влияет на два момента. Во-первых, неиспользуемый HTTP-клиент после этого таймаута удаляется из pool‘а. Правда, не сразу, а перед очередным запросом. В начале метода session_for pool очищается от протухших HTTP-клиентов вызовом хелпер-метода _clean (source):

# Removes stale sessions from the pool.  This method *must* be called
# @note **Must** be called behind a `@pool_mutex` synchronize block.
def _clean
  now = Aws::Util.monotonic_milliseconds
  @pool.each_pair do |endpoint,sessions|
    sessions.delete_if do |session|
      if session.last_used.nil? or now - session.last_used > http_idle_timeout * 1000
        session.finish
        true
      end
    end
  end
end

Во-вторых, http_idle_timeout используется при инстанцировании и настройке нового HTTP-клиента и влияет на свойство keep_alive_timeout (source):

http.keep_alive_timeout = http_idle_timeout if http.respond_to?(:keep_alive_timeout=)

Согласно документации Net::HTTP#keep_alive_timeout это:

Seconds to reuse the connection of the previous request. If the idle time is less than this Keep-Alive Timeout, Net::HTTP reuses the TCP/IP socket used by the previous communication. The default value is 2 seconds.

То есть, HTTP-клиент создаст новое соединение если между запросами прошло больше чем keep_alive_timeout секунд. Это дублирует уже описанный выше механизм устаревания HTTP-клиентов в pool‘е. Ну да ладно.

Замечу, что это не влияет никоим образом на заголовки HTTP-запроса Keep-Alive и Connection (как можно было бы ожидать) - т.е. это клиентская настройка (документация) и ничего не подсказывает серверу.

PS

После этого маленького исследования я могу быть спокоен за производительность и безопасность AWS-клиентов в веб-приложении. Создать клиента на лету и использовать в разных потоках совершенно безопасно. И не нужны дополнительные усилия вроде мьютексов и pool‘а клиентов.