📺

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

2021/09/16に公開

サービスの仕様

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

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

配信基盤の設計

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

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

ここでの認証基盤はfirebase authでも自前の認証機能でも問題ありません。

筆者の場合はfirebase authのクッキー認証を利用し、アプリケーションサーバーでユーザーの認証、コンテンツの権限確認を行います。権限を持ったユーザーと判断した場合、CloudKeyManagementに保管されている秘密鍵を使用し署名付きCookieの署名を行い、ユーザーに返却しています。

また、記事の内容と逸れてしまうため省略しましたが署名付きCookieの暗号化方式がsha1であるため、セキュリティに不安がある場合は適当な期間での秘密鍵の更新処理を別途用意する必要があります。

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では無料枠はありませんし、使用の仕方によっては無視できないほどのコストになりえます。

クライアントサイドの実装

今回はNext.jsを利用したWebクライアントを想定した実装を行なっていきます。

Next.jsで実装するルーティングは以下の通りです。

  • / トップページ
  • /contents/[id] コンテンツページ
  • /login ログインページ
  • /api/upload_policy ポリシードキュメント用エンドポイント
  • /api/login セッションクッキー発行用エンドポイント

特筆すべき点としてはAPIエンドポイントの2つです。

Cookieの発行

筆者は認証基盤にFirebase Authを採用しており、サーバーサイドでのユーザー認証のためにCookieを利用したセッション管理を行なっています。

https://firebase.google.com/docs/auth/admin/manage-cookies?hl=ja

これにより、後述するポリシードキュメントの発行や保護されたコンテンツへのアクセスをセッションCookieを介してやり取りすることが可能になります。

また、セッションCookieの利用例として、特定のユーザーにのみ閲覧制限をかけたいコンテンツを用意することが挙げられます。セッションCookieを通してユーザーを認証し、前述の配信基盤の設計にて紹介した署名付きCookieの処理をNext.jsのSSR時に実行するか、専用のエンドポイントを用意しクライアントサイドで必要に応じてリクエストを飛ばすといった実装が想定できます。

ポリシードキュメントの作成

ポリシードキュメントとはCloudStorageのセキュアルールにて制限することが難しいケースをカバーする場合に非常に有効な手段です。

https://cloud.google.com/storage/docs/xml-api/post-object-forms

アップロードフォームを表示する際、あらかじめエンドポイントで発行したid、ファイルフォーマット、ファイルあたりのサイズ上限の各パラメーターに加えそれらを署名した、一意のトークンをformタグ内のhidden属性を付与したinputに値としてセットします。

また、発行するタイミングと同時にFirestore等のDataStoreに同じid(もしくは関連づけた)オブジェクトを予約することでコンテンツの衝突を避けることもできます。合わせて認証情報も持ち合わせているためオブジェクトのIDとユーザーを紐づけることも可能です。

// /content/{id}
{
  title: "title",
  createdAt: "2020/01/01 00:00:00",
  uploader: "xxxx",
  storagePath: "user/xxxx/media/foo", 
  uploaded: false,
}

具体的な実装は下記の記事で紹介しています。
https://zenn.dev/tera_ny/articles/cd19497b39e1d5

動画の表示

プレーヤーは表示できれば基本どんなものを利用していいと思います。今回はHLSを利用した動画配信に加えReactでの画面構築を行うため下記の様なhls.jsを利用した動画プレーヤーを実装しました。
https://zenn.dev/tera_ny/articles/42bc5e704fbdb7

また、Next.jsで実装したルーティングとは別にLoadBalancerにて動画用のURLマップの構築を行う必要があります。

https://cloud.google.com/load-balancing/docs/url-map?hl=ja

今回の場合は/video/[id]に前セクションで実装したTranscodedBucketのエンドポイントを設定することで同一オリジンポリシー下で下記のようなフローを実行することが可能になりました。

  1. https://example.com/contents/xxxx にアクセス
  2. コンテンツサーバーにてid: xxxxが公開中か、ユーザーに対してアクセスする権限があるかチェック
  3. 非公開及びアクセス権限を持つ場合にのみSet-CookieヘッダにCloudCDNCookieと該当するパスを指定しコンテンツを返却。
  4. videoタグを通してhttps://example.com/video/xxxxにCloudCDNCookieを送信
  5. CloudCDNで認証し問題なければセグメントファイルを返却する

Discussion