トップ «前の日記(2018-01-27) 最新 次の日記(2018-01-29)» 編集

日々の破片

Subscribe with livedoor Reader
著作一覧

2018-01-28

_ nginx-unicorn-railsで大きなファイルを扱う

大体400MBくらいのファイルをクライアントから上げてもらってそれをRails側で処理する必要が出てきた。

仕組み上、400MBの送信がクライアント-nginxとnginx-Unicornで必要となる。このときnginxとUnicornが同じマシンであればこの間の通信は無駄なので避けたほうが良い。

というわけで検索したらRailsで大きなファイルを扱う際のポイントという記事を見つけたのだが、今は2018年だ。nginx-upload-moduleはapt-getでインストールできるが、実際に動作しているnginx()では動かなかった。検索すると、自分でソースにパッチしてmakeすれば良いというようなのは見つかったが、それはデプロイを考えた場合避けたい。

さらに検索するとNginxを使ってファイルアップロードを高速化する方法というのが見つかった。

こちらは、nginxにリクエストボディをファイル出力させて、それをRails側で利用する(出力したファイルのファイル名はリクエストヘッダで受け取る)ことにしていて、筋が良さそうだ。

しかし、想定がリクエストボディに直接ファイルの内容を出力(ブラウザーを介さずプログラムtoプログラムでのファイルアップロード)ことを想定しているように見える(あと、nginx.confとRails側でヘッダ変数名の書き間違いがあるのでそのままで使えない)。

以下のようにした。

まずnginx側でリクエストボディをそのままファイルとして残すことは変えない。

  location /upload {
    limit_except POST          { deny all; }
 
    client_body_in_file_only   clean;
    client_body_temp_path      /tmp/;
    client_body_buffer_size    128K;
    client_max_body_size       500M;
 
    proxy_set_header            X-File $request_body_file;
    proxy_set_body              0;
    proxy_pass_request_body     off;
    proxy_pass http://unix:/tmp/sockets/unicorn.sock;
  }

client_body_in_file_only clean;で仮にRails側の処理中に例外ですっ飛んでもnginx側でファイルを消させるようにした。Rails側で消せるのであれば、client_body_in_file_only on;で良い。proxy_set_bodyでUnicornに転送するリクエストボディは0にした(意味わからないが、offとすれば良いと書いてあるのを見てoffと記述したらリクエストボディにoffという文字列が送られてきた。proxy_set_headerでContent-Length:0としてみたが、それだとrackがIOエラーで死ぬ(リクエストボディが残っているからだ)。どうにかproxy_set_bodyで空文字列を設定できないかと試したが無理っぽいので意味はないが0としてみたが、?とか*とかのほうが良かったかも。proxy_pass_request_body off;はどうも有効ではないが、残してある。 Unicoornへ送るリクエストボディの設定については、nginxのproxy_moduleの説明だとproxy_pass_request_bodyとproxy_set_headerで良さそうなのだが実際にはうまく動作しなかった。

これを書いていて、ふとrackのバグじゃないかと気づいたが、上の設定で落ち着いているのでとりあえずこのままだ。

参考:proxy_moduleの説明のproxy_pass_request_bodyの記述例(rackがうまく処理しない)

 location /x-accel-redirect-here/ {
    proxy_method GET;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";

とりあえず、これでnginxは/uploadに対しては、/tmpに00000001.tmpみたいな名前のファイルを作って、リクエストヘッダのX-File変数に実ファイル名を入れてくるのでRails側でファイル処理が可能となる。

問題は、該当ファイルのパーミッションがwww-dataのrw-------となることだ。

Railsをsudoersで動かしているのでsystem "sudo chmod a+rw #{request.headers['X-File']}"で逃げた。

次はこのファイルの読み方だが、ブラウザーがファイルをアップロードしてくるのでマルチパートで読む必要がある。調べると、multipart-parserというGemがあった。SAXのように読みながらイベントを通知するタイプのようなので、それなりの速度は維持できるだろう。2012年に更新されてから静かだが、1度作れば落ち着くタイプなのでむしろ更新が無いのは良い知らせだろう。

が、使い方がさっぱりわからん。

しょうがないので、unit testのソースを見て使い方を調べる。ParserとReaderの2つのクラスのいずれかを使って処理を記述すれば良いということがわかった。バウンダリーを与えてReaderを作り(Parserをそのまま利用するより楽そうだ)、readerのon_errorとon_partにブロックを与えて全体を処理し、各パートの中身はon_partのブロックパラメータのon_dataで行えば良いということがわかった。

試しにユニットテストを実行してみた。すると、テストが通らない。

見るとfixtureがおかしい。しょうがないのでプルリクを投げた(今日マージされたのでこれ書いている)。

コントローラは以下のように処理する。

require 'multipart_parser/reader'
...
# ファイルアップロード受信処理
def upload
  # バウンダリーを与えてReaderのインスタンスを生成する。
  reader = MultipartParser::Reader.new(extract_boundary(request.headers['Content-Type']))
  # SAXというよりも、XHRの使い方に似ている。
  reader.on_error do |msg|  # エラーの場合のイベント処理
    logger.debug("on_error:#{msg}")
  end
  # パーティションを見つけた場合のイベント処理
  reader.on_part do |part|
    logger.debug("on part:#{part.filename}, #{part.name}, #{part.mime}");
    # RailsのCSRF対策用パラメータ
    if part.name == 'authenticity_token'
      # パーティション内の読み取りでon_dataイベントが通知される
      part.on_data do |token|
        unless valid_authenticity_token?(session, token)
          # 403を返したほうが良いかも知れないが412を返す(まともに使えば出るはずないのでいい加減)
          render html: '412 Precondition Failed', status: 412
          return
        else
          logger.debug('good token')
        end
      end
      # パーティションの終了通知
      part.on_end do
        logger.debug('end of authenticity_token part')
      end
    else #ここではauthenticity_tokenとfileしか見ないが、他のパラメータもあればその処理もあるはず
      file_model = FileModel.new(part.filename)
      part.on_datda do |file_line|
        # ファイル処理
        file_model.add(file_line)
      end
      part.on_end do 
        logger.debug('end of file')
        file_model.do_fantastic
      end
    end
  end
  # ファイルパース開始
  File.open(request.headers['X-File'], 'r').each_line do |line|
    # 1行単位にリーダーに与える
    reader.write(line)
  end.close
  head :no_content
end
 
BOUNDARY_MARK = 'boundary='
 
def extract_boundary(ctype)
  ctype[ctype.index(BOUNDARY_MARK) + BOUNDARY_MARK.length..-1]
end

2003|06|07|08|09|10|11|12|
2004|01|02|03|04|05|06|07|08|09|10|11|12|
2005|01|02|03|04|05|06|07|08|09|10|11|12|
2006|01|02|03|04|05|06|07|08|09|10|11|12|
2007|01|02|03|04|05|06|07|08|09|10|11|12|
2008|01|02|03|04|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|

ジェズイットを見習え