LoginSignup
48
33

More than 3 years have passed since last update.

CloudFrontとLambdaで実現するServerlessセキュアHLSストリーミング配信

Last updated at Posted at 2018-11-30

【概要】この記事について

簡単に説明すると...
https://www.slideshare.net/kkitasako/jawsdays2014-cf/16
↑のJAWS DAYS 2014の記事(CloudFrontで実現するセキュアコンテンツ配信と効果のトラッキング)のp16~17に書かれている「マニフェストファイルの書き換え」の部分を、今ならLambda@Edgeでやれるんじゃない?と思ったのでやってみました。
その時の知見の共有です。

http://akiyoko.hatenablog.jp/entry/2015/08/24/192618
こちら↑の記事様でEC2サーバでやっていることをLambdaでserverless化してみたような感じかと思います。
署名付きURLの生成やHLSのマニフェストファイル書換の仕組みを構築するためのサーバをEC2などで用意しなくてよいのでサーバレスアーキテクチャのコストメリットを得ることができます。

ちなみにServerlessAdventCalndar2018の存在はAWSのDevDayTokyo2018をストリーミングで視聴している際にコメント欄で「有志の方が作ったこんなんあるよー」と宣伝されているのを見て知り、ちょっくら自分も記事を書いてみようかと興味を持ちました。

やってみたこと概要

  • mp4動画をAWS Elemental MediaConvertを利用してHLS形式に変換
  • 変換後のファイルセット(m3u8ファイルとtsファイル)をS3バケット+CloudFrontで配信できるようにする
  • LambdaでCloudFrontの署名付きURL(ワイルドカード)を生成してApiGatewayでRestAPI化(CognitoでのIAM認証付き)
  • デモ用Webアプリ(SPA)にて、html5のvideoタグのソースとして上のRestAPIで生成した署名付きURLを指定
  • Lambda@EdgeをCloudFrontのViewerRequestに配置して「マニフェストファイルの書き換え」を行い、videoタグで直接参照しているm3u8ファイルだけでなく、各関連ファイルにも同様に署名付きURLでアクセスできることを確認

構成図

Untitled Diagram (1).png
※ この図はdraw.ioで書きました。

Webアプリと動画配信のドメインは分かれており、後者の方にだけ署名付きURLのアクセスを強制するようにCloudFrontの設定で制限をかけています。

Lambdaでやっていることがこの記事の主役です。
Lambda以外の情報も書いてますが当記事内では"脇役"として扱ってます。

Lambda@Edgeについて

CloudFrontのエッジロケーションで受けたリクエストとレスポンスの4種類のタイミングに対してLambdaの処理を差し込めるというものです。
今回はViewerRequestのタイミングにLambdaの処理を差し込みました。
EdgeのLambdaはバージニア北部のリージョンに配置する必要があり、通常のLambdaよりも制限が厳しくなってます。
公式: Lambda@Edge
公式: Lambda@Edge の制限

HLS(HTTP Live Streaming)形式と当記事との関連

HLSは米Apple社が提案したストリーミングフォーマットで、現時点では最も一般的なストリーミングの形式らしいです。
Safariブラウザではhtml5のvideoタグでそのまま視聴できますが、Chrome等の他ブラウザではjs(javaScript)ライブラリの力を借りる必要があります(2018-12-01現在)。今回はhls.jsというライブラリを使っています(後述の脇役その②を参照)。

そして、ここからが当記事との関連なのですが...
HLS形式はm3u8という拡張子が付いたマニフェストと分割された複数のtsファイルで構成されます。
html5のvideoタグのソースとして指定するマニフェストだけでなく、そのファイル内に書かれている全てのファイルへの参照にも署名をつけないとエラーになって期待通りに動画が視聴できません。

そこで、マニフェストファイル(m3u8)へのリクエストをLambda@Edgeに流してマニフェストの書き換えを行い、他ファイルへの参照にも自分のリクエストのURLに付与されているものと同じ署名のパラメータをくっつけてしまおう、というワケです(詳しくは後述の主役②を参照)。


