📹

AWS CloudFront+S3で動画アップロードからHLSストリーミング再生できるようにしてみた

はじめに

こんにちはエンジニアの藤田です。先日健康診断を受けまして中性脂肪と悪玉コレステロールの結果が悪かったことから人生 1000 回目くらいの禁酒を始めました。今回は割と長続きしています。

失恋の引きずりに近いようで何かしていないとついお酒のことを考えてしまうので、ブログ記事を書くことにしました。
最近、Smart Craft では動画アップロードおよび動画再生できる機能をリリースしまして「動画再生なんて画像表示みたいなもんでしょ」くらいに思っていましたらそうでもなかったので備忘録として書いていきたいと思います。

最終的に、例えば以下のように動画をアップロードし、再生できるようになりました。
image

記事の構成は以下のとおりです。

  • 全体構成
  • 動画アップロードについて
  • HLS 変換処理
  • 動画再生

全体構成

システムの構成はざっくり以下のようになっています。また前提として弊社技術スタックの紹介はこちらになります。

動画アップロードについて

まずは動画ファイルのアップロードについてです。
ここでのゴールはフロントエンドから S3 バケットへ動画ファイルをアップロードすることです。
バックエンドへの負荷を軽減し、同時にアクセス制御などのセキュリティ面も考慮に入れた結果、フロントエンドから S3 に直接動画ファイルをアップロードするために署名付き URL を利用する方法を採用しました。アップロード手順は大まかに以下の通りです。

  • バックエンドで S3 バケットへ動画ファイルをアップロードするための署名付き URL を生成します。
  • 生成された署名付き URL を利用して、S3 バケットに動画ファイルをアップロードします。

バックエンドの API では以下のように署名付き URL を生成しレスポンスを返します。Rails で書いています。

#署名付きURLを生成。(期限は1時間)
presined_url = ActiveStorage::Blob.service.url_for_direct_upload(
  "bucket_dir/#{video_name_key}",
  expires_in: 1.hours,
  content_type: nil,
  content_length: nil,
  checksum: nil
)

{ presined_url: }

フロントエンドでは上記 API を実行して署名付き URL を取得し、その URL を用いて添付ファイルをアップロードします。

//添付動画ファイルアップロード
axios.put(presignedUrl, image).then(async () => {
  //アップロード完了後の処理
});

これにより、動画ファイルの S3 へのアップロードが完了します。

HLS 変換処理

S3 にアップロードする動画ファイルは mp4 または mov ファイル名を想定しているので、ストリーミング再生に適した HLS 形式へ変換します。
ストリーミング配信により、製造現場の作業者は動画の読み込みを待つことなく、スムーズに再生できるようになります。

HLS とは

Apple 社が開発したストリーミングビデオ配信用のプロトコルであり、HTTP ベースでメディアコンテンツのストリーム配信を実現します。
元の mp4、mov ファイルから品質レベル設定により圧縮できることも期待でき、現在主流な規格のひとつなこともあり採用しました。

HLS のセグメントファイルについて

mp4 ファイル(以降 mov ファイルの内容は省略します)を HLS 形式に変換すると、数秒程度の長さに分割されたセグメントファイル(例:XXX_00001.ts、XXX_00002.ts、 ...)が生成されます。同時に、これらの ts ファイルの再生順序を定義するインデックスファイル(例:XXX.m3u8)も作成されます。

実際に AWS Elemental MediaConvert を利用して VIDEO0163.mp4 というファイルを HLS 変換すると以下のようなファイルが生成されます。

VIDEO0163.m3u8 インデックスファイルの中身。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=2741006,AVERAGE-BANDWIDTH=2716448,CODECS="avc1.64001e,mp4a.40.2",RESOLUTION=720x480,FRAME-RATE=29.970
VIDEO0163test-hls.m3u8  # <- このインデックスファイルを呼ぶ

VIDEO0163test-hls.m3u8 インデックスファイルの中身。VIDEO0163test-hls_00001.ts から順に動画ファイルを再生します。

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:10,
VIDEO0163test-hls_00001.ts
#EXTINF:10,
VIDEO0163test-hls_00002.ts
#EXTINF:9,
VIDEO0163test-hls_00003.ts
#EXT-X-ENDLIST

