maybe daily dev notes

私の開発日誌

AWS CDKでWebフロントエンドをデプロイする3つの方法

CDKでWebフロントエンドのSPAをデプロイするときの方法まとめ。下図のありがちなSPA構成を想定している。

このような場合、フロントエンドアプリからAPI GatewayのエンドポイントURLやCognitoのUser Pool IDなどの値を参照する必要があって、それらの値をどのように解決するか、どうデプロイするかなどに選択肢がある。早速、それらの方法を見ていこう。

方法

以下では、フロントエンドが参照すべきリソースを 依存するリソース と呼ぶ。(上図では API Gateway や Cognito User Pool のこと。)

方法1. 依存するリソースをデプロイ後、フロントエンドをデプロイする

依存するリソースをまず先にデプロイし、参照したい値 (エンドポイントURLなど) を確定させる。これらの値をフロントエンドのコードにハードコードするか、ビルド時の環境変数として注入することで、フロントエンドから参照できるようにする。

コードは単純で、BucketDeployment でビルドした静的ファイルをS3にコピーするだけ。

// これをデプロイする前に以下を完了させる
//    1. 依存するリソースのデプロイ
//    2. 依存するリソースの値 (エンドポイントURL) などをフロントエンドに埋め込み
//    3. フロントエンドのビルド
// ビルド生成物が frontend/dist に出力されたものとする
    new BucketDeployment(this, 'Frontend', {
      sources: [
        // frontendのビルド生成物のディレクトリを指定
        Source.asset('frontend/dist'),
      ],
      destinationBucket,
      distribution,
    });

割と直感的なので、よく使われている方法と思う。しかし、欠点がいくつかある:

  • 依存するリソースとフロントエンドのデプロイを1つのスタックにまとめることができない
    • 依存するリソースがデプロイされるまではフロントエンドのビルドができないので、デプロイの単位を分ける必要がある
    • もちろん、依存するリソースをデプロイした後に同スタックにフロントエンドを追加すれば1スタックにできるが、そうするとスタックの作り直しや環境の追加などメンテが大変になるので、やらない前提
  • 値を注入するために、CDK外で何らかの手作業やコードが必要になる
    • 例えばデプロイされたREST APIのエンドポイントを手作業でフロントエンドのコードにコピーしたり
      • 環境が増えてきたときや、環境を頻繁に作り直す場合は、手間になる
    • 例えば依存するリソースを含むスタックの Stack output から値をCLIで取ってきて、ファイルに書き出すなど
      • CDKの事情がフロントエンド側に漏れてしまっている感はある
      • ちなみに s3-assets.AssetProps.bundling を使うと、CDK synth時にこのコマンドを自動で実行させるようなことは可能

方法2. S3Deployment の機能で値をファイルとして書き出す

方法1の欠点を意識してか、数ヶ月前に追加されたのがこの機能。以下のようなコードで、依存するリソースの値をCDKのデプロイ中にファイルとしてS3に書き出すことができる。

const appConfig = {
  topic_arn: topic.topicArn,
  base_url: 'https://my-endpoint',
};

new s3deploy.BucketDeployment(this, 'BucketDeployment', {
  sources: [s3deploy.Source.jsonData('config.json', config)],
  destinationBucket: destinationBucket,
});

この書き出されたファイルをフロントエンドから動的にロードすることで、フロントエンドは動的に値を解決することができる。 実際にこの仕組みを利用した例が ↓のリポジトリ

github.com

書き出したファイルを、Reactコンテキストの初期化時に取得することで、必要な値を注入している。

const fetcher = async () => {
  const res = await fetch('/env.json')
  return res.json()
}

export default function useEnv() {
  const { data: env } = useSWR<Env>(dev ? null : '/env.json', fetcher, {
    revalidateOnFocus: false,
    revalidateOnReconnect: false,
  })
// https://github.com/aws-samples/nextjs-authentication-ui-using-amplify-ui-with-cognito/blob/main/app/lib/useEnv.ts

この方法では、フロントエンドをビルドする段階では値を解決しなくて良い。このため、方法1とは異なり、フロントエンドと依存するリソースを1スタックでまとめてデプロイすることができるのは利点。

欠点としては、以下が考えられる:

  • 現状クロススタックで値を解決できないバグがあるため、方法1とは逆にスタックを分割するのが困難
    • 元の実装はCDKの創造主である eladb によるものだが、かなりハッキーに見えるので、根本解決には時間を要すると(完全外野からの)予想
    • 一応、SSM Parameterを経由することで値を解決するWorkaroundはある
  • フロントエンド側で上記のような特別な実装が必要
    • エンドポイントURLをネットワークから動的に読み込むのは一般的とは言えない
    • これもCDKの事情がある種リークした結果で、あまり好ましくはない

方法3. CDKデプロイ中にフロントエンドをビルドする

この記事で推したい方法。そもそもこれらの問題が起きる原因は、依存するリソースの値はそれらをデプロイするまで確定せず、またフロントエンドアプリはそれらの値が確定するまでビルドできないという点が主だった。このために、フロントエンドを別にデプロイしたり、リソースの値を動的に取得するような措置をしていた。

もう一つ簡単な解決方法があって、それがデプロイ中にフロントエンドをビルドするというもの。CloudFormationのカスタムリソースの中でフロントエンドをビルドすれば、依存するリソースの値も同スタック内で解決できるし、フロントエンドのビルドの中に静的に値を埋め込むことができる。 リポジトリはこちら:

github.com

これを使うと、以下のインターフェースでフロントエンドアプリをデプロイできる。*1

import { NodejsBuild } from 'deploy-time-build';

declare const api: apigateway.RestApi;
declare const destinationBucket: s3.IBucket;
declare const distribution: cloudfront.IDistribution;
new NodejsBuild(this, 'ExampleBuild', {
    assets: [
        {
            // フロントエンドのソースコードがあるローカルのディレクトリ
            path: 'example-app',
            exclude: ['dist', 'node_modules'],
        },
    ],
    destinationBucket,
    distribution,
    // Build artrifactが出力されるディレクトリ
    outputSourceDirectory: 'dist',
    // Buildに使うコマンド
    buildCommands: ['npm ci', 'npm run build'],
    // ビルド環境に注入する環境変数 (deploy-time valueも利用可能)
    buildEnvironment: {
        VITE_API_ENDPOINT: api.url,
    },
});

これなら1スタック構成でもマルチスタック構成でも同じように対応できる。また、フロントエンド側にCDKの知識は必要なく、よくある環境変数を埋め込む方法を利用できる。方法1や2の欠点が解消されたことがわかる。

一方で欠点もあり、

  • ビルドという割と複雑な作業を、汎用的なConstructに実行させている
    • 環境によってビルドにまつわる事情は無数にあると思われるので、もしかするとこの汎用Constructでは対応できないユースケースもあるかもしれない
  • CDKデプロイのたびに (フロントエンドのコードに変更があれば) フロントエンドがビルドされるので、デプロイの時間が長くなりがち
    • 例えばバックエンドのフロントエンドを1スタックにまとめて、 cdk watch しているような場合、開発効率が悪化するかもしれない
    • このため、開発環境においてはフロントを別スタックに隔離する、アセットハッシュを固定するなどの措置が必要かも

とはいえ、現状趣味プロジェクトで使っている分には、今のところバッチリ動作しているし、特に不都合もないので満足している。

まとめ

CDKでWebフロントエンドをデプロイする方法を3つ紹介した。それぞれPros Consがあるので、ぜひ一度お試しください。

*1:deploy-time valueとはCDK用語で、デプロイ時に確定する値のこと。リソースのARNやREST APIのエンドポイントなど。