Rack 的轻量级流式 HTTP 代理(Ruby CPU-light HTTP 客户端库)
Lightweight streaming HTTP proxy for Rack (Ruby CPU-light HTTP client library)
所以我正在尝试一种情况,我想通过我的服务器将巨大的文件从 third-party URL 流式传输到请求客户端。
到目前为止,我已尝试通过遵守 "eachable" 响应主体的标准 Rack 实践,使用 Curb 或 Net::HTTP 实现此功能,如下所示:
class StreamBody
...
def each
some_http_library.on_body do | body_chunk |
yield(body_chunk)
end
end
end
但是我不能让这个系统使用少于 40% CPU(在我的 MacBook Air 上)。如果我尝试对 Goliath 做同样的事情,使用 em-synchrony(就像 Goliath 页面上建议的那样)我可以将 CPU 的使用率降低到大约 25% CPU,但是我无法做到冲洗 headers。我在请求客户端中的流式下载 "hangs" 和 headers 在整个响应发送到客户端后显示,无论我提供什么 headers。
我是否认为这是 Ruby 非常糟糕的情况之一,我必须转向世界的 go 和 nodejs'es?
相比之下,我们目前使用 PHP 从 CURL 流式传输到 PHP 输出流,并且只需要很少的 CPU 开销。
或者是否有我可以要求处理我的东西的上游代理解决方案?问题是 - 我想在整个 body 被发送到套接字后可靠地调用 Ruby 函数,而 nginx 代理之类的东西不会为我做。
更新: 我试图为 HTTP 客户端做一个简单的基准测试,看起来大多数 CPU 使用的是 HTTP 客户端库。有 Ruby HTTP 客户端的基准测试,但它们基于响应接收时间 - 而从未提及 CPU 用法。在我的测试中,我执行了 HTTP 流式下载,将结果写入 /dev/null
,并获得了一致的 30-40% CPU 使用率,这与我通过任何流式传输时的 CPU 使用率相匹配机架处理程序。
更新:事实证明,大多数 Rack 处理程序(Unicorn 等)在响应 body 上使用 write() 循环,这可能会进入忙等待( CPU 负载高)当响应写入速度不够快时。这可以通过使用 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
没有答案,但最终我们设法找到了解决方案。它非常成功,因为我们每天都通过它传输数 TB 的数据。以下是关键成分:
- patron 作为 HTTP 客户端。我会在答案下面解释选择
- 一个强大的线程网络服务器(如 Puma)
- 发送文件gem
想要用 Ruby 构建这样的东西的主要问题是我称之为 string churn。 基本上,在 VM 中分配字符串不是免费的。当你推送大量数据时,你最终会为从上游源接收到的每个数据块分配一个 Ruby 字符串,如果你无法 write()
,你也可能最终会分配一个字符串整个块到代表您的客户端通过 TCP 连接的套接字。因此,在我们尝试过的所有方法中,我们无法找到一种解决方案来避免字符串流失 - 在我们偶然发现 Patron 之前,也就是说。
事实证明,Patron 是唯一允许在用户 space 中直接写入文件的 Ruby HTTP 客户端。这意味着您可以通过 HTTP 下载一些数据,而无需为您提取的数据分配 ruby 字符串。 Patron 有一个函数可以打开 FILE*
指针并使用 libCURL 回调直接写入该指针。这发生在 Ruby GVL 解锁时,因为所有内容都折叠到 C 级别。实际上,这意味着在 "pull" 阶段,不会在 Ruby 堆中分配任何内容来存储响应主体。
请注意,另一个广泛使用的 CURL 绑定库 curb 没有 具有该功能 - 它会在堆上分配 Ruby 个字符串并将它们产生给你,这违背了目的。
下一步是将该内容提供给 TCP 套接字。碰巧 - 再一次 - 有三种方法可以做到这一点。
- 从你下载的文件中读取数据到Ruby堆中,并写入socket
- 编写一个薄的 C 垫片来为您执行套接字写入,避免 Ruby 堆
- 使用
sendfile()
系统调用在内核space中执行文件到套接字操作,完全避免用户space。
无论哪种方式,您都需要获得 TCP 套接字 - 因此您需要具有全部或部分 Rack 劫持支持(验证您的网络服务器文档是否具有)。
我们决定采用第三种选择。 sendfile
是 Unicorn 和 Rainbows 作者的精彩 gem,它完成了这一点 - 给它一个 Ruby 文件对象和 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
这使我们能够为多个连接提供服务,无需事件,CPU 负载和堆压力非常小。我们经常看到服务数百名用户的盒子在这样做时使用了大约 2% CPU。 Ruby GC 保持快乐。本质上,我们唯一不喜欢此实现的是 MRI 强加的每线程 8MB RAM 开销。然而,要解决这个问题,我们需要切换到事件服务器(大量意大利面条代码)或编写我们自己的 IO 反应器,将大量连接多路复用到更小的线程齐发上,这当然是可行的,但也需要很多时间。
希望这对某人有所帮助。
所以我正在尝试一种情况,我想通过我的服务器将巨大的文件从 third-party URL 流式传输到请求客户端。
到目前为止,我已尝试通过遵守 "eachable" 响应主体的标准 Rack 实践,使用 Curb 或 Net::HTTP 实现此功能,如下所示:
class StreamBody
...
def each
some_http_library.on_body do | body_chunk |
yield(body_chunk)
end
end
end
但是我不能让这个系统使用少于 40% CPU(在我的 MacBook Air 上)。如果我尝试对 Goliath 做同样的事情,使用 em-synchrony(就像 Goliath 页面上建议的那样)我可以将 CPU 的使用率降低到大约 25% CPU,但是我无法做到冲洗 headers。我在请求客户端中的流式下载 "hangs" 和 headers 在整个响应发送到客户端后显示,无论我提供什么 headers。
我是否认为这是 Ruby 非常糟糕的情况之一,我必须转向世界的 go 和 nodejs'es?
相比之下,我们目前使用 PHP 从 CURL 流式传输到 PHP 输出流,并且只需要很少的 CPU 开销。
或者是否有我可以要求处理我的东西的上游代理解决方案?问题是 - 我想在整个 body 被发送到套接字后可靠地调用 Ruby 函数,而 nginx 代理之类的东西不会为我做。
更新: 我试图为 HTTP 客户端做一个简单的基准测试,看起来大多数 CPU 使用的是 HTTP 客户端库。有 Ruby HTTP 客户端的基准测试,但它们基于响应接收时间 - 而从未提及 CPU 用法。在我的测试中,我执行了 HTTP 流式下载,将结果写入 /dev/null
,并获得了一致的 30-40% CPU 使用率,这与我通过任何流式传输时的 CPU 使用率相匹配机架处理程序。
更新:事实证明,大多数 Rack 处理程序(Unicorn 等)在响应 body 上使用 write() 循环,这可能会进入忙等待( CPU 负载高)当响应写入速度不够快时。这可以通过使用 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
没有答案,但最终我们设法找到了解决方案。它非常成功,因为我们每天都通过它传输数 TB 的数据。以下是关键成分:
- patron 作为 HTTP 客户端。我会在答案下面解释选择
- 一个强大的线程网络服务器(如 Puma)
- 发送文件gem
想要用 Ruby 构建这样的东西的主要问题是我称之为 string churn。 基本上,在 VM 中分配字符串不是免费的。当你推送大量数据时,你最终会为从上游源接收到的每个数据块分配一个 Ruby 字符串,如果你无法 write()
,你也可能最终会分配一个字符串整个块到代表您的客户端通过 TCP 连接的套接字。因此,在我们尝试过的所有方法中,我们无法找到一种解决方案来避免字符串流失 - 在我们偶然发现 Patron 之前,也就是说。
事实证明,Patron 是唯一允许在用户 space 中直接写入文件的 Ruby HTTP 客户端。这意味着您可以通过 HTTP 下载一些数据,而无需为您提取的数据分配 ruby 字符串。 Patron 有一个函数可以打开 FILE*
指针并使用 libCURL 回调直接写入该指针。这发生在 Ruby GVL 解锁时,因为所有内容都折叠到 C 级别。实际上,这意味着在 "pull" 阶段,不会在 Ruby 堆中分配任何内容来存储响应主体。
请注意,另一个广泛使用的 CURL 绑定库 curb 没有 具有该功能 - 它会在堆上分配 Ruby 个字符串并将它们产生给你,这违背了目的。
下一步是将该内容提供给 TCP 套接字。碰巧 - 再一次 - 有三种方法可以做到这一点。
- 从你下载的文件中读取数据到Ruby堆中,并写入socket
- 编写一个薄的 C 垫片来为您执行套接字写入,避免 Ruby 堆
- 使用
sendfile()
系统调用在内核space中执行文件到套接字操作,完全避免用户space。
无论哪种方式,您都需要获得 TCP 套接字 - 因此您需要具有全部或部分 Rack 劫持支持(验证您的网络服务器文档是否具有)。
我们决定采用第三种选择。 sendfile
是 Unicorn 和 Rainbows 作者的精彩 gem,它完成了这一点 - 给它一个 Ruby 文件对象和 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
这使我们能够为多个连接提供服务,无需事件,CPU 负载和堆压力非常小。我们经常看到服务数百名用户的盒子在这样做时使用了大约 2% CPU。 Ruby GC 保持快乐。本质上,我们唯一不喜欢此实现的是 MRI 强加的每线程 8MB RAM 开销。然而,要解决这个问题,我们需要切换到事件服务器(大量意大利面条代码)或编写我们自己的 IO 反应器,将大量连接多路复用到更小的线程齐发上,这当然是可行的,但也需要很多时间。
希望这对某人有所帮助。