AWS CloudFront+S3で動画アップロードからHLSストリーミング再生できるようにしてみた
はじめに
こんにちはエンジニアの藤田です。先日健康診断を受けまして中性脂肪と悪玉コレステロールの結果が悪かったことから人生 1000 回目くらいの禁酒を始めました。今回は割と長続きしています。
失恋の引きずりに近いようで何かしていないとついお酒のことを考えてしまうので、ブログ記事を書くことにしました。
最近、Smart Craft では動画アップロードおよび動画再生できる機能をリリースしまして「動画再生なんて画像表示みたいなもんでしょ」くらいに思っていましたらそうでもなかったので備忘録として書いていきたいと思います。
最終的に、例えば以下のように動画をアップロードし、再生できるようになりました。
記事の構成は以下のとおりです。
- 全体構成
- 動画アップロードについて
- 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 を開いた状態で開発者ツールを使用して確認すると、インデックスファイルへのアクセスの後、セグメントファイルに順次アクセスしている様子が確認できます。
具体的にどのように HLS ファイルを生成するか
少し前述しましたが、AWS Elemental MediaConvert を利用して HLS ファイルを生成します。また動画のサムネイルも作成できます。
※最終的に Terraform で管理していますが、ここではマネジメントコンソールで解説します。
AWS Elemental MediaConvert を開きます。まずは試しに「ジョブの作成」から HLS 変換をしてみます。
入力 1 に S3 バケットの HLS ファイル変換する対象の mp4 ファイルを指定します。
出力グループに Apple HLS を指定して送信先に HLS ファイルの出力先を指定します。
出力動画ファイルの情報を入力します。
サムネイルファイルの出力先を指定します。JPEG で出力することを指定します。
作成ボタンを押下すると output フォルダに HLS とサムネイルが出力されていることを確認します。
HLS ファイルとサムネイルファイルが生成されました。実験は完了です。
AWS Elemental MediaConvert のジョブテンプレート作成
毎回、ジョブを手動で作成するわけにはいかないのでテンプレートを作成します。テンプレート化することで再利用できます。
ジョブテンプレートからテンプレートの作成をクリックします。
入力は特に設定していません。あとでジョブテンプレートを実行する lambda で指定します。
出力グループは先ほどのジョブ作成と同様に設定します。
テンプレートが完了しました。次に 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 が自動生成できないのでビューアアクセス制限しているとブロックされてしまいます。
代わりに署名付き Cookie を渡してアクセスするようにします。
署名付き Cookie で HLS 再生のイメージ図
CloudFront の設定
S3 に置いてある HLS ファイルを CloudFront 経由で再生できるようにするため、まずはディストリビューションを作成します。
一般設定
一般設定はこのように設定しました。
オリジン設定
オリジン設定はこのように設定しました。基本的に S3 の HLS ファイルを格納しているバケットの内容を指定します。
ビヘイビア設定
ビヘイビア設定はこのように設定しました。
ビューワーのアクセスを制限する
を Yes
に設定し、署名付き URL または署名付き Cookie を使用したアクセスのみを許可しました。署名付き Cookie を用いてアクセスするため設定します。
ビヘイビアのカスタムレスポンスヘッダーポリシーの設定
Access-Control-Allow-Origin
にアクセス元の URL やヘッダー情報に Cookie をセットしました。
また、アクセスブロックを防ぐためには、Access-Control-Allow-Credentials
チェックを ON にする必要がありました。一方、Access-Control-Allow-Headers
と Access-Control-Expose-Headers
には All headers
を指定できないので、Customize
を選択し、Cookie
を指定しました。
これで CloudFront を経由して署名付き Cookie を使用した HLS ファイルへのアクセスが可能となりました。
署名付き Cookie の生成
バックエンド 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 がブラウザにセットされることを確認。
ネットワークの HLS ファイルアクセスに関してもリクエストヘッダーに Cookie がセットされることを確認しました。
また動画を再生できることを確認しました。
一方で、セットした Cookie を書き換えるとこのようにブロックされことが確認できます。
おわりに
署名付き Cookie によるアクセス制限の扱いがなかなか苦労しましたが、最終的には動画のアップロードから再生に至る一連のプロセスを無事に実装できました。
AWS CloudFront と S3 を使用した HLS ストリーミングの実現は、私たちのサービスにとって大きな一歩になったのではないでしょうか。
ということで、Rails や React を使いこなすエンジニアの方、Smart Craft のビジネスに興味を持っていただいた方はぜひご連絡ください!
Discussion