7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudFrontで複数のSPAを配信しようとすることでハマったポイント

Last updated at Posted at 2024-10-24

概要

S3バケットにReactなどで書かれたSPA(Single Page Application)をアップロードし、そのバケットをDistributionのオリジンとして指定し、CloudFrontを通じてウェブサイトを配信するというAWSにおけるウェブサービス鉄板構成ですが、複数のSPAがプロジェクトに存在しかつ同じDistributionで配信したい場合、実はかなりややこしい罠が存在します。

本記事でその罠を紹介し、併せてその回避策を解説します。

背景

ユーザ向け画面と管理者画面が分けられるウェブアプリを開発していましたが、ついSPA二つ作ってしまいました(後によく考えたら完全に不要ですが)。もちろん、開発上の都合や、要件的にそうしないといけませんなどの理由で、同じアプリケーション内に複数SPAを入れる場合もあるでしょう。

結論

まず、複数SPAを持つことをできるだけ避けましょう!一つのSPAを含んだバケットをCloudFrontのデフォルトオリジンとして設定しておけば、ほぼすべての罠が回避できます。

どうしても複数SPAを持ちたい場合、CloudFront FunctionかLambda@EdgeでURIを書き換えるなどの回避策はありますが、副作用を考えた上でそれらをご活用ください~

コードはこちら:
https://github.com/zhang-hang-valuesccg/MultiSPAinOneDistribution

事前準備

CDKとSPA二つ用意します(ついてにnpm installやビルドも行います):

cdk init -l typescript
npm creat vite@latest pageA -- --template react-ts
npm creat vite@latest pageB -- --template react-ts
npm i && cd pageA && npm i && ../pageB && npm i && cd ..
cd pageA && npx vite build && cd ../pageB && npx vite build

ちょっとSPAを書き換えて区別できるように、PageAとPageBという文字を入れます:

現時点のディレクトリはこんな感じ:
image.png

CDKでバケットとDistribution作ります。ちなみに、使っているCDKは2.162.0ですが、少し前のバージョンからようやくOACでCloudFrontのS3アクセスを制御(現時点の推奨プラクティス)できるようになりましたので本記事ではそのやり方を採用します。(参考:AWSドキュメント

multi_sp_ain_cloud_front-stack.ts
// import everything...

export class MultiSPAinCloudFrontStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const bucketA = new Bucket(this, "bucket-pagea", {
      autoDeleteObjects: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const bucketB = new Bucket(this, "bucket-pageb", {
      autoDeleteObjects: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const distribution = new Distribution(this, "distribution-multi-spa", {
      enableLogging: true,
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucketA),
        cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
      },
      },
    });

    new BucketDeployment(this, "deploy-pagea", {
      sources: [Source.asset("./pageA/dist")],
      destinationBucket: bucketA,
      distribution: distribution,
      distributionPaths: ["/*"],
    });

    new BucketDeployment(this, "deploy-pageb", {
      sources: [Source.asset("./pageB/dist")],
      destinationBucket: bucketB,
      distribution: distribution,
      distributionPaths: ["/*"],
    });
  }
}

CDKにあんま馴染みのない人のために解説しますが、二つのSPAがあるので二つのバケットを作って、それぞれPageAとPageBの静的ファイルを入れてきます。Distributionも作って、デフォルトオブジェクトにindex.htmlを指定して、デフォルトオリジンにまずPageAのバケットを指定します。

npx tsc
cdk deploy --all

でAWSにデプロイします。ここまでごく一般的なやり方でAWSでのウェブサービスホスティング経験がある人なら理解しやすいと思います。

CloudFrontのドメインにアクセスすれば、PageAが思い通り表示されましたね!めでたしめでたし~
image.png

…PageBは?

ハマりポイント

そうですね…PageBも足しておかないと!

multi_sp_ain_cloud_front-stack.ts
// …
    const distribution = new Distribution(this, "distribution-multi-spa", {
      enableLogging: true,
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucketA),
        cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
      },
      },
+     additionalBehaviors: {
+       "/pageb*": {
+         origin: S3BucketOrigin.withOriginAccessControl(bucketB),
+         cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
+     },
+       },
+     },
    });

// …

デフォルトオリジンでPageAを置きましたので、additional behaviorになりますが、無事PageBも足しておきました!えい!デプロイ!

前回同様、CloudFrontのドメインをアクセスしたらPageAが出てきました!
で、/pagebを足すと…

