rubyで動画などの大きなファイルをアップロードする場合のメモリの使い方について

動画ファイルなどの、メモリ量に対して巨大なファイルをアップロードする場合、ファイルを全て読み込んで送信／受信を行うとメモリを圧迫してしまうため問題がある場合があります。
こういう場合はメモリの使用量は一定範囲に抑えるために、バッファに少しづつ読み込んでは送信し、送信できたら次を送信し、あるいは少し受け取ったらファイルシステムに書き出し、書き出したら次を読み込むといったことをやることになります。
rubyでこれを行う場合にどういう方法をとればいいのかについて確認したのでまとめてみました。

# 結論

* 送信（クライアント側）
  * [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) を使う場合は [Net::HTTPGenericRequest#body_stream=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) で読み出し可能な [File](http://docs.ruby-lang.org/ja/2.1.0/class/File.html) や [IO](http://docs.ruby-lang.org/ja/2.1.0/class/IO.html)  を渡す。
  * [HTTPClient](https://github.com/nahi/httpclient) を使う場合はデータとして読み出し可能な [File](http://docs.ruby-lang.org/ja/2.1.0/class/File.html) や [IO](http://docs.ruby-lang.org/ja/2.1.0/class/IO.html) を渡す。
  * [RestClient](https://github.com/rest-client/rest-client) は1.7.3時点では対応していない。今後のバージョンでの対応は検討されている気配。
* 受信（サーバ側）
  * [Ruby on Rails](http://rubyonrails.org/)（というか [Rack](https://github.com/rack/rack)）を使っていれば、 multipart/form-data で送信されたものを受け取る分には問題なさそう。
  * ただし、実際に [Ruby on Rails](http://rubyonrails.org/) を動かしているサーバにも依存する。
  * [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html) ではダメだが、 [Thin](http://code.macournoyer.com/thin/), [Unicorn](http://unicorn.bogomips.org/), [PhusionPassenger](https://www.phusionpassenger.com/)([nginx](http://nginx.org/)で使用)などでは問題なさそう。
  * メモリの使い方で問題がなくても、一時ファイルの作り方には癖があるので考慮が必要な可能性がある。

確認したバージョンと環境は以下。

* ruby-2.1.2
* [httpclient-2.3.4.1](https://github.com/nahi/httpclient)
* [rest_client-1.7.3](https://github.com/rest-client/rest-client)
* [rack-1.5.2](https://github.com/rack/rack)
* [rails-4.1.1](http://rubyonrails.org/)
* [thin-1.6.2](http://code.macournoyer.com/thin/)
* [unicorn-4.8.3](http://unicorn.bogomips.org/)
* [passenger-4.0.42](https://www.phusionpassenger.com/)
* [nginx 1.6.0](http://nginx.org/)
* OS: lubuntu 14.04

# 送信

## [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) の場合

rubyでHTTP通信するといえば [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) です。標準で使えてけっこういろいろできますが、個人的にはどのIFを使うべきかがわかりくいです。まずはこちらを調べます。
[net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) のドキュメントに何パターンか使用例が挙げられていますが、全データをメモリに読み込まずに送信する方法は書かれていないので眺めても無駄です。
ではどうするかというと [Net::HTTPGenericRequest#body_stream=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) を使用します。
具体的なコード例は以下。

````ruby:net_http_upload_test.rb
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=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) に読み出し可能なIOを渡すと勝手にそこから少しづつ読み込んで送信してくれるわけですね。
ただし、上記のコードではファイルの中身をそのまま送信してしまうため、multipart/form-dataとかの形式では送信してくれません。
なので、そのままではきっとサーバ側でうまく対応できません。
multipart/form-dataにしたい場合は以下のようなコードでできます。
（とりあえずファイルひとつのみを送ることしかできないコードになっています。複数送れるようにするのは簡単だと思われます）

````ruby:net_http_upload_test2.rb
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](https://www.google.co.jp/search?hl=ja&q=ruby+Net+HTTP&lr=lang_ja#hl=ja&lr=lang_ja&q=ruby+net%2Fhttp&tbs=lr:lang_1ja)で検索すると、数日前までruby1.8.7のマニュアルが先頭に出てしまっていました。
[Net::HTTPGenericRequest#body_stream=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) は1.9系以降で追加されたメソッドらしく、1.8.7のマニュアルには記載がありません。このため、先頭に出てきたリンクから辿ろうとすると目的のメソッドにはたどり着けない羽目に陥っていたのでした。
バージョンにはくれぐれも気をつけましょう。


### バッファのサイズ

さて、ところで [Net::HTTPGenericRequest#body_stream=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) を使うと具体的にはどの程度のバッファサイズで読み込みが行われるのでしょうか。
せっかくIOを渡しても中で全部読み込んでたりなんかしようものなら意味がありません。そうでなくとも、MB単位のバッファサイズなら同時に複数同じ処理が走るようなケースでは影響を無視できません。
というわけで [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) のコードをおいかけてみると、以下のことがわかりました。

* [Net::HTTPGenericRequest#body_stream=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) でセットしたIOを使用しているのは [HTTPGenericRequest.html#send_request_with_body_stream](http://yard.ruby-doc.org/stdlib-2.0/Net/HTTPGenericRequest.html#send_request_with_body_stream-instance_method)
* [IO.copy_stream](http://docs.ruby-lang.org/ja/2.1.0/method/IO/s/copy_stream.html) で直接ソケットに渡している
* [IO.copy_stream](http://docs.ruby-lang.org/ja/2.1.0/method/IO/s/copy_stream.html) の実装はMRIではC言語で実装されていて、ストリームの中身を読み出して書き込みをしているのは、io.cの ``nogvl_copy_stream_read_write`` あたり。
* 上記の場所で ``char buf[1024*16];`` として16KBのメモリをスタック上に確保している。

ここで判明した16KBはあくまで [Net::HTTPGenericRequest#body_stream=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) に渡したIOから次のIOに渡す際に消費しているメモリであって、さらにその先の処理でどのようにメモリが使用されているかは別であることに注意してください。
そちらについては調べていませんが、ruby内で使用するメモリとしてはおそらく大きくても合計で100KBを超えることはないレベルだろうと予想できます。
（rubyの外まで考えると、TCPのウィンドウサイズとかまで考える必要があります）

## [HTTPClient](https://github.com/nahi/httpclient)

何も [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) を直接叩かなくても [HTTPClient](https://github.com/nahi/httpclient) とか使えばいいじゃない。ということでそちらも調べてみたところ、単純に [File](http://docs.ruby-lang.org/ja/2.1.0/class/File.html) オブジェクトを渡せばうまく処理してくれるようです。
[HTTPClient](https://github.com/nahi/httpclient) を使ったサンプルコードは以下。 multipart/form-data で送ってくれるようです。

````ruby:httpclient_upload_test.rb
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](https://github.com/nahi/httpclient) はどうやら中で [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) は使用せず Socket を使って独自に実装しているようでした。

## [RestClient](https://github.com/rest-client/rest-client)

[HTTPClient](https://github.com/nahi/httpclient) 以外に [RestClient](https://github.com/rest-client/rest-client) なんてものがあります。けっこう便利そうです。
最初はこれも単に [File](http://docs.ruby-lang.org/ja/2.1.0/class/File.html) オブジェクトを渡せばうまく処理してくれそうに見えたんですが、 [rest_client-1.7.3](https://github.com/rest-client/rest-client) ではまだ対応していないということがわかりました。

* [RestClient](https://github.com/rest-client/rest-client) 内のコードを眺めていると [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) の [Net::HTTPGenericRequest#body_stream=](http://docs.ruby-lang.org/ja/2.1.0/method/Net=3a=3aHTTPGenericRequest/i/body_stream.html) を使用するかのようなコードがある
* ただし、それを通常のルートで呼び出している場所では、 ``to_s`` してしまっているのでストリームとしては処理されない。特に回避方法も用意されていなさそう。

で、これはさすがにpull requestとかissueとかあるんじゃないかと思って探したところ、まさに以下で議論されているようです。（しばらく放置されてるけど）
https://github.com/rest-client/rest-client/pull/220
この対応が入れば何も考えずに [RestClient](https://github.com/rest-client/rest-client) 使ってOKということになりそうです。

## 送信まとめ

巨大ファイルの送信には [net/http](http://docs.ruby-lang.org/ja/2.1.0/library/net=2fhttp.html) か [HTTPClient](https://github.com/nahi/httpclient) を使いましょう。
個人的に [RestClient](https://github.com/rest-client/rest-client) が対応してないのは意外でした。何事も調べてみないとわからないものですね。

# 受信

送信だけでなく、サーバ側でアップロードされたものを受信する際にはどうなのか。気になったので [Ruby on Rails](http://rubyonrails.org/) について確認しました。
といってもファイルの受け取り方はいくつかあるので、ここではmultipart/form-dataあたりのケースで確認します。

## [Ruby on Rails](http://rubyonrails.org/), [Rack](https://github.com/rack/rack), [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html)

* [Ruby on Rails](http://rubyonrails.org/) ではmultipart/form-dataで送信されたファイルは [ActionDispatch::Http::UploadedFile](http://api.rubyonrails.org/classes/ActionDispatch/Http/UploadedFile.html) オブジェクトとして渡される。
* [ActionDispatch::Http::UploadedFile](http://api.rubyonrails.org/classes/ActionDispatch/Http/UploadedFile.html) は中に [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) オブジェクトを保持しており、これがファイルの中身となっている。
* この [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) は [Rack](https://github.com/rack/rack) の [rack-1.5.2/lib/rack/multipart/parser.rb](https://github.com/rack/rack/blob/1.5.2/lib/rack/multipart/parser.rb#L104) で作成されている。
* [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) への書き込みのバッファサイズは [Rack::Multipart::Parser::BUFSIZE](https://github.com/rack/rack/blob/1.5.2/lib/rack/multipart/parser.rb#L6) という定数で決められている。
* サイズは16384で [IO.copy_stream](http://docs.ruby-lang.org/ja/2.1.0/method/IO/s/copy_stream.html) と同じ大きさ。

というわけで、 [Rack](https://github.com/rack/rack) を使用しているサーバなら、メモリにファイル全体を読み込まない受信に対応しているといえそうです。
と、思ったのですが、実際に [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html) で試してみるとメモリにファイルを読み込んでしまいました。
どこでファイルを読み込んでいるかを調べてみたところ、 [Rack::Handler::WEBrick#service](https://github.com/rack/rack/blob/master/lib/rack/handler/webrick.rb#L45) でした。
ここでbodyをto_sしたタイミングでせっかくのストリームの中身を一度メモリに読み込んでしまっているようです。
まあ、 [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html) なのでしょうがないのかもしれません。

## [PhusionPassenger](https://www.phusionpassenger.com/), [Thin](http://code.macournoyer.com/thin/), [Unicorn](http://unicorn.bogomips.org/)

では [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html) 以外ならどうなっているのか、 [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html) 以外のサーバを使用した場合について、実際に動かしてメモリを消費するか確認してみました。
結論としては、 [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html) 以外はメモリの使用の仕方については全てOKでした。
以下は各サーバを使用したときの感想です。

### [PhusionPassenger](https://www.phusionpassenger.com/) (with [nginx](http://nginx.org/))

[nginx](http://nginx.org/) は [Rack](https://github.com/rack/rack) とは別に受信したmultipartのファイルを一時ファイルに保存しているようです。
[Rack](https://github.com/rack/rack) 側でも [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) が作られるので、一時的には受信したファイルサイズの二倍のディスク容量が必要になります。

実際にデータがどのように渡されるかを見てみると、以下のようになっています。

1. ngincがファイル全体を一度受信し一時ファイルを作成してから、 [PhusionPassenger](https://www.phusionpassenger.com/) を通じて [Rack](https://github.com/rack/rack) に渡す。
2. [Rack](https://github.com/rack/rack) でも一度ファイルを受信しきってから、railsに渡す。

このため、実際に受け取り始めてから、railsアプリケーションが処理を開始できるまでに、大きなファイルを２回ローカルのファイルシステムに書き込むだけの時間が必要になってしまいます。

### [Thin](http://code.macournoyer.com/thin/)

[Thin](http://code.macournoyer.com/thin/) の場合でも [nginx](http://nginx.org/)-[PhusionPassenger](https://www.phusionpassenger.com/) の場合と同様、まず [Thin](http://code.macournoyer.com/thin/) 側で一時ファイルを作成して書き込み、完全に受信した後に [Rack](https://github.com/rack/rack) 側にデータを渡し、そこでまた [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) が作られてrailsに渡されるようです。

理由はわかりませんが１度のリクエストで、内部的には２回 [Rack](https://github.com/rack/rack) へ処理が渡っているように見受けられました。
この２回の [Rack](https://github.com/rack/rack) での処理でそれぞれ [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) を作っているらしく、 [Thin](http://code.macournoyer.com/thin/) 側のものを含めると合計３倍のディスク容量が一時的に消費されていました。
もしかすると、設定が悪いのかもしれませんが、この動作はあまり望ましくはなさそうです。
当然ながらrailsアプリケーションで処理を開始するまでにかかる時間も [PhusionPassenger](https://www.phusionpassenger.com/) の場合より長くなる可能性があります。

[Thin](http://code.macournoyer.com/thin/) 側の一時ファイルは [Rack](https://github.com/rack/rack) と同じく [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) が使われているようです。

### [Unicorn](http://unicorn.bogomips.org/)

[Unicorn](http://unicorn.bogomips.org/) の場合でも一時ファイルに書き込まれているのは同じようですが、完全に受信するのを待たず、 [Rack](https://github.com/rack/rack) 側にデータを渡しているようです。
このため、他のサーバよりも早く処理を開始できそうでした。
といっても [Rack](https://github.com/rack/rack) では一度 [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) に書き出すのを待つので、即開始できるわけではありません。
さらにいうと、これは [Unicorn](http://unicorn.bogomips.org/) を直接使った場合で、 [nginx](http://nginx.org/) を間に入れると [nginx](http://nginx.org/) が受信をまってしまいますので結局２回書き込む分の時間が必要になります。

なお、 [Unicorn](http://unicorn.bogomips.org/) では [Tempfile](http://docs.ruby-lang.org/ja/2.1.0/class/Tempfile.html) ではなく独自に一時ファイル作成クラスを持っているようです。

## 受信まとめ

受信側のまとめとしては、 [WEBrick](http://docs.ruby-lang.org/ja/2.1.0/library/webrick.html) 以外であればメモリにファイル全体を読み込んでしまう心配はなさそうです。
ただし、それぞれ一時ファイルの作り方などに癖があるようなので、ディスクサイズやタイムアウト時間を決める上では考慮したほうがよいかもしれません。
ここで見た観点だけで考えると、 [nginx](http://nginx.org/) とセットで使用するなら [PhusionPassenger](https://www.phusionpassenger.com/)、単独で使用するなら [Unicorn](http://unicorn.bogomips.org/) がよさそうです。

# 全体感想

やはりというかrubyは今のところ、完全にストリームだけでデータをやりとりするには（ライブラリを含めた環境という意味で）向いていないな、という感想です。
そういう部分はrubyには期待すべきではないのかもしれません。