ブログ

【Ruby on Rails】Rackミドルウェアで静的ファイルを効率よく配信する

サイバーウェーブは、Ruby on Railsに強みのある技術の会社です。Ruby on Railsを突き詰めていくと「Rack」という技術を向き合うことになることがあります。Ruby on RailsとRackで役割分担をすることで、アプリケーションの高速化を実現しました。

Ruby on Railsを経由しないで、しかしアップロードしたファイルを管理できる状態で公開したい

Ruby on Railsにおいて、静的ファイルは一般にpublicディレクトリにおかれます。たとえば./public/hello.htmlというファイルがあれば、http://localhost:3000/hello.htmlで参照できます。

publicディレクトリにある静的ファイルは、ほぼApacheやNginxなどのWebサーバーによって処理され、ファイルがそのまま送信されます。Railsのルーティングをほぼ経由しないため、高速に動作します。また、Railsのアプリケーションログにはシンプルなリクエストとレスポンスの記録だけが残ります。

publicディレクトリにおいたファイルは、良くも悪くもロジックを介さずにそのまま外部に公開されます。

あるプロジェクトで、ユーザーが大量の静的ファイルをアップロードし公開できるシステムを開発しました。アップロードしたファイルはpublicディレクトリ配下におこうと考えましたが、ファイル名がわかってしまうと外部からアクセスできてしまいます。

そこでまず考えたのは、Ruby on Railsプロジェクトのルート直下に任意のディレクトリを新設し、Railsのルーティングで分配する方法です。スクラッチで実装することもできますし、Active StorageのようなしくみやCarrierWaveのようなgemを用いることもできます。

しかし実際にスクラッチで実装してみたところ、大量の静的ファイル配信をRuby on Railsを経由させると、過剰なトラフィックによる応答速度の明らかな低下が起こりました。さらには静的ファイルひとつひとつにRialsのアプリケーションログが残るため、ログが膨大になり可読性がいちじるしく悪くなりました。

「Ruby on Railsを経由しないで、しかしアップロードしたファイルを管理できる状態で公開することはできないか?」と考えました。

Rackに独自の処理を追加することで高速化を試みる

Ruby on Railsに詳しくなってくると「Rack」という単語を目にする機会があります。RackはWebサーバー(たとえばApacheやNginx、Unicorn)とアプリケーションをつなぐライブラリです。

もう少し具体的に言うと、Webサーバーはリクエストを受け取って、それをアプリケーションに渡しますが、Ruby on RailsはそのままだとWebサーバーのリクエスト形式を理解できません。Rackが間に入り、ウェブサーバーから受け取ったリクエストをRuby on Railsが処理できる形に変換し、逆にRailsから返されたレスポンスをWebサーバーに渡せる形にしてくれます。

具体的には、次のような役割をはたします。

  1. たとえば、Nginxがユーザーからの「http://myapp.com/」というリクエストを受け取ったとします。
  2. リクエストは最初にRackに渡されます。RackはリクエストのURLやパラメータ、リクエストメソッド(GETやPOST)などをRuby on Railsがあつかいやすい形式にまとめます。
  3. Rackを通じたリクエストをRailsが処理し、ユーザーが求めているページやデータを生成します。たとえば/postsというURLにアクセスしてきたら、ブログの記事一覧ページを返す処理がされます。
  4. Railsが生成したページは、ふたたびRackに渡されます。Rackは、これをNginxに渡すことができる形へ変換します。
  5. Nginxがそのレスポンスをユーザーに返します。ユーザーはブラウザでページを見ることができます。

Ruby on RailsもRackというライブラリの上に構築されています。

Rackには、Ruby on Railsとは別に独自の機能を追加することができます。「ミドルウェア」「Rackミドルウェア」などと呼ばれます。

大量の静的ファイル配信は、べつに複雑なロジック処理を必要としません。そこでRailsアプリケーションからRackのミドルウェアへ「大量の静的ファイル配信」機能を委譲することで、高速に動作させることを考えました。

Rackミドルウェアの実装をする

Ruby on Railsアプリケーションでは、いちばん外側にRackミドルウェア群が配置されています。routes – controller – viewよりも外側にRackミドルウェア群があります。Rackミドルウェアは、リクエストとレスポンスに処理を追加することができます。ちなみに、Railsアプリケーションでは、デフォルトで20ほどのRackミドルウェアが使われていて、リクエストとレスポンスはそのすべてを通過します。