スクリーンショット 2024-10-11 220612.png

なぜか出なくなりましたね。デフォルトの方でindex.htmlを指定したのでこちらで手動/pageb/index.html指定しても、結果は同様でした。

原因

image.png

リクエストを確認したところ、どうやら/pageb/index.htmlへのアクセスはそもそもCloudFrontの想定外らしいですね。もっと深掘りますと、同じパスがPageBのバケット内にもあるようにCloudFrontが思い込んでいます。

こうなるんだと我々は思うかもしれんが
/pageb/index.html: バケット(PageB)→index.html

CloudFrontはこう思っているんだ!
/pageb/index.html: バケット(PageB)→ pageb/ -> index.html

ふむ…じゃあこうしたらどうかな?元々ルートフォルダにある静的ファイルをpagebというフォルダの中に入れました!

CDKでやるとこういう感じでprefixを足しておきます:

multi_sp_ain_cloud_front-stack.ts
    new BucketDeployment(this, "deploy-pageb", {
      sources: [Source.asset("./pageB/dist")],
+     destinationKeyPrefix: "pageb/",
      destinationBucket: bucketB,
      distribution: distribution,
      distributionPaths: ["/*"],
    });

ここで面白いことが起こりました:

image.png

pageAはいつもの通りアクセスできます。pageBのindex.htmlもちゃんと見つかりましたね。筆者の予想通り、/index.htmlじゃなく/pageb/index.htmlをCloudFrontが求めています。が、.jsと.cssの挙動が怪しいです!

スクリーンショット 2024-10-11 224813.png

よくよく見ると、バケット(PageB)のpageb/assets/xxxx.jsじゃなくて、なんと!バケット(PageA)の/assets/xxxx.jsを参照しようとしています!PageAとPageBの静的ファイルの.cssの名前は一緒なのでPageBの.cssがバケット(PageA)でも見つかっちゃったわけです。

つまり、URIにもある通り、/pageb/assets/...に見に行くわけではなく、/assets/...(デフォルトオリジン)へリクエストが行っているのです。

回避策

そこで、一個目の回避策を思いつきました。/pageb/index.html以外のものはバケット(PageA)を参照するつもりなら、そうしてあげましょう!

image.png
(CDKでBucketDeploymentのpruneフラグでアセットたちを共存させようとしましたが、できませんでした。なぜでしょうか?とりあえず手動で両方のアセットをアップロードしました)

PageBのアセットをバケット(PageA)のassets/フォルダにアップロードしますとPageBもちゃんとアセットにアクセスできるようになります!

スクリーンショット 2024-10-12 005452.png

でもこうなっちゃうとやはりモヤモヤが残っています。同じSPAなのにhtmlとassetがバラバラですし、アセットたちが同じフォルダに混在してしまいますし、事情知らない人(運用者、後継者など)にとって迷惑でしかありません。

筆者としては、これ以上の解決策を見出せず結局AWSサポートに頼りました…
ここからはAWSサポートのご意見を基づいた回避策のご紹介です。

考え方

今の課題を一旦整理しましょう:二つのバケットにそれぞれSPAを配置し、CloudFront側でその中の一バケット(ここではPageAを含んだバケット)をデフォルトオリジンにし、もう一つのバケット(PageB)をadditional behavior(/pageb)として取り込みましたが、CloudFrontがうまくindex.html以外のバケット(PageB)のオブジェクトを取得てきていません。

Referer URI destination
PageA /(index.html) BucketA: ~/
PageA /assets/xxx.js BucketA: ~/assets
PageA /assets/xxx.css BucketA: ~/assets
PageB /pageb/index.html BucketB: ~/pageb/
PageB /assets/xxx.js BucketA: ~/assets
PageB /assets/xxx.css BucketA: ~/assets

テーブルが示している通り、結局バケット(PageB)にはリクエストが行きませんね。しかもPageAもPageBもアセット取得する時URIが一緒になっています。となると、

  • まず、バケット二つは要りません、どうせアセットが同じバケット内にないといけないから
  • PageAとPageBの区別がつくようにうまくURIを書き換えればいいかも?
  • つまりこうなると目指します:
Referer URI destination
PageA /(index.html) Bucket: ~/
PageA /assets/xxx.js Bucket: ~/assets
PageA /assets/xxx.css Bucket: ~/assets
PageB /pageb/index.html Bucket: ~/pageb/
PageB /pageb/assets/xxx.js Bucket: ~/pageb/assets
PageB /pageb/assets/xxx.css Bucket: ~/pageb/assets

