Облегченный потоковый HTTP-прокси для Rack (клиентская библиотека HTTP с легким процессором Ruby)

Поэтому я экспериментирую с ситуацией, когда я хочу передавать огромные файлы со стороннего URL-адреса через мой сервер запрашивающему клиенту.

До сих пор я пытался реализовать это с помощью Curb или Net::HTTP, придерживаясь стандартной практики Rack для "каждого" тела ответа, например:

class StreamBody
  ...
  def each
    some_http_library.on_body do | body_chunk |
      yield(body_chunk)
    end
  end
end

Однако я не могу заставить эту систему использовать меньше, чем, скажем, 40% ЦП (на моем MacBook Air). Если я попытаюсь сделать то же самое с Goliath, используя em-synchrony (как рекомендуется на странице Goliath), я смогу снизить использование ЦП примерно до 25% ЦП, однако мне не удастся сбросить заголовки. Моя потоковая загрузка «зависает» в запрашивающем клиенте, и заголовки появляются после того, как весь ответ был отправлен клиенту, независимо от того, какие заголовки я предоставляю.

Прав ли я, думая, что это один из тех случаев, когда Ruby просто изумительно отстой, и вместо этого мне приходится обращаться к go и nodejs мира?

Для сравнения, в настоящее время мы используем потоковую передачу PHP из CURL в выходной поток PHP, и это работает с очень небольшой нагрузкой на ЦП.

Или есть решение для проксирования вверх по течению, которое я мог бы попросить обработать мои вещи? Проблема в том, что я хочу надежно вызвать функцию Ruby после того, как все тело будет отправлено в сокет, и такие вещи, как прокси-серверы nginx, не сделают этого за меня.

ОБНОВЛЕНИЕ: я попытался провести простой тест для HTTP-клиентов, и похоже, что большая часть ЦП используется библиотеками HTTP-клиентов. Существуют тесты для HTTP-клиентов Ruby, но они основаны на времени получения ответа, тогда как использование ЦП никогда не упоминается. В моем тесте я выполнил потоковую загрузку HTTP, записав результат в /dev/null, и получил стабильную загрузку ЦП на 30-40%, что примерно соответствует загрузке ЦП, которую я имею при потоковой передаче через любой обработчик Rack.

ОБНОВЛЕНИЕ: оказалось, что большинство обработчиков Rack (Unicorn и т. д.) используют цикл записи() в теле ответа, что может привести к ожиданию занятости (с высокой загрузкой ЦП), когда ответ не может быть записан быстро. довольно. Это можно до некоторой степени смягчить, используя rack.hijack и записывая в выходной сокет, используя write_nonblock и IO.select (удивлен, что серверы не делают этого сами по себе).

lambda do |socket|
  begin
    rack_response_body.each do | chunk |
      begin
        bytes_written = socket.write_nonblock(chunk)
        # If we could write only partially, make sure we do a retry on the next
        # iteration with the remaining part
        if bytes_written < chunk.bytesize
          chunk = chunk[bytes_written..-1]
          raise Errno::EINTR
        end
      rescue IO::WaitWritable, Errno::EINTR # The output socket is saturated.
        IO.select(nil, [socket]) # Then let's wait on the socket to be writable again
        retry # and off we go...
      rescue Errno::EPIPE # Happens when the client aborts the connection
        return
      end
    end
  ensure
    socket.close rescue IOError
    rack_response_body.close if rack_response_body.respond_to?(:close)
  end
end

person Julik    schedule 05.06.2015    source источник


Ответы (1)


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

  • патрон в качестве HTTP-клиента. Я объясню выбор ниже ответа
  • Надежный многопоточный веб-сервер (например, Puma)
  • драгоценный камень отправки файла

Основная проблема с желанием создать что-то подобное на Ruby — это то, что я называю изменением строк. По сути, выделение строк в виртуальной машине не является бесплатным. Когда вы проталкиваете много данных, вы в конечном итоге выделяете Ruby String для каждого фрагмента данных, полученного из вышестоящего источника, и, возможно, вы также в конечном итоге выделяете строки, если вы не можете write() весь этот фрагмент в сокет, который представляет ваш клиент подключен через TCP. Таким образом, из всех подходов, которые мы пробовали, мы не смогли найти решение, которое позволило бы нам избежать оттока строк, то есть до того, как мы наткнулись на Patron.

Patron, как оказалось, является единственным HTTP-клиентом Ruby, который позволяет выполнять прямую запись в файл в пользовательском пространстве. Это означает, что вы можете загружать некоторые данные через HTTP, не выделяя ruby ​​String для данных, которые вы извлекаете. В Patron есть функция, которая открывает указатель FILE* и записывает прямо в этот указатель, используя обратные вызовы libCURL. Это происходит, когда Ruby GVL разблокирован, так как все складывается в уровень C. На практике это означает, что на этапе «вытягивания» в куче Ruby не будет выделено ничего для хранения тела ответа.

Обратите внимание, что curb, другая широко используемая библиотека связывания CURL, нет этой функции — она будет выделять строки Ruby в куче и выдавать их вам, что противоречит цели.

Следующим шагом является подача этого содержимого в сокет TCP. Как это бывает - опять же - есть три способа сделать это.

  • Прочитайте данные из загруженного вами файла в кучу Ruby и запишите их в сокет
  • Напишите тонкую C-прокладку, которая выполняет запись сокета за вас, избегая кучи Ruby.
  • Используйте системный вызов sendfile() для выполнения операции «файл-сокет» в пространстве ядра, полностью избегая пользовательского пространства.

В любом случае вам нужно получить доступ к сокету TCP, поэтому вам нужна полная или частичная поддержка Rack hijack (проверьте документацию вашего веб-сервера, есть ли она у него или нет).

Мы решили пойти по третьему пути. sendfile — замечательный драгоценный камень автора Unicorn и Rainbows, и он выполняет именно это — дайте ему объект Ruby File и TCPSocket, и он попросит ядро ​​​​отправить файл в сокет, минуя как можно больше механизмов. Опять же, вам не нужно ничего читать в кучу. Итак, в конце концов, вот подход, который мы выбрали (псевдокодовый, не обрабатывает пограничные случаи):

# Use Tempfile to allocate a unique file name
tf = Tempfile.new('chunk')

# Download a part of the file using the Range header 
Patron::Session.new.get_file(the_url, tf.path, {'Range' => '..-..'})

# Use the blocking sendfile call (for demo purposes, you can also send in chunks).
# Note that non-blocking sendfile() is broken on OSX
socket.sendfile(file, start_reading_at=0, send_bytes=tf.size)

# Make sure to get rid of the file
tf.close; tf.unlink

Это позволяет нам обслуживать несколько подключений без обработки событий с очень небольшой загрузкой ЦП и очень небольшим объемом кучи. Мы регулярно видим, как ящики, обслуживающие сотни пользователей, используют при этом около 2% ЦП. И Ruby GC остается довольным. По сути, единственное, что нам не нравится в этой реализации, — это накладные расходы ОЗУ на 8 МБ на поток, накладываемые MRI. Однако, чтобы обойти это, нам нужно переключиться на событийный сервер (изобилие спагетти-кода) или написать собственный реактор ввода-вывода, который будет мультиплексировать большое количество соединений в гораздо меньший набор потоков, что, безусловно, выполнимо, но требует слишком много времени. много времени.

Надеюсь, это поможет кому-то.

person Julik    schedule 16.05.2016