動画ファイルなどの、メモリ量に対して巨大なファイルをアップロードする場合、ファイルを全て読み込んで送信/受信を行うとメモリを圧迫してしまうため問題がある場合があります。
こういう場合はメモリの使用量は一定範囲に抑えるために、バッファに少しづつ読み込んでは送信し、送信できたら次を送信し、あるいは少し受け取ったらファイルシステムに書き出し、書き出したら次を読み込むといったことをやることになります。
rubyでこれを行う場合にどういう方法をとればいいのかについて確認したのでまとめてみました。
結論
- 送信(クライアント側)
- net/http を使う場合は Net::HTTPGenericRequest#body_stream= で読み出し可能な File や IO を渡す。
- HTTPClient を使う場合はデータとして読み出し可能な File や IO を渡す。
- RestClient は1.7.3時点では対応していない。今後のバージョンでの対応は検討されている気配。
- 受信(サーバ側)
- Ruby on Rails(というか Rack)を使っていれば、 multipart/form-data で送信されたものを受け取る分には問題なさそう。
- ただし、実際に Ruby on Rails を動かしているサーバにも依存する。
- WEBrick ではダメだが、 Thin, Unicorn, PhusionPassenger(nginxで使用)などでは問題なさそう。
- メモリの使い方で問題がなくても、一時ファイルの作り方には癖があるので考慮が必要な可能性がある。
確認したバージョンと環境は以下。
- ruby-2.1.2
- httpclient-2.3.4.1
- rest_client-1.7.3
- rack-1.5.2
- rails-4.1.1
- thin-1.6.2
- unicorn-4.8.3
- passenger-4.0.42
- nginx 1.6.0
- OS: lubuntu 14.04
送信
net/http の場合
rubyでHTTP通信するといえば net/http です。標準で使えてけっこういろいろできますが、個人的にはどのIFを使うべきかがわかりくいです。まずはこちらを調べます。
net/http のドキュメントに何パターンか使用例が挙げられていますが、全データをメモリに読み込まずに送信する方法は書かれていないので眺めても無駄です。
ではどうするかというと Net::HTTPGenericRequest#body_stream= を使用します。
具体的なコード例は以下。
require "net/http"
def post_huge_file(url_str, path)
url = URI.parse(url_str)
http = Net::HTTP.new(url.host, url.port)
http.start do |http|
req = Net::HTTP::Post.new(url.path)
File.open(path, 'rb') do |f|
req.body_stream = f
req["Content-Length"] = f.size
return http.request(req)
end
end
end
file_path = "file.mp4"
url_str = "http://localhost:3000/upload"
p post_huge_file(url_str, file_path)
Net::HTTPGenericRequest#body_stream= に読み出し可能なIOを渡すと勝手にそこから少しづつ読み込んで送信してくれるわけですね。
ただし、上記のコードではファイルの中身をそのまま送信してしまうため、multipart/form-dataとかの形式では送信してくれません。
なので、そのままではきっとサーバ側でうまく対応できません。
multipart/form-dataにしたい場合は以下のようなコードでできます。
(とりあえずファイルひとつのみを送ることしかできないコードになっています。複数送れるようにするのは簡単だと思われます)
require "net/http"
require "stringio"
class MultiPartFormDataStream
def initialize(name, filename, file, boundary=nil)
@boundary = boundary || "boundary"
first = [boundary_line, content_disposition(name, filename), "", ""].join(new_line)
last = ["", boundary_last, ""].join(new_line)
@first = StringIO.new(first)
@file = file
@last = StringIO.new(last)
@size = @first.size + @file.size + @last.size
end
def content_type
"multipart/form-data; boundary=#{@boundary}"
end
def boundary_line
"--#{@boundary}"
end
def boundary_last
"--#{@boundary}--"
end
def content_disposition(name, filename)
"content-disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\""
end
def new_line
"\r\n"
end
def read(len=nil, buf=nil)
return @first.read(len, buf) unless @first.eof?
return @file.read(len, buf) unless @file.eof?
return @last.read(len, buf)
end
def size
@size
end
def eof?
@last.eof?
end
end
def post_huge_file(url_str, path)
url = URI.parse(url_str)
http = Net::HTTP.new(url.host, url.port)
http.start do |http|
req = Net::HTTP::Post.new(url.path)
File.open(path, 'rb') do |f|
form_data = MultiPartFormDataStream.new("file", File.basename(path), f)
req.body_stream = form_data
req["Content-Length"] = form_data.size
req["Content-Type"] = form_data.content_type
return http.request(req)
end
end
end
file_path = "file.mp4"
url_str = "http://localhost:3000/upload"
p post_huge_file(url_str, file_path)
あと、実はもうひとつ、ここにたどり着くまでに微妙な落とし穴がありました(現在は問題無いようです)。
それは何かと言うと、Googleでruby net/httpで検索すると、数日前までruby1.8.7のマニュアルが先頭に出てしまっていました。
Net::HTTPGenericRequest#body_stream= は1.9系以降で追加されたメソッドらしく、1.8.7のマニュアルには記載がありません。このため、先頭に出てきたリンクから辿ろうとすると目的のメソッドにはたどり着けない羽目に陥っていたのでした。
バージョンにはくれぐれも気をつけましょう。
バッファのサイズ
さて、ところで Net::HTTPGenericRequest#body_stream= を使うと具体的にはどの程度のバッファサイズで読み込みが行われるのでしょうか。
せっかくIOを渡しても中で全部読み込んでたりなんかしようものなら意味がありません。そうでなくとも、MB単位のバッファサイズなら同時に複数同じ処理が走るようなケースでは影響を無視できません。
というわけで net/http のコードをおいかけてみると、以下のことがわかりました。
- Net::HTTPGenericRequest#body_stream= でセットしたIOを使用しているのは HTTPGenericRequest.html#send_request_with_body_stream
- IO.copy_stream で直接ソケットに渡している
-
IO.copy_stream の実装はMRIではC言語で実装されていて、ストリームの中身を読み出して書き込みをしているのは、io.cの
nogvl_copy_stream_read_write
あたり。 - 上記の場所で
char buf[1024*16];
として16KBのメモリをスタック上に確保している。
ここで判明した16KBはあくまで Net::HTTPGenericRequest#body_stream= に渡したIOから次のIOに渡す際に消費しているメモリであって、さらにその先の処理でどのようにメモリが使用されているかは別であることに注意してください。
そちらについては調べていませんが、ruby内で使用するメモリとしてはおそらく大きくても合計で100KBを超えることはないレベルだろうと予想できます。
(rubyの外まで考えると、TCPのウィンドウサイズとかまで考える必要があります)
HTTPClient
何も net/http を直接叩かなくても HTTPClient とか使えばいいじゃない。ということでそちらも調べてみたところ、単純に File オブジェクトを渡せばうまく処理してくれるようです。
HTTPClient を使ったサンプルコードは以下。 multipart/form-data で送ってくれるようです。
require 'httpclient'
def send_huge_file(url_str, file_path)
boundary = "boundary"
client = HTTPClient.new
File.open(file_path) do |io|
postdata = {
"file" => io,
}
return client.post_content(url_str, postdata, {
"content-type" => "multipart/form-data; boundary=#{boundary}",
})
end
end
file_path = "file.mp4"
url_str = "http://localhost:3000/upload"
p send_huge_file(url_str, file_path)
少し中身のコードも見てみたところ、 HTTPClient はどうやら中で net/http は使用せず Socket を使って独自に実装しているようでした。
RestClient
HTTPClient 以外に RestClient なんてものがあります。けっこう便利そうです。
最初はこれも単に File オブジェクトを渡せばうまく処理してくれそうに見えたんですが、 rest_client-1.7.3 ではまだ対応していないということがわかりました。
- RestClient 内のコードを眺めていると net/http の Net::HTTPGenericRequest#body_stream= を使用するかのようなコードがある
- ただし、それを通常のルートで呼び出している場所では、
to_s
してしまっているのでストリームとしては処理されない。特に回避方法も用意されていなさそう。
で、これはさすがにpull requestとかissueとかあるんじゃないかと思って探したところ、まさに以下で議論されているようです。(しばらく放置されてるけど)
https://github.com/rest-client/rest-client/pull/220
この対応が入れば何も考えずに RestClient 使ってOKということになりそうです。
送信まとめ
巨大ファイルの送信には net/http か HTTPClient を使いましょう。
個人的に RestClient が対応してないのは意外でした。何事も調べてみないとわからないものですね。
受信
送信だけでなく、サーバ側でアップロードされたものを受信する際にはどうなのか。気になったので Ruby on Rails について確認しました。
といってもファイルの受け取り方はいくつかあるので、ここではmultipart/form-dataあたりのケースで確認します。
Ruby on Rails, Rack, WEBrick
- Ruby on Rails ではmultipart/form-dataで送信されたファイルは ActionDispatch::Http::UploadedFile オブジェクトとして渡される。
- ActionDispatch::Http::UploadedFile は中に Tempfile オブジェクトを保持しており、これがファイルの中身となっている。
- この Tempfile は Rack の rack-1.5.2/lib/rack/multipart/parser.rb で作成されている。
- Tempfile への書き込みのバッファサイズは Rack::Multipart::Parser::BUFSIZE という定数で決められている。
- サイズは16384で IO.copy_stream と同じ大きさ。
というわけで、 Rack を使用しているサーバなら、メモリにファイル全体を読み込まない受信に対応しているといえそうです。
と、思ったのですが、実際に WEBrick で試してみるとメモリにファイルを読み込んでしまいました。
どこでファイルを読み込んでいるかを調べてみたところ、 Rack::Handler::WEBrick#service でした。
ここでbodyをto_sしたタイミングでせっかくのストリームの中身を一度メモリに読み込んでしまっているようです。
まあ、 WEBrick なのでしょうがないのかもしれません。
PhusionPassenger, Thin, Unicorn
では WEBrick 以外ならどうなっているのか、 WEBrick 以外のサーバを使用した場合について、実際に動かしてメモリを消費するか確認してみました。
結論としては、 WEBrick 以外はメモリの使用の仕方については全てOKでした。
以下は各サーバを使用したときの感想です。
PhusionPassenger (with nginx)
nginx は Rack とは別に受信したmultipartのファイルを一時ファイルに保存しているようです。
Rack 側でも Tempfile が作られるので、一時的には受信したファイルサイズの二倍のディスク容量が必要になります。
実際にデータがどのように渡されるかを見てみると、以下のようになっています。
- ngincがファイル全体を一度受信し一時ファイルを作成してから、 PhusionPassenger を通じて Rack に渡す。
- Rack でも一度ファイルを受信しきってから、railsに渡す。
このため、実際に受け取り始めてから、railsアプリケーションが処理を開始できるまでに、大きなファイルを2回ローカルのファイルシステムに書き込むだけの時間が必要になってしまいます。
Thin
Thin の場合でも nginx-PhusionPassenger の場合と同様、まず Thin 側で一時ファイルを作成して書き込み、完全に受信した後に Rack 側にデータを渡し、そこでまた Tempfile が作られてrailsに渡されるようです。
理由はわかりませんが1度のリクエストで、内部的には2回 Rack へ処理が渡っているように見受けられました。
この2回の Rack での処理でそれぞれ Tempfile を作っているらしく、 Thin 側のものを含めると合計3倍のディスク容量が一時的に消費されていました。
もしかすると、設定が悪いのかもしれませんが、この動作はあまり望ましくはなさそうです。
当然ながらrailsアプリケーションで処理を開始するまでにかかる時間も PhusionPassenger の場合より長くなる可能性があります。
Thin 側の一時ファイルは Rack と同じく Tempfile が使われているようです。
Unicorn
Unicorn の場合でも一時ファイルに書き込まれているのは同じようですが、完全に受信するのを待たず、 Rack 側にデータを渡しているようです。
このため、他のサーバよりも早く処理を開始できそうでした。
といっても Rack では一度 Tempfile に書き出すのを待つので、即開始できるわけではありません。
さらにいうと、これは Unicorn を直接使った場合で、 nginx を間に入れると nginx が受信をまってしまいますので結局2回書き込む分の時間が必要になります。
なお、 Unicorn では Tempfile ではなく独自に一時ファイル作成クラスを持っているようです。
受信まとめ
受信側のまとめとしては、 WEBrick 以外であればメモリにファイル全体を読み込んでしまう心配はなさそうです。
ただし、それぞれ一時ファイルの作り方などに癖があるようなので、ディスクサイズやタイムアウト時間を決める上では考慮したほうがよいかもしれません。
ここで見た観点だけで考えると、 nginx とセットで使用するなら PhusionPassenger、単独で使用するなら Unicorn がよさそうです。
全体感想
やはりというかrubyは今のところ、完全にストリームだけでデータをやりとりするには(ライブラリを含めた環境という意味で)向いていないな、という感想です。
そういう部分はrubyには期待すべきではないのかもしれません。