CDKを利用してSPA用のCloudFront+S3環境を作った
SPAをCloudFront+S3構成の環境にデプロイする機会があったのでAWS CDKを活用してみました。
本記事ではCDKを利用した以下の内容について共有します。
- CloudFront+S3構成の静的ファイルホスティング環境を構築する
 - GitHub ActionsとAWSのOIDC連携用のIAM Roleを作成する
 
類似内容を検討している方の参考になれば幸いです。
CloudFront+S3構成の静的ファイルホスティング環境を構築する
静的ファイルのデプロイ先S3バケット作成
SANDBOX環境で実行する場合はremovalPolicyにRemovalPolicy.DESTROYを指定しておくと破棄が楽になると思います。
const sourceBucket = new s3.Bucket(this, "S3Bucket", {
      bucketName: "cdk-static-file-deploy",
      encryption: s3.BucketEncryption.S3_MANAGED,
      versioned: false,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      enforceSSL: true,
    });
CloudFront Distribution作成
アクセスログ収集先S3バケット作成
lifecycleRulesを設定して古いログはコストの低いStorageClassに移行しても良さそうです。
const cloudfrontLoggingBucket = new s3.Bucket(
      this,
      "CloudfrontLoggingBucket",
      {
        bucketName: "cdk-cloud-front-access-log",
        encryption: s3.BucketEncryption.S3_MANAGED,
        versioned: false,
        blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
        removalPolicy: cdk.RemovalPolicy.RETAIN,
        objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
        enforceSSL: true,
      }
    );
CloudFrontからS3のアクセス制御設定作成
利用の推奨がされているOAC(Origin Access Control)方式を採用します。L2コンストラクタが存在しないので、Issueを参考にしつつ作成します。
const originAccessControl = new cloudfront.CfnOriginAccessControl(
      this,
      "CloudFrontOAC",
      {
        originAccessControlConfig: {
          name: "OriginAccessControlForSourceBucket",
          originAccessControlOriginType: "s3",
          signingBehavior: "always",
          signingProtocol: "sigv4",
          description: "Origin access control provisioned by aws-cloudfront-s3",
        },
      }
    );
SPAリダイレクト用のCloudFront Functions作成
root path以外でリロード等をしても動作するようにします。
まずは関数の中身を別ファイルで定義します。
function handler(event) {
  var request = event.request;
  var requiresRedirect =
    request.method === "GET" && request.uri.indexOf(".") === -1;
  if (requiresRedirect) {
    request.uri = "/index.html";
  }
  return request;
}
上記ファイルを参照する形でCloudFront Functionsを作成します。
const cfFunctionForRedirect = new cloudfront.Function(
      this,
      "RedirectForSPA",
      {
        code: cloudfront.FunctionCode.fromFile({
          filePath: "./infrastructures/assets/cfFunctions/redirect.js",
        }),
      }
    );
レスポンスヘッダーポリシー作成
要件等に応じて設定します。
各項目の内容はMDNを確認してください。
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
      this,
      "ResponseHeadersPolicy",
      {
        securityHeadersBehavior: {
          contentTypeOptions: { override: true },
          frameOptions: {
            frameOption: cloudfront.HeadersFrameOption.DENY,
            override: true,
          },
          referrerPolicy: {
            referrerPolicy: cloudfront.HeadersReferrerPolicy.SAME_ORIGIN,
            override: true,
          },
          strictTransportSecurity: {
            accessControlMaxAge: cdk.Duration.seconds(63072000),
            includeSubdomains: true,
            preload: true,
            override: true,
          },
          xssProtection: {
            protection: true,
            modeBlock: true,
            override: true,
          },
        },
        customHeadersBehavior: {
          customHeaders: [
            {
              header: "Cache-Control",
              value: "no-cache",
              override: true,
            },
            {
              header: "pragma",
              value: "no-cache",
              override: true,
            },
            {
              header: "server",
              value: "",
              override: true,
            },
          ],
        },
      }
    );