【主役①】CloudFrontの署名付きURLを生成するLambda

CloudFront用の署名付きURLを生成するLambdaのサンプルコードです。
Lambdaのランタイムはnodejs8.10を使用しています。

署名付きURLはワイルドカードのカスタムポリシーで生成します。

このソースコードでは動画ファイル名のベース部をパラメータとして受け取り、ワイルドカードURLのカスタムポリシーで生成した署名を付与したマニフェストのURLを返しています。
("sample_movie"というパラメータを受け取ったら"https://ドメイン/hls/sample_movie.m3u8?Signature=XXXXX"のような署名付きURLにして返却。署名はこのファイルだけでなく"https://ドメイン/hls/sample_movie*"に適用できる)

この処理で生成されたURLは、クランアント側でhtml5のvideoタグのソースとして使用されます。

const aws = require('aws-sdk');

const keypairId = 'CloudFrontのキーペアのID';
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
// CloudFrontのキーペアprivateKeyの中身(もちろんQiitaやgithubで公開なんかしない)
// パラメータストアにKMSで暗号化した値で保存するのがよさそう
-----END RSA PRIVATE KEY-----
`;
const signer = new aws.CloudFront.Signer(keypairId, privateKey);

// CloudFrontDomain
const CF_DOMAIN = '動画配信URLのドメイン';
// 有効期限(ミリ秒) 3分
const EXPIRE_MS = 3 * 60 * 1000;

exports.lambdaHandler = async (event, context) => {
  const baseFileName = event.pathParameters.baseFileName;
  const signedUrl = await getSignedUrlAsync(baseFileName);
  // policyの作成に使ったURLのワイルドカード部をマニフェストファイルへの参照用に変更する
  const signedManifestUrl = signedUrl.replace(/\*\?/, `.m3u8?`)

  const resbody = {
    signedManifestUrl
  };

  const res = {
    statusCode: 200,
    headers: {
      "Access-Control-Allow-Origin": "*"
    },
    body: JSON.stringify(resbody)
  };
  return res;
};

const getSignedUrlAsync = (baseFileName) => {
  // baseFileNameの後ろが何でも適用できるワイルドカードURL指定のカスタムポリシーを生成
  const wildcardUrl = `https://${CF_DOMAIN}/hls/${baseFileName}*`;
  // 署名の有効期限を設定
  const expiresUtcUnix = getExpireUtcUnixTimestamp(EXPIRE_MS);
  console.log('expiresUtcUnix:', expiresUtcUnix);
  const policy = createPolicy(wildcardUrl, expiresUtcUnix);
  // signerにpromiseを返してくれるメソッドが無いようなので自分でpromiseを作る(async, awaitできるように)
  return new Promise((resolve, reject) => {
    const options = {
      url: wildcardUrl,
      policy: JSON.stringify(policy)
    }
    // SDKを使用して署名付きURLの生成
    signer.getSignedUrl(options, (err, url) => {
      if (err) {
        console.log(err);
        reject(err);
      }
      resolve(url);
    });
  });
};

const createPolicy = (url, expiresUtcUnix) => {
  return {
    "Statement": [{
      "Resource": url,
      "Condition": {
        "DateLessThan": {
          "AWS:EpochTime": expiresUtcUnix
        }
      }
    }]
  }
};

const getExpireUtcUnixTimestamp = (millSec) => {
  // cloudfrontのjsライブラリのexpiresはUNIXエポック時間で指定する必要があるらしい
  // ライブラリのmomentを使えばもっと簡単にできる( moment().utc().add(3, 'minutes').unix(); のように)
  // が、aws-sdkのrequireだけでやりたかったので面倒だが同じ結果を得れる実装で
  const now = new Date();
  const utcTimestamp = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds(), now.getUTCMilliseconds());
  const expireTimestamp = utcTimestamp + millSec;
  return Math.floor(expireTimestamp / 1000);
};

