MediaPackage が CloudFormation 対応したので MediaLive + MediaPackage + CloudFront によるライブ配信構成を一発で作成するテンプレートを作成してみた

一発でライブ配信環境、作ってみませんか
2020.11.12

こんにちは、大前です。

先日のアップデートでついに AWS Elemental MediaPackage(以下 MediaPackage)を CloudFormation で作成できる様になりました。

これはもう MediaPackage を使ったライブ配信構成を一発で作るテンプレートを書くしかないよね! という事で作成してみました。

投稿時点(2020/11)での動作は確認しておりますが、今後のアップデート等による影響で動かなくなる可能性がある点についてはご容赦ください。

作成するテンプレート

下記構成図のリソースを作成する CloudFormation テンプレートを作成します。

RTMP で MediaLive に映像を入力し、CloudFront 経由で HLS のストリームを配信する構成になります。

構成図にはありませんが、MediaLive にアタッチする為の IAM ロールも作成します。

MediaLive の設定概要は以下です。

  • H.264, AAC
  • 720p
  • 1Mbps
  • 29.97fps

作成したテンプレート

早速ですが、作成したテンプレートは下記になります。そこそこ長いです。

template.json
{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Parameters": {
        "StreamKeyA": {
            "Description": "StreamKey of Stream A",
            "Type": "String",
            "Default": "streamA"
        },
        "StreamKeyB": {
            "Description": "StreamKey of Stream B",
            "Type": "String",
            "Default": "streamB"
        },
        "AllowedMediaLiveInputCidr": {
            "Description": "Allowed CIDR of MediaLive Input",
            "Type": "String",
            "Default": "0.0.0.0/0"
        },
        "SegmentLength": {
            "Description": "Segment Length",
            "Type": "Number",
            "Default": "6"
        }
    },
    "Resources": {
        "MediaLiveInput": {
            "Type": "AWS::MediaLive::Input",
            "Properties": {
                "Destinations": [
                    {
                        "StreamName": { "Fn::Sub" : "${AWS::StackName}/${StreamKeyA}" }
                    },
                    {
                        "StreamName": { "Fn::Sub" : "${AWS::StackName}/${StreamKeyB}" }
                    }
                ],
                "InputSecurityGroups": [
                    { "Ref" : "MediaLiveInputSecurityGroup" }
                ],
                "Name": { "Fn::Sub" : "${AWS::StackName}-input" },
                "Tags": {
                    "Name": { "Fn::Sub" : "${AWS::StackName}-input" }
                },
                "Type": "RTMP_PUSH"
            }
        },
        "MediaLiveInputSecurityGroup": {
            "Type": "AWS::MediaLive::InputSecurityGroup",
            "Properties": {
                "Tags": {
                    "Name": { "Fn::Sub" : "${AWS::StackName}-inputsg" }
                },
                "WhitelistRules": [
                    {
                        "Cidr": { "Ref" : "AllowedMediaLiveInputCidr" }
                    }
                ]
            }
        },
        "MediaLiveChannel": {
            "Type": "AWS::MediaLive::Channel",
            "Properties": {
                "ChannelClass": "STANDARD",
                "Destinations": [
                    {
                        "Id": {"Ref" :  "MediaPackageChannel"},
                        "MediaPackageSettings": [
                            {
                                "ChannelId": {"Ref" :  "MediaPackageChannel"}
                            }
                        ]
                    }
                ],
                "EncoderSettings": {
                    "AudioDescriptions": [
                        {
                            "AudioSelectorName": "default",
                            "CodecSettings": {
                                "AacSettings": {
                                    "Bitrate": 96000,
                                    "RawFormat": "NONE",
                                    "Spec": "MPEG4"
                                }
                            },
                            "AudioTypeControl": "FOLLOW_INPUT",
                            "LanguageCodeControl": "FOLLOW_INPUT",
                            "Name": "audio_3_aac96"
                        }
                    ],
                    "OutputGroups": [
                        {
                            "OutputGroupSettings": {
                                "MediaPackageGroupSettings": {
                                    "Destination": {
                                        "DestinationRefId": {"Ref" :  "MediaPackageChannel"}}
                                }
                            },
                            "Outputs": [
                                {
                                    "OutputSettings": {
                                        "MediaPackageOutputSettings": {}
                                    },
                                    "OutputName": "1280_720_1",
                                    "VideoDescriptionName": "video_1280_720_1",
                                    "AudioDescriptionNames": [
                                        "audio_3_aac96"
                                    ]
                                }
                            ]
                        }
                    ],
                    "TimecodeConfig": {
                        "Source": "SYSTEMCLOCK"
                    },
                    "VideoDescriptions": [
                        {
                            "CodecSettings": {
                                "H264Settings": {
                                    "AfdSignaling": "NONE",
                                    "ColorMetadata": "INSERT",
                                    "AdaptiveQuantization": "HIGH",
                                    "Bitrate": 1000000,
                                    "EntropyEncoding": "CABAC",
                                    "FlickerAq": "ENABLED",
                                    "FramerateControl": "SPECIFIED",
                                    "FramerateNumerator": 30000,
                                    "FramerateDenominator": 1001,
                                    "GopBReference": "ENABLED",
                                    "GopClosedCadence": 1,
                                    "GopNumBFrames": 3,
                                    "GopSize": 60,
                                    "GopSizeUnits": "FRAMES",
                                    "SubgopLength": "FIXED",
                                    "ScanType": "PROGRESSIVE",
                                    "Level": "H264_LEVEL_4_1",
                                    "LookAheadRateControl": "HIGH",
                                    "NumRefFrames": 1,
                                    "ParControl": "SPECIFIED",
                                    "Profile": "HIGH",
                                    "RateControlMode": "CBR",
                                    "Syntax": "DEFAULT",
                                    "SceneChangeDetect": "ENABLED",
                                    "SpatialAq": "ENABLED",
                                    "TemporalAq": "ENABLED",
                                    "TimecodeInsertion": "DISABLED"
                                }
                            },
                            "Height": 720,
                            "Name": "video_1280_720_1",
                            "RespondToAfd": "NONE",
                            "Sharpness": 50,
                            "ScalingBehavior": "DEFAULT",
                            "Width": 1280
                        }
                    ]
                },
                "InputAttachments": [
                    {
                        "InputAttachmentName": { "Fn::Sub" : "${AWS::StackName}-ChannelInput" },
                        "InputId": {"Ref" :  "MediaLiveInput"},
                        "InputSettings": {
                            "DeblockFilter": "DISABLED",
                            "DenoiseFilter": "DISABLED",
                            "FilterStrength": 1,
                            "InputFilter": "AUTO",
                            "SourceEndBehavior": "CONTINUE"
                        }
                    }
                ],
                "InputSpecification": {
                    "Codec": "AVC",
                    "MaximumBitrate": "MAX_20_MBPS",
                    "Resolution": "HD"
                },
                "Name": { "Fn::Sub" : "${AWS::StackName}-Channel" },
                "RoleArn": { "Fn::GetAtt" : [ "MediaLiveAccessRole", "Arn" ] },
                "Tags": {
                    "Name": { "Fn::Sub" : "${AWS::StackName}-Channel" }
                }
            }
        },
        "MediaLiveAccessRole": {
            "Type": "AWS::IAM::Role",
            "Properties": {
                "AssumeRolePolicyDocument": {
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Effect": "Allow",
                            "Principal": {
                                "Service": [
                                    "medialive.amazonaws.com"
                                ]
                            },
                            "Action": [
                                "sts:AssumeRole"
                            ]
                        }
                    ]
                },
                "ManagedPolicyArns": [
                    "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess"
                ],
                "Path": "/",
                "Policies": [
                    {
                        "PolicyName": "Sample-MediaLiveCustomPolicy",
                        "PolicyDocument": {
                            "Statement": [
                                {
                                    "Effect": "Allow",
                                    "Action": [
                                        "s3:ListBucket",
                                        "s3:PutObject",
                                        "s3:GetObject",
                                        "s3:DeleteObject"
                                    ],
                                    "Resource": "*"
                                },
                                {
                                    "Effect": "Allow",
                                    "Action": [
                                        "mediastore:ListContainers",
                                        "mediastore:PutObject",
                                        "mediastore:GetObject",
                                        "mediastore:DeleteObject",
                                        "mediastore:DescribeObject"
                                    ],
                                    "Resource": "*"
                                },
                                {
                                    "Effect": "Allow",
                                    "Action": [
                                        "logs:CreateLogGroup",
                                        "logs:CreateLogStream",
                                        "logs:PutLogEvents",
                                        "logs:DescribeLogStreams",
                                        "logs:DescribeLogGroups"
                                    ],
                                    "Resource": "arn:aws:logs:*:*:*"
                                },
                                {
                                    "Effect": "Allow",
                                    "Action": [
                                        "mediaconnect:ManagedDescribeFlow",
                                        "mediaconnect:ManagedAddOutput",
                                        "mediaconnect:ManagedRemoveOutput"
                                    ],
                                    "Resource": "*"
                                },
                                {
                                    "Effect": "Allow",
                                    "Action": [
                                        "ec2:describeSubnets",
                                        "ec2:describeNetworkInterfaces",
                                        "ec2:createNetworkInterface",
                                        "ec2:createNetworkInterfacePermission",
                                        "ec2:deleteNetworkInterface",
                                        "ec2:deleteNetworkInterfacePermission",
                                        "ec2:describeSecurityGroups"
                                    ],
                                    "Resource": "*"
                                },
                                {
                                    "Effect": "Allow",
                                    "Action": [
                                        "mediapackage:DescribeChannel"
                                    ],
                                    "Resource": "*"
                                }
                            ]
                        }
                    }
                ],
                "RoleName": { "Fn::Sub" : "${AWS::StackName}-MediaLiveAccessRole" }
            }
        },
        "MediaPackageChannel": {
            "Type": "AWS::MediaPackage::Channel",
            "Properties": {
                "Description": "Deploy from CloudFormation",
                "Id": { "Fn::Sub" : "${AWS::StackName}" },
                "Tags": [
                    {
                        "Key": "Name",
                        "Value": { "Fn::Sub" : "${AWS::StackName}" }
                    }
                ]
            }
        },
        "MediaPackageEndpoint1": {
            "Type": "AWS::MediaPackage::OriginEndpoint",
            "Properties": {
                "ChannelId": {"Ref" :  "MediaPackageChannel"},
                "Description": "Endpoint 1 for HLS packaging",
                "HlsPackage": {
                    "PlaylistWindowSeconds": 60,
                    "SegmentDurationSeconds": { "Ref" : "SegmentLength" }
                },
                "Id": { "Fn::Sub" : "${AWS::StackName}-Endpoint1" },
                "ManifestName": "index",
                "Origination": "ALLOW",
                "Tags": [
                    {
                        "Key": "Name",
                        "Value": { "Fn::Sub" : "${AWS::StackName}-Endpoint1" }
                    }
                ]
            }
        },
        "CloudFrontDistribution": {
            "Type": "AWS::CloudFront::Distribution",
            "Properties" : {
                "DistributionConfig" : {
                    "CacheBehaviors" : [
                        {
                            "AllowedMethods" : [ "GET", "HEAD", "OPTIONS" ],
                            "CachePolicyId" : { "Ref" : "CloudFrontCachePolicy"},
                            "OriginRequestPolicyId" : "59781a5b-3903-41f3-afcb-af62929ccde1",
                            "PathPattern" : "*.m3u8",
                            "TargetOriginId" : { "Fn::Sub" : "${AWS::StackName}-originId" },
                            "ViewerProtocolPolicy" : "redirect-to-https"
                        },
                        {
                            "AllowedMethods" : [ "GET", "HEAD", "OPTIONS" ],
                            "CachePolicyId" : { "Ref" : "CloudFrontCachePolicy"},
                            "OriginRequestPolicyId" : "59781a5b-3903-41f3-afcb-af62929ccde1",
                            "PathPattern" : "*.ts",
                            "TargetOriginId" : { "Fn::Sub" : "${AWS::StackName}-originId" },
                            "ViewerProtocolPolicy" : "redirect-to-https"
                        }
                    ],
                    "Comment" : { "Fn::Sub" : "${AWS::StackName}-cf" },
                    "DefaultCacheBehavior" : {
                        "AllowedMethods" : [ "GET", "HEAD", "OPTIONS" ],
                        "CachePolicyId" : { "Ref" : "CloudFrontCachePolicy"},
                        "OriginRequestPolicyId" : "59781a5b-3903-41f3-afcb-af62929ccde1",
                        "TargetOriginId" : { "Fn::Sub" : "${AWS::StackName}-originId" },
                        "ViewerProtocolPolicy" : "redirect-to-https"
                    },
                    "Enabled" : true,
                    "Origins" : [
                        {
                            "CustomOriginConfig" : {
                                "HTTPPort" : 80,
                                "HTTPSPort" : 443,
                                "OriginProtocolPolicy" : "match-viewer"
                            },
                            "DomainName" : { "Fn::Select" : [ 1, { "Fn::Split" : [ "//", { "Fn::Select" : [ 0, { "Fn::Split" : [ "/out", { "Fn::GetAtt" : [ "MediaPackageEndpoint1", "Url" ] } ] } ] } ] } ] },
                            "Id" : { "Fn::Sub" : "${AWS::StackName}-originId" }
                        }
                    ]
                },
                "Tags" : [
                    {
                        "Key": "Name",
                        "Value": { "Fn::Sub" : "${AWS::StackName}-cf" }
                    }
                ]
            }
        },
        "CloudFrontCachePolicy": {
            "Type" : "AWS::CloudFront::CachePolicy",
            "Properties" : {
                "CachePolicyConfig" : {
                    "DefaultTTL" : { "Ref" : "SegmentLength" },
                    "MaxTTL" : { "Ref" : "SegmentLength" },
                    "MinTTL" : { "Ref" : "SegmentLength" },
                    "Name" : { "Fn::Sub" : "${AWS::StackName}-cachepolicy" },
                    "ParametersInCacheKeyAndForwardedToOrigin" : {
                        "EnableAcceptEncodingGzip" : true,
                        "HeadersConfig" : {
                            "HeaderBehavior" : "whitelist",
                            "Headers" : [ "origin" ]
                        },
                        "CookiesConfig" : {
                            "CookieBehavior" : "none"
                        },
                        "QueryStringsConfig" : {
                            "QueryStringBehavior" : "none"
                        }
                    }
                }
            }
        }
    },
    "Outputs": {
        "RtmpEndpointA": {
            "Description": "RTMP EndPoint of Stream A",
            "Value": { "Fn::Select" : [ 0, { "Fn::Split" : [ ",", { "Fn::Join" : [ ",", { "Fn::GetAtt" : [ "MediaLiveInput", "Destinations" ] } ] } ] } ] }
        },
        "RtmpEndpointB": {
            "Description": "RTMP EndPoint of Stream B",
            "Value": { "Fn::Select" : [ 1, { "Fn::Split" : [ ",", { "Fn::Join" : [ ",", { "Fn::GetAtt" : [ "MediaLiveInput", "Destinations" ] } ] } ] } ] }
        },
        "HlsEndpoint": {
            "Description": "HLS Endpoint",
            "Value": { "Fn::Join" : [ "/", [ "https:/", { "Fn::GetAtt" : [ "CloudFrontDistribution", "DomainName" ] }, "out/v1", { "Fn::Select" : [ 1, { "Fn::Split" : [ "/out/v1/", { "Fn::GetAtt" : [ "MediaPackageEndpoint1", "Url" ] } ] } ] }] ] }
        }
    }
}