Distribution作成
これまでに作成したリソースを組み合わせてdistributionを作ります
const distribution = new cloudfront.Distribution(this, "DistributionId", {
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: new cloudfront_origins.S3Origin(sourceBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        responseHeadersPolicy,
        functionAssociations: [
          {
            function: cfFunctionForRedirect,
            eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
          },
        ],
      },
      enableLogging: true,
      logBucket: cloudfrontLoggingBucket,
      logFilePrefix: "distribution-access-log/",
      logIncludesCookies: true,
    });
    const cfnDistribution = distribution.node
      .defaultChild as cloudfront.CfnDistribution;
    // OACの設定
    cfnDistribution.addPropertyOverride(
      "DistributionConfig.Origins.0.OriginAccessControlId",
      originAccessControl.getAtt("Id")
    );
    // Originのドメイン名の設定
    cfnDistribution.addPropertyOverride(
      "DistributionConfig.Origins.0.DomainName",
      sourceBucket.bucketRegionalDomainName
    );
    // デフォルトで付与されるOAIを削除
    cfnDistribution.addOverride(
      "Properties.DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity",
      ""
    );
    cfnDistribution.addPropertyDeletionOverride(
      "DistributionConfig.Origins.0.CustomOriginConfig"
    );
静的ファイルのデプロイ先S3バケットのバケットポリシー作成
CloudFront Distributionからのアクセスのみを許可します。
    const bucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [new iam.ServicePrincipal("cloudfront.amazonaws.com")],
      resources: [`${sourceBucket.bucketArn}/*`],
      conditions: {
        StringEquals: {
          "AWS:SourceArn": `arn:aws:cloudfront::${cdk.Aws.ACCOUNT_ID}:distribution/${distribution.distributionId}`,
        },
      },
    });
    sourceBucket.addToResourcePolicy(bucketPolicyStatement);
S3へファイルをアップロード
手動で別途アップロードも可能ですが、手間に感じるのでCDKで実施してます。
new s3_deployment.BucketDeployment(this, "S3Deployment", {
      sources: [s3_deployment.Source.asset("./build")],
      destinationBucket: sourceBucket,
      distribution,
      distributionPaths: ["/*"],
    });
GitHub ActionsとAWSのOIDC連携用のIAM Roleを作成する
引数でrepogitoryやbranchを指定できるようにしておきます。
アタッチするpolicyにAdministratorAccessを指定していますが、必要な権限に絞ったポリシーを別途用意して指定した方が望ましいです。
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export interface GithubActionsAwsAuthCdkStackProps extends cdk.StackProps {
  readonly repositoryConfig: { owner: string; repo: string; filter?: string }[];
}
export class GithubActionsOidcStack extends cdk.Stack {
  constructor(
    scope: Construct,
    id: string,
    props: GithubActionsAwsAuthCdkStackProps
  ) {
    super(scope, id, props);
    const iamRepoDeployAccess = props.repositoryConfig.map(
      (r) => `repo:${r.owner}/${r.repo}:${r.filter ?? "*"}`
    );
    const provider = new iam.OpenIdConnectProvider(
      this,
      "GithubActionsProvider",
      {
        url: "https://token.actions.githubusercontent.com",
        clientIds: ["sts.amazonaws.com"],
      }
    );
    const role = new iam.Role(this, "GithubDeployRole", {
      roleName: "GithubActionsDeployRole",
      maxSessionDuration: cdk.Duration.hours(2),
      assumedBy: new iam.WebIdentityPrincipal(
        provider.openIdConnectProviderArn,
        {
          StringEquals: {
            ["token.actions.githubusercontent.com:aud"]: "sts.amazonaws.com",
          },
          StringLike: {
            ["token.actions.githubusercontent.com:sub"]: iamRepoDeployAccess,
          },
        }
      ),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess"),
      ],
    });
    new cdk.CfnOutput(this, "GithubActionOidcIamRoleArn", {
      value: role.roleArn,
      description: `Arn for AWS IAM role with Github oidc auth for ${iamRepoDeployAccess}`,
      exportName: "GithubActionOidcIamRoleArn",
    });
  }
}
GitHub Actionsを利用してデプロイworkflowを作成
developブランチにPushされたら、最新のdevelopブランチの内容でデプロイさせます。
secretsの登録
以下を登録します。
| key | description | 
|---|---|
| AWS_ROLE_ARN | 上で作成したRoleのARN | 
| AWS_DEPLOY_BUCKET_NAME | 静的ファイルのデプロイ先S3バケット名 | 
| AWS_CLOUDFRONT_DISTRIBUTION_ID | CloudFront DistributionのID | 
workflow作成
OIDC連携しつつ、S3にbuild成果物をアップロードしてCloudFrontのキャッシュを削除します。
name: deploy
on:
  push:
  # 任意のブランチを指定してください
    branches: [develop]
