あけましておめでとうございます。
メドピアの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 がバケットへ対象ファイルにアクセス
理解を深めるべく 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 のパイプラインを作成していきます。
任意(Optional) で 動画エンコード成功可否について通知設定があります。
完了時(On Complete Event)とエラー発生時(On Error Event)にSNS 経由で Slack に通知する様にしました。
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
へのアクセスポリシーを更新します。
Behavior 設定
ここで重要なのは Restrict Viewer Access (Use Signed URLs or Signed Cookies)
の設定を Yes にすることです。
これにより署名付き URL/Cookie のみ CloudFront へのアクセスが可能となり、直リンクを防止できます。
また、Whitelist Headers で Origin を追加し S3 transcoder.processed の CORS でアクセス許可する URL を絞ることができます。
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 を選択します。
- 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 ログも取っておきます。
以上設定完了後、Create Distribution
ボタンを押下し作成します。
Lambda 作成
Lambda の役割
- mp4 ファイルを
transcoder.raw
にアップしたことを検知し - ElasticTranscoder のパイプラインに渡し
- 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) に出力
上位の 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
上記設定し保存します。
名前入れなくとも、自動で名前が生成されます。
これで transcoder.raw
に mp4 ファイルをアップロードすると Lambda が検知し
HLS ファイルを生成してくれる様になります。
エンコードされるか試してみる
transcoder.raw
に medpeer/soreha/sutekina/shokuba
とフォルダを作成し mp4 ファイルをアップロードします。
無料動画素材を以下から取得しました。*2
http://www1.nhk.or.jp/archives/creative/material/view.html?m=D0002060315_00000
Slack から通知が届きました。*3
エンコードしたファイルの格納先 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パラメータが付与されていることが確認できます。
そして 10 秒後、ソースから生成された期限付きURLを別途プライベートモードのブラウザで開こうとすると閲覧不可状態となっていることがわかります。
署名付き 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
そして 10 秒後、Cookie を保存した同一のブラウザ上で 動画URL にアクセスしてみると閲覧不可状態となっていることがわかります。
https://cdn.example.com/medpeer/soreha/sutekina/shokuba/D0002060315_00000_V_000.m3u8
また動画再生中に通信状況を意図的に劣化させることで再生される動画ファイルが変更されている様子がわかります。
是非試してみてください♪
元々の 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開発してみたい人、新しい技術に意欲的な人、リードエンジニアに教育されてみたい(?)人、医療に関わるサービス開発を行ってみたい人、その他メドピアに興味を持った方などは、是非、コンタクトを取って頂ければと思います!