Tech Racho エンジニアの「?」を「!」に。
  • 開発

Railsで大きなファイルを扱う際のポイント

Railsで大きなファイルを扱う際のポイントをまとめてみました。

前提

大きなファイルとは

だいたい100MB~10GBくらいのファイルをダウンロード・アップロードするのを想定することにします。
数MB程度だと、特別な工夫なしでもそれほど問題になりません。10GBを超えてくると、気をつけるべき点が変わってくるかと思います。

以下では主にサンプルとして、1GBのファイル(ISOファイルやZIPファイルなど)を想定します。

環境

以下のような環境を想定します。

  • Railsは4系
  • Nginx + Unicornのスタンダードな構成
  • サーバ1台のシンプルな構成(ロードバランサを使用した複数台構成については、末尾に少し記載しています)

ダウンロード

ファイルのダウンロード

まずは、Railsアプリから大きなファイルを配信するケースを考えましょう。
たとえば、ISOファイルをサーバ内に保存しておいて、認証されたユーザはそれをダウンロードできる、というケースです。

シンプルに実装すると、このような感じになると思います。

# app/controllers/files_controller.rb
class FilesController < ApplicationController
  def show
    send_file Rails.root.join("files/01.iso")
  end
end

しかし、巨大なファイルをRailsが配信するのは、よく考えるとかなり無駄です。

1GBのISOファイルを配信する場合、クライアントとの帯域が50Mbps確保できたとしても、単純計算で160秒間ワーカーが占有されることになります。これでは、Unicornのワーカーを50個用意してもすぐになくなってしまいます。

ファイルを送信するだけなら、Reverse Proxyとして使っているNginxやApacheに処理してもらった方が効率的ですよね。Nginxなら、同時接続数3000くらいあっても余裕でしょう。

方法1: publicに置く

アクセス制限が不要なら、publicなディレクトリにファイルを配置し、そのままNginxやApacheで配信してしまえば良いです。スタイルシートなどのassetsも、この方法で配信します。

方法2: X-Sendfile / X-Accel-Redirectを使う

認証が必要な場合、Rails側からX-Sendfileを指定すると良いでしょう。
方法は、production.rbで以下の行を記述するだけです。デフォルトではコメントアウトで記載されていると思います。

# config/environments/production.rb
# ...
    # Apacheを使う場合
    config.action_dispatch.x_sendfile_header = 'X-Sendfile'

    # Nginxを使う場合
    config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'
# ...

これを記載すると、RailsはISOファイル本体を読み取ってResponse Bodyを送信する代わりに、HTTPヘッダに「X-Accel-Redirect: /path/to/files/01.iso」のようなヘッダを付与し、Response Bodyを空にしてレスポンスを終了します。そのヘッダを受け取ったNginxがpublicディレクトリの場合と同じようにファイルを送信するため、Railsのワーカーはすぐに解放されます。

これは内部的にはRack::SendfileというMiddlewareで実現されているので、Sinatraなどで活用することも可能です。

方法3: 別のサーバから配信する

静的ファイルなら、S3など別のサーバに置いておけば、帯域などの心配をせずにすんで楽です。
認証が必要な場合でも、S3のAPIで有効期限付きのURLを発行できるので、問題ないでしょう。
ダウンロード数が多い場合は、検討すべきです。

ストリーミングダウンロード

データがサーバ上の実ファイルではない場合、Railsで順次送信するしかありません。
データの生成に時間がかかる場合(巨大なPDFを作る、別サーバからダウンロードしたデータを順次流すなど)、すべてのデータが準備できる前に送信開始したいものです。

その場合は、response.streamを使用します。
http://api.rubyonrails.org/classes/ActionController/Streaming.html

# app/controllers/download_controller.rb
class DownloadController < ApplicationController
  include ActionController::Live

  def show
    3.times do 
      response.stream.write "Some Data"
      sleep 1
    end
  ensure
    response.stream.close
  end
end

いくつか注意点があります。

なるべくContent-Lengthを指定する

事前にContent-Lengthを指定しておけば、ブラウザ側で「何%ダウンロード中...」とプログレスが表示されます。
もちろん、ヘッダはボディ送信後には送信できないので、最初のデータを送信する前にContent-Lengthを指定する必要があります。

pumaなど対応サーバを使う

UnicornやPumaなどのサーバなら問題ありませんが、Webrickなどを使うとストリーミングが動作しません。
この場合、レスポンスがすべてバッファリングされるため、実質通常レスポンスと同じような挙動になります。

エラー処理を工夫する

データ送信中にエラーが発生しても、すでにレスポンスコードは返しているため、あとからInternal Server Errorに変更するわけにもいきません。
このあたりは設計でカバーする必要があります。

上段でのバッファリングを無効化する