動画の再生にはreact-playerを採用しました。以下のように設定することで動画を再生できます。また、VIDEO0163test-hls.m3u8 ファイルを指定しても、同様に再生が可能です。

<ReactPlayer url="https://xxxcloudfront/VIDEO0163.m3u8" />

以下のスクリーンショットでは、ページ上で react-player を開いた状態で開発者ツールを使用して確認すると、インデックスファイルへのアクセスの後、セグメントファイルに順次アクセスしている様子が確認できます。
image

具体的にどのように HLS ファイルを生成するか

少し前述しましたが、AWS Elemental MediaConvert を利用して HLS ファイルを生成します。また動画のサムネイルも作成できます。

※最終的に Terraform で管理していますが、ここではマネジメントコンソールで解説します。

AWS Elemental MediaConvert を開きます。まずは試しに「ジョブの作成」から HLS 変換をしてみます。

入力 1 に S3 バケットの HLS ファイル変換する対象の mp4 ファイルを指定します。
image

出力グループに Apple HLS を指定して送信先に HLS ファイルの出力先を指定します。
image

出力動画ファイルの情報を入力します。
image

サムネイルファイルの出力先を指定します。JPEG で出力することを指定します。
image

image

作成ボタンを押下すると output フォルダに HLS とサムネイルが出力されていることを確認します。

HLS ファイルとサムネイルファイルが生成されました。実験は完了です。
image

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

毎回、ジョブを手動で作成するわけにはいかないのでテンプレートを作成します。テンプレート化することで再利用できます。
ジョブテンプレートからテンプレートの作成をクリックします。

入力は特に設定していません。あとでジョブテンプレートを実行する lambda で指定します。
image

出力グループは先ほどのジョブ作成と同様に設定します。
image

テンプレートが完了しました。次に S3 にアップロードされたタイミングで、MediaConvert のジョブテンプレートを実行するための lambda を用意します。

AWS Lambda の用意

S3 へのアップロードをトリガーに MediaConvert のジョブテンプレートを呼び出してジョブを実行し、HLS ファイルおよびサムネイルファイルを出力します。

最低限以下を満たした実験的コードは以下です。Python で書いています。

  • アップロードされたファイルを取得
  • job.json から HLS 出力先情報を取得
  • MediaConvert のジョブテンプレートからジョブを実行
import json
import urllib.parse
import boto3
import os
import time

print('Loading function')

s3 = boto3.client('s3')
mediaconvert =  boto3.client('mediaconvert', region_name='ap-northeast-1', endpoint_url='https://xxxx.mediaconvert.ap-northeast-1.amazonaws.com')

def lambda_handler(event, context):
    # Get the object from the event and show its content type
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    inputFile = "s3://" + bucket + "/" + key
    base_name = os.path.basename(key)
    file_name_without_ext = os.path.splitext(base_name)[0]
    print(f"Input file: {inputFile}")
    outputKey = "output/" + file_name_without_ext + "/"
    thumbnailOutputKey = "thumbnail/" + file_name_without_ext + "/"

    try:
        # Load job.json from disk and store as Python object: job_object
        with open("job.json", "r") as jsonfile:
            job_object = json.load(jsonfile)

        # Input/Output Setting
        job_object["OutputGroups"][0]["OutputGroupSettings"]["HlsGroupSettings"]["Destination"] = "s3://" + bucket + "/" + outputKey
        job_object["OutputGroups"][1]["OutputGroupSettings"]["FileGroupSettings"]["Destination"] = "s3://" + bucket + "/" + thumbnailOutputKey
        job_object["Inputs"][0]["FileInput"] = inputFile

        # Exec MediaConvert's job
        response = mediaconvert.create_job(
          JobTemplate='media-convert-test',
          Role='arn:aws:iam::xxxxxx:role/service-role/xxxxxxx',
          Settings=job_object
        )
        print(response)

        # Save job ID
        job_id = response['Job']['Id']
        print(f"Job ID: {job_id}")

        # Poll for job completion
        while True:
            job_info = mediaconvert.get_job(Id=job_id)
            status = job_info['Job']['Status']
            if status == 'COMPLETE':
                # List files in output directory
                response = s3.list_objects_v2(Bucket=bucket, Prefix=outputKey)
                for obj in response['Contents']:
                    print(f"Output file: s3://{bucket}/{obj['Key']}")
                # List files in thumbnail directory
                response = s3.list_objects_v2(Bucket=bucket, Prefix=thumbnailOutputKey)
                for obj in response['Contents']:
                    print(f"Thumbnail file: s3://{bucket}/{obj['Key']}")
                break
            elif status == 'ERROR':
                print(f"Job ended with an error: {job_info['Job']['ErrorMessage']}")
                raise Exception('MediaConvert job ended with an error')
            elif status == 'CANCELLED':
                raise Exception('MediaConvert job was cancelled')
            time.sleep(5)  # wait for 5 seconds before polling again

        print("finish!!")

    except Exception as e:
        print(e)
        print('Error getting object {} from bucket {}. Make sure they exist and your bucket is in the same region as this function.'.format(key, bucket))
        raise e

