RubyとAWSでつくるメディアストレージ基盤

# はじめに

今年の４月にドワンゴに入社し、アプリケーションエンジニアからインフラエンジニアに転向した経緯をもつ筆者が、アプリケーションエンジニアとしての知識を活かし開発した最初の成果ともいえるメディアストレージ基盤について、その概要と実装方法について述べる。


この基盤は主に、新規プロダクトを開発するにあたって必要となってくるメディアファイルに関連する一連の処理を括り出して共通化・基盤化しておくことで、新規プロダクトの開発効率を向上させるという狙いがある。
本稿ではその目的をAWSを用いて達成し、かつAWS独自の仕組みに則ることで実現した部分について着目しながら解説したい。

# 用語

|用語  |説明  |
|---|---|
|本基盤  |本稿の主題であるメディアストレージ基盤を指す。  |
|利用サービス |*本基盤*を利用するアプリケーションを指す。 |
|利用者 | *利用サービス*のユーザーを指す。 |
|一時認証情報 | *利用者*がアップロード用S3にファイルをアップロードするための*一時認証情報*。|
|アップロードチケット |上述の*一時認証情報*を含む、ファイルをアップロードする際に必要となる情報群の名称。 |
|ジョブ |*アップロードチケット*発行時に内部的に作成するファイル変換・配信処理の取り扱いのための情報群の名称。DynamoDBを使って管理が行われる。|

# 概要

*本基盤*の果たす役割としては、「*利用者*が*本基盤*に向けてファイルをアップロードし、なんらかの(変換を含む)処理を行って利用サービス側に通知する」というものになる。
そこで、想定する利用イメージを大まかにでも理解してもらうため、抽象的なイメージを図示する。