上段のReverse Proxyで、結局全部バッファリングされてしまうようなケースもあります。Nginxなら、X-Accel-Bufferingなどを確認しましょう。

また特定バージョン(忘れました)で、Keep-Aliveとバッファリングの設定によって、Rails側からcloseしてもタイムアウトまでレスポンスを待ち続けてしまうことがありました。なにやら30秒~60秒くらい固まってしまうときは、この辺のバグも疑ってみましょう。

アップロード

次は、大きなファイルをアップロードするケースを考えてみます。
たとえば、ファイルアップローダを作る場合です。ファイルアップロードには、CarrierwaveのGemを使うことを想定します。

クライアントサイドの工夫

クライアント側は、通常のHTMLによる<input type="file">でも良いですが、これだとアップロード中に固まったように見えるため(最近のブラウザではステータスバーに進捗が出ることはありますが)、UXとしてはいまいちです。

jQuery File Uploadなどを使えば、視覚的なプログレスバーを表示できるのでおすすめです。なお、今回はクライアント側についての詳細は省きます。

何も工夫しない場合

単純に実装すると、このような流れになります。

upload1

青い部分がデータ転送やファイルコピー・移動で、上下の太さはかかる時間の大小を表すつもりで見てください。1GBのファイルをアップロードする場合では、以下のような点で時間がかかります。
ファイルアップロードに関係ない各種処理やオーバーヘッドは除外しています。

1:ブラウザからNginxへHTTP送信
通常、一番時間がかかる部分です。帯域に依存します。たとえば実効速度で50Mbpsの帯域なら、160秒程度かかります。
2:NginxからRailsにHTTP送信
Nginxがバッファリングしたデータを、Railsアプリに送信します。Reverse ProxyであるNginxとRailsアプリは、通常ローカルネットワークまたは同一マシンにあるため、帯域は十分でしょう。仮に800Mbpsとして、10秒程度かかります。Railsアプリが受信したデータは、Rackのレイヤーで一時ファイルに保存され、Rack::Multipart::UploadedFileとしてControllerから参照できます。
3:Carrierwaveのcache dirにコピー
Carrierwaveは、Rack::Multipart::UploadedFileを一時ディレクトリにコピーします。これはローカルストレージ内でのコピーなので比較的高速です。仮に200MB/秒として、5秒程度かかります。
4:Carrierwaveの途中処理
version指定をした場合など、ここで加工処理(たとえばImageMagickで画像縮小)が実施されます。今回はシンプルなアップローダなので、何もしません。
5:Carrierwaveのstore dirにコピー
モデルをsaveするタイミングで、cache dirに保存されたファイルを、保存ディレクトリにコピーします。これはローカルストレージ内でのコピーなので比較的高速です。仮に200MB/秒として、5秒程度かかります。

これでようやく、クライアントに「アップロード完了」のレスポンスを送信できます。ブラウザから見た時間を合計すると、

  • (A) リクエスト送信開始~送信完了するまで: 160秒
  • (B) リクエスト送信完了~レスポンス受信開始まで:20秒

となります。

ここで、(A)はある程度仕方ないのですが、(B)に20秒かかっていることが問題です。これだけ時間がかかると、ファイルサイズ次第でクライアント側がタイムアウトしてしまう恐れがあります。

サーバ側での対策

そこで、大きなファイルを扱う場合には、以下のような対策を両方実施するのが有効です。

  • リバースプロキシ(Nginx)とRailsを同一マシンで動作させ、Nginx upload moduleを使う
  • Carrierwaveでmove_to_cache, move_to_storeをtrueにする

Nginx upload moduleを使う

「2. NginxからRailsにHTTP送信」で、NginxからRailsに巨大なファイルをHTTP経由でRackに渡している部分は、大きな無駄です。そこで、Nginxが直接一時を生成してしまうのがNginx upload moduleです。

http://wiki.nginx.org/HttpUploadModule

Nginxの設定方法は概ね以下のような感じです。

# Ubuntuでは以下のコマンドでupload moduleをインストールできる
apt-get install nginx-extras
# /etc/nginx/nginx.conf
# nginxの実行ユーザとRailsの実行ユーザを同一にする。
# これをしないと、CarrierWaveがchmodするときにエラーになる。
user my_app_user;
# /etc/nginx/sites-available/my_app.conf
upstream my_app {
  server 127.0.0.1:8080;
}