参考:
AWSJavaScriptSDK - Signer
カスタムポリシーを使用する署名付き URL のポリシーステートメントの作成

【主役②】マニフェストの書き換えを行うLambda@Edge

EdgeロケーションのViewerRequestでマニフェストファイルの書き換えを行うLambdaの処理です。
普通のLambdaとは異なるEdgeのLambda用のIAMロールを作成する必要があります(詳しくはAmazon CloudFrontとAWS Lambda@EdgeでSPAのBasic認証をやってみるを参照)。

処理内容としては素のマニフェストファイル(m3u8)をS3バケットから取得して、内容の一部を文字列置換したものをレスポンスBodyとして返却しています。
自分自身のHTTPリクエストのquerystring(署名付きURLとしてのクエリパラメータ)を、マニフェスト内に書かれている他ファイルへの参照の末尾にもくっつけてやる、という文字列置換を行っています。

署名付きURLのパラメータについては毎回違う値を生成してアクセスしてもCloudFrontのキャッシュの恩恵を受けられます(詳しくはクエリ文字列パラメータに基づくコンテンツのキャッシュを参照)。

参考:
Lambda@Edge を使用したエッジでのコンテンツのカスタマイズ
Lambda@Edge イベント構造
クエリ文字列パラメータに基づくコンテンツのキャッシュ
Lambda@Edge 用の IAM アクセス権限とロールの設定
Amazon CloudFrontとAWS Lambda@EdgeでSPAのBasic認証をやってみる
Lambda@EdgeでCloudFrontへのアクセスをいい感じに振り分ける

const aws = require('aws-sdk');
const s3 = new aws.S3({ apiVersion: '2006-03-01' });
const bucketName = 'HLSストリーミング動画用のリソースを配置したS3バケット名';

// Lambda@Edgeのソース
// CloudFrontのビヘイビア(Behaviors)で"*.m3u8"のリクエストだけをこのLambdaに処理させる
exports.lambdaHandler = async (event, context, callback) => {
    const request = event.Records[0].cf.request;
    // uriの先頭の"/"をとってS3オブジェクトのkey名を取得
    const key = request.uri.substring(1);
    const s3Params = {
        Bucket: bucketName,
        Key: key
    };
    // 素のマニフェストファイルの内容をS3バケットから取得
    const s3Res = await s3.getObject(s3Params).promise();
    const srcBody = s3Res.Body.toString('utf-8');

    // 自分のリクエストに付与された署名パラメータをファイル内の参照にも付与(書き換え処理)
    const qs = request.querystring;
    const signedBody = srcBody.replace(/\.m3u8/g, `.m3u8?${qs}`).replace(/\.ts/g, `.ts?${qs}`);

    // content-typeとCORS用のResposeヘッダを付与
    const response = {
        status: 200,
        statusDescription: 'OK',
        headers: {
            'content-type': [{
                key: 'Content-Type',
                value: 'application/x-mpegURL'
            }],
            'access-control-allow-methods': [{
                key: 'Access-Control-Allow-Methods',
                value: 'GET,HEAD'
            }],
            'access-control-allow-origin': [{
                key: 'Access-Control-Allow-Origin',
                value: '*'
            }]
        },
        body: signedBody,
    };
    callback(null, response);
};

Lambda@Edgeの配置の仕方

動画用リソースを配信するCloudFrontのビヘイビアに*.m3u8のパスパターンを追加して...
cf1-1.PNG
ViewerRequestのCloudFront EventにEdge用に実装したLambdaを紐づけます。
LambdaはARNでバージョン(下記の例だと最後の":23"の部分)まで明示する必要があります。aliasは使えません(今のところ)。
cf2.PNG

このLambda@Edgeの処理でどうなるのか?

CloudFrontのEdgeロケーションが"https://ドメイン/hls/sample_movie.m3u8?Signature=XXXXX"(主役①の処理で生成されたURL)というURLのリクエストを受け取ったとして、オリジン(S3バケット)にある対象のマニフェストファイルが下記のような中身だった場合...

