何らかの画像や PDF / CSV ファイルなど Rails で生成したファイルを、そのまま Rails のプロセスで UserAgent に返すのではなく nginx などの Web サーバで返す仕組みがあります。nginx だと X-Accel-Redirect、lighttpd や Apache では X-Sendfile と呼ばれる機能です。これを利用することで UserAgent がファイルをダウンロードするまでの処理を Rails のようなアプリケーションサーバがリソースを使うことなく、Web サーバに肩代わりしてもらえます。

データフローを図にするとこんな感じです。

x-sendfile-flow.gif

Rails + nginx の X-Accel-Redirect の設定方法

Rails

Rails 側の設定はいたって簡単です。 /config/environments/development.rb などの環境ファイルで設定をするだけです。

config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'

そしてファイルをレスポンスするときは send_file を使います。ここでは例として '/tmp/hoge.jpg' というファイルを Rails で動的に生成し保存したとします。

create_photo '/tmp/hoge.jpg'
send_file '/tmp/hoge.jpg'

nginx

nginx では rack に X-Sendfile-Type ヘッダで nginx の X-Accel-Redirect を利用することを伝えます。そして、 X-Accel-Mapping を使って、Rails 側が渡してきたファイルパスを nginx の location パスにマッチするように変更することができます。この例だと /tmp/hoge.jpg/x-files/hoge.jpg として nginx に渡されます。

そして location ~ ^/x-files/(.*) でファイルを返却します。重要なのは internal を指定することです。これが指定されていると外部からはアクセスができなくなります。

server {
  listen 80;
  server_name .*;
  
  location ~ ^/x-files/(.*) {
    internal;
    alias /tmp/$1;
  }

  location / {
    proxy_set_header   Host                $host;
    proxy_set_header   X-Real-IP           $remote_addr;
    proxy_set_header   X-Forwarded-For     $proxy_add_x_forwarded_for;
    proxy_set_header   X-Sendfile-Type     X-Accel-Redirect;
    proxy_set_header   X-Accel-Mapping     /tmp/=/x-files/;
    proxy_pass         http://host.docker.internal:3000;
  }
}

以上の設定だけで Rails はファイル生成のみを、ファイルのレスポンスは nginx がやってくれるようになります。静的なアセットであれば事前に nginx から配信するようにできますが、動的に生成されるファイルはこのようなやり方をする必要があります。

AWS S3 などのファイルサーバにアップロードして、そちらにリダイレクトすることでも似たような事は可能ですが、例えばホストしているファイル URL を露出したくない・アプリケーションの認証セッションを利用した状態でファイルへアクセスさせたいなどの要件を満たす必要があるときに利用すると良いと思います。

ソースコードリーディング

まずは Rails の send_file メソッドから。

def send_file(path, options = {})
  ActiveSupport::Notifications.instrument("send_file.action_controller",
    options.merge(path: path)) do
    super
  end
end

https://github.com/rails/rails/blob/09cc7967b970373de8a748f68d027ec4bf331832/actionpack/lib/action_controller/metal/instrumentation.rb#L51-L56

superActionController::DataStreaming#send_file が呼ばれる。

def send_file(path, options = {}) #:doc:
  raise MissingFile, "Cannot read file #{path}" unless File.file?(path) && File.readable?(path)

  options[:filename] ||= File.basename(path) unless options[:url_based_filename]
  send_file_headers! options

  self.status = options[:status] || 200
  self.content_type = options[:content_type] if options.key?(:content_type)
  response.send_file path
end

https://github.com/rails/rails/blob/09cc7967b970373de8a748f68d027ec4bf331832/actionpack/lib/action_controller/metal/data_streaming.rb#L69-L78

ActionDispatch::Response#send_file が呼び出されて、ファイルパスがセットされる。

def send_file(path)
  commit!
  @stream = FileBody.new(path)
end

https://github.com/rails/rails/blob/09cc7967b970373de8a748f68d027ec4bf331832/actionpack/lib/action_dispatch/http/response.rb#L366-L369

通常 Rails でファイルを返す場合はこちらの処理が呼び出される。nginx で返す場合はこれは呼ばれない。

# Stream the file's contents if Rack::Sendfile isn't present.
def each
  File.open(to_path, "rb") do |file|
    while chunk = file.read(16384)
      yield chunk
    end
  end
end

https://github.com/rails/rails/blob/09cc7967b970373de8a748f68d027ec4bf331832/actionpack/lib/action_dispatch/http/response.rb#L355-L363

Rack::Sendfile#call ここで nginx からの proxy_set_header に X-Sendfile-Type: X-Accel-Redirect; がセットされていると、Rails からのレスポンスボディの中身を空にする。そして nginx へのレスポンスヘッダに content-length を 0 で空に、X-Accel-Redirect に送りたいファイルのパスをセットする。

when 'X-Accel-Redirect'
  path = ::File.expand_path(body.to_path)
  if url = map_accel_path(env, path)
    headers[CONTENT_LENGTH] = '0'
    # '?' must be percent-encoded because it is not query string but a part of path
    headers[type] = ::Rack::Utils.escape_path(url).gsub('?', '%3F')
    obody = body
    body = Rack::BodyProxy.new([]) do
      obody.close if obody.respond_to?(:close)
    end
  else
    env[RACK_ERRORS].puts "X-Accel-Mapping header missing"
  end

https://github.com/rack/rack/blob/4f3ed9d7616b9cb4529529848c208524ec044df8/lib/rack/sendfile.rb#L116-L128

以上で、nginx が送信すべきファイルパスを手に入れて UserAgent へレスポンスすることができるようになる。