Облегченный потоковый 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

1 ответ

Решение

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

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

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

Оказывается, Patron является единственным HTTP-клиентом Ruby, который позволяет осуществлять прямую запись в файл в пространстве пользователя. Это означает, что вы можете загрузить некоторые данные по HTTP, не выделяя строку ruby ​​для данных, которые вы извлекаете. Патрон имеет функцию, которая откроет FILE* указатель и запись прямо в этот указатель, используя обратные вызовы libCURL. Это происходит, когда Ruby GVL разблокирован, поскольку все складывается в уровень C. На практике это означает, что на этапе "извлечения" в куче 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. Однако, чтобы обойти это, нам нужно было бы переключиться на сервер с четным кодом (большой объем кода спагетти) или написать собственный реактор ввода-вывода, который бы мультиплексировал большое количество соединений в гораздо меньший залп потоков, что, безусловно, выполнимо, но потребовало бы слишком много времени.

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

Другие вопросы по тегам