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

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

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

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

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

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

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

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

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

ОБНОВИТЬ: Оказывается, что большинство обработчиков Rack (Unicorn и т. Д.) Используют цикл write () в теле ответа, что может привести к занятому ожиданию (с высокой загрузкой ЦП), когда ответ не может быть записан достаточно быстро. Это может быть смягчено до некоторой степени с помощью 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

3

Решение

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

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

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

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

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

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

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

В любом случае, вам нужно добраться до сокета 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. Однако, чтобы обойти это, нам нужно было бы переключиться на сервер с четным кодом (большой объем кода спагетти) или написать собственный реактор ввода-вывода, который бы мультиплексировал большое количество соединений в гораздо меньший залп потоков, что, безусловно, выполнимо, но потребовало бы слишком много времени.

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

1

Другие решения

Других решений пока нет …

По вопросам рекламы [email protected]