【メモ】GCPで完結する動画配信サービスの設計と実装


現時点では実装できていない機能もある為詳しく記述できない箇所や実際の仕様と異なる、変更される箇所も出てきます。その点を考慮して閲覧していただけると嬉しいです。

サービスの仕様

以下の特徴を持つようなサービスを設計します。

  • ユーザーがmp4形式の動画をアップロード
  • アップロードされた動画をHLSにトランスコードし閲覧時に配信
  • 配信されている動画はユーザー単位のアクセス制限をかける事が可能
    今回は動画をメインに取り扱っていますが、動画以外にも画像や音声、テキストデータなどを扱う事が可能だと思います。

配信基盤の設計

配信にはCloudStorageを利用します。加えてCloudStorageのrulesでは表現の難しいユーザー単位でのアクセスコントロールを予定しているのでHttpLoadBalancer, CloudCDNを利用する予定です。

CloudCDNを使用した静的コンテンツのアクセスコントロールについてはこちらの記事で紹介しています。

以下は署名付きCookieを使用した場合のシークエンス図になります。

sequenceDiagram
    participant 閲覧者
    participant ApplicationServer
    participant CloudCDN
    participant CloudStorage
    閲覧者->>ApplicationServer: send AuthorizationToken
    ApplicationServer->>閲覧者: Set-Cookie: Cloud-CDN-Cookie
    閲覧者->>CloudCDN: Cookie: Cloud-CDN-Cookie
    alt incorrect cookie value
        CloudCDN-->>閲覧者: 403 Error
    else correct cookie value and cached resource
        CloudCDN->>閲覧者: return resource
    else correct cookie value and not cached resource
        CloudStorage->>閲覧者: return resource
    end

Transcodeを考慮する

ユーザーが動画をアップロードする場合はYoutubeが が殆どだと考えられます。ただ、動画をオンデマンド配信するのにあたってこれらの形式を配信するのはあまり現実的ではありません。

例えばプログレッシブダウンロードに対応していないmp4を配信場合ダウンロード完了するまで視聴する事ができず、ユーザーはダウンロード完了するまで待機する必要があります。この時配信するコンテンツがFullHDで1時間程度の動画であれば約10GB程の転送量になります。Wifiや有線接続ならまだしも、4Gや速度制限がかけられているネットワークで試聴するとなると膨大な時間が必要になるのは目に見えています。

そこで配信可能な形式、今回はHLS形式にトランスコーディングする処理が必要になります。またデバイスの通信環境を考慮するのであれば配信するコンテンツの解像度はデバイスによって切り替えられるとUX向上につながります。

以上を考慮した上で実装すると以下のような構成になりました。

ユーザーはオリジナルデータを保存するバケットに対しコンテンツをアップロードします。CloudFunctionsのonFinalizedイベントをトリガーにアップロードされたオリジナルデータを複数の解像度を持ったHLS形式にトランスコードするリクエストをTranscoderAPIに送信します。

リクエストを受け取ったTranscoderAPIはCloudFunctionsのリクエストに基づいてHLS形式にトランスコードし、変換されたデータは配信用のバケットに保存されます。

以下はNode.jsを使用したFirebase CloudFunctionsのサンプルです。


const contentTypeRegex = /^video\/mp4$/
const extRegex = /^.mp4$/

const generateURI = (bucketID: string, path: string) =>
  "gs://" + bucketID + "/" + path

export const callTranscoder = functions.storage
  .bucket('origin')
  .object()
  .onFinalize(async (metadata) => {
    if (!(metadata.name && metadata.contentType)) return
    const objectPath = path.parse(metadata.name)
    const mimeType = metadata.contentType
    if (!(mimeType.match(contentTypeRegex) && objectPath.ext.match(extRegex)))
      return
    const matches = objectPath.dir.match(
      /^contents\/video$/
    )
    if (!objectPath.dir.match(
      /^contents\/video$/
    )) return
    const inputUri = generateURI('origin', metadata.name)
    const outputUri = generateURI(
      'transcoded',
      `contents/video/${objectPath.name}/`
    )
    const request = {
      parent: client.locationPath(projectID, regionID),
      job: {
        inputUri: inputUri,
        outputUri: outputUri,
        config: {
          pubsubDestination: {
            topic: `projects/${projectID}/topics/${topicID}`,
          },
          elementaryStreams: [
            {
              key: "video-stream0",
              videoStream: {
                h264: {
                  heightPixels: 360,
                  widthPixels: 640,
                  bitrateBps: 600000,
                  frameRate: 60,
                  gopDuration: { seconds: 10 },
                },
              },
            },
            {
              key: "video-stream1",
              videoStream: {
                h264: {
                  heightPixels: 720,
                  widthPixels: 1280,
                  bitrateBps: 4000000,
                  frameRate: 60,
                  gopDuration: { seconds: 6 },
                },
              },
            },
            {
              key: "video-stream2",
              videoStream: {
                h264: {
                  heightPixels: 1080,
                  widthPixels: 1920,
                  bitrateBps: 8000000,
                  frameRate: 60,
                  gopDuration: { seconds: 6 },
                },
              },
            },
            {
              key: "audio-stream0",
              audioStream: {
                codec: "aac",
                bitrateBps: 64000,
              },
            },
          ],
          muxStreams: [
            {
              key: "media-sd",
              container: "ts",
              elementaryStreams: ["video-stream0", "audio-stream0"],
              segmentSettings: {
                segmentDuration: { seconds: 10 },
                individualSegments: true,
              },
            },
            {
              key: "media-hd",
              container: "ts",
              elementaryStreams: ["video-stream1", "audio-stream0"],
              segmentSettings: {
                segmentDuration: { seconds: 6 },
                individualSegments: true,
              },
            },
            {
              key: "media-fullhd",
              container: "ts",
              elementaryStreams: ["video-stream2", "audio-stream0"],
              segmentSettings: {
                segmentDuration: { seconds: 6 },
                individualSegments: true,
              },
            },
          ],
          manifests: [
            {
              fileName: "master.m3u8",
              type: "HLS",
              muxStreams: ["media-sd", "media-hd", "media-fullhd"],
            } as { fileName: string; type: "HLS"; muxStreams: string[] },
          ],
        },
      },
    }

    const [response] = await client.createJob(request)
    console.log("start job:", response.name)
  })

このサンプルではアップロードされたmp4から60fpsの360p, 720p, 1080pの動画を生成しています。jobの設定についてはこちらで詳しく確認することができます。

TranscoderAPIを利用するにあたって注意するべき点としてはサポートしている形式課金方法はよく確認した方が良いです。GCPでは多くのサービスが無料か、非常に低いコストで利用することができますがTranscoderAPIでは無料枠はありませんし、使用の仕方によっては無視できないほどのコストになりえます。

クライアントサイドの設計

今後追加予定です。