🎁

TimeTree ギフトのフロントエンド環境をAWS Amplifyから移行した話 #TimeTreeアドカレ

2023/12/24に公開

概要

これは株式会社TimeTree Advent Calendar 2023の24日目の記事です。

こんにちは。TimeTreeのSREの@SonHです。

この記事では、TimeTree ギフト(以下、ギフト)のフロントエンドアプリケーションのホスティング先をAWS AmplifyからAmazon Cloudfront、ALB(Application Load Balancer)、およびECS Fargateに移行した経験について共有します。

※なお、本記事は移行に伴うフロントエンドアプリケーション側での変更を担当した弊社フロントエンドエンジニア @fujikky とともに書いています。

1. はじめに

1.1背景

2023/4/3にリリースしたTimeTree ギフトのフロントエンドアプリケーションは、Next.jsをStatic Site Generation (SSG)して、AWS Amplifyにホスティングしていました。

しかし、Amplify+SSGでは後述する運用上の課題から、2023/10/1にホスティング先をAmplifyからCloudfrontALB(Application Load Balancer)、およびAWS Fargate + Server Side Rendering(SSR)に移行しました。

1.2 そもそもなぜAmplifyを用いていたのか?

新サービスにフロントエンドにNext.js を採用することは決定していて、ホスティング環境の選定をしていた際に AWS Amplify が候補に上がりました。

AmplifyはSSG、SSRに対応しており、SSRはEdgeで実行されるため、いわゆるFE環境で最新のベストプラクティスと呼ばれている環境を簡単に構築できます。また、VercelのようにPRを立てた際にプレビュー環境を立ち上げることができ、サービス立ち上げ時の試行錯誤をしやすいのではという判断で採用に至りました。

2. 移行前の構成概要

2.1 運用してきて生じた、Amplify+ SSGの課題

  • セキュリティ上の問題
    • インフラレイヤーで用意できる認証として、BASIC認証だけしか対応できませんでした。
  • Amplifyで作られた各AWSリソースを直接触れないので細かい調整ができない
  • 各ステージ間でビルド成果物を共有できない
    • staging と productionでドメインを分けていて、 それぞれのドメインで再ビルドが必要になりました。
    • 各ドメインに紐づくブランチは1つのみなので、staging用のドメインとproduction用のドメイン、それぞれ用意する必要がありました。
  • 開発中機能に対するレビューの制約
    • Amplify環境下ではドメインとブランチが1対1になっている関係上、mainブランチへマージされていない機能は開発環境にデプロイできませんでした。
    • 開発者は各自のローカル端末で動かす形でしかレビューしかできず、開発者レビューの前に実際に開発環境にデプロイして開発用アプリケーションで動かすことで、事前にUIのすり合わせが困難でした。
  • リリースロールバックが困難
  • ギフトを運用していくにあたって、SSGでは限界が来ていた
    • Next.jsのDynamic Routesを使った際に、AmplifyがSSGモードだと、動的なパスごとにAmplify Console でリライトの設定を追加する必要がありました。
    • AmplifyでSSGからSSRに移行するには、Amplyのアプリケーション設定を1から再構築する必要がありました。
  • 国内の採用事例が少なく、問題があったときに解決方法を探しづらい
  • 商品情報更新の適用にかかるFrontendエンジニアの人的コスト
    • 商品情報自体はBackendアプリケーション内で管理しており、商品管理者がBackendで行った商品情報の更新をFrontendのコンテンツに適用するためにSSGのリビルドが必要になります。そのため、商品情報の更新が入ると都度Frontendエンジニアの作業が発生していました。

3. 移行後の構成概要

主な変更点

  • AmplifyにSSGでホスティングしていたアプリケーションを、ECS上にSSRでホスティング
    • 移行前はビルド時のみBackendにアクセスしていたが、FrontendでSSRするたびにアクセスが発生することになります。
    • Cloudfront + ALB + ECSの構成で、ECSでSSRしたコンテンツをCloudfrontにキャッシュする方式です。
  • コンテンツの更新手順がSSGのリビルドからCloudfrontのキャッシュクリアに
    • Backendで商品情報の更新後にCloudfrontのCreate Invalidation APIを叩くことで更新の適用ができるようになりました。
  • 午前0時の商品情報定期更新をBackendのバッチサーバーに移行
    • リリース時にキャッシュ削除を行う必要があるので、従来通りGItHub Actionsでキャッシュ削除を行うことは可能ですが、GitHub Actionsのスケジュール実行ではワークフローの起動に分単位のタイムラグがあるため、なるべくリアルタイムにコンテンツを更新するなら自前のバッチサーバーでキャッシュ削除することが望ましいため移行を判断しました。

4. 移行プロセス

4.1 移行先リソースの作成

上記3.で提示した移行先のAWSリソースや各リソースやGitHub Actions利用するAWS IAM Roleを作成しました。

4.2 アプリケーションの設定をSSR用に変更し、Dockerfileを用意

Next.js には公式の Dockerfile サンプルが用意されているため、こちらを参考に Dockerfile を用意します。サンプルでは Docker のマルチステージビルドを利用してコンテナサイズの圧縮が測られています。

https://github.com/vercel/next.js/tree/canary/examples/with-docker

また、Next.js の standalone 機能を有効にすることで、コンテナサイズを減らすテクニックも紹介されています。