作成にあたって苦戦した部分など

テンプレートの中身全てを細かく説明すると大変なので、テンプレート作成中にハマった部分などのみピックアップしたいと思います。

MediaLive Channel - 出力設定

MediaLive の出力タイプとして MediaPackage を指定する形になるのですが、CloudFormation での記法が少し独特で苦戦しました。

"Outputs": [
    {
        "OutputSettings": {
            "MediaPackageOutputSettings": {}
        },
        "OutputName": "1280_720_1",
        "VideoDescriptionName": "video_1280_720_1",
        "AudioDescriptionNames": [
            "audio_3_aac96"
        ]
    }
]

上記記載の通り、空の MediaPackageOutputSettings 要素を指定する必要があります。

AWS::MediaLive::Channel MediaPackageOutputSettings

これを yaml で記載しようするとすると CloudFormation のテンプレートアップロード時にエラーが発生してしまう為、現状は json 形式でしかテンプレートを作成出来ない形になっていると思われます。(yaml で作成しはじめたので json への置換が大変だった。。。)

CloudFront Distribution - オリジンドメイン設定

作成する CloudFront Distribution のオリジンとして MediaPackage のエンドポイントドメインを指定する必要があるのですが、AWS::MediaPackage::OriginEndpoint からはエンドポイントの URL しか取得出来ない為、組み込み関数を駆使してドメインを抽出しています。