Rackミドルウェアを実装するときは、下記の仕様を満たすようにします。

  • コンストラクタの第一引数でappを受け取ること
  • callメソッドが実装されていること
  • 引数にenvを取ること
  • [HTTPステータスコード, ヘッダーのハッシュ, ボディーの配列]からなる戻り値を返すこと
class MyMiddleware
  # Rackアプリケーションを引数にinitializeすると、
  # そのアプリケーションをcallするRackアプリケーションを返すことが期待される
  def initialize(app)
    @app = app
  end

  # callの中に行いたい処理を記述
  def call(env)

    # 何らかの処理...

    @app.call(env)
  end
end

引数envには、リクエストに関するさまざまな情報が含まれています。

{"GATEWAY_INTERFACE"=>"CGI/1.1",
"PATH_INFO"=>"/",
"QUERY_STRING"=>"",
"REMOTE_ADDR"=>"127.0.0.1",
"REMOTE_HOST"=>"127.0.0.1",
"REQUEST_METHOD"=>"GET",
"REQUEST_URI"=>"http://127.0.0.1:3000/",
"SCRIPT_NAME"=>"",
"SERVER_NAME"=>"127.0.0.1",
"SERVER_PORT"=>"3000",
"SERVER_PROTOCOL"=>"HTTP/1.1",
"HTTP_HOST"=>"127.0.0.1:3000",
"HTTP_CONNECTION"=>"keep-alive",
"HTTP_UPGRADE_INSECURE_REQUESTS"=>"1",
"HTTP_USER_AGENT"=>
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36",
(...後略...)

Ruby on Rails上に実装していた静的ファイル配信のロジックを、Rackミドルウェアへ実装しなおしました。ところがRubu on Railsでは動いていたはずのところで、いくつかのつまづきがありました。

つまづき1: Rackにはparamsメソッドがない

Rackにリクエストパラメータは渡っていますが、Ruby on Railsのようにparamsメソッドで直接取得することはできませんでした。下記のコードを記述して取得できるようにします。

def call(env)
  request = Rack::Request.new(env)
  params = request.params
end

つまづき2 HTTP範囲をそのまま移植したら動かなかった

「HTTP範囲リクエスト」とは、サーバーからクライアントにHTTPメッセージの一部のみを送信できる通信方式です。動画ファイルや音声ファイルなど大容量のメディアや、一時停止や再開機能を持つファイルのダウンロードに役立ちます。

Ruby on Railsのときは、下記のコードでiOS向けにmp4ストリーミングを行なっていました。

# iOS向けmp4ストリーミング
def stream_video(filepath, mime, opts)
  # ファイルサイズの取得
  size = File.size(filepath)
  # バイト範囲の取得
  bytes = Rack::Utils.byte_ranges(request.headers, size)[0]
  return unless bytes.present?

  offset = bytes.begin
  length = bytes.end - bytes.begin + 1

  # レスポンスヘッダーの設定
  response.header['Accept-Ranges'] = 'bytes'
  response.header['Content-Range'] = "bytes #{bytes.begin}-#{bytes.end}/#{size}"
  response.header['Content-Length'] = length.to_s

  # データの送信
  options = { type: mime.type, stream: true, disposition: :inline, filename: opts[:filename], status: 206 }
  send_data(IO.binread(filepath, length, offset), **options)
end

以下はコメント個所の解説です。

【バイト範囲の取得】
リクエストヘッダーに含まれるバイト範囲を取得しています。Rack::Utils.byte_ranges メソッドを使用して、ストリーミングすべき部分の開始位置と終了位置を決定します。バイト範囲が指定されていない場合は、ストリーミングを行いません。

【レスポンスヘッダーの設定】
Accept-RangesがHTTPレスポンスに存在している場合、部分リクエストに対応していることを示します。
Content-Rangeで送信する範囲を指定します。
Content-Lengthは、受信者に送信される本文の長さをバイト単位で示します。

【データの送信】
IO.binreadでファイルの指定された範囲(offset からlength 分)を読み込み、send_data でデータを送信します。optionsとして渡しているstatus: 206は、部分的なコンテンツを送信することを示しています。

上記のRuby on RailsのコードをそのままRackへ移植すると、次のエラーとなります。

つまづき2-1. Rackではresponse.headerが使えない

Rackにはresponseオブジェクトが存在しません。代わりに[status, headers, body]という形式の配列でレスポンスを返す必要があります。

response.headerの代わりとして、headersという単にヘッダーを設定するためのハッシュを作成し、なかに必要なレスポンスヘッダーを記述していきます。

headers = {}
headers['Content-Length'] = length.to_s
headers['Accept-Ranges'] = 'bytes'
headers['Content-Range'] = "bytes #{ranges[0].begin}-#{ranges[0].end}/#{size}"

そのあとRackのレスポンスを [status, headers, body] 形式で返します。

つまづき2-2. Rackではsend_dataメソッドは使えない

File.binreadを使って、ファイルのデータを直に読み込むようにします。バイト範囲が指定されている場合は、その範囲のデータだけを読み込むようにします。

なお、Rackではファイルの内容をそのままレスポンスのbodyとして返します。

body = File.binread(fullpath, length, offset)

完成したRackミドルウェアのコード

上記の修正を反映した、Rackミドルウェアの最終的なコードは以下です。

def call(env)
  @env = env
  @request = Rack::Request.new(env)
  mime = MimeMagic.by_path(fullpath) # fullpathは別途定義
  # ファイルが存在しないか、MIMEタイプが空の場合は通常のRailsアプリへリクエストを渡す
  return @app.call(env) if !File.exist?(fullpath) || mime.blank?

  size = File.size(fullpath)
  headers = {}
  headers['Content-Type'] = mime.type
  headers['Content-Length'] = size
  status = 200

  ranges = Rack::Utils.byte_ranges(env, size)
  # バイト範囲リクエストが存在し、MP4ファイルの場合
  if ranges.present? && env['HTTP_RANGE'].present? && mime.type == 'video/mp4'
    offset = ranges[0].begin
    length = ranges[0].end - ranges[0].begin + 1
    # ヘッダーを設定する
    headers['Content-Length'] = length.to_s
    headers['Accept-Ranges'] = 'bytes'
    headers['Content-Range'] = "bytes #{ranges[0].begin}-#{ranges[0].end}/#{size}"

    # バイト範囲に従ってデータを読み込む
    body = File.binread(fullpath, length, offset)
    status = 206
  else
    # バイト範囲リクエストがない場合は、ファイル全体を返す
    body = File.read(fullpath, '.' + mime.subtype)
  end
  # Rackの形式に従って、ステータス、ヘッダー、ボディを返す
  [status, headers, [body]]
end

RailsアプリケーションからRackのミドルウェアへ「大量の静的ファイル配信」機能を委譲したところ、期待どおりに大きく高速化をすることができました。

また、Railsのアプリケーションログも不要な肥大化がなくなり、読みやすくなりました。

処理を工夫してパフォーマンスを改善しよう

WebサーバーのリクエストがRailsアプリケーションに到達する前に、Rackミドルウェアで処理することで静的ファイルを効率よく配信する方法を解説しました。そしてRackミドルウェアを記述するときに気をつけるべきポイントを解説しました。

パフォーマンスの低下にぶつかったときは、Railsアプリケーションで処理すべきものを見極め、Rackへ委譲できないかも視野に入れてみましょう。

サイバーウェーブでは一緒に働く仲間を募集しています

サイバーウェーブでは一緒に働く仲間を募集しています。当社は創業20年を機に「第2の創業期」として、事業を拡大方針へと舵を切りました。会社が急拡大しており、若いメンバーやインターン生がどんどん入社しています。個人の成長は、勢いのある環境のなかでこそ加速されるものです。成長事業に参画できるチャンスです!

サイバーウェーブはコード1行1行に対してこだわりを持って、プロ意識をもったエンジニアを育てている、技術力に自信のあるシステム開発会社です。社内には、創業23年のノウハウの詰まった研修コンテンツや、安定したシステム開発をするための手順が整っています。実力のあるシステム開発会社だからこそ、経験を積みながら、実践的なシステム開発の技術も学べます。自信をもって主義主張ができる『飯が食える』エンジニアを目指していただきます。

エンジニアとしてしっかりと飯を食べていけるまでには、道のりは決して短くありません。長期で頑張り、エンジニアになるという強い思いがあれば、実戦的な開発経験と、周りの仲間とコミュニケーションしながら、しっかりと成長できます。当社のノウハウを余すことなく活かし、技術力を大きく伸ばしていただきます。

ぜひ、エントリーをお待ちしております!

インターン採用

インターン採用

新卒・既卒・第二新卒採用

新卒・既卒・第二新卒

おすすめ記事