server {
  listen 80;
  root /home/my_app/rails/current/public;
  client_max_body_size 5g;

  location / {
    try_files $uri @application;
  }

  location ~ ^/upload$ {
    if ($request_method = POST) {
      upload_pass @rails;
      upload_store /tmp;
      upload_store_access user:rw group:rw all:rw;
      upload_set_form_field "$upload_field_name[filename]"   "$upload_file_name";
      upload_set_form_field "$upload_field_name[type]"       "$upload_content_type";
      upload_set_form_field "$upload_field_name[tempfile]"   "$upload_tmp_path";
      upload_aggregate_form_field "$upload_field_name[md5]"  "$upload_file_md5";
      upload_aggregate_form_field "$upload_field_name[size]" "$upload_file_size";
      upload_pass_form_field ".*";
    }
  }

  location @rails {
    proxy_redirect off;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_buffering off;
    proxy_pass http://my_app;
  }
}

このモジュールを利用すると、Nginxがファイルアップロードのリクエストを受け付けた際、それをProxy先に転送する代わりに指定ディレクトリに保存するようになります。
RackにはNginxが作成した一時ファイルのパスだけが渡されるので、「2. NginxからRailsにHTTP送信」で10秒かかっていた処理は一瞬で完了します。ただしそのままだとRack::Multipart::UploadedFileが生成されないため、Railsアプリ側で多少処理が必要です。

# app/controllers/upload_controller.rb
class UploadController
  # POST /upload
  def upload
    file = ensure_uploaded_file(params[:file])
    # ...
  end

  private
  # Nginx upload module経由で受信した"tempfile"パラメータを参照し、
  # 手動でActionDispatch::Http::UploadedFileを作成する。
  #
  # Nginx設定をしなくても低速ながら動作するように、ifチェックを入れておく
  def ensure_uploaded_file(file_or_hash)
    if file_or_hash.is_a?(Hash) && file_or_hash[:tempfile]
      # Nginx upload module経由の場合
      file_or_hash[:tempfile] = File.new(file_or_hash[:tempfile])
      ActionDispatch::Http::UploadedFile.new(file_or_hash)
    else
      # 通常の場合
      file_or_hash
    end
  end
end

Carrierwaveでmove_to_cache, move_to_storeをtrueにする

Carrierwaveでは、デフォルトでファイルを2回コピーします。これは、画像のサイズ変換などをしやすいほか、Permission問題が起きづらいメリットがあるのですが、大きなファイルにおいては非常に大きな時間の無駄です。

今回のようなシンプルなアップローダでは、コピーの必要はないので、mvしてしまいましょう。これは、オプションを指定するだけで簡単にできます。
もちろん、Nginx upload moduleで指定するupload_storeと、最終的な保存先であるstore_dirは、同じパーティションにする必要があります。

# app/uploaders/files_uploader.rb
class FileUploader < CarrierWave::Uploader::Base
  storage :file

  def move_to_cache
    true
  end

  def move_to_store
    true
  end
end

これにより、Nginxが作成した一時ファイルが、そのまま最終的なstore directoryに移動されることになります。移動は一瞬なので、「3. Carrierwaveのcache dirにコピー」「5. Carrierwaveのstore dirにコピー」がほぼゼロになることになります。

合計すると以下のようになります。

  • (A) リクエスト送信開始~送信完了するまで: 160秒
  • (B) リクエスト送信完了~レスポンス受信開始まで:ほぼ0秒

upload2

通常のアクションと同じ反応速度を実現できるようになりました。めでたしめでたし。

ところで

お気づきかと思いますが、これだけやってようやくPHPでmove_uploaded_file使うのと同じになっただけです。特別早くなったのではなく、普通になったのです。

多段Proxyの場合どうするのか

  • 負荷分散のため、Reverse ProxyとRailsアプリを別マシンにしたい
  • グローバルIPの関係で多段Reverse Proxyしたい

このような場合は、前段にNginxを使うのはあきらめましょう

現バージョンのNginxでは、ProxyにUploadする際にはリクエストをすべてBufferingする必要があります。
そのため、多段ではどうしても上記「2. NginxからRailsにHTTP送信」に相当する時間がかかってしまいます。

upload3

対策としては以下のようなものが考えられます。

  • もうすぐ実装されるらしいので、それを待つ
  • バッファリングせずにアップロードできるReverse Proxyを使う
    • Tengine (Nginxのfork) だと実現できるらしい
    • 多くのロードバランサーもおそらく大丈夫でしょう

まとめ

本記事の内容を活用すれば、RailsでもPHPを普通に使うのと同じ程度のことはできるようになると思います。小規模チーム用のアップローダを作る程度なら、この程度で十分でしょう。
小規模なら本当にRailsで実装する必要があるのかは疑問ですが、使い慣れたツールでのやり方を把握するのは無駄ではないでしょう。

アクセスの多い公開用サイトでは、先に帯域の問題と向き合わなければならず、アプリだけでなくインフラやサービスレイヤーでの工夫が必須になります。そのあたりは今回触れていませんが、おもしろいテーマなので機会があれば検証してみたいですね。

1年近く前に小規模なファイルアップローダを実装した際の記憶で書いているため、誤りがあればご指摘いただけば幸いです。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。