//job.json
{
  "OutputGroups": [
    {
      "Name": "Apple HLS",
      "OutputGroupSettings": {
        "Type": "HLS_GROUP_SETTINGS",
        "HlsGroupSettings": {
          "Destination": ""
        }
      }
    },
    {
      "Name": "File Group",
      "OutputGroupSettings": {
        "Type": "FILE_GROUP_SETTINGS",
        "FileGroupSettings": {
          "Destination": ""
        }
      }
    }
  ],
  "Inputs": [
    {
      "FileInput": ""
    }
  ]
}

これで S3 に動画ファイルをアップロードしたら自動で HLS ファイル変換及びサムネイルファイルを出力できるようになりました。

動画再生

フロントエンドから HLS ファイルにアクセスして動画を再生します。
セキュアな動画再生を実現するため、CloudFront の署名付き Cookie を活用し、動画ファイルへのアクセスを可能にします。
署名付き URL でアクセスでもいいのではという疑問が最初ありましたが、HLS ファイルで再生する場合は実現できないので署名付き Cookie で動画再生することにしました。

なぜ署名付き URL で HLS 再生できないのか

前述でこのように react-player を設定すれば HLS ファイルにアクセスすることを記述しました。

<ReactPlayer url="https://xxxcloudfront/VIDEO0163.m3u8" />

セキュアなアクセスで署名付き URL を利用すると、このようなパラメータを生成してアクセスします。

<ReactPlayer url="https://xxxcloudfront/VIDEO0163.m3u8?response-content-disposition=inline%3B%20filename%XXXXXX" />

ただし、セグメントファイルの方は署名付き URL が自動生成できないのでビューアアクセス制限しているとブロックされてしまいます。

image

代わりに署名付き Cookie を渡してアクセスするようにします。

image

CloudFront の設定

S3 に置いてある HLS ファイルを CloudFront 経由で再生できるようにするため、まずはディストリビューションを作成します。

一般設定

一般設定はこのように設定しました。
image

オリジン設定

オリジン設定はこのように設定しました。基本的に S3 の HLS ファイルを格納しているバケットの内容を指定します。
image

ビヘイビア設定

ビヘイビア設定はこのように設定しました。
ビューワーのアクセスを制限するYes に設定し、署名付き URL または署名付き Cookie を使用したアクセスのみを許可しました。署名付き Cookie を用いてアクセスするため設定します。
image

image

ビヘイビアのカスタムレスポンスヘッダーポリシーの設定

Access-Control-Allow-Origin にアクセス元の URL やヘッダー情報に Cookie をセットしました。
また、アクセスブロックを防ぐためには、Access-Control-Allow-Credentials チェックを ON にする必要がありました。一方、Access-Control-Allow-HeadersAccess-Control-Expose-Headers には All headers を指定できないので、Customize を選択し、Cookie を指定しました。

image

image

これで CloudFront を経由して署名付き Cookie を使用した HLS ファイルへのアクセスが可能となりました。

バックエンド API で署名付き Cookie を生成します。

