メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックリーディングカンパニーのエンジニアブログです。PHPからRubyへ絶賛移行中!継続的にアウトプットを出し続けられるようにみんなでがんばりまっす!

Rails + AWS でモバイルフレンドリーな動画配信サイト構築

あけましておめでとうございます。
メドピアのSRE @kenzo0107 です。

2018年もよろしくお願いします。

今回は昨年リニューアルした動画配信システムについてです。

経緯

これまでのメドピアの動画配信は CloudFront 経由で S3 上の mp4 を video タグで参照し配信してました。

この配信方法では CloudFront でキャッシュしづらく
通信状況によってはファーストビューまでに時間が掛かり、サイト離脱へ繋がります。

また、直リンク禁止の動画の場合、
リファラチェック等をするかと思いますが
一部 IE Edge のバージョンで video タグでリファラ参照ができないという仕様があり*1
既存の仕組みをフロントから変える必要がありました。

以上の経緯から動画配信の仕組みを見直し要件を洗い出しました。

要件

  • 動画は mp4 で納品される為、HLS形式へエンコードする機構を用意する。
  • 通信状況に依らずサクサクと見ることができる様にする。
  • 電波状況によってレートを変換する。あくまで見続けられる。
  • 直リンク禁止にする。
  • 今後を見据えて特定のユーザにのみ閲覧を許可する機能を盛り込む。

システム概要

要件を満たすべく環境構築しました。概要は以下となります。

① S3 Bucket transcoder.raw に mp4 ファイルをアップ
② ファイルアップをトリガーに Lambda を起動
③ Lambda が ElasticTranscoder を呼び出し
④ mp4 を HLS(m3u8+ts)形式 へ変換し S3 Bucket transcoder.processed にアップ
⑤ エンコードの成功・失敗をSlackに通知
⑥ ユーザがサイトにアクセス
⑦ Rails から 認証機能を利用し CloudFront にアクセス
⑧ CloudFront がバケットへ対象ファイルにアクセス

f:id:kenzo0107:20180118162230p:plain

理解を深めるべく AWS コンソールで構築手順をお伝えします。

S3 Bucket 作成

