何らかの画像や PDF / CSV ファイルなど Rails で生成したファイルを、そのまま Rails のプロセスで UserAgent に返すのではなく nginx などの Web サーバで返す仕組みがあります。nginx だと X-Accel-Redirect、lighttpd や Apache では X-Sendfile と呼ばれる機能です。これを利用することで UserAgent がファイルをダウンロードするまでの処理を Rails のようなアプリケーションサーバがリソースを使うことなく、Web サーバに肩代わりしてもらえます。
データフローを図にするとこんな感じです。
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
super
で ActionController::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
ActionDispatch::Response#send_file
が呼び出されて、ファイルパスがセットされる。
def send_file(path)
commit!
@stream = FileBody.new(path)
end
通常 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
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
以上で、nginx が送信すべきファイルパスを手に入れて UserAgent へレスポンスすることができるようになる。