AWS::MediaPackage::OriginEndpoint

"Origins" : [
    {
        "CustomOriginConfig" : {
            "HTTPPort" : 80,
            "HTTPSPort" : 443,
            "OriginProtocolPolicy" : "match-viewer"
        },
        "DomainName" : { "Fn::Select" : [ 1, { "Fn::Split" : [ "//", { "Fn::Select" : [ 0, { "Fn::Split" : [ "/out", { "Fn::GetAtt" : [ "MediaPackageEndpoint1", "Url" ] } ] } ] } ] } ] },
        "Id" : { "Fn::Sub" : "${AWS::StackName}-originId" }
    }
]

CloudFront - キャッシュポリシー

少し前のアップデートで、CloudFront のキャッシュ動作などはキャッシュポリシーを別途作成し、アタッチする事が推奨されています。

AWS が用意しているキャッシュポリシーとして Managed-Elemental-MediaPackage というものがあるのですが、キャッシュ TTL をクライアント側からリクエストする必要がある設定となっている為、キャッシュ TTL をセグメント長に合わせたポリシーを別途作成し、アタッチする様にしています。

"CloudFrontCachePolicy": {
    "Type" : "AWS::CloudFront::CachePolicy",
    "Properties" : {
        "CachePolicyConfig" : {
            "DefaultTTL" : { "Ref" : "SegmentLength" },
            "MaxTTL" : { "Ref" : "SegmentLength" },
            "MinTTL" : { "Ref" : "SegmentLength" },
            "Name" : { "Fn::Sub" : "${AWS::StackName}-cachepolicy" },
            "ParametersInCacheKeyAndForwardedToOrigin" : {
                "EnableAcceptEncodingGzip" : true,
                "HeadersConfig" : {
                    "HeaderBehavior" : "whitelist",
                    "Headers" : [ "origin" ]
                },
                "CookiesConfig" : {
                    "CookieBehavior" : "none"
                },
                "QueryStringsConfig" : {
                    "QueryStringBehavior" : "none"
                }
            }
        }
    }
}

