PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

大規模フロントエンド分離を成功させるための具体的なアプローチと知見

こんにちは、PLAY CLOUD本部 技術推進室の市川です。

前回は、サービス間の差異をなくすための共通Node.jsモジュール管理について解説しました。 developers.play.jp

今回もPLAY CLOUD全体の改善の一環として実施した、アーキテクチャの大幅な変更について、その背景や具体的な手法についてご紹介します。 具体的には、PLAY CLOUDの管理画面をSPA(Single Page Application)化し、フロントエンドとバックエンドを完全に分離させた話になります。

従来の構成と課題

初期のPLAY CLOUDは、クラウド上のコンピューティングサービスにAPIとReact製の管理画面を同居させるモノリシックな構成でした(下図参照)。

前回も触れていますがPLAY CLOUDは単一のサービスから始まっており、事業の拡大に伴い、既存の構成を踏襲し、新たなサービスの管理画面を同じ構成で追加していきました。 後続のサービスは個別のAPIサーバーを持つものの、認証やユーザー管理など一部の機能は既存サービスと共有していたり(以降共通API)、既存の構成と同様にコンピューティングサービスが管理画面をホスティングする構成でした。 サービス間の負荷影響を避けるため、サービスごとにサーバーを立て、単一ドメインのアクセスをパスベースルーティングで振り分けていました。(下図)

既存の構成に追加していくというのはサービスを素早く展開して行くのに有効な手段でしたが、サービスが増え組織が大きくなっていくに連れて、開発や運用面で以下の課題が顕在化しました。

  • デプロイ時間の長期化
    UIの軽微な修正であっても、共通APIを含めたビルドが必要となり、デプロイに10分以上を要していました。これは開発生産性の低下を招く大きな要因でした。
  • 不要なサーバーコスト
    後続のサービスのAPIは別サーバーで稼働しているにもかかわらず、管理画面をホストするためだけに旧サーバーを維持する必要があり、余分なコストが発生していました。
  • 権限管理の問題
    全サービスが単一のリポジトリで管理されていたため、開発者のアクセス権限をサービスごとに分離できず、全員が全コードにアクセス可能な状態はガバナンス上の課題でした。

これらの課題に加え、将来的にフロントエンドとバックエンドで開発チームを分ける可能性も考慮し、組織の成長や変化に柔軟に対応できるアーキテクチャを目指すことにしました。

実施した構成変更

上記の課題を解決するため、以下の構成変更を実施しました。

リポジトリのサービスごとの分離

まず、単一のリポジトリで管理されていたフロントエンドのコードをサービスごとに分離・分割し、それぞれ独立したリポジトリに移行しました。 これにより、開発者ごとに必要なリポジトリへのアクセス権を付与できるようになり、ガバナンスが強化されました。また、フロントエンドのコードのみを切り出すことでコードベースが整理され、見通しも向上しました。

フロントエンドとバックエンドの完全分離 (SPA化)

次に、管理画面をAPIから完全に分離し、静的ファイルとしてS3にホスティングする構成に変更しました。これにより、APIサーバーの負荷が軽減され、不要なサーバーを削減できました。 また、UIのデプロイは静的ファイルのアップロードのみで完了するため、デプロイ時間も大幅に短縮されます。

最終的な構成のイメージは以下です。

構成変更後のイメージ

対応の詳細

いきなりフロントエンドとバックエンドを分離してくれと言われたら、みなさんも少し戸惑うのではないでしょうか? 現状のサービスの構成にもよると思うのですが、従来のPLAY CLOUDは、初回アクセス時にサーバーサイドでHTMLを生成し、その後はSPAとして動作するハイブリッドな構成でした。 そのため、分離の鍵は「サーバーサイドで動的に生成していたHTMLを、クライアントサイドで再現すること」にあります。 具体的な対応を説明していきます。

動的に生成されるHTMLの再現

旧構成(サーバーサイド)

従来は、以下のようにHTMLテンプレートにプロジェクトやアカウントといった動的なコンテキスト情報(初期データ)を埋め込んでいました。

${service}.html

<body>
  <div id="appMountPoint"></div>
  <script type="text/javascript">
    window.app = {};
    window.app.appData = { accountInfo: { id: 'XXX' }, /* ... */ }
        

この動的に変わるコンテキスト情報をクライアントサイドで取得するため、それらを返す専用のAPIエンドポイントを新たに用意しました。

新構成(クライアントサイド)

S3に配置する静的なindex.htmlは、汎用的なテンプレートとします。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <title>PLAY CLOUD</title>
</head>
<body>
  <div id="appMountPoint"></div>
  <script type="module" src="/entry/${service}.ts"></script>
</body>
</html>

アプリケーションのエントリーポイントとなる/entry/${service}.tsで、これまでサーバーサイドが担っていたコンテキスト情報の取得とReactコンポーネントのマウント処理を実装します。

/entry/${service}.ts

// コンテキスト情報取得
const response = await fetch(`${baseURL}/get_context`);

// 認証エラーの場合はログインページへリダイレクト
if (response.status === 401) {
    window.location.href = `${baseURL}/sign_in?redirect_to=${encodeURIComponent(location.href)}`;
}

const context = await response.json();
window.app = { appData: context };
        ︙
