こうこく
作 ▸

AWS サーバーレスなSPAを単一ドメインで構築する (CloudFront, S3, API Gateway)

後述するけどいろいろ注意点があるので、それを理解したうえでやること。

掲載してるのはCDK (TypeScript) だけど、作法はほぼCfnで書いてるので、CDKを使わない人でも読んでもらえれば内容はわかると思う。

aws-cdk-lib 2.180.0
もくじ

概要

AWS構成図

単一のCloudFrontディストリビューションの後ろに、S3とAPI Gatewayを配置する。

S3に画面SPAの静的ファイルを配置し、API GatewayのREST APIでバックエンドAPIを作る。バックエンドAPIのパスは /api/* とする。

SPAをホストするには index.html へのアクセスの集約が必要になるが、そこはCloudFront FunctionsでURLをリライトして実現する。同様に、バックエンドAPIのパス /api/* も、オリジンのAPI Gatewayのパスに合わせるためにリライトする。

それと上記の構成図には書いてないけど、CloudFrontで独自ドメインを使うためにACMとRoute53も使う。ACMの証明書はリージョン us-east-1 で作成すること。

なお、ここではAPI GatewayはREST APIを使ってるが、HTTP APIでも同じ構成ができることは確認済み。その場合はAPIのURLにステージ名が含まれなくなるので、CloudFrontのオリジンパスの指定が不要になる。

注意点

この記事で書いてる構成には以下の注意点がある。気になる場合は適宜なんとかしてほしい。

  • CloudFrontの後ろにAPI Gatewayを配置してるので、CloudFrontでのエラー時は、APIに対するレスポンスボディもHTMLで返ってきてしまう。
  • 同上の理由で、CloudFrontにWAFを設定した場合は、WAFで弾かれた時のAPIに対するレスポンスボディもHTMLで返ってきてしまう。
  • 画面用のリライトルールは、リクエストされたURLが静的ファイルっぽければリライトせずに通してるが、URLの拡張子の有無だけでそれを判定してしまってる。
  • API Gatewayのデフォルトエンドポイントへの直アクセスは、特に制限してない。
  • API GatewayはREST APIを使ってるが、もし実際のCloudFormationにREST APIのデプロイメントを書く場合は慎重に。初回しかデプロイメントが作成されず、2回目以降の実行時もデプロイメントが置き換えられない挙動になる。
  • CloudFrontのキャッシュ設定は適当。一応、APIのキャッシュだけは無効にしてる。

CDKのコード

bin/s3-apigateway-spa-cloudfront.ts
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';

import { S3ApiGatewaySpaCloudFrontStack, STACK_NAME } from './stack';

const app = new cdk.App();

new S3ApiGatewaySpaCloudFrontStack(app, STACK_NAME);
lib/s3-apigateway-spa-cloudfront-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';

export const PREFIX = 's3-apigateway-spa-cloudfront';
export const STACK_NAME = `${PREFIX}--stack`;

/** CloudFrontのエイリアス用のホストゾーンID (固定) */
const CLOUD_FRONT_ALIAS_TARGET_HOSTED_ZONE_ID = 'Z2FDTNDATAQYW2';

/** ドメイン名 */
const DOMAIN_NAME = 'example.com';

/** ドメインのパブリックホストゾーンID */
const DOMAIN_PUBLIC_HOSTED_ZONE_ID = 'Z99999999999999';

/** ACMの証明書ARN (us-east-1) */
const CERTIFICATE_ARN = 'arn:aws:acm:us-east-1:999999999999:certificate/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';

/**
 * CloudFront + S3 + API Gateway (REST API) でSPA
 */
export class S3ApiGatewaySpaCloudFrontStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ----------------------------------------
    // バックエンドAPI
    // ----------------------------------------

    // Lambda関数名
    const functionName = `${PREFIX}--func`;

    // Lambda実行ロール
    const lambdaExecutionRole = new iam.CfnRole(this, 'LambdaExecutionRole', {
      assumeRolePolicyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Effect: 'Allow',
            Principal: { Service: ['lambda.amazonaws.com'] },
            Action: ['sts:AssumeRole'],
          },
        ],
      },
      policies: [
        {
          policyName: 'LambdaBasicExecutionRole',
          policyDocument: {
            Version: '2012-10-17',
            Statement: [
              {
                Sid: 'AllowCreateLogGroup',
                Effect: 'Allow',
                Action: 'logs:CreateLogGroup',
                Resource: `arn:aws:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:*`,
              },
              {
                Sid: 'AllowPutLogEvents',
                Effect: 'Allow',
                Action: ['logs:CreateLogStream', 'logs:PutLogEvents'],
                Resource: [`arn:aws:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/lambda/${functionName}:*`],
              },
            ],
          },
        },
      ],
    });

    // Lambda関数 (乱数を返すだけ)
    const lambdaFunction = new lambda.CfnFunction(this, 'Function', {
      functionName,
      runtime: 'nodejs20.x',
      handler: 'index.handler',
      memorySize: 128,
      timeout: 3,
      role: lambdaExecutionRole.attrArn,
      architectures: ['x86_64'],
      code: {
        zipFile: `exports.handler = async (event) => {
      return {
        statusCode: 200,
        headers: {
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        },
        body: JSON.stringify(String(Math.random())),
      };
    };`,
      },
    });

    // REST API
    const restApi = new apigateway.CfnRestApi(this, 'RestApi', {
      name: `${PREFIX}--rest-api`,
      description: `${PREFIX}のAPI`,
      endpointConfiguration: {
        types: ['REGIONAL'],
      },
    });

    // REST APIのルート: GET /hello
    const helloResource = new apigateway.CfnResource(this, 'RestApiHelloResource', {
      restApiId: restApi.ref,
      parentId: restApi.attrRootResourceId,
      pathPart: 'hello',
    });
    const helloGetMethod = new apigateway.CfnMethod(this, 'RestApiHelloMethodGet', {
      restApiId: restApi.ref,
      resourceId: helloResource.ref,
      httpMethod: 'GET',
      authorizationType: 'NONE',
      integration: {
        type: 'AWS_PROXY',
        integrationHttpMethod: 'POST', // Lambdaプロキシ統合の場合、ここはPOST固定
        passthroughBehavior: 'WHEN_NO_TEMPLATES',
        integrationResponses: [{ statusCode: '200' }],
        uri: `arn:aws:apigateway:${cdk.Aws.REGION}:lambda:path/2015-03-31/functions/${lambdaFunction.attrArn}/invocations`,
      },
    });

    // REST APIのLambda関数のパーミッション
    new lambda.CfnPermission(this, 'PermissionForRestApiHello', {
      action: 'lambda:InvokeFunction',
      functionName: lambdaFunction.attrArn,
      principal: 'apigateway.amazonaws.com',
      sourceArn: `arn:aws:execute-api:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${restApi.ref}/*/*/hello`,
    });

    // REST APIのデプロイメント
    const deployment = new apigateway.CfnDeployment(this, 'RestApiDeployment', {
      restApiId: restApi.ref,
    });
    deployment.addDependency(helloGetMethod);

    // REST APIのステージ (ここでは名称は "default")
    new apigateway.CfnStage(this, 'RestApiDefaultStage', {
      restApiId: restApi.ref,
      stageName: 'default',
      deploymentId: deployment.ref,
    });

    // ----------------------------------------
    // 画面
    // ----------------------------------------

    // Webサイト用S3バケット
    const webBucket = new s3.CfnBucket(this, 'WebBucket', {
      bucketName: `${PREFIX}-web-bucket`,
      publicAccessBlockConfiguration: {
        blockPublicAcls: true,
        ignorePublicAcls: true,
        blockPublicPolicy: true,
        restrictPublicBuckets: true,
      },
    });

    // ※バケットポリシーの設定は↓でやってる

    // ----------------------------------------
    // CloudFront
    // ----------------------------------------

    // CloudFront OAC
    const oac = new cloudfront.CfnOriginAccessControl(this, 'WebBucketOAC', {
      originAccessControlConfig: {
        name: `${PREFIX}-oac`,
        signingBehavior: 'always',
        signingProtocol: 'sigv4',
        originAccessControlOriginType: 's3',
      },
    });

    // 画面用のオリジンリクエストポリシー
    const webOriginRequestPolicy = new cloudfront.CfnOriginRequestPolicy(this, 'WebOriginRequestPolicy', {
      originRequestPolicyConfig: {
        name: `${PREFIX}-web-orp`,
        headersConfig: {
          headerBehavior: 'none',
        },
        cookiesConfig: {
          cookieBehavior: 'none',
        },
        queryStringsConfig: {
          queryStringBehavior: 'none',
        },
      },
    });

    // バックエンドAPI用のオリジンリクエストポリシー
    const apiOriginRequestPolicy = new cloudfront.CfnOriginRequestPolicy(this, 'ApiOriginRequestPolicy', {
      originRequestPolicyConfig: {
        name: `${PREFIX}-api-orp`,
        headersConfig: {
          // ※API Gatewayをオリジンにするときは、Hostヘッダを転送しないこと。
          //   API GatewayがHostヘッダを見てルーティングしてるのを邪魔してしまうから。
          headerBehavior: 'allExcept',
          headers: ['host'],
        },
        cookiesConfig: {
          cookieBehavior: 'none',
        },
        queryStringsConfig: {
          // ここではクエリは全て通してる
          queryStringBehavior: 'all',
        },
      },
    });

    // 画面用のレスポンスヘッダーポリシー
    const webResponseHeaderPolicy = new cloudfront.CfnResponseHeadersPolicy(this, 'WebResponseHeadersPolicy', {
      responseHeadersPolicyConfig: {
        name: `${PREFIX}-web-rpc`,
        securityHeadersConfig: {
          frameOptions: {
            frameOption: 'DENY',
            override: true,
          },
        },
      },
    });

    // CloudFront Function (バックエンドAPIビューワーリクエスト用)
    const restApiViewerRequestCfFunction = new cloudfront.Function(this, 'RestApiViewerRequestCfFunction', {
      functionName: `${PREFIX}-rest-api-viewer-request`,
      runtime: cloudfront.FunctionRuntime.JS_2_0,
      code: cloudfront.FunctionCode.fromInline(`async function handler(event) {
  const request = event.request;

  // リクエストされたパスから先頭の "/api/" を除去
  request.uri = request.uri.replace(/^\\/api\\//, '/');
  return request;
}`),
    });

    // CloudFront Function (画面ビューワーリクエスト用)
    const webViewerRequestCfFunction = new cloudfront.Function(this, 'WebViewerRequestCfFunction', {
      runtime: cloudfront.FunctionRuntime.JS_2_0,
      code: cloudfront.FunctionCode.fromInline(`async function handler(event) {
  const request = event.request;

  // リクエストされたパスに拡張子が無さそうなら "/index.html" にリライト
  // ※拡張子の有無で静的ファイルを判定してるので、拡張子が無い静的ファイルは扱えない
  const lastPart = request.uri.split('/').pop();
  if (!lastPart.includes('.')) {
    request.uri = '/index.html';
  }
  return request;
}`),
    });

    // CloudFrontディストリビューション
    const cloudfrontDistribution = new cloudfront.CfnDistribution(this, 'WebDistribution', {
      distributionConfig: {
        origins: [
          {
            id: 'WebBucket',
            // バケットがus-east-1に行きわたるまで待たなくていいように、現在のリージョンでドメイン指定
            domainName: `${webBucket.bucketName}.s3-${cdk.Aws.REGION}.amazonaws.com`,
            originAccessControlId: oac.attrId,
            s3OriginConfig: {
              // 設定するものは無いが書かなきゃエラーになる
              originAccessIdentity: '',
            },
          },
          {
            id: 'RestApi',
            domainName: `${restApi.attrRestApiId}.execute-api.${cdk.Aws.REGION}.amazonaws.com`,
            originPath: '/default', // REST APIのステージ名と合わせる (HTTP APIを使う場合は不要)
            customOriginConfig: {
              originProtocolPolicy: 'https-only',
              originSslProtocols: ['TLSv1.2'],
            },
          },
        ],
        defaultCacheBehavior: {
          // 画面用キャッシュ動作
          targetOriginId: 'WebBucket',
          compress: true,
          viewerProtocolPolicy: 'redirect-to-https', // http -> httpsにリダイレクト
          allowedMethods: ['GET', 'HEAD'],
          cachedMethods: ['GET', 'HEAD'],
          cachePolicyId: cloudfront.CachePolicy.CACHING_OPTIMIZED.cachePolicyId,
          originRequestPolicyId: webOriginRequestPolicy.attrId,
          responseHeadersPolicyId: webResponseHeaderPolicy.attrId,
          functionAssociations: [
            {
              eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
              functionArn: webViewerRequestCfFunction.functionArn,
            },
          ],
        },
        cacheBehaviors: [
          {
            // バックエンドAPI用キャッシュ動作
            pathPattern: '/api/*',
            targetOriginId: 'RestApi',
            viewerProtocolPolicy: 'https-only',
            allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH'],
            cachePolicyId: cloudfront.CachePolicy.CACHING_DISABLED.cachePolicyId,
            originRequestPolicyId: apiOriginRequestPolicy.attrId,
            functionAssociations: [
              {
                eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
                functionArn: restApiViewerRequestCfFunction.functionArn,
              },
            ],
          },
        ],
        aliases: [DOMAIN_NAME],
        viewerCertificate: {
          acmCertificateArn: CERTIFICATE_ARN,
          sslSupportMethod: 'sni-only',
          minimumProtocolVersion: 'TLSv1.2_2021',
        },
        httpVersion: 'http2',
        defaultRootObject: 'index.html',
        customErrorResponses: [
          // 「見つからなければ index.html を返す」は、ここではなくCloudFront Functionsでやる。
          // ここでやるとAPI Gatewayの4XXエラーも index.html に飛ばしてしまうので都合が悪い。
        ],
        ipv6Enabled: true,
        enabled: true,
        comment: `${PREFIX}-distribution`,
      },
    });

    // Webサイト用S3バケットポリシー
    // ※CloudFrontのディストリビューションIDが必要なのでここでやってる
    new s3.CfnBucketPolicy(this, 'WebBucketPolicy', {
      bucket: webBucket.bucketName as string,
      policyDocument: {
        Version: '2012-10-17',
        Statement: [
          // OACでのアクセス用
          {
            Sid: 'AllowCloudFrontServicePrincipalReadOnly',
            Effect: 'Allow',
            Principal: {
              Service: 'cloudfront.amazonaws.com',
            },
            Action: 's3:GetObject',
            Resource: `arn:aws:s3:::${webBucket.bucketName}/*`,
            Condition: {
              StringEquals: {
                'AWS:SourceArn': `arn:aws:cloudfront::${cdk.Aws.ACCOUNT_ID}:distribution/${cloudfrontDistribution.attrId}`,
              },
            },
          },
        ],
      },
    });

    // CloudFront宛のエイリアス
    new route53.CfnRecordSet(this, 'AliasToCloudFront', {
      hostedZoneId: DOMAIN_PUBLIC_HOSTED_ZONE_ID,
      name: DOMAIN_NAME,
      type: 'A',
      aliasTarget: {
        dnsName: cloudfrontDistribution.attrDomainName,
        hostedZoneId: CLOUD_FRONT_ALIAS_TARGET_HOSTED_ZONE_ID,
      },
    });
  }
}