defaults:
  run:
    shell: bash
env:
  DEPLOY_RESOURCE: ./build
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    environment:
      name: stg
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - name: build
        run: yarn build
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1
      - name: deploy
        run: aws s3 sync ${{ env.DEPLOY_RESOURCE }} s3://${{ secrets.AWS_DEPLOY_BUCKET_NAME }} --delete
      - name: clear cache
        run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"
最終的なコード
infrastructures/bin/cdk.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { CloudFrontToS3Stack } from "../stacks/cloudfront-s3";
import { GithubActionsOidcStack } from "../stacks/github-actions-oidc";
import { CONFIG } from "../constants";
const app = new cdk.App();
new CloudFrontToS3Stack(app, "CdkCloudFrontToS3Stack", {
  env: { account: CONFIG.STG.AWS.ACCOUNT_ID, region: CONFIG.STG.AWS.REGION },
});
new GithubActionsOidcStack(app, "CdkGithubActionsOidcStack", {
  env: { account: CONFIG.STG.AWS.ACCOUNT_ID, region: CONFIG.STG.AWS.REGION },
  repositoryConfig: [
    { owner: CONFIG.COMMON.GITHUB.OWNER, repo: CONFIG.COMMON.GITHUB.REPO },
  ],
});
infrastructures/stacks/cloudfront-s3.ts
import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3_deployment from "aws-cdk-lib/aws-s3-deployment";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as cloudfront_origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export class CloudFrontToS3Stack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    /**
     *  静的ファイルのデプロイ先S3バケット作成
     */
    const sourceBucket = new s3.Bucket(this, "S3Bucket", {
      bucketName: "cdk-static-file-deploy",
      encryption: s3.BucketEncryption.S3_MANAGED,
      versioned: false,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      enforceSSL: true,
    });
    /**
     *  アクセスログ収集先S3バケット作成
     */
    const cloudfrontLoggingBucket = new s3.Bucket(
      this,
      "CloudfrontLoggingBucket",
      {
        bucketName: "cdk-cloud-front-access-log",
        encryption: s3.BucketEncryption.S3_MANAGED,
        versioned: false,
        blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
        removalPolicy: cdk.RemovalPolicy.RETAIN,
        objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
        enforceSSL: true,
      }
    );
    /**
     * OAC作成
     * SEE: https://github.com/aws/aws-cdk/issues/21771
     */
    const originAccessControl = new cloudfront.CfnOriginAccessControl(
      this,
      "CloudFrontOAC",
      {
        originAccessControlConfig: {
          name: "OriginAccessControlForSourceBucket",
          originAccessControlOriginType: "s3",
          signingBehavior: "always",
          signingProtocol: "sigv4",
          description: "Origin access control provisioned by aws-cloudfront-s3",
        },
      }
    );
    /**
     * SPAリダイレクト用Cloudfront Functions作成
     */
    const cfFunctionForRedirect = new cloudfront.Function(
      this,
      "RedirectForSPA",
      {
        code: cloudfront.FunctionCode.fromFile({
          filePath: "./infrastructures/assets/cfFunctions/redirect.js",
        }),
      }
    );
    /**
     * レスポンスヘッダポリシー作成
     */
    const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
      this,
      "ResponseHeadersPolicy",
      {
        securityHeadersBehavior: {
          contentTypeOptions: { override: true },
          frameOptions: {
            frameOption: cloudfront.HeadersFrameOption.DENY,
            override: true,
          },
          referrerPolicy: {
            referrerPolicy: cloudfront.HeadersReferrerPolicy.SAME_ORIGIN,
            override: true,
          },
          strictTransportSecurity: {
            accessControlMaxAge: cdk.Duration.seconds(63072000),
            includeSubdomains: true,
            preload: true,
            override: true,
          },
          xssProtection: {
            protection: true,
            modeBlock: true,
            override: true,
          },
        },
        customHeadersBehavior: {
          customHeaders: [
            {
              header: "Cache-Control",
              value: "no-cache",
              override: true,
            },
            {
              header: "pragma",
              value: "no-cache",
              override: true,
            },
            {
              header: "server",
              value: "",
              override: true,
            },
          ],
        },
      }
    );
    /**
     * Cloudfront Distribution作成
     */
    const distribution = new cloudfront.Distribution(this, "DistributionId", {
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: new cloudfront_origins.S3Origin(sourceBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        responseHeadersPolicy,
        functionAssociations: [
          {
            function: cfFunctionForRedirect,
            eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
          },
        ],
      },
      enableLogging: true,
      logBucket: cloudfrontLoggingBucket,
      logFilePrefix: "distribution-access-log/",
      logIncludesCookies: true,
    });
    const cfnDistribution = distribution.node
      .defaultChild as cloudfront.CfnDistribution;
    // OACの設定
    cfnDistribution.addPropertyOverride(
      "DistributionConfig.Origins.0.OriginAccessControlId",
      originAccessControl.getAtt("Id")
    );
    // Originのドメイン名の設定
    cfnDistribution.addPropertyOverride(
      "DistributionConfig.Origins.0.DomainName",
      sourceBucket.bucketRegionalDomainName
    );
    // デフォルトで付与されるOAIを削除
    cfnDistribution.addOverride(
      "Properties.DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity",
      ""
    );
    cfnDistribution.addPropertyDeletionOverride(
      "DistributionConfig.Origins.0.CustomOriginConfig"
    );
    /**
     * S3バケットのバケットポリシー作成
     * 上で作成したCloudfront Distributionからのみアクセスを許可
     */
    const bucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [new iam.ServicePrincipal("cloudfront.amazonaws.com")],
      resources: [`${sourceBucket.bucketArn}/*`],
      conditions: {
        StringEquals: {
          "AWS:SourceArn": `arn:aws:cloudfront::${cdk.Aws.ACCOUNT_ID}:distribution/${distribution.distributionId}`,
        },
      },
    });
    sourceBucket.addToResourcePolicy(bucketPolicyStatement);
    /**
     *  S3へ静的ファイルをアップロード
     */
    new s3_deployment.BucketDeployment(this, "S3Deployment", {
      sources: [s3_deployment.Source.asset("./build")],
      destinationBucket: sourceBucket,
      distribution,
      distributionPaths: ["/*"],
    });
  }
}
infrastructures/stacks/github-actions-oidc.ts
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export interface GithubActionsAwsAuthCdkStackProps extends cdk.StackProps {
  readonly repositoryConfig: { owner: string; repo: string; filter?: string }[];
}
export class GithubActionsOidcStack extends cdk.Stack {
  constructor(
    scope: Construct,
    id: string,
    props: GithubActionsAwsAuthCdkStackProps
  ) {
    super(scope, id, props);
    const iamRepoDeployAccess = props.repositoryConfig.map(
      (r) => `repo:${r.owner}/${r.repo}:${r.filter ?? "*"}`
    );
    const provider = new iam.OpenIdConnectProvider(
      this,
      "GithubActionsProvider",
      {
        url: "https://token.actions.githubusercontent.com",
        clientIds: ["sts.amazonaws.com"],
      }
    );
    const role = new iam.Role(this, "GithubDeployRole", {
      roleName: "GithubActionsDeployRole",
      maxSessionDuration: cdk.Duration.hours(2),
      assumedBy: new iam.WebIdentityPrincipal(
        provider.openIdConnectProviderArn,
        {
          StringEquals: {
            ["token.actions.githubusercontent.com:aud"]: "sts.amazonaws.com",
          },
          StringLike: {
            ["token.actions.githubusercontent.com:sub"]: iamRepoDeployAccess,
          },
        }
      ),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess"),
      ],
    });
    new cdk.CfnOutput(this, "GithubActionOidcIamRoleArn", {
      value: role.roleArn,
      description: `Arn for AWS IAM role with Github oidc auth for ${iamRepoDeployAccess}`,
      exportName: "GithubActionOidcIamRoleArn",
    });
  }
}
infrastructures/assets/cfFunctions/redirect.js
/**
 * pathに拡張子がない場合、index.htmlにリダイレクトする
 */
function handler(event) {
  var request = event.request;
  var requiresRedirect =
    request.method === "GET" && request.uri.indexOf(".") === -1;
  if (requiresRedirect) {
    request.uri = "/index.html";
  }
  return request;
}
参考にした記事など
ユーザーファーストなサービスを伴に考えながらつくる、デザインとエンジニアリングの会社です。エンジニア積極採用中です!hrmos.co/pages/funteractive/jobs
Discussion