//  Reactアプリケーションの初期化処理
initAppContext(model!, history, function (appContext) {}

この処理により、まずAPIからコンテキスト情報を取得し、それをグローバル変数(window.app.appData)に格納します。認証に失敗した場合はログイン画面へリダイレクトします。

※サーバー側でオープンリダイレクト対策を行っている場合は、リダイレクト元となるフロントエンドのドメインを許可リストに追加する必要があります。

これで、動的に生成していたHTMLをクライアントサイドで再現できるようになりました。

ルーティングの制御

SPAでは、どのURLパス(例: /serviceA/users/123)に直接アクセスされても、アプリケーションの起点となるindex.htmlを返す必要があります。 このリクエストパスの書き換えは、CloudFront Functionsを利用して実現しました。
Lambda@Edgeでも同様の処理は可能ですが、単純なパス書き換えやリダイレクトであれば、より軽量で高速なCloudFront Functionsが適しています。 実際に使用した関数は、以下のように非常にシンプルです。

const SERVICES = ["serviceA", "serviceB", ....]

function handler(event) {
  const request = event.request;
  const uri = request.uri;
  // アセットファイルへのリクエストはそのまま通す
  if (uri.includes('.') || uri.endsWith('/index.html')) {
    return request;
  }

  const service = uri.split("/")[3]
  // 有効なサービス名がパスに含まれている場合
  if (SERVICES.includes(service)) {
    //常にindex.htmlを返すようにURIを書き換える
    request.uri = `/${service}/index.html`
    return request
  }

  // 存在しないサービスの場合はプロジェクト一覧へリダイレクト
  const host = request.headers.host.value;
  const response = {
    statusCode: 301,
    statusDescription: 'Moved Permanently',
    headers: {
      "location": { "value": `https://${host}/projects` }
    }
  }

  return response
}

この関数をCloudFrontディストリビューションのビューワーリクエストイベントに設定します。これにより、ブラウザからのリクエストがオリジン(S3)に到達する前にパスが書き換えられ、 どのURLへのアクセスであってもS3上のindex.htmlが返されるようになります。

デプロイ周りの対応

デプロイフローの自動化とキャッシュ

管理画面のデプロイは、ビルドされた静的ファイルをS3にアップロードするだけのシンプルなプロセスになりました。 このフローは GitHub Actions を用いて自動化しています。

      - name: Deploy S3
        run: |
          echo "upload start..."
          aws s3 cp public/static/assets s3://${{ inputs.s3_bucket }}/${service}/static/assets --recursive \
            --cache-control "public, max-age=21600, s-maxage=86400" \
            --metadata-directive REPLACE
          aws s3 cp public/index.html s3://${{ inputs.s3_bucket }}/${service}/index.html \
            --cache-control "public, max-age=60, s-maxage=60" \
            --content-type "text/html" \
            --metadata-directive REPLACE
          echo "done"

デプロイにおいて特に考慮したのは、静的ファイル配信におけるキャッシュの効率化です。 /static/assets配下のアセットファイルは、内容がほとんど変更されず、また変更されない限りファイルパスも変わりません。そこで、ブラウザとCDNの両方で長期間キャッシュが効くよう、Cache-Controlヘッダーのmax-ageとs-maxageに大きな値を設定しました。 一方で、アプリケーションのエントリーポイントとなるindex.htmlは、ビルド時にバンドラーが生成するハッシュ値付きのJavaScriptファイル名が書き込まれるため、デプロイごとに内容が更新されます。この変更を迅速に反映させるため、キャッシュ期間を1分と短く設定しています。 このようにすることで、S3へのリクエストを最小限に抑制しつつ、変更のないアセットはキャッシュから高速に配信されるため、リソース消費の最適化と、フロントエンドの表示速度・安定性の向上を実現しています。
代替案として、index.htmlも長期間キャッシュさせ、デプロイ時にCloudFrontのキャッシュを削除(Invalidation)する方法も検討しました。しかし、今回はキャッシュの更新ミスや漏れといった運用リスクを避けるため、Cache-Controlヘッダーで制御する確実な方法を選択しました。

オリジンの切り替えと段階的リリース

最後に、CloudFrontのオリジンを従来のALBから、静的ファイルをホストするS3バケットへ切り替えます。 PLAY CLOUDは${host}/projects/${project_id}/${service}というURL構造を持つため、CloudFrontのビヘイビア設定で/projects/*/${service}/*のパスパターンに一致するリクエストをS3オリジンへルーティングするように変更しました。 この変更は影響範囲が広いため、段階的なリリースを実施しました。まず、特定のプロジェクトID(例:/projects/${specific_project_id}/${service}/*)のみを新しいオリジンに向けることで影響を最小限に留め、動作検証を入念に行った上で、徐々に対象を拡大していきました。
また、CloudFrontからS3へのアクセスには、現在AWSが推奨する OAC (Origin Access Control) を採用しています。OACを利用することで、S3バケットへの直接アクセスを完全にブロックし、CloudFrontからの署名付きリクエストのみを許可できます。これにより、S3バケットを非公開に保ったまま安全にコンテンツを配信でき、セキュリティを大幅に強化できます。

OACの設定方法については、以下の記事などで詳しく解説されています。

【AWS】OACを設定してCloudFront経由でS3に格納したコンテンツを公開する #AWS - Qiita

おわりに

今回、フロントエンドとバックエンドを分離するための対応を解説しました。 まだ全てのプロダクトを分離できているわけではないですが、1プロダクトの改修だけでも変更ファイル数5000、コード追加1万行、削除82万行とかなり大きな変更でした(変更ファイル数や削除が多いのは、共通API部分をまるごと削除できたためです)。

構成変更により課題としてあった、デプロイ時間の長期化、不要なサーバーコスト、権限管理の問題を解消することができました。 特にデプロイに関しては10分以上かかっていたものが2分程度になり、開発時のサイクルが素早く周るようになったため、生産性が大きく向上したと実感しています。

PLAY CLOUDを例としたフロントエンドの分離ですが、似たような課題を抱えておりSPA化を考えている方の参考になればと思います。