スタックの出力設定

CloudFormation のスタック出力に RTMP のエンドポイントと HLS のエンドポイントを表示したかった為、Outputs を記載しています。

こちらも、各リソースから単純に取得できる値ではなかった為、組み込み関数を駆使して出力を整形しています。

"Outputs": {
    "RtmpEndpointA": {
        "Description": "RTMP EndPoint of Stream A",
        "Value": { "Fn::Select" : [ 0, { "Fn::Split" : [ ",", { "Fn::Join" : [ ",", { "Fn::GetAtt" : [ "MediaLiveInput", "Destinations" ] } ] } ] } ] }
    },
    "RtmpEndpointB": {
        "Description": "RTMP EndPoint of Stream B",
        "Value": { "Fn::Select" : [ 1, { "Fn::Split" : [ ",", { "Fn::Join" : [ ",", { "Fn::GetAtt" : [ "MediaLiveInput", "Destinations" ] } ] } ] } ] }
    },
    "HlsEndpoint": {
        "Description": "HLS Endpoint",
        "Value": { "Fn::Join" : [ "/", [ "https:/", { "Fn::GetAtt" : [ "CloudFrontDistribution", "DomainName" ] }, "out/v1", { "Fn::Select" : [ 1, { "Fn::Split" : [ "/out/v1/", { "Fn::GetAtt" : [ "MediaPackageEndpoint1", "Url" ] } ] } ] }] ] }
    }
}

あまり綺麗な書き方ではありませんが、ちゃんと下記の様に出力されます。

出力された RTMP エンドポイントに配信を行い、HLS エンドポイントを指定して再生を行えば良い形になっているので、使いやすいはずです。

おわりに

CloudFormation を使用して MediaLive + MediaPackage + CloudFront を一括で作成するテンプレートを作成してみました。

思っていた以上に作成に手間取りましたが、無事動くものが作成できて良かったです。


以上、AWS 事業本部の大前でした。

参考