(S3バケットにある素のマニフェストファイルの中身のイメージ)
#EXTM3U
...
#EXTINF:12,
sample_movie_00001.ts
#EXTINF:12,
sample_movie_00002.ts
#EXT-X-ENDLIST

Lambda@Edgeによって下記のように書き換えられたレスポンスがクライアントに返却されます。
(.tsファイル名の末尾に、自ファイルへのリクエストのquerystringと同じ署名のURLパラメータがついていますね)

(書き換え後のイメージ)
#EXTM3U
...
#EXTINF:12,
sample_movie_00001.ts?Signature=XXXXX...
#EXTINF:12,
sample_movie_00002.ts?Signature=XXXXX...
#EXT-X-ENDLIST

※ 主役①の処理で署名付きURLを生成する際にワイルドカードのURLを指定したカスタムポリシーを使っているので、複数のファイルに同じURLパラメータ(署名)を使えます。

デモWebでの動作確認

当記事のデモ用に作成したショボいWebアプリより、期待通りに全てのtsファイルへのHTTPリクエストに同じ署名パラメータがついていることを確認できました。
demoweb2.PNG


この記事の脇役達

ここからは先は脇役の記事です。
他の方の記事と被りそうな内容ですが、人によってはこちらのほうが役にたつかもしれません。
ご参考までにどうぞ。

【脇役その①】mp4動画のHLS形式への変換について

AWS Elemental MediaConvertを利用してmp4からHLS形式に変換しました。
AWSのサービスを使ったストリーミング形式への変換に関する情報を検索するとElasticTranscoder使う記事が多い印象ですが、MediaConvertはより新しく高機能なサービスでElasticTranscoderの公式URLからも利用を勧めているようだったのでそちらを使いました。
変換元動画のmp4はNHK CreativeLibraryから適当な素材を選んで使わせて頂きました。

参考:
AWS Elemental MediaConvert とは
Elemental MediaConvertを利用して動画変換を自動化する

以下にMediaConvertでのmp4→hls変換の手順について簡単に書かせて頂きます。
(基本的にデフォルト設定から変更した部分だけを載せます)

ジョブテンプレートの作成

Apple HLSを選択
mc1.PNG

出力のカスタムグループ名と出力先のs3バケットを設定
mc2.PNG

名前修飾子に、ここでは"_hls"を入力(ここは入力しないとエラーになります)
mc1-2.PNG

レート制御モードでQVBRを選択して最大ビットレートに700000を入力(この辺詳しくないですが今回はQVBRを使用してみました)。
mc3.PNG

参考: https://docs.aws.amazon.com/ja_jp/mediaconvert/latest/ug/cbr-vbr-qvbr.html

IAMロール(コンバートジョブ作成用)の作成

IAMロールは次手順のジョブの作成に必要になります。
IAM コンソールから「ロールの作成」に進むと「このロールを使用するサービスを選択」の画面でMediaConvertを選択できるので選択してロールを作成します。
参考: https://docs.aws.amazon.com/ja_jp/mediaconvert/latest/ug/iam-role.html
mc6.PNG

ジョブの作成

作成したジョブテンプレートを選択して「ジョブの作成」
mc8.PNG

「入力」でS3バケット上の変換元のmp4ファイルを指定します(変換元ファイルはアップロードしてください)
mc5.PNG

「ジョブの設定」でIAMロールを指定します(先の手順で作ったものを指定)
mc7.PNG

画面右下の「作成」ボタンを押下して、入力内容に不足や誤りが無ければ次のような画面になってジョブが作成されたことがわかります。変換が終わると「更新」を押すことで「終了時刻」などの情報も出てきます。
mc9.PNG

S3バケットにHLSストリーム用にコンバートされたファイル群が作成されています。
"m3u8"という拡張子がついたファイルが、この記事の主役②のLambda@Edgeで内容の書き換えを行っている対象の元ファイルになります。
mc10.PNG

【脇役その②】デモ用Webアプリ(with React)

