見出し画像

CloudFrontの署名付きCookieを利用して低負荷な配信と認可を両立する

こんにちは、グロービスでエンジニアをしている大澤(@qwyng)と申します。
グロービスでは、GLOPLA LMSという企業の研修を助けるプロダクトを開発しています。
今回はそのプロダクトにおいてファイル配信について課題が発生していたので、行った調査と解決方法を書いていきます。

なにが問題だったか
我々が作成してるGLOPLA LMSは企業の研修を管理するシステムです。
そして企業研修というのは月初がピークタイムです。
ですが、この月初のピークにレスポンスタイムがとても長かったり、エラーを返したりしてしまう障害が発生していました。

研修を受ける社員の方々は月初が一番やる気があるタイミングなので、そのタイミングにシステムから悪い体験を受けるのはせっかく高まっている学習意欲を削いでしまいます。
プロダクトの目標である人のポテンシャルを開放するという目的において大きな障害になるということで改善タスクをスプリントに積みました。

ボトルネックだった箇所
調査した結果、画像や動画を配信しているエンドポイントにアクセスが集中した際に、RailsサーバーのCPU負荷やメモリ使用率が高まっていました。詳しく調べてみると、大きいサイズのファイルを送信するRailsプロセスに時間がかかっていました。
当時の状況を表した図です。
弊プロダクトでは主にTypeScriptで書かれたSPAとRailsサーバーで構成されており、両者はGraphQLでやりとりしています。

問題を解決するうえでの前提

ファイル配信においては必ず認可が必要です。以下の要件があります。

  • 企業の研修には機密情報が大量に存在しているので、認可が必要

  • 今後の人事計画等、同じ会社に所属していても閲覧できていい人と閲覧できてはいけない人が存在するので会社単位の大まかな認可以外にも細かな単位での柔軟な認可が求められる

解決策の洗い出し
解決策は色々洗い出しました。

  • - エンコード等を行いファイルサイズを小さくする

  • - nginxの機能であるX-Accel-Redirectを使う

  • - S3の期限付き署名URLを使う

それぞれpros consをチームのエンジニアと相談して最終的にCloudFrontの署名付きCookieを利用してファイル配信をCloudFrontから直接行うことにしました。

これはCloudFrontの機能の一つで、アプリケーション側でクライアントの認証と認可を行い、クライアントにアクセス権限があればSet-Cookieヘッダをつかってクライアントに時限式のCookieを作成した上でCloudFrontにアクセスをしてもらうという方法です。
決め手としては

  • 期限付き署名URLのように制限のないURLが(一瞬でも)露出するようなことがない

  • ファイル配信の負荷を自前のアーキテクチャでどうにかしなくても良くなる

  • AWS-SDK-Rubyが対応しており、複雑な実装が必要なさそう

  • Set-Cookieを付与するかどうかのロジックはRails側で自由に組み立てられる

といったものがありました。
以下が図になります。

Cookieを付与するための模擬コードです。
今回はカスタムポリシーを用いて認可を行うのでjsonを組み立ててライブラリに暗号化をまかせています。

def show
  object = Hoge.find()
  cookie_params = cloud_front_signed_cookie_parameter(object)
  set_cloud_front_signed_cookies!(cookie_params, cookies)

  redirect_to object.file.url, allow_other_host: true
end
       def cloud_front_signed_cookie_parameter(content)
      object_path = permitted_object_path(content)
      policy_json = generate_policy_json(object_path, expires_at: expires_at)
            signer = 
        Aws::CloudFront::CookieSigner.new(
          key_pair_id: ENV['CLOUDFRONT_KEY_PAIR_ID'],
          private_key: ENV['CLOUDFRONT_PRIVATE_KEY'],
        )

      signer.signed_cookie(object_path, policy: policy_json)
    end

    def generate_policy_json(object_path, expires_at:)
      {
        'Statement' => [
          {
            'Resource'  => object_path,
            'Condition' => {
              'DateLessThan' => { 'AWS:EpochTime' => expires_at.utc.to_i },
            },
          },
        ],
      }.to_json
    end

    def set_cloud_front_signed_cookies!(cookie_params, cookies)
      cookie_params.each do |k, v|
        cookies[k] = { value: v, domain: ENV['HOSTNAME'] }
      end
    end

Domain=globis.co.jpと設定されたCookieは subdomain.globis.co.jpにも送信されます。SecureやHttpOnly等他の重要な属性の設定も必ず確認しておきましょう。

実際に実装して判明した課題と解決策
上記のようにRails側のコードを変更したのですが、実際に環境を用意して試したみたところ問題が発生しました。
ブラウザが非同期に複数の動画や写真を要求した際にSet-Cookieが競合してしまって、Aファイルの認可に対してBファイルのCookieを送信してしまうような事態が発生することが判明しました。
解決策としてCloudFrontの署名付きCookieはポリシーとしてディレクトリ全体を指定して認可できることを利用しました。

今まで、S3にファイルを階層なしで保存していたのですが、以下のように変更しました。

before → "/#{file識別子}"

after → "/#{親オブジェクト識別子}/#{親オブジェクト識別子}/#{file識別子}"

このようにすることで、親オブジェクト単位で署名Cookieを発行することができ、ブラウザが複数同時にファイルをリクエストしてもディレクトリが適切であれば、Set-Cookieが競合状態になってもCookieの値が変わらないので問題なく認可できるようになります。
この解決策のために既存のS3のファイルをmvさせる必要がでたのですが、頼もしすぎる同僚氏がしっかり行ってくれました。この場を借りて感謝申し上げます。

結果
ピーク時のレスポンスに1分近くかかっていたのが、長くても3秒程度になりました。あわせてRailsノードのCPUとメモリの使用率も劇的に低下し、5xx系エラーを返すこともなくなりました。
以下は月初のアクセスを比較したグラフです。変更前の数字がなかなか大変な数字になっていますが、改善したのではないかと思います。

グロービスでは一緒に働く仲間を募集しています!
グロービスの開発組織では、一緒に働けるエンジニアを探しています!
カジュアル面談からご気軽にお願いいたします。


この記事が気に入ったらサポートをしてみませんか?