方法

こうするために、CloudFront FunctionかLambda@Edgeが必要となります。ビューワーリクエストかオリジンリクエストで関数を配置し、Refererヘッダーに基づいてURIを随時書き換えれば、リクエストをほしいところに導くことができます!

本記事では、手軽さを取ってCloudFront Functionをビューワーリクエストで配置する方法にしましたが、Lambda@Edgeをオリジンリクエストに配置しても同様な効果が得られるはずです。

まずは以下のようにCloudFront Functionを書きます。RefererヘッダーがPageBである(正規表現で検証)のときに、URIの先頭に/pagebがついていることを確保します。

index.js
// cloudfront fn
function handler(event) {
  let request = event.request;
  const uri = request.uri;
  const headers = request.headers;

  const regex_pageb = /^https:\/\/.*\/pageb/;

  if (headers.referer && regex_pageb.test(headers.referer.value)) {
    if (!request.uri.startsWith("/pageb")) {
      request.uri = "/pageb" + request.uri;
    }
  }

  return request;
}

インフラ側でDistributionにCloudFront Functionをデフォルトオリジンにアタッチし、PageBの静的ファイルを~/pageb/にアップロードします。

multi_sp_ain_cloud_front-stack.ts
// ...
+   const cloudfrontfn = new Function(this, "cloudfront-fn", {
+     code: FunctionCode.fromFile({ filePath: "./cloudfrontfn/index.js" }),
+     runtime: FunctionRuntime.JS_2_0,
+   });

    const distribution = new Distribution(this, "distribution-multi-spa", {
      enableLogging: true,
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucketA),
        cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
+       functionAssociations: [
+         {
+           eventType: FunctionEventType.VIEWER_REQUEST,
+           function: cloudfrontfn,
+         },
+       ],
      },
-     additionalBehaviors: {
-       "/pageb*": {
-         origin: S3BucketOrigin.withOriginAccessControl(bucketB),
-         cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
-       },
-     },
    });

    new BucketDeployment(this, "deploy-pagea", {
      sources: [Source.asset("./pageA/dist")],
      destinationBucket: bucketA,
      distribution: distribution,
      distributionPaths: ["/*"],
    });

+   new BucketDeployment(this, "deploy-pageb", {
+     sources: [Source.asset("./pageB/dist")],
+     destinationKeyPrefix: "pageb/",
+     destinationBucket: bucketA,
+     distribution: distribution,
+     distributionPaths: ["/*"],
+   });

-   new BucketDeployment(this, "deploy-pageb", {
-     sources: [Source.asset("./pageB/dist")],
-     destinationBucket: bucketB,
-     distribution: distribution,
-     distributionPaths: ["/*"],
-   });
// ...

効果

デフォルトドメインにアクセスすると、PageAが従来通り表示できます。
スクリーンショット 2024-10-12 152547.png
/pageb/index.htmlにアクセスすると、PageBも表示できます!
スクリーンショット 2024-10-12 152602.png

もちろん、デフォルトオブジェクトを使わずにPageAへのアクセスも関数で制御するとか、PageBへのアクセスでindex.htmlをURIに足すとか、関数の導入でできることも結構あります。二つだけでなく、三つ、四つのSPAも同じバケット内に配置し、アクセスできるようになれるはずです。

最後に

シングルバケット+関数制御で複数SPAをシングルDistributionで配信することができました!が、以下のデメリットも考えられます:

  • 管理するリソースが増える:CloudFront Functionはまだしも、Lambda@Edgeはus-east-1に位置しないといけないので場合によっては望ましくないかもしれません(そのためにCDKでもう一つスタック作らないといけませんね)
  • URI処理自体がややこしい場合もある:CloudFront FunctionとLambda@Edgeでベーシック認証を配置したり、ヘッダーを書き換えたりする人もいるでしょう。こうなるとURI処理との干渉も考えないといけなくなり、処理自体が分かりにくくなる可能性もあります。筆者個人としても、CloudFront FunctionやLambda@Edgeに複雑な仕事を任せたくありません

また、バケットの静的ウェブサイトエンドポイントを作ってそのエンドポイントをDistributionのオリジンとして指定する手もあると思いますが、現時点ではややレガシーと考えられているので本記事では割愛します。

あくまで回避策ですが、お役立にてると嬉しいです!またこの課題に対してもっといいやり方あるよ!の人はぜひコメント欄で教えてください!

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?