この記事は、Makuake Development Team Advent Calendar 2019 3日めの記事です。
遅刻しちゃいましたが、頑張って書きましたので読んでください!
やりたいこと、よくある課題感
ブラウザからS3などのストレージに、サイズの大きな画像や動画をアップロードしたい。
そうなると、サーバー側にPOSTするとタイムアウトしてしまったり、メモリを食い潰してしまったりして、サイズの上限を設定したりしないといけないなどつらいシーンが増えてくると思います。
そのためにブラウザからのダイレクトアップロードというのが検討の遡上に上ることも多いと思うのですが、
- やり方がわからない
- セキュリティ的に安全なのかがわからない ( トークン流出したりするんじゃないの? とか )
といった問題などがあり、何となくスルーされることも多いのではないかと思います。
今回はその辺りについて、やってみたのでわかったことをまとめていこうと思います。
この記事で前提とするレベル
- Ajaxとか非同期の通信はある程度わかる(
async
/await
わかるぐらいを想定) - S3 + IAM は普通に使っているレベル。
SECRET_ACCESS_KEY
・ACCESS_KEY_ID
あたりの言葉が当たり前にわかる
使用した技術・関連キーワード
役割 | 技術 |
---|---|
クライアントサイド | Vue.js / axios |
ストレージ | S3 |
バックエンド | Ruby on Rails |
※ Vue.js については、本記事では methods に書き込むfunctionの事例を記載しており、コンポーネントレベルでのサンプルは記載しておりません。
Railsを選んだのはサンプルが豊富で試しやすそうだったからですが、S3のSDKが提供されている言語であれば基本的には同じことが実現できると思います。
同様に、CloudStorageなどでもpresigned urlにあたる機能はある ( 参考 署名付きURL ) ということなので、この考え方自体はS3に限らず流用可能かと思います。
presigned url / presigned_post
presigned(事前に署名した) url
というだけあって、予め発行しておいたURLに対してのPOSTでのアップロードを受け付ける機能です。
厳密にはURLだけではなく、合わせて発行されるトークンなどとセットで可能になります。サンプルコードの中でこれは見せられればと思います。
presigned_post
はruby用の aws-sdk
で、 presigned url を含む各種情報を発行するためのメソッドの名前です。
presigned url を用いたファイルのダイレクトアップロードの手順
AWSとRails側の準備
- S3のbucketを作成する
- 対象のbucketにオブジェクト追加ができるIAMを発行し、ACCESS_KEY_ID / SECRET_ACCESS_KEY を発行する
- CORSの設定を行い、ダイレクトアップロードするドメインのみから投稿を受け付けるようにする
- Rails側でaws-sdkをセットアップする (
Aws::S3::...
のクラスが呼び出せるようになっていればok ) - presigned_postを呼び出し、presigned urlを含むアップロードに必要な各種情報を返却するエンドポイントを作る
なお、僕はこの辺りの前準備は、大部分を ブラウザからS3へのダイレクトアップロード を参考にさせていただきました。
クライアントサイドでやること
- presigned urlを発行するエンドポイントを呼び出す ( Railsへのリクエスト。ファイルは送らない )
- 返却された値を使って、当該URLに画像ファイルも含めたリクエストを送る ( S3へのリクエスト。ファイルを送る )
- S3から返却されてきた値をparseし、保存先のURLをクライアントサイドで取得する → この値をDB等に送る
ソースコードのサンプル(バックエンド側)
「AWSとRails側の準備」の1~4についてはオリジナリティが0のため、 ブラウザからS3へのダイレクトアップロード を参考URLとさせていただいて、省略させていただきます。
-
- presigned_postを呼び出し、presigned urlを含むアップロードに必要な各種情報を返却するエンドポイントを作る
自分の場合はAPIのエンドポイントとして使いたかったため、以下のようにしました。
# その他は省略
# 以下を追記して `bundle` を実行すること
gem 'aws-sdk', '~> 2'
# AWSの環境変数の設定、及びS3のbucketへのアクセス方法を定数化したファイル
# 参考 https://qiita.com/minsu/items/a3bfc8f8f807f6b51fdf
# 環境変数に、準備のところで取得した値を入れてください
Aws.config.update({
region: 'ap-northeast-1',
credentials: Aws::Credentials.new(
ENV['AWS_S3_ACCESS_KEY_ID'],
ENV['AWS_S3_SECRET_ACCESS_KEY']
),
})
S3_BUCKET = Aws::S3::Resource.new.bucket(ENV['AWS_S3_BUCKET_NAME'])
Rails.application.routes.draw do
# その他のルーティングは省略します
namespace :api, format: 'json' do
get 'presigned-url', to: 's3#presigned_url'
end
end
class Api::S3Controller < Api::ApplicationController
def presigned_url
# S3_BUCKET は config/aws.rb
presigned_object = S3_BUCKET.presigned_post(
key: "uploads/test/#{SecureRandom.uuid}/#{params[:filename]}",
success_action_status: '201',
acl: 'public-read'
)
render json: { url: presigned_object.url, fields: presigned_object.fields }
end
end
この状態でアプリケーションをデプロイすると、 sample.com/api/presigned-url
にアクセスした際に、以下のようなレスポンスを取得できます。12
{
"url": "https://[your-bucket-name].s3.ap-northeast-1.amazonaws.com",
"fields": {
"key": "xxx",
"success_action_status": "201",
"acl": "public-read",
"policy": "xxx",
"x-amz-credential": "xxx",
"x-amz-algorithm": "xxx",
"x-amz-date": "xxx",
"x-amz-signature": "xxx"
}
}
ここで取得できた値を用いて、クライアントサイドでファイルをアップロードします。
ソースコードのサンプル(クライアント側)
メソッドとしては以下のような感じになります。説明は主にコメントアウトで。
Vueのコンポーネントとして表現するともう少し複雑になるのですが、中核部分の実装は以下で表現できていると思います。
async sampleFunction () {
// ※ eslint は standard style にしています
// 対象のファイルを取得
const target = getElementById('対象のinput要素のid')
// multiple指定がないinput[type=file]のオブジェクトは、files[0]に要素が保持されている
const file = target.files[0]
// presigned post を使うため、対象のURLを取得する
const baseUrl = 'https://sample.com/api/'
const presignedObject = await this.axios.get(`${baseUrl}presigned-url?filename=${file.name}`)
.then(response => response.data)
.catch(e => console.log(e.message))
// POSTする form に持たせるデータを生成する
const formData = new FormData()
for (const key in presignedObject.fields) {
formData.append(key, presignedObject.fields[key])
}
// input[type=file] に持たせているデータを取得して送信する formData に追加する
formData.append('file', file)
// アップロード実施
// presigned-url エンドポイントから取得したオブジェクトのURLと、作成したformDataを送信するとアップロードができる
await this.axios.post(presignedObject.url, formData, {
headers: {
'accept': 'multipart/form-data'
}
})
.then((response) => {
// XML形式でデータが返却される。アップロードに成功すると Location というタグの中に入っているので、
// この値をURLとして取得して使う
const matchedObject = response.data.match(/<Location>(.*?)<\/Location>/)
const s3Url = unescape(matchedObject[1])
})
.catch(e => console.log(e.message))
}
}
この辺りの機能を使って、もうちょっと気の利いたことをやると、以下のような動作を実現できます。
(動画が一部見切れてるのはご容赦ください)
作ってみて、実際どうだったか?
クライアントサイドでアップロードを制御できる安心感はかなりあるなという感じでした。
タイムアウトなどを気にしながらサーバー側で大きめのファイルを扱わなくていいので、基本的にはURLだけ取り回せばいい・・・というのは実用上もありがたいところです。
課題感としては、一時的かつCORSの制御をするとはいえ、S3への書き込み権限を発行してしまうので、クライアントサイドでのファイルサイズの上限設定をしたり、攻撃に準ずる使い方に対する対策を決める必要があるな、と感じます。
サーバーを通じてアップロードする形式でも同じ課題は存在するにはするのですが、user_idなどがなくてもアップロードできる権限が発行されたり、presigned url発行後の制御が効かないという点で、監視体制の強化やいざというときの対策を決めておかないと、本番運用時にはリスクがあるところだな、と思いました。
さて、Makuakeでは、一緒に働きたいエンジニアを募集しています。
いろいろと新しく面白い試みも続けておりますので、Advent calendarを読んで興味が湧いた方は、ぜひチームの募集にもお目通しください!
明日(というか今日)は @convto がどこかの媒体で何かをやってくれるようです。そちらの記事もお楽しみにどうぞ。