![abstract.png](https://qiita-image-store.s3.amazonaws.com/0/10419/d06459ef-34ec-07db-2b20-23c005ca77c9.png)

ファイルをアップロードしたいユーザーは、まず*本基盤*の*利用サービス*に対してアップロード権限の発行を依頼する。
図では省略したものの、*利用サービス*側はその依頼を受けて、*本基盤*に対して*アップロードチケット*の発行を依頼し、取得した情報をアップロードしたいユーザーに対して返す。
アップロードユーザーはそれを受けて、*本基盤*に対してファイルのアップロードを行い、アップロード・バリデーション・変換が済んだものについては*本基盤*が*利用サービス*に結果を通知するというのが大まかな流れとなる。

次に、*本基盤*の持つ責務について簡単に解説したい。
*本基盤*は、メディアファイルのアップロード、およびメディアファイルの配信・変換・通知の４つの責務を持つ。

## ファイルアップロード

* アップロード用S3に対する一時的なファイルのアップロードの権限を*利用者*に発行するために、*本基盤*は*一時認証情報*を払い出す。
* *利用者*は払い出された*一時認証情報*を用いて、アップロード用S3に対してメディアファイルのアップロードを行う。

## 配信

* 配信用S3の前段にあるCloud Front経由で、オリジナルファイルを配信するほか、画像ファイルについてはパスによる制御から**画像のリサイズや切り取り**を行う。

## 変換

初期段階でサポートしている変換機能は次の三つとなる。

* 画像ファイルのサムネイル作成(resize・crop)。
* PDFファイルのページ毎の画像ファイルへの切り分け。
* 音声ファイルのエンコード。

## 通知

* ファイルに対する処理状況や配信用URLなどは、ジョブAPIを通して確認することができる。
* ファイルの処理状況に応じて、*本基盤*の*利用サービス*に対する通知を行うことができる。

# 利用するAWSのリソース・サービス

ここからは、AWSのリソース・サービスをどのように活用しているかという点に着目しながら解説する。

## EC2

まず、EC2で運用するサーバ群の種類は次の通りになる。

|名称  |説明  |
|-----------|---|
|APIサーバ |*アップロードチケット*の発行や、DynamoDBで管理しているジョブの現在の情報返すなど、*利用サービス*向けのAPIを提供するAPIサーバ。  |
|バッチサーバ | SQSを監視するデーモンを運用し、取り出したメッセージの情報とDynamoDB上のジョブとの情報を照合した上で適切に処理を振り分けるいわばコントローラ的なサーバ。AWSのサービスを使った変換ができないものについては、このバッチサーバ自身が処理を請け負う。 |
|通知サーバ | SNSによって変換状態が通知されたものについて、AWSからのプッシュかどうかを署名を使って検証し、必要となる情報をまとめて*利用サービス*に通知する通知サーバ。|
|サムネイル作成サーバ |画像のリサイズ・切り取り処理を行うサムネイル作成サーバ。 |

**なお、サムネイル作成サーバを除くすべてのサーバ上では、Rubyを用いたアプリケーションを実装・運用している。**

### APIサーバ

*アップロードチケット*の発行やジョブの状態API、リトライなどの、*利用サービス*向けのAPIを運用するサーバである。
[Grape](https://github.com/intridea/grape)単体でAPIアプリケーションは実装している。
API専任であれば、PadrinoやRailsを使わずとも、Grape単体で充分であるように思う。

<del>また、APIを開発する際にDynamoDBの簡易的なORMを実装したので、これについては近日中にOSSとしてgithubで公開する予定でいる。</del>

<ins><a href="http://qiita.com/namusyaka/items/cf75dba11d71ed19d75f">dinamoというgemを公開した。</a></ins>

#### Note: *一時認証情報*について

AWSの`sts:GetFederationToken`という仕組みに則った払い出しを行っている。
この仕組みを採用することで、*一時認証情報*の払い出しに使うIAMアカウントが持つポリシーと、`sts:GetFederationToken`アクションの実行時にアタッチしたポリシーの両方を照らし合わせて、いずれのポリシーにも共通して存在しているアクションのみが許可されたフェデレーティッドユーザー用の*一時認証情報*を作成することができる。

詳細は[Permissions for GetFederationToken](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_control-access_getfederationtoken.html)を参照のこと。

### バッチサーバ

PDFの分解やElastic Transcoderにおけるジョブの作成など、Lambdaで引き受けられない処理を引き受けるサーバである。
SQSを監視するデーモンを主に運用することとなる。
監視用のデーモンの実装には[Shoryuken](https://github.com/phstc/shoryuken)を採用した。
ShoryukenはRuby製のSQS監視ワーカーを作成するためのライブラリで、導入も非常に容易である。
SQSから取り出したメッセージに関した処理が正常に終了した場合、メッセージをキューから削除するといったことも、次のようなコードを一行設定するだけで実現できる。

```
shoryuken_options queue: 'default', auto_delete: true
```

#### Note: ShoryukenからSQSを監視する際の注意

SQSは複数のデータセンターでreplicationされている都合上、同一のメッセージを複数回取得してしまう可能性がある。
Shoryuken単体ではこの問題には対応できないため、別途自身で排他制御を実装しなければならない。

### 通知サーバ

詳しい解説は後述の**利用サービスへのwebフック通知の流れ**に譲るが、各サーバから`sns:Publish`されたメッセージは一旦この通知サーバが受ける。
その後、*本基盤*独自の署名を施した上で、*利用サービス*側に対してジョブの現在の値を送信する。

SNSからの通知内容が「変換処理の完了」を意味するものであった場合は、配信用URLの配列を通知内容に含めて、*利用サービス*に通知する。

このサーバ上でのAPIについても、前述のAPIサーバ同様に*Grape*を用いて実装されている。

### サムネイル作成サーバ

後述の配信用S3に対してリバースプロキシを行い、コンテンツを取得後、nginxのモジュールを用いて画像のリサイズなどを行う。このサーバに関してのみ、すべての処理はnginxで完結している。
サムネイル作成時に*利用サービス*は事前に受け取ってある秘密の鍵を使い、指定されたアルゴリズムに基づいてハッシュ値を算出し、URLの一部としてURLを生成する。
サムネイル作成サーバは、そのハッシュ値をもとにURLに対するバリデーションを行い、妥当であると判定できなければ、画像変換は行わずに`400 Bad Request`を返すといった実装になっている。

## S3

*利用者*から直接ファイルのアップロードを受ける**アップロード用S3**と、CloudFrontのoriginとして指定する**配信用S3**の２つを用意する。

### アップロード用S3

APIサーバによって払い出された*アップロードチケット*を用いて、*利用者*がファイルをアップロードすることのできるS3。

*利用者*からのファイルアップロードを受け付けるため、次の制限を課すものとする。

* (*アップロードチケット*に含まれる)*一時認証情報*をもとにファイルをアップロードできるのは、アップロード用S3にの一意に定められたキーのみであり、複数個のファイルアップロードは認めない。
* アップロードされたファイルは外部から確認できないようにする。
* アップロードされたファイルは後述のLambdaやその他バッチサーバを用いて適切にバリデーション(ヘッダ検証・サイズ検証)を行い、不正なファイルであると判断した際には即座に削除される。
* 同一箇所へのファイルの上書きについては、S3が提供するVersioningの仕組みを用いて、初回にアップロードされたファイル以降のファイルデータを即座に削除する。

Lambdaとの連携のために、本S3についてはS3 Notificationの仕組みを使って後述のLambdaを起動するように設定されている。

### 配信用S3

*利用者*の目に触れることになるファイルを配置するための配信用S3。

全てのバリデーション・変換処理を通過したファイルが、最終的にLambda・バッチサーバ・Elastic Transcoderからアップロードされる場所でもある。

なお、配信用S3に対して*利用者*が直接アクセスすることはなく、後述のCloud Front、もしくはサムネイル作成サーバ経由でファイルを取得し、エンドユーザーに返すこととなる。

## DynamoDB

*ジョブ*を管理するためのデータベース。
主に次のような構成を持っており、変換・配信状態の情報は全てDynamoDBを使って管理する。

```
{
  "data": {
      "job": {
          "convert_method": "変換方法", // 変換方法
          "job_id": "ジョブID", // 一意に定められるプライマリキー
          "service_name": "サービス名" // *利用サービス*名
          "status": "ジョブの状態", // uploaded, invalid, completionなど
          "urls": ["配信用URL"] // 配信用URL
          "mode": "配信状態の管理" // distributed, suspendedなど
      }
  }
}
```

## Elastic Transcoder

音声ファイルのエンコードを行う。
Elastic Transcoderに対して前述のアップロード用S3と配信用S3を指定したElastic Transcoder Pipelineを作成し、そこに適切にジョブを作成するだけでエンコード処理が実行される。

処理が終了した際には、Elastic TranscoderからSNSを経由して通知サーバへと通知が行われるようになっている。

音声ファイル変換時のジョブ更新については通知サーバで行われる。


## CloudFront

ファイルの配信を行うためのCDNを提供する。

*本基盤*においては基本的に配信用S3にあるファイルを配信するが、特定の文字列から始まるパスに対してリクエストが発生した際には、キャッシュを除いてサムネイル作成サーバにリクエストを移譲する。

SSL対応サイトにおいてもファイルの配信に対応するため、SSL証明書を適用してhttpsなURLを提供する。

## Lambda

前述のアップロード用S3に対してファイルがアップロードされた際に、同期的に起動してファイルに対する処理を行うインスタンス。

*本基盤*におけるLambdaが行う処理の流れは次の通りである。

1. アップロード用S3にファイルがアップロードされると、Lambdaが起動する。
2. 一つのファイルに対するLambdaの起動が重複している危険性があるため、既に処理の始まっているもかどうかを、ジョブの現在の情報を取得して確認する。重複していれば処理はストップする。
3. アップロードされたファイルの、サイズ・ヘッダ情報に対するバリデーションを行う。不正なファイルと判断すれば、ジョブの情報を更新して、`invalid`なファイルと認定する。
4. Lambdaによるバリデーションは通過したため、ジョブの情報を更新して、正常にファイルがアップロードされたものとする。この時、ファイルがアップロードされたことを*利用サービス*に対して通知するため、SNSに対するpushを送信する。

ここから、変換方法に応じて処理が大きく分岐する。

* 画像ファイルのアップロードであれば、この時点で配信用S3に対するファイルのアップロードを行う。完了すれば*利用サービス*に対してそのことを通知するため、SNSに対するpushを送信する。
* その他PDF・音声ファイルについては、必要情報をSQSに送信し、前述のバッチサーバに処理を移譲する。この時点で*利用サービス*に対する通知は行わない。

## SNS

*本基盤*では、ジョブの更新時に必要であれば*利用サービス*に対して通知を行う。

ただし変換方法によって、通知を行いたいタイミングはまちまちであるため、通知を行いたい全てのタイミングでSNSにpushを行い、前述の通知用インスタンスがそれを受けて、必要となる情報を*利用サービス*に対して通知していくという流れになる。

通知は全て*利用サービス*のAPIをエンドポイントとしてリクエストを行うため、この方式が妥当であると判断した。

### SNSによるリクエストの署名検証

AWS SNSから送信されるメッセージには署名が含まれており、aws-sdkを使って簡単に署名の検証を行える。

次のコードは、署名検証の一例である。本来はRack Middlewareとして実装すべきだろう。

```
def verify!(json)
  verifier = Aws::SNS::MessageVerifier.new
  error!('VerificationError', 400) unless verifier.authentic?(json)
rescue
  error!('Bad Request', 400)
end
```

### SNSからのRequestのContent-Typeに注意

SNSからのRequestのContent-Typeは、たとえリクエストボディがJSONであったとしても`text/plain`となっている。
そのため、それを考慮したRack Middlewareなどを実装すべきだと考えられる。

## SQS

*本基盤*におけるLambdaではカバーしきれない処理をバッチサーバに移譲するためのメッセージキュー。

SQSに送信する情報はアップロード用S3のバケット名と、キー名のみである。

EC2の項にて説明したバッチサーバ上でSQSを監視するデーモンを起動しており、新たにメッセージを取得した際には、バケット名・キー名を元にアップロード用S3からファイルを取得して処理を開始することになる。

# 各処理の流れ

前項までが、*本基盤*で用いられるAWSの各サービスの利用についての解説となる。
本項では、**それらのサービスを組み合わせ**、どのような流れで処理が進んでいくのか、というところに着目して解説したい。

なお、全ての処理において、共通してジョブの情報取得のためにDynamoDBを利用するが、図が複雑化しそうであったため省略した。
すなわちどのサーバからも、 **原則的に** DynamoDB上で管理されているジョブの値に基づいて処理を行なっているという点に注意されたい。

## *一時認証情報*の払い出しの流れ

1. *利用者*が*利用サービス*に対して、ファイルをアップロードしたいという旨のリクエストをする。
2. *利用サービス*はそれを受け、*本基盤*に対して、*利用者*が*本基盤*の用意するアップロード用S3へのアップロードを行うための*一時認証情報*を含む*アップロードチケット*をリクエストする。
3. リクエストを受けた*本基盤*のAPIサーバは、`sts:GetFederationToken`を用いた*一時認証情報*の生成を試みる。
4. STSを用い、アップロード用S3の特定のキーへの`s3:PutObject`権限を移譲する*一時認証情報*を生成する。
5. *アップロードチケット*(４で生成した*一時認証情報*とジョブの現在の情報を統合したもの)を*利用サービス*に対して返す。
6. *利用サービス*は*利用者*に対して、ファイルのアップロードに必要な情報を*アップロードチケット*から切り出して返す。
7. *利用者*はクライアントサイドの実装(JavaScript, objective-c, swift)から、*一時認証情報*をもとにアップロード用S3に対してアップロードを試みる。

![flow-temporary-credentials.png](https://qiita-image-store.s3.amazonaws.com/0/10419/270fd246-4028-5286-7de4-d993ba602464.png)

## 画像ファイルの処理の流れ

1. *利用者*は払い出された*一時認証情報*を用い、アップロード用S3に対してファイルのアップロードを行う。
2. アップロード用S3にファイルがアップロードされたタイミングでS3 Notificationの仕組みから、Lambdaが起動する。
3. Lambdaにて、アップロードされた画像ファイルのバリデーションを行い、問題がなければ配信用S3にファイルを`s3:CopyObject`を用い、コピーする。`invalid`であると判断された場合には、即座に処理を中止し、*利用サービス*に対して通知を行う。

![flow-upload_image.png](https://qiita-image-store.s3.amazonaws.com/0/10419/0a26a1aa-ddb3-3e69-78a6-7f0faf58d9ea.png)

## PDFファイルの処理の流れ

1. 画像ファイルの処理同様、*利用者*は払い出された*一時認証情報*を用い、アップロード用S3に対してファイルのアップロードを行う。
2. アップロード用S3にファイルがアップロードされたタイミングでS3 Notificationの仕組みから、Lambdaが起動する。
3. Lambdaにて、アップロードされたPDFファイルに対して簡易的なバリデーション(サイズ・ヘッダ検証など)を行い、問題がなければ、ジョブを一意に特定するプライマリキーをメッセージとして、SQSに対して`sqs:SendMessage`を実行する。`invalid`であると判断された場合には、即座に処理を中止し、*利用サービス*に対して通知を行う。
4. SQSを監視するバッチサーバがqueueされたメッセージを取り出し、改めてPDFファイルに対するバリデーションを行う。問題がなければ、バッチサーバ上でPDFファイルを画像に切り分ける。
5. 切り分けられた画像群と、元のPDFファイルを配信用S3に対して`s3:PutObject`を用い、アップロードする。

![flow-extract_pdf.png](https://qiita-image-store.s3.amazonaws.com/0/10419/9883a8a7-f096-2a7f-fed5-190831aec1cd.png)

## 音声ファイルの処理の流れ

1. 画像ファイルの処理同様、*利用者*は払い出された*一時認証情報*を用い、アップロード用S3に対してファイルのアップロードを行う。
2. アップロード用S3にファイルがアップロードされたタイミングでS3 Notificationの仕組みから、Lambdaが起動する。
3. Lambdaにて、アップロードされた音声ファイルに対して簡易的なバリデーション(サイズ・ヘッダ検証など)を行い、問題がなければ、ジョブを一意に特定するプライマリキーをメッセージとして、SQSに対して`sqs:SendMessage`を実行する。`invalid`であると判断された場合には、即座に処理を中止し、*利用サービス*に対して通知を行う。
4. SQSを監視するバッチサーバがqueueされたメッセージを取り出し、音声ファイルに対するバリデーションを行う。
5. ４にて、問題がなければ予め用意してあるElastic Transcoder Pipelineの仕組みに則ってジョブを作成する。
6. Elastic Transcoderによるエンコードが完了すると、自動的に配信用S3にエンコード後のファイルがアップロードされる。

![flow-encode_audio.png](https://qiita-image-store.s3.amazonaws.com/0/10419/fa7d9a1b-6f26-bb90-ca50-d349b9b52e4f.png)

## *利用サービス*へのwebフック通知の流れ

次のタイミングで各サーバからSNSに対し、`sns:Publish`に則ってメッセージをPublishし、*利用サービス*に対して変更状態を通知する。

* ファイルがアップロードされたタイミングで起動するLambdaにて、対象ファイルに対するvalidationを行い、その結果を通知する。
* 画像ファイルが配信用S3へコピーされたタイミングで、画像ファイルにおける一連の処理が完了したことを通知する。
* PDFファイルの切り分け処理が正常に終了し、配信用S3へファイル群がアップロードされたタイミングで、PDFファイルにおける一連の処理が完了したことを通知する。
* 音声ファイルのエンコードが正常に終了したことを、Elastic Transcoderからの通知で確認したタイミングで、音声ファイルにおける一連の処理が完了したことを通知する。

![flow-push.png](https://qiita-image-store.s3.amazonaws.com/0/10419/64356f93-6972-773c-101e-574d1560043a.png)

## ファイル配信の流れ

すべてのファイルはCloudFrontを経由して配信される。
原則的にはoriginに設定されたS3のファイルを返すが、特定のプレフィックスが付与されているケースでは、サムネイル作成サーバのLoadbaracerに対して接続され、適宜画像変換処理をした上で返す。

![flow-thumbnail.png](https://qiita-image-store.s3.amazonaws.com/0/10419/6b722666-e958-193f-74fa-80473ac78551.png)

# 所感

AWSが提供するサービスを利用することで、CDNを用いたファイル配信を手軽に実現できるようになったほか、従来のオンプレ環境では困難だったオートスケーリングの仕組みを容易に導入することができた。

複数の*利用サービス*からの持続的な利用が想定されるこのような基盤は、十分な可用性と耐障害性を担保する存在でなければならず、AWSはそれらの問題の解消を容易にする。
いずれ、この開発・運用を通して得た知見についてはQiitaなど、技術系コミュニティの場で共有していこうと考えている。

# おわりに

後半にかけてだいぶ駆け足となってしまったが、ドワンゴではオンプレだけでなく、AWSも使っているということが伝わっているとありがたい。
*本基盤*はすでに超パーティ2015で大々的に発表された[ニコナレ](http://niconare.nicovideo.jp)で導入されており、今後リリースされるサービスにも導入される予定である。
筆者はそれらのサービスのインフラエンジニアとしても参加しているので、私的な感情ではあるが、ぜひ、それらのサービスについても興味を持っていただけると幸いである。