def presigned_cookie
  key_pair_id = "XXXXX"  # CloudFront -> キーグループ -> 詳細 -> パブリックキーのIDを指定します
  # video_name_keyにHLSファイル名を指定。test.m3u8、test.00001.tsがある場合testが入るようにする
  resource = "https://xxxcloudfront/{video_name_key}*"
  expires = 24.hours

  policy = {
    'Statement' => [
      {
        'Resource' => resource,
        'Condition' => {
          'DateLessThan' => {
            'AWS:EpochTime' => Time.now.to_i + expires
          }
        }
      }
    ]
  }.to_json
  encoded_policy = Base64.strict_encode64("#{policy}\n").tr('+=/', '-_~')
  encoded_signature = create_signature(policy)
  {
    'cloud_front_policy' => encoded_policy,
    'cloud_front_signature' => encoded_signature,
    'cloud_front_key_pair_id' => key_pair_id,
    'option_domain' => Settings.url.docs.sub(Settings.url.base_docs_domain, '')
  }
end

def create_signature(policy)
  private_key = OpenSSL::PKey::RSA.new(Settings.cloudfront.docs_private_key.gsub('\\n', "\n"))
  signature = private_key.sign(OpenSSL::Digest.new('SHA1'), "#{policy}\n")
  base64_signature = Base64.strict_encode64(signature)
  base64_signature.tr('+=/', '-_~').strip
end

上記 API のコードで以下の形式のデータを返すように実装しました。

{
  "data":{
    "url":"https://xxxcloudfront/test.m3u8"
    "presignedCookie": {
      "cloudFrontPolicy":"XXXX"
      "cloudFrontSignature":"XXXX"
      "cloudFrontKeyPairId":"XXXX"
    }
  }
}

動画再生のためのフロントエンド実装

バックエンドから取得した対象の動画ファイル URL と署名付き Cookie を利用して動画再生をします。
ここでは React + react-player で動画再生します。

import { useDocumentCookie } from "hooks";

const DocumentImageBlock: React.FC<Props> = ({ url, presignedCookie }) => {
  //Cookieセット
  const cookieSet = useDocumentCookie(presignedCookie);

  return (
    cookieSet && (
      <ReactPlayer
        controls={true}
        playing={true}
        volume={0}
        muted={true}
        width="100%"
        height="100%"
        url={url}
        config={{
          file: {
            hlsOptions: {
              xhrSetup: function (xhr: { withCredentials: boolean }) {
                xhr.withCredentials = true; //trueにしないとCookieをセットできません
              },
            },
          },
        }}
      />
    )
  );
};
//Cookieセット
export function useDocumentCookie(presignedCookie: PresignedCookie) {
  const [, setCookie] = useCookies([
    "CloudFront-Policy",
    "CloudFront-Signature",
    "CloudFront-Key-Pair-Id",
  ]);
  const [cookieSet, setCookieSet] = useState(false);
  useEffect(() => {
    const domain: string = presignedCookie?.optionDomain; //サブドメイン指定でCloudFrontドメインとCookieを共有できるようにする
    const cookieOptions = {
      domain,
      path: "/",
      sameSite: "none" as const,
      secure: true,
    };
    setCookie(
      "CloudFront-Policy",
      presignedCookie?.cloudFrontPolicy,
      cookieOptions
    );
    setCookie(
      "CloudFront-Signature",
      presignedCookie?.cloudFrontSignature,
      cookieOptions
    );
    setCookie(
      "CloudFront-Key-Pair-Id",
      presignedCookie?.cloudFrontKeyPairId,
      cookieOptions
    );
    setCookieSet(true);
  }, [presignedCookie]);

  return cookieSet;
}

動作確認

Cookie がブラウザにセットされることを確認。
image

ネットワークの HLS ファイルアクセスに関してもリクエストヘッダーに Cookie がセットされることを確認しました。
また動画を再生できることを確認しました。
image

一方で、セットした Cookie を書き換えるとこのようにブロックされことが確認できます。
image

image

おわりに

署名付き Cookie によるアクセス制限の扱いがなかなか苦労しましたが、最終的には動画のアップロードから再生に至る一連のプロセスを無事に実装できました。
AWS CloudFront と S3 を使用した HLS ストリーミングの実現は、私たちのサービスにとって大きな一歩になったのではないでしょうか。

ということで、Rails や React を使いこなすエンジニアの方、Smart Craft のビジネスに興味を持っていただいた方はぜひご連絡ください!

Smart Craft Tech Blog

Discussion