👾

CDKを利用してSPA用のCloudFront+S3環境を作った

2024/03/21に公開

SPAをCloudFront+S3構成の環境にデプロイする機会があったのでAWS CDKを活用してみました。
本記事ではCDKを利用した以下の内容について共有します。

  • CloudFront+S3構成の静的ファイルホスティング環境を構築する
  • GitHub ActionsとAWSのOIDC連携用のIAM Roleを作成する

類似内容を検討している方の参考になれば幸いです。

CloudFront+S3構成の静的ファイルホスティング環境を構築する

静的ファイルのデプロイ先S3バケット作成

SANDBOX環境で実行する場合はremovalPolicyRemovalPolicy.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以外でリロード等をしても動作するようにします。
まずは関数の中身を別ファイルで定義します。

./infrastructures/assets/cfFunctions/redirect.js
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;
}

参考にした記事など

https://qiita.com/ta__k0/items/bd700a074c394aa4d6f4
https://dev.classmethod.jp/articles/cdk-githubactions-oidc-iam-role/
https://github.com/awslabs/aws-solutions-constructs/tree/main/source/patterns/%40aws-solutions-constructs/aws-cloudfront-s3
https://www.m3tech.blog/entry/2023/06/13/110000

ファンタラクティブテックブログ

Discussion