エンジニアのはしがき

プログラミングの日々の知見を書き連ねているブログです

CloudFront+S3構成で単一ドメインで複数のアプリを配信する

f:id:tansantktk:20211023151356p:plain

やりたいこと

同一ドメインで複数のSPAアプリケーション(Angular)を配信する要件があった為、今回下記の内容で実装をしました。

  • 単一のCloudFrontから複数のOrigin(S3 static website hosting)にアクセスを振り分ける。
  • Originへの振り分けルールはパスパターンで指定する。
  • Angularのルーティングが正しく動作するようにする。

前提条件

  • CloudFrontディストリビューションは作成済み。https配信できるよう代替ドメイン名(CNAME)を指定しておきます。
  • 今回は例としてhogefuga.comというドメインでアプリを配信し、以下のルールで配信するアプリを振り分けることとします。
    • https://hogefuga.com/angular-app1/でリクエストされた -> Angularアプリ1を配信
    • https://hogefuga.com/angular-app2/でリクエストされた -> Angularアプリ2を配信
    • https://hogefuga.com/angular-app3/でリクエストされた -> Angularアプリ3を配信

Angularのホスティング設定

Angularのビルド設定

  • ビルド時のコマンドng buildのオプションに--deploy-url /{パス名}/ --base-href /{パス名}/を付与します。
    • --deploy-urlでassetsフォルダを読み込む際のパスを指定します。この設定をしないと、AngularはCloudFrontに対して、パス無しでassetsフォルダの画像ファイル等にGETリクエストを試みてしまい、画像がロードされません。
    • --base-hrefでjs, cssファイルを読み込む際のルートとなるパスを指定します。この設定をしないと、AngularはCloudFrontに対して、パス無しでjs, cssファイルのGETリクエストを試みてしまい、うまくアプリが動作しません。

AngularをS3から配信する

各Angularアプリ毎にS3を作成し、static website hostingの機能を使ってホスティングさせます。

  • S3バケットを作成し、ビルドしたAngularのソースをアップロードする。必ずS3のルートにソースを配置する。
  • 「静的ウェブサイトホスティング」を有効にする。
  • ホスティングタイプ」は「静的ウェブサイトをホストする」を指定。
  • 「インデックスドキュメント」は「index.html」を指定。
  • 「エラードキュメント」は「index.html」を指定。
    • Angular等のSPAをホスティングする際に、ルーティングを正しく機能させるための設定です。
    • Angularでは実態のあるhtmlファイルはindex.htmlのみで、あとはjsでDOM要素を生成・除外することでページ遷移を表現します。その為ブラウザからルーティング用のパスを含めたURLでリクエストされると、S3には実態となるファイルが存在しないため、エラードキュメントを指定しないとエラーが返されてしまいます。
  • S3はCloudFront経由でのみアクセスするよう制限したいので、「ブロックパブリックアクセス」をオフにしてから、「バケットポリシー」にRefererヘッダでのアクセス制限を追記します。値はランダム文字列等の推測不可能なものを指定しておきましょう。この値は後で使います。

バケットポリシーの例

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowCloudFrontReadonlyAccess",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::{S3バケット名}/*",
            "Condition": {
                "StringLike": {
                    "aws:Referer": "{外部に推測されないような文字列}"
                }
            }
        }
    ]
}

CloudFrontの設定

Originを追加する

OriginはCloudFrontが配信する具体的なリソースを指します。S3だけでなく、EC2やALBなどのAWSのサービスも指定ができます。

  • AWSコンソールでCloudFrontのOriginを新たに作成。
  • 「オリジンドメイン」にはS3のstatic website hosting有効化時に生成されるバケットウェブサイトエンドポイントのドメインを指定します。
    • S3そのものドメインではないので注意。(かつてこの部分を勘違いしてドハマりしました)
  • S3で指定したバケットポリシーを満たす為、カスタムヘッダーとして「Referer」ヘッダを追加し、バケットポリシーで指定した値を指定します。これでCloudFrontからOrigin(S3)へリクエストする際にRefererヘッダが付与され、S3にアクセスできるようになります。
  • 同じ要領でS3の数だけOriginを追加していきます。

Behaviorを追加する