mp4 ファイルをアップロード先のバケット(transcoder.raw)、
エンコードされたファイルを格納するバケット(transcoder.processed
を作成します。

transcoder.raw のポリシー作成

mp4 ファイルをアップロード元を許可します。

今回は以下からの全アクションを許可しています。

  • 管理画面URL(https://admin.example.com)
  • 社内IP
{
    "Version": "2012-10-17",
    "Id": "transcoder.raw",
    "Statement": [
        {
            "Sid": "allow-referer",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::transcoder.raw/*",
            "Condition": {
                "StringLike": {
                    "aws:Referer": [
                        "https://admin.example.com/*"
                    ]
                }
            }
        },
        {
            "Sid": "allow-ad-referer",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::transcoder.raw/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": [
                        "<社内 IP>/32"
                    ]
                }
            }
        }
    ]
}

Elastic Transcoder 作成

動画変換の肝の部分です。

以下の様に Elastic Transcoder のパイプラインを作成していきます。

f:id:kenzo0107:20180117133302p:plain

任意(Optional) で 動画エンコード成功可否について通知設定があります。

完了時(On Complete Event)とエラー発生時(On Error Event)にSNS 経由で Slack に通知する様にしました。

f:id:kenzo0107:20180117133342p:plain

CloudFront 作成

S3 bucket transcoder.processed を参照する CloudFront を立てます。

  • delivery method は Web です。

Origin 設定

  • Restrict Bucket Access : Yes ... アクセス制限を設定します。
  • Origin Access Identity : Create a New Identity ... ID を作成します。
  • Grant Read Permissions on Bucket : Yes, Update Bucket Policy ... transcoder.processed へのアクセスポリシーを更新します。

f:id:kenzo0107:20171224161208p:plain

Behavior 設定

ここで重要なのは Restrict Viewer Access (Use Signed URLs or Signed Cookies) の設定を Yes にすることです。
これにより署名付き URL/Cookie のみ CloudFront へのアクセスが可能となり、直リンクを防止できます。

f:id:kenzo0107:20180120232221p:plain

また、Whitelist Headers で Origin を追加し S3 transcoder.processed の CORS でアクセス許可する URL を絞ることができます。

f:id:kenzo0107:20180120232319p:plain

Distribution 設定

  • Alternate Domain Names : cdn.example.com ... Cookie を有効化させる為、参照元ドメイン (example.com) のサブドメインとします。
  • SSL Certificate : Custom SSL Certificate ... *.cloudfront.net というドメインでなく固有のドメインを利用する為、カスタムSSL証明書を選択
  • Custom SSL Certificate Support : Only Clients that Support Server Name Indication (SNI) 選択します
  • Security Policy : 特に希望なければ recommended を選択します。

f:id:kenzo0107:20180120232946p:plain

  • Supported HTTP Versions : HTTP/2, HTTP/1,1, HTTP/1.0 ... HTTP/2 の恩恵を受けましょう
  • Logging : On ... アクセスログを取るかどうかの設定です。取れるものは取りましょう!
  • Bucket for Logs : transcoder.processed.s3.amazonaws.com ... ログを貯めるバケットです。エンコードされた動画用バケットを使うこととしました。
  • Log Prefix : logs-transcoder-cloudfront ... 保存するログのプリフィックスです。分かり易くしましょう。
  • Cookie Logging : On ... Cookie ログも取っておきます。

f:id:kenzo0107:20171224163250p:plain

以上設定完了後、Create Distribution ボタンを押下し作成します。

Lambda 作成

Lambda の役割

  1. mp4 ファイルを transcoder.raw にアップしたことを検知し
  2. ElasticTranscoder のパイプラインに渡し
  3. m3u8, ts ファイルを transcoder.processed に格納します

Lambda 関数作成

Lambda 関数の作成で 一から作成 を選択

  • 関数の名前: VideoTranscodingInAWS
  • ランタイム: Node.js 6.10
  • ロール: カスタムロールの作成

新しい IAM ロールの作成

ロール名: lambda_elastictranscoder_execution

  • ポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:xxxxxxxxxxxx:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::transcoder.raw/*",
                "arn:aws:s3:::transcoder.processed/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "elastictranscoder:CreateJob"
            ],
            "Resource": "arn:aws:elastictranscoder:ap-northeast-1:xxxxxxxxxxxx:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "sns:Publish"
            ],
            "Resource": "arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:ElasticTranscoderNotificationToSlack"
        }
    ]
}
  • 信頼ポリシー
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

スクリプト

ハンドラ: index.handler

'use strict';

var AWS = require('aws-sdk');
var s3 = new AWS.S3({
    apiVersion: '2012-09-25'
});

var transcoder = new AWS.ElasticTranscoder({
    apiVersion: '2012-09-25',
    region: 'ap-northeast-1'
});

// return dirname without extensionn
function dirname(path) {
    var p = path.split(path.sep).pop().split('.')[0];
    return decodeURIComponent(p);
}

exports.handler = function(event, context) {

    console.log('Executing Elastic Transcoder Orchestrator');

    var bucket = event.Records[0].s3.bucket.name;
    if (bucket !== 'transcoder.raw') {
        context.fail('Incorrect Video Input Bucket');
        return;
    }
    
    var pipelineId = '<ElasticTranscoder Pipeline ID>';
    var key = event.Records[0].s3.object.key;
    var dkey = dirname(key);

    console.log("(^-^)key");    
    console.log(key);
    console.log(dkey);

    var params = {
        Input: {
          Key: key,
          FrameRate: 'auto',
          Resolution: 'auto',
          AspectRatio: 'auto',
          Interlaced: 'auto',
          Container: 'auto',
        },
        PipelineId: pipelineId,
        Outputs: [
          {
            Key: dkey + '/600k/s',
            PresetId: '1351620000001-200040', // hls 600k
            SegmentDuration: '10'
          }
          ,{
            Key: dkey + '/1M/s',
            PresetId: '1351620000001-200030', // hls 1M
            SegmentDuration: '10'
          }
          ,{
            Key: dkey + '/2M/s',
            PresetId: '1351620000001-200010', // hls 2M
            SegmentDuration: '10'
          }
          ,{
            Key: key,
            PresetId: '1351620000001-000010', //Generic 720p - mp4
            ThumbnailPattern: dkey + '-{count}'
          }
        ],
        Playlists: [
          {
            Name: dkey,
            Format: 'HLSv3',
            OutputKeys: [
                dkey + '/600k/s', 
                dkey + '/1M/s', 
                dkey + '/2M/s'
            ]
          }
        ]
    };

    transcoder.createJob(params, function(err, data){
        if (err) {
            console.log(err, err.stack);
            context.fail();
            return;
        }
        context.succeed('Job well done');
    });
};

<ElasticTranscoder Pipeline ID> を先ほど作成した Pipeline ID を設定してください。

上記スクリプトの要点は以下です。

ビットレート毎 (600k, 1M, 2M) に出力

f:id:kenzo0107:20171224150345p:plain

上位の m3u8 ファイルによってビットレート毎の再生ファイルが管理されています。

帯域に余裕がある場合はプレイヤー側で
高いレート(2M: High)のファイルを選択するようになり
高画質の動画が閲覧できます。

逆に帯域に余裕がなく通信状況が悪い場合は
低いレート(600k: Low)を選択するようになります。

これによってユーザの通信状況にリアルタイムに合わせたレートでのストリーミング配信が可能になります。

ディレクトリ構成を担保したままファイル出力

transcoder.raw にアップしたディレクトリ構成を担保した状態で transcoder.processed に ts ファイルを作成するようにしています。
既存環境からの移行を加味しました。

SLACK_WEBHOOK_URL を変数で設定

環境変数として設定することで Slack 通知先を変えられる様にしました。

transcoder.processed アクセスポリシー確認

Elastic Transcoder での IAM 作成や
CloudFront での Bucket ポリシーをアップデート処理で
以下の様なポリシーが生成されています。

{
    "Version": "2012-10-17",
    "Id": "Policy-CDN",
    "Statement": [
        {
            "Sid": "Stmt1505204403832",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<aws account ID>:role/Elastic_Transcoder_Default_Role"
            },
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::transcoder.processed/*"
        },
        {
            "Sid": "2",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <CloudFront Origin Access ID>"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::transcoder.processed/*"
        }
    ]
}

S3 Bucket transcoder.raw で mp4 ファイルアップロード検知

S3 Bucket transcoder.raw の プロパティ > Events 設定します。

  • ObjectCreate (All) にチェックを入れます。
  • サフィックス : mp4
  • 送信先 : Lambda 関数
  • Lambda : VieoTranscodingInAWS

上記設定し保存します。

名前入れなくとも、自動で名前が生成されます。

f:id:kenzo0107:20171224170139p:plain

これで transcoder.raw に mp4 ファイルをアップロードすると Lambda が検知し
HLS ファイルを生成してくれる様になります。

エンコードされるか試してみる

transcoder.rawmedpeer/soreha/sutekina/shokuba とフォルダを作成し mp4 ファイルをアップロードします。

無料動画素材を以下から取得しました。*2

http://www1.nhk.or.jp/archives/creative/material/view.html?m=D0002060315_00000

f:id:kenzo0107:20180117171230p:plain

Slack から通知が届きました。*3

f:id:kenzo0107:20180117171920p:plain

エンコードしたファイルの格納先 transcoder.processed を見てみます。
無事フォルダ構成を担保したまま HLSフォーマット m3u8 ファイルやサムネイル画像が出力されています。

transcoder.processed/medpeer/soreha/sutekina/shokuba/
├── D0002060315_00000_V_000/
│        ├── 1M/
│        │         ├── s.m3u8
│        │         ├── s0000.ts
│        │         ├── ...
│        │         └── s0004.ts
│        ├── 2M/
│        │         ├── s.m3u8
│        │         ├── s0000.ts
│        │         ├── ...
│        │         └── s0004.ts
│        └── 600k/
│        │         ├── s.m3u8
│        │         ├── s0000.ts
│        │         ├── ...
│        │         └── s0004.ts
├── D0002060315_00000_V_000-00001.png
├── D0002060315_00000_V_000.m3u8
└── D0002060315_00000_V_000.mp4

Rails 改修

CloudFront の認証機能によってローカルの Rails on Vagrant から参照できる様にします。
ローカル Rails 環境のドメインを dev.example.com とします。

CloudFront 認証機能には以下 2つの方法があります。

  • 署名付き URL
    • URL に対して CloudFront 認証情報・期限を URL パラメータで渡す
    • CloudFront 上の 1 ファイルに対して認証設定可能*4
  • 署名付き Cookie
    • Browser 上に CloudFront 認証情報・期限を Cookie に保存
    • CloudFront 上の 複数のファイルに対して認証設定可能

上記特性より
署名付き URL を 1ファイルで動画を構成する mp4 で
署名付き Cookie を 複数ファイルで動画を構成する m3u8 で
試験したいと思います。

作成・修正するファイルリストです。

  • app/config/secrets.yml
  • app/controllers/concerns/common.rb
  • app/controllers/hoges_controller.rb (署名付き URL 用)
  • app/views/hoges/index.html.erb (署名付き URL 用)
  • app/controllers/moges_controller.rb (署名付き Cookie 用)
  • app/views/moges/index.html.erb (署名付き Cookie 用)

app/config/secrets.yml

AWS console 上で root 権限で作成した Key Pair IDと Private Key を設定しています。

development:
  secret_key_base: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  cloudfront_key_pair_id: xxxxxxxxxxxxxxxxxx
  cloudfront_private_key: "-----BEGIN RSA PRIVATE KEY-----\nxxxxxxxxxxxxxxxxxxxxx+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n-----END RSA PRIVATE KEY-----"

app/controllers/concerns/common.rb

module Common
  extend ActiveSupport::Concern

  # CloudFront 署名付き Cookie 認証用 Cookie 設定
  def cookie_data(resource, expiry)
    raw_policy = policy(resource, expiry)
    {
      'CloudFront-Policy' => safe_base64(raw_policy),
      'CloudFront-Signature' => sign(raw_policy),
      'CloudFront-Key-Pair-Id' => Rails.application.secrets.cloudfront_key_pair_id,
    }
  end

  # CloudFront 用 署名付き URL 取得
  def get_cloudfront_signed_url(resource, expiry)
    expire      = expiry.utc.to_i
    raw_policy  = policy(resource, expiry)
    signature   = sign(raw_policy)
    key_pair_id = Rails.application.secrets.cloudfront_key_pair_id
    "#{resource}?Expires=#{expire}&Signature=#{signature}&Key-Pair-Id=#{key_pair_id}"
  end

  private

  def policy(url, expiry)
    {
      "Statement" => [
        {
          "Resource" => url,
          "Condition" => {
            "DateLessThan" => { "AWS:EpochTime" => expiry.utc.to_i },
          },
        },
      ],
    }.to_json.gsub(/\s+/, '')
  end

  def safe_base64(data)
    Base64.strict_encode64(data).tr('+=/', '-_~')
  end

  def sign(data)
    digest = OpenSSL::Digest::SHA1.new
    key    = OpenSSL::PKey::RSA.new Rails.application.secrets.cloudfront_private_key
    result = key.sign digest, data
    safe_base64(result)
  end
end

署名付き URL

app/controllers/hoges_controller.rb

署名付き URL を生成します。
mp4 ファイル URL に対して10秒間有効な期限付き動画 URL を生成します。*5

class HogesController < ApplicationController
  include Common

  def index
    # 署名付き URL
    @cloudfront_signed_url = get_cloudfront_signed_url(
      'https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000.mp4',
      10.seconds.from_now
    )
  end
end

app/views/hoges/index.html.erb

<video poster="https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000-00001.png" preload="auto" controls="controls">
  <source src='<%= @cloudfront_signed_url %>' type='video/mp4'>
</video>

署名付き URL を確認してみる

動画が再生されました!
生成された期限付き動画URLを確認すると非常に長いURLパラメータが付与されていることが確認できます。

f:id:kenzo0107:20180118114358p:plain

そして 10 秒後、ソースから生成された期限付きURLを別途プライベートモードのブラウザで開こうとすると閲覧不可状態となっていることがわかります。

f:id:kenzo0107:20180118114949p:plain

署名付き Cookie

app/controllers/moges_controller.rb

10秒間有効な期限付きの署名付き Cookie を生成します。

class MogesController < ApplicationController
  include Common

  def index
    # 署名付き Cookie
    @cloudfront_url = 'https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000.m3u8'
    cookie_data('https://cdn.example.com/*', 10.seconds.from_now).each do |k, v|
      cookies[k] = { value: v, domain: 'example.com', path: '/' }
    end
  end
end

app/views/moges/index.html.erb

簡易的にクラウド上の hls.js 上を利用していますが、実際にはダウンロードして利用しています。

<video id="video" width="600" height="300" class="video-js vjs-default-skin" controls>
<script src="https://cdn.jsdelivr.net/npm/hls.js"></script>
<script>
if(Hls.isSupported()) {
  var video = document.getElementById('video');
  var config = {
    xhrSetup: function(xhr, url) {
      xhr.withCredentials = true;
    }
  }
  var hls = new Hls(config);
  hls.loadSource("<%= @cloudfront_url %>");
  hls.attachMedia(video);
  hls.on(Hls.Events.MANIFEST_PARSED,function() {
    // video.play();
  });
}
</script>

署名付き Cookie を確認してみる

署名付きURLとは異なり、埋め込まれる動画URLは変更ありません。
CloudFront 関連 Cookie が追加されていることがわかります。

  • CloudFront-Signature
  • CloudFront-Policy
  • CloudFront-Key-Pair-Id

f:id:kenzo0107:20180118124520p:plain

そして 10 秒後、Cookie を保存した同一のブラウザ上で 動画URL にアクセスしてみると閲覧不可状態となっていることがわかります。

https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000.m3u8

f:id:kenzo0107:20180118114949p:plain

また動画再生中に通信状況を意図的に劣化させることで再生される動画ファイルが変更されている様子がわかります。
是非試してみてください♪

元々の mp4 が ts ファイルに小分けにされキャッシュヒット率も上がり CloudFront 的にもメリットが大きいと感じました。

以上で Rails + AWS で直リンク対策を施した HTTP Live Streaming 動画配信環境が構築できました。

おまけ

既存動画バケットから新規バケットへ移動

新たに今回の HLS 動画閲覧システムを作成した後、
既存の mp4 を格納した S3 Bucket から移行が必要かと思います。

そんな時はコレ

macOSX%$ aws s3 sync --profile <profile> --region <region> \
--exclude "*" \
--include "*.mp4" \
s3://<既存の mp4 バケット名>/ \
s3://transcoder.raw/

初回動画確認前には Invalidation でキャッシュ削除

意外とハマりました。
CloudFront で誤ったキャッシュを保持しアクセスしても期待した動作にならない事象があった為、
問題があった場合はキャッシュを削除しておくと問題の切り分けができます。

以上
参考になれば幸いです。


是非読者になってください(︎ ՞ਊ ՞)︎


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

Rails未経験/経験年数が2年以内のポテンシャルエンジニア絶賛募集中です!*6

Rails開発してみたい人、新しい技術に意欲的な人、リードエンジニアに教育されてみたい(?)人、医療に関わるサービス開発を行ってみたい人、その他メドピアに興味を持った方などは、是非、コンタクトを取って頂ければと思います!

*1:Microsoft Edge 40 1506300, Microsoft EdgeHTML 15.15063 でリファラ参照不可であることを確認しています。

*2:NHKクリエイティブ・ライブラリーで無料提供されている動画素材です。試験するのにうってつけでした。

*3:ElasticTranscoder のイベントログを加工してます。

*4:複数の URL に対して署名付きURL生成設定すれば複数設定は可能

*5:試験の為、10秒間としています。

*6:リードエンジニアも募集中です!