https://nextjs.org/docs/pages/api-reference/next-config-js/headers

アプリケーション側では getStaticPropsgetServerSideProps に変えるぐらいで、大きな変更はありませんでした。

4.3 デプロイワークフロー準備

4.3.1 開発環境Deploy Workflow

4.3.2 本番環境Deploy Workflow

GitHub Actionsからアプリケーションをビルドし、ECRにPushするGitHub ActionsのWorkflowと、CodeDeployと連携してBlue/GreenデプロイでリリースするWorkflowを作成しました。

4.4 先行リリース

社内のみアクセスできる形で移行する5日前にアプリケーションを先行リリースし、稼働させることでBackendとの連携など含めたシステム全体の構成に問題がないことを確認しました。

4.5 ギフト用ドメインの再作成

もともとギフトで利用していたドメインはAmplifyで作成・管理されているもの(カスタムドメイン)で、これを自前のCloudfrontに紐付け直すことはできません。

そのため、まずAmplifyカスタムドメインを削除し、改めてRoute53で同名のドメインを取得しました。

一時的にギフトのドメインを削除することでサービス利用ができなくなるため、ユーザーにサービスメンテナンスという形で告知して対応しました。

4.6 Amplifyの削除

移行完了後、不要になったAmplfyを削除しました。

5. 遭遇した課題と解決策

5.1 移行当初はキャッシュが有効に働いていなかった

CDNにキャッシュを効かせるには Cache-Control を適切な値にする必要があるのですが、Next.js のデフォルト設定ではそのようになっておらず、独自で設定が必要でした。

SSR しないページ (SSGのページ)

next.config.jsheaders を設定すると静的なページの Cache-Control を上書きできるようでした。

https://nextjs.org/docs/pages/api-reference/next-config-js/headers

値については以下のポリシーで決めています。

  • ブラウザにはキャッシュしない
  • CDN にはキャッシュする
headers: async () => [
    {
      source: "/:path*",
      headers: [
        {
          key: "Cache-Control",
          value: "public, max-age=0, s-maxage=86400, must-revalidate",
        },
      ],
    },
  ],

SSR するページ

SSR は getServerSideProps 内で個別に res.setHeader(...) を呼んであげる必要があるようだったので、 setHeader をする関数を作り、すべての getServerSideProps の中で呼ぶようにしました。

export const setCacheControl = (res: GetServerSidePropsContext["res"]) => {
  res.setHeader(
    "Cache-Control",
    "public, max-age=0, s-maxage=86400, must-revalidate",
  );
};
import { setCacheControl } from "~/src/lib/next";

export const getServerSideProps: GetServerSideProps = async ({ res }) => {
  setCacheControl(res);
  ...
};

5.2 router.queryがキャッシュされる

SSR のページに限った話ですが /page?foo=bar という URL にアクセスした際に、このクエリストリングの情報がが HTML に出力される仕様になっているようで、これが CDN にキャッシュされてしまい、結果、アクセスしたユーザーごとに異なるはずの router.query が全ユーザーで同じものになってしまう問題が発生しました。開発中に気付けてよかったです。サービス上の作りとしては SSR の時点でクエリストリングのデータを扱う必要はなく、クライアントサイドでのみ必要だったので、HTMLに出力されない方法を模索しました。

調査の結果 Next.js 13 の App Router 用に作られた next/navigationuseSearchParams を使うと HTML にキャッシュされないことが分かったのでそちらを使うように切り替えました。 (Next.js 13.5 から Pages Router でも useSearchParams が使えるようになりました)

https://nextjs.org/docs/app/api-reference/functions/use-search-params

6. 移行してよかったこと

6.1 開発中の機能を先行レビュー可能になった

AmplifyでもPRごとにプレビュー環境を立てる機能があるのですが、認証のセッションCookieのドメイン周りの制限から、複数のフロントエンド環境に1つのバックエンド環境を疎通させることができず、活用できていませんでした。

ECSへのDeployは指定したブランチをフロントエンドとバックエンドを同時に上げるので、開発者レビューの前に検証環境にデプロイすることで実際のアプリ上でのUIをレビュー可能になりました。

6.2 Blue/Greenデプロイメントで安全にロールバック可能になった

CodeDeployでのDeployを可能にしたので、Blue / Green Deployが安全かつ容易に可能になりました。

6.3 商品情報更新の人的コストと適用時間の削減

移行後の環境では、商品情報を更新後にCloudfrontのキャッシュ削除を行えるようにしたため、商品情報管理者が自分で商品情報を更新後に反映することができるようになったため、FrontendエンジニアのSSGリビルド作業が不要になった上、キャッシュクリアの方が早く適用されるためコンテンツの更新適用時間も短くなりました。

6.4 AWSリソースのコスト削減

移行前時点では月2,500ドル前後利用していたのに対し、移行後は月2,000ドル前後にまで減少しました。

最後に・・・

TimeTreeの採用情報

TimeTreeのミッションに向かって一緒に挑戦してくれる仲間を探しています。TimeTreeで働くことに興味がある方はぜひ、Company Deck(会社紹介資料)や採用ページをご覧ください!

https://bit.ly/3IyEEWt

https://bit.ly/3WFPDn1

TimeTree Tech Blog

Discussion