なお、CDK内にインラインで書いてるリライト用のCloudFront Functionsを抜粋したものは以下。

バックエンドAPI用のリライト
async function handler(event) {
  const request = event.request;

  // リクエストされたパスから先頭の "/api/" を除去
  request.uri = request.uri.replace(/^\/api\//, '/');
  return request;
}
画面用のリライト
async function handler(event) {
  const request = event.request;

  // リクエストされたパスに拡張子が無さそうなら "/index.html" にリライト
  // ※拡張子の有無で静的ファイルを判定してるので、拡張子が無い静的ファイルは扱えない
  const lastPart = request.uri.split('/').pop();
  if (!lastPart.includes('.')) {
    request.uri = '/index.html';
  }
  return request;
}

動作確認

S3バケットに、こんな感じのファイルを配置する。ただのHTMLとJavaScript。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="landscape" content="width=device-width, initial-scale=1.0" />
    <meta name="format-detection" content="telephone=no" />
    <title>サンプル</title>
  </head>
  <body>
    <h1>サンプル</h1>
    <p id="result"></p>

    <script src="/script.js"></script>
  </body>
</html>
script.js
fetch('https://example.com/api/hello')
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
    document.getElementById('result').textContent = `response: ${data}`;
  });

そしたら、以下を確認できるはず。

  • CloudFrontに設定したドメイン (ここでは https://example.com) にアクセスして、画面の内容と、そこからfetchしてるバックエンドAPIのレスポンスが表示されること。
  • 同ドメインの存在しないパス (https://example.com/piyopiyo とか) にアクセスして、同じ内容が表示されること。

以上。

この記事に何かあればこちらまで (非公開)