Behaviorではクライアントからのリクエストパスのパターンを判定してOriginへリクエストを振り分ける為の設定ができます。CloudFrontディストリビューションを作成した段階で「デフォルト(*)」というパスパターンがデフォルトで設定されているかと思います。

  • Behaviorを新たに作成。
  • パスパターンを指定します。「*」はワイルドカードです。例えば/spa-app1/*と指定するとリクエストパスが前方一致すると、次で指定するオリジンとオリジングループにリクエストが振り分けられます。
  • オリジンとオリジングループに先ほど追加したOriginを指定します。
  • ビューワープロトコルポリシーは「Redirect HTTP to HTTPS」を指定。
  • 許可されたHTTPメソッドは「GET, HEAD」を指定。
  • 同じ要領でOriginの数だけBehaviorを追加していきます。

エラーページは未指定にする

今回、S3側でAngularのルーティングを動作させる為のエラードキュメントの設定をしている為、CloudFrontでは何も指定しません。

Lambda@edgeでOriginへのリクエストパスを除外させる

この段階ではCloudFrontからOrigin(S3)へのアクセスが失敗する

ここまでの設定だと、クライアントからCloudFrontまでのリクエストは通るものの、CloudFrontからOrigin(S3)へのリクエストはパス付きで実施されてしまいます。 具体的には/angular-app1/******といった形のパスが付いた状態でOriginへリクエストされます。

すると、S3側はルートではなく/angular-app1という存在しないS3のフォルダを参照しようとしてしまい、エラーになってしまい、アプリが動作しないという問題が出てきます。

ここはかなり頭を悩ませましたが、Lambda@Edgeを使いCloudFrontからOriginへのリクエスト直前にパスを除去することで解決ができました。

Lambda@Edgeを作成する

Lambda@EdgeはCloudFrontで動作するLambdaで、CloudFrontへのリクエスト、レスポンスをトリガーに実行し、リクエストを加工したりリクエストに応じた処理をさせたりといったことができます。

  • まずはnode.jsのLambdaを新規作成します。Lambda@Edgeはバージニア北部リージョンで作成する必要があります。

チュートリアル: シンプルな Lambda@Edge 関数の作成 - Amazon CloudFront

  • 以下のソースコードをアップロードまたはAWSコンソール上から直接入力してデプロイします。
exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    // パスの内、一番最初のスラッシュとスラッシュで囲われた文字列だけを除外する。
    // "/angular-app1/admin/config-page/"といったパスならば"/admin/config-page/"に変換される。
    request.uri = request.uri.replace(/^\/[^\/]+\//,'/');
    return callback(null, request);
};
  • 新しいLambdaのバージョンを発行します。
  • 発行したバージョンのトリガーにCloudFrontを指定します。
  • トリガーの設定では、対象のCloudFrontディストリビューションを指定し、「キャッシュ動作」にはBehaviorで指定したパスパターンがリストアップされるのでまず1つを指定します。
  • 「Lambda@Edgeへのデプロイを確認」のチェックボックスにチェックを入れてから、トリガーを追加します。
  • 他のBehaviorのパスパターン分のトリガーも同様の手順で追加していきます。
    • このトリガー設定はCloudFrontのBehaviorの「関数の関連付け」から設定も可能です。

CloudFrontに設定が反映されるまで待つ

各種設定を変更したのでCloudFrontはデプロイ状態になっていると思います。最終変更日が「デプロイ」から日付に変わるまで数分かかるので待ちます。

CloudFrontのキャッシュを消す

CloudFrontはキャッシュがあればそれを使いまわしてクライアントへレスポンスするので、設定変更を即時反映したい場合はキャッシュ削除が必要になります。

  • 「キャッシュ削除を作成」し、オブジェクトパスには「/*」を指定してキャッシュ削除を実施します。
  • これでhttps://hogefuga.com/angular-app1/, https://hogefuga.com/angular-app2/, https://hogefuga.com/angular-app3/のそれぞれにアクセスすると、異なるAngularアプリへアクセスができるようになりました!

あとがき

CloudFrontは設定項目が多く恥ずかしながら漠然と使っていたところがあったので、仕様を理解する良い機会になりました。

今回Lambda@Edgeはパスを加工する為に利用しましたが、リクエスト、レスポンスをキャッチできるので外部からのアクセス分析や制御にも応用できそうですね。

参考

amazon web services - Multiple Cloudfront Origins with Behavior Path Redirection - Stack Overflow