この記事用にcreate-react-appをベースに実装した簡易SPA(SiglePageApplication)で、主役②の「動作確認」にリンクを貼っている先のデモアプリです。
ショボいです。
ライブラリとしてAWS Amplifyhls.jsを使っています。
ここにはポイントのみ記載しますが、githubにソース全体を置いてありますのでよかったらどうぞ。

依存ライブラリの追加

yarn add aws-amplify hls.js

AmplifyでAPIGatewayのAPI(IAM認証とAPIキー制限付き)を呼び出すための設定

import Amplify, { API } from 'aws-amplify';

const REGION = 'yourRegion';
const ENDPOINT_NAME = 'DemoWebBackend';
const ENDPOINT = 'https://XXXXXXXX.execute-api.yourRegion.amazonaws.com';
const IDENTITYPOOL_ID = 'your identity pool id';
const APIKEY = 'your api key';
Amplify.configure({
  Auth: {
    region: REGION, // REQUIRED
    identityPoolId: IDENTITYPOOL_ID, // REQUIRED
    mandatorySignIn: false
  },
  API: {
    endpoints: [
      {
        name: ENDPOINT_NAME,
        endpoint: ENDPOINT,
        region: REGION,
        custom_header: async () => { return { 'x-api-key': APIKEY } }
      }
    ]
  }
});

参考: https://aws-amplify.github.io/docs/js/api#custom-request-headers

ApiGatewayのRestAPIを呼び出しているところ(抜粋)

  onClickIssueSignedUrl = async () => {
    const baseFileName = 'D0002030503_00000_V_000';
    const path = `/prod/signedurl-demo/${baseFileName}`;
    const resBody = await API.get(ENDPOINT_NAME, path);
    console.log('resBody:', resBody);

hls.jsのsourceに発行した署名付きURLを設定(抜粋)

import Hls from 'hls.js';
// ~省略~

  var video = document.getElementById('video');
  if(Hls.isSupported()) {
    var hls = new Hls();
    hls.loadSource(singedUrl);
    hls.attachMedia(video);
    hls.on(Hls.Events.MANIFEST_PARSED, function() {
      console.log("MANIFEST_PARSED (in Hls.isSupported)");
    });

【脇役その③】CognitoとApiGatewayとLambdaを使ったRestAPIとIAM認証

今回作成したデモ用のRestAPIは、Cognito IdentityPoolの未認証ユーザ用IAMロールと紐づけてRestAPIにIAM認証を追加しています。
さらに、APIキーによる制限も設定してスロットリングとクォータの制限も行ってます。

ApiGatewayの対象のAPIのARNを...
(ちなみに右側にあるLambda"qiita2018adc-cf-signer"は記事内の「主役①」のLambdaの名前です)
api5.PNG

CognitoのIdentityPoolを作ったときに追加された認証ユーザ用IAMロールのポリシーに実行許可を追加して...
cog2.PNG

RestAPIにIAM認証をかけます。
クライアントは上記の未認証ユーザ用IAMロールの権限を持つ一時的な認証をCognito経由で発行してもらうことにより、限定された権限内においてAPIを実行できます。
api4.PNG

今回はついでに(?)APIキーも必須にしているのでHTTPリクエストの"x-api-key"ヘッダにこのAPIのステージに関連付けられた適切なAPIキーの値が設定されていないと403エラーになります。
APIキーを設定したAPI呼び出し例は「脇役その②」をご参照下さい。
ちなみに、APIキーはIAM認証とは異なりセキュリティを確保するためのものではありません。

参考:
[API Gateway] APIキーと使用量プランを使用してアクセス制限を掛ける

【脇役その④】S3をオリジンとしたCloudFrontでのプライベートコンテンツ配信

この部分は既にいろいろな方が書いていると思うので詳細は省略させて頂きます。
自分的なポイントやハマった点としては下記などでしょうか...

  • 管理者権限のあるAWSアカウントでログインして「CloudFront のキーペア」を作成し、このキーペアとSDKを使って署名を生成することになります(nodejsでの署名生成のコードサンプルはこの記事の主役①を参照のこと)。
  • CloudFrontのOriginの設定で「Restrict Bucket Access」をYesにすることで、CloudFrontを経由しないアクセスを禁止できます。
  • CloudFrontのビヘイビアの設定で「Restrict Viewer Access(Use Signed URLs or Signed Cookies)」をYesにすることで、署名付きURLのアクセスのみ許可するようにできます。
  • javascript(hls.js)から違うドメインの動画のリソースにアクセスするのでオリジンとなるS3バケットにCORSの設定が必要です。
  • このデモ用に新しく作成したS3バケットへのCloudFrontからのアクセスになぜか失敗するなあと思ったら、バケット作成時に最近(2018-11-15?)追加されたS3のセキュリティ関連機能を何も考えずに有効にしていたせいでした(参考:S3で誤ったデータの公開を防ぐパブリックアクセス設定機能が追加されました )

【補足・注意点など】

署名付きURLではなく、Cookieを使う選択もある

S3+CloudFrontのプライベートコンテンツの配信には、当記事で書いている署名付きURLではなく署名付きCookieを使う方法もあり、ワイルドカードで生成した署名付きCookieを使えばこの記事で書いているようなマニフェストファイルの動的な書き換えば不要になるはずです。
もし、業務要件としてログイン済ユーザに対して特定のフォルダ単位でまるっとプライベートコンテンツへの参照許可を与えても良いWebアプリなのであればCookieを使う方が簡単かと思われます。

参考:
署名付き URL と署名付き Cookie の選択
Rails + AWS でモバイルフレンドリーな動画配信サイト構築
Using Cloudfront Signed Cookies

当記事用のサンプルアプリケーションの制限について

主役②にもリンクを張っている当記事のデモ用Webアプリは日本とアメリカからのみアクセス可能となるようにCloudFrontのGeoRestrictionで制限しています。それ以外の国から参照している方がもしいたらごめんなさい。
また、デモ用Webのために公開しているRestAPIには、ApiGatewayでかなり厳しめのスロットリングとクォータの制限をかけてます。
もしも想定外の大量のAPIアクセスがあるとApiGatewayのスロットリングやクォータの制限に引っかかって動画再生のための署名付きURL生成に失敗するかもしれません(まあないと思うけど)。
その場合は、次の日などに再びトライしてみればうまくいくかもしれません。クォータは日単位でリセットするように設定しているので。

デモWebではWindows10のChromeとFirefoxとEdge、MacのSafariとChrome、iPhone8のSafari、AndroidのChromeで動画が再生できることを確認しました。

当デモ用のRestAPIで生成する署名付きURLの有効期限は3分に設定しています。

(注意)デモ用Webは、今のところAdventCalendar2018の期間内(だいたい2018年内?)の期間限定公開を予定しています。様子を見て、CloudFrontのデータ転送(out)料金などの今回のサーバレス構成デモ全体での運用コストが極小(月100円以内?)で済みそうなのであれば延長してそのまま公開し続けるかもです(実はその辺の実験も兼ねてのデモ公開だったりします)。

※ 2019-04-12追記:運用コストは全くかかってないのでデモ用Webアプリはそのまま運用(放置)することにしました
メンテしなければならないサーバが存在しないので放置しても大丈夫なのもサーバレスのいいところですね!
※ 2019-09-03追記:CloudFrontの署名のPrivateKeyについてのコメントを"環境変数に"から"パラメータストアに"に変更

AWSとYシャツと私

アソシエイトレベルのAWS認定ソリューションアーキテクトとAWS認定デベロッパー(2018版)の資格をいちおう持っています。ソリューションアーキテクトのプロフェッショナルにもいつかチャレンジしたいです。
今はSAM(ServerlessApplicationModel)を使った開発環境の構築や運用について興味を持って勉強しているところです。
(Yシャツ関係ない)

48
33
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
48
33