LoginSignup
0
0

More than 1 year has passed since last update.

AWS CDK v2を使ってSPA用のCloudFrontをデプロイしたメモ

Last updated at Posted at 2022-03-05

概要

cdk v2で、S3 + cloud frontの構成でSPAをデプロイしたのでメモ。
v1のときと、デフォルト値が変わったものがちらほら存在したのでまとめる。
エラーのリダイレクトを使ってSPAのルーティングを以前は実現していたが、Functionsを使ってルーティングを行うように変更した。

2023/05/20 追記 deprecatedのクラスを使っている。修正版はこちら

環境

  • Windows 10
  • node v16.14.0
  • aws-cdk: 2.15.0
  • aws-cdk-lib: 2.15.0
  • constructs: 10.0.79

フォルダ構造

- cdk
  - cdk.json
  - package.json
- client
  + build
  + src

CDKの設定確認

S3

CloudFront用ファイル置き場。

cdk-docs

設定項目 設定値 解説
blockPublicAccess BLOCK_ALL パブリックアクセスはできないようにしておく
removalPolicy DESTROY スタック削除したときにゴミが残らないようにしておく
autoDeleteObjects true スタック削除したときにゴミが残らないようにしておく(removalPolicyのみだと空でないときにBucketNotEmpty;のエラーが発生

CloudFront経由でのみアクセス可能にする設定

オリジンアクセスアイデンティティ(OAI)
cdk-docs
S3への制限

ポリシー
オリジンアクセスアイデンティティ (OAI) を使用して Amazon S3 コンテンツへのアクセスを制限する
OAI

プロパティ 設定値 説明
effect Allow
actions ['s3:GetObject', 's3:ListBucket']
principals [CanonicalUserPrincipal(作成したOAIのUserId)]
resources [bucket.bucketArn + '/*', bucket.bucketArn]

CloudFront

CloudFrontDistribution設定

プロパティ 設定値 デフォルト値 説明
defaultRootObject /index.html オリジンのルート
priceClass 200 100 日本は200~。*
minimumProtocolVersion - TLS_V1_2_2021 TLSバージョン
domainNames ドメイン` ランダム値 今回はfreenomで取得したドメイン
certificate 後述の証明書
defaultBehavior 後述

証明書

プロパティ 設定値 デフォルト値 説明
domainName ドメイン ドメイン
hostedZone route53(ルートドメイン) ルートドメインを指定
region us-east-1 固定値
validation fromDns fromEmail

デフォルトビヘイビア設定

プロパティ 設定値 デフォルト値 説明
origin OAIを指定 S3オリジン
allowedMethods - ALLOW_GET_HEAD
cachedMethods - CACHE_GET_HEAD
comporess - true gzip圧縮3点セット
viewerProtocolPolicy REDIRECT_TO_HTTPS
cachePolicy 後述
functionAssociations VIEWER_REQUESTに以下JSを設定 SPA用のパス書き換え。index.htmlへのリダイレクトを行う
// ルーティング用Function
// 拡張子が含まれないURLはSPAファイルにリダイレクト
      function handler(event) {
        var request = event.request;
        if(request.method === 'GET' && !request.uri.includes('.')){
          request.uri = '/index.html';
        }
        return request;
      }

SPA用ルーティング設定にFunctionを利用する。*

参考
公式
SPA を AWS CloudFront で動かすときにカスタムエラーレスポンスを使わない
AWS CloudFront FunctionsでSPAのルーティング処理

デフォルトキャッシュポリシー

プロパティ 設定値 デフォルト値 説明
defaultTtl - 1日 クライアント レスポンスで未指定の場合のTTL
minTtl - 0秒 クライアントレスポンスでno-cacheのときに上書きするTTL
maxTtl - 1年 クライアント レスポンスでmaxTtl以上のExpiresが指定されたときに上書きする
cookiBehavior - none 自動でcookieを含めるか。取りうる値
headerBehavior - none なし。ヘッダーをオリジンに転送し、ヘッダの値に基づいてデバイスの種類などでキャッシュを切り替える設定。
queryStringBehavior - none ?param=valueに応じてキャッシュを切り替える設定。
enableAcceptEncodingGzip true false gzip圧縮3点セット
enableAcceptEncodingBrotli true false gzip圧縮3点セット

参考
参考
qiita - CloudFront の Cache Policy と Origin Request Policy を理解する
aws - 署名付き Cookie の使用
aws - リクエストヘッダーに基づくコンテンツのキャッシュ
aws - コンテンツがキャッシュに保持される期間 (有効期限) の管理
aws-圧縮ファイルの供給

ファイル配置設定

ファイルデプロイ設定

設定項目 設定値 解説
sources ../client/build デプロイ元フォルダ
destinationBucket 作成したS3
distribution 作成したCloudFront
distributionPaths /デプロイ先フォルダ/* 有効期限前にキャッシュの削除。月ごとに一定数まで無料。*
destinationKeyPrefix デプロイ先フォルダ デプロイ先フォルダ

フォルダ構成(再)

- cdk
  - package.json
- client
  + build
  + src

cdk - BucketDeployment

ルーティング設定

route53でAレコードとAaaaレコードを指定。

プロパティ 設定値 デフォルト値 説明
recordName ドメイン
target 今回作成のCloudFront

ソースコード

cdk/package.json
{
  "name": "cdk",
  "version": "0.1.0",
  "bin": {
    "cdk": "bin/cdk.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk",
    "pipeline-deploy": "cdk deploy --require-approval never --all",
    "deploy": "cdk deploy"
  },
  "devDependencies": {
    "@types/jest": "^27.4.1",
    "@types/node": "17.0.21",
    "aws-cdk": "2.15.0",
    "jest": "^27.5.1",
    "ts-jest": "^27.1.3",
    "ts-node": "^10.6.0",
    "typescript": "~4.6.2"
  },
  "dependencies": {
    "aws-cdk-lib": "2.15.0",
    "constructs": "^10.0.79",
    "dotenv": "^16.0.0",
    "source-map-support": "^0.5.21"
  }
}
cdk/cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
  "watch": {
    "include": [
      "**"
    ],
    "exclude": [
      "README.md",
      "cdk*.json",
      "**/*.d.ts",
      "**/*.js",
      "tsconfig.json",
      "package*.json",
      "yarn.lock",
      "node_modules",
      "test"
    ]
  },
  "context": {
    "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
    "@aws-cdk/core:stackRelativeExports": true,
    "@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
    "@aws-cdk/aws-lambda:recognizeVersionProps": true,
    "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
    "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
    "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
    "@aws-cdk/core:target-partitions": [
      "aws",
      "aws-cn"
    ]
  }
}
cdk/.env
PROJECT_ID=hogefuga
ROOT_DOMAIN=hoge.example.com
DEPLOY_DOMAIN=www.hogexxx.example.com
TAG_PROJECT_NAME=hogehoge
cdk/bin/cdk.ts
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { SPAClientStack } from '../lib/cdk-stack'
import * as dotenv from 'dotenv'

dotenv.config()
const envList = [
  'PROJECT_ID',
  'ROOT_DOMAIN',
  'DEPLOY_DOMAIN',
  'TAG_PROJECT_NAME',
] as const
for (const key of envList) {
  if (!process.env[key]) throw new Error(`please add ${key} to .env`)
}
const processEnv = process.env as Record<typeof envList[number], string>

const app = new cdk.App()
const env = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
}
const projectId = processEnv.PROJECT_ID

new SPAClientStack (app, `${projectId}-stack`, {
  bucketName: `${projectId}-s3-bucket`,
  identityName: `${projectId}-origin-access-identity-to-s3-bucket`,
  defaultCachePolicyName: `${projectId}-cache-policy-default`,
  routingFunctionName: `${projectId}-spa-routing-function`,
  distributionName: `${projectId}-distribution-cloudfront`,
  rootDomain: processEnv.ROOT_DOMAIN,
  deployDomain: processEnv.DEPLOY_DOMAIN,
  projectNameTag: processEnv.TAG_PROJECT_NAME,
  env,
})
cdk/constants/paths.ts
export const basePath = 'deploy_dist_hogehoge'
cdk/lib/cdk-stack.ts
import * as core from 'aws-cdk-lib'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cf from 'aws-cdk-lib/aws-cloudfront'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'
import * as certManager from 'aws-cdk-lib/aws-certificatemanager'
import { Construct } from 'constructs'
interface Props extends core.StackProps {
  bucketName: string
  identityName: string
  defaultCachePolicyName: string
  distributionName: string
  routingFunctionName: string
  rootDomain: string
  deployDomain: string
  projectNameTag: string
}
import { basePath } from '../constants/paths'

export class SPAClientStack extends core.Stack {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id, props)
    // CloudFront オリジン用のS3バケットを作成
    const bucket = this.createS3(props.bucketName)
    // CloudFront で設定する オリジンアクセスアイデンティティ を作成
    const identity = this.createIdentity(bucket, props.identityName)
    // S3バケットポリシーで、CloudFrontのオリジンアクセスアイデンティティを許可
    this.createPolicy(bucket, identity)

    const zone = this.findRoute53HostedZone(props.rootDomain)
    const cert = this.createTLSCertificate(props.deployDomain, zone)
    // CloudFrontディストリビューションを作成
    const distribution = this.createCloudFront(
      bucket,
      identity,
      cert,
      props.defaultCachePolicyName,
      props.distributionName,
      props.deployDomain,
      props.routingFunctionName,
    )
    // 指定したディレクトリをデプロイ
    this.deployS3(bucket, distribution, '../client/build', props.bucketName)

    // route53 の CloudFrontに紐づくレコード作成
    this.addRoute53Records(zone, props.deployDomain, distribution)

    // 確認用にCloudFrontのURLに整形して出力
    new core.CfnOutput(this, `${props.distributionName}-top-url`, {
      value: `https://${distribution.distributionDomainName}/`,
    })

    core.Tags.of(this).add('Project', props.projectNameTag)
  }

  private createS3(bucketName: string) {
    const bucket = new s3.Bucket(this, bucketName, {
      bucketName,
      accessControl: s3.BucketAccessControl.PRIVATE,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: core.RemovalPolicy.DESTROY,
    })
    return bucket
  }

  private createIdentity(bucket: s3.Bucket, identityName: string) {
    const identity = new cf.OriginAccessIdentity(this, identityName, {
      comment: `${bucket.bucketName} access identity`,
    })
    return identity
  }

  private createPolicy(bucket: s3.Bucket, identity: cf.OriginAccessIdentity) {
    const myBucketPolicy = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:GetObject', 's3:ListBucket'],
      principals: [
        new iam.CanonicalUserPrincipal(
          identity.cloudFrontOriginAccessIdentityS3CanonicalUserId,
        ),
      ],
      resources: [bucket.bucketArn + '/*', bucket.bucketArn],
    })
    bucket.addToResourcePolicy(myBucketPolicy)
  }

  private createCloudFront(
    bucket: s3.Bucket,
    identity: cf.OriginAccessIdentity,
    cert: certManager.DnsValidatedCertificate,
    defaultCachePolicyName: string,
    distributionName: string,
    deployDomain: string,
    routingFunctionName: string,
  ) {
    const defaultPolicyOption = {
      cachePolicyName: defaultCachePolicyName,
      comment: 'gzip有効化ポリシー',
      // defaultTtl: core.Duration.days(2),
      // cookieBehavior: cf.CacheCookieBehavior.all(),
      enableAcceptEncodingGzip: true,
      enableAcceptEncodingBrotli: true,
    }
    const myCachePolicy = new cf.CachePolicy(
      this,
      defaultCachePolicyName,
      defaultPolicyOption,
    )

    const origin = new origins.S3Origin(bucket, {
      originAccessIdentity: identity,
    })

    const spaRoutingFunction = new cf.Function(this, 'SpaRoutingFunction', {
      functionName: routingFunctionName,
      // 拡張子が含まれないURLはSPAファイルにリダイレクト
      code: cf.FunctionCode.fromInline(`
      function handler(event) {
        var request = event.request;
        if(request.method === 'GET' && !request.uri.includes('.')){
          request.uri = '/index.html';
        }
        return request;
      }
      `),
    })

    const d = new cf.Distribution(this, distributionName, {
      defaultRootObject: '/index.html',

      priceClass: cf.PriceClass.PRICE_CLASS_200,
      defaultBehavior: {
        origin,
        cachePolicy: myCachePolicy,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        functionAssociations: [
          {
            eventType: cf.FunctionEventType.VIEWER_REQUEST,
            function: spaRoutingFunction,
          },
        ],
      },
      // SPAの向け先をオリジンがエラーレスポンスを返すときに変更していたが、Functionで出来るようになったので移行
      // errorResponses: [
      //   {
      //     httpStatus: 404,
      //     responseHttpStatus: 200,
      //     responsePagePath: '/index.html',
      //     ttl: core.Duration.seconds(0),
      //   },
      //   {
      //     httpStatus: 403,
      //     responseHttpStatus: 200,
      //     responsePagePath: '/index.html',
      //     ttl: core.Duration.seconds(0),
      //   },
      // ],

      // Route53と連携するためのカスタムドメイン
      certificate: cert,
      domainNames: [deployDomain],
    })
    core.Tags.of(d).add('Service', 'Cloud Front')

    return d
  }

  private deployS3(
    siteBucket: s3.Bucket,
    distribution: cf.Distribution,
    sourcePath: string,
    bucketName: string,
  ) {
    new s3deploy.BucketDeployment(
      this,
      `${bucketName}-deploy-with-invalidation`,
      {
        sources: [s3deploy.Source.asset(sourcePath)],
        destinationBucket: siteBucket,
        distribution,
        distributionPaths: [`/${basePath}/*`],
        destinationKeyPrefix: basePath,
      },
    )
  }

  private findRoute53HostedZone(rootDomain: string) {
    return route53.HostedZone.fromLookup(this, `${rootDomain}-hosted-zone`, {
      domainName: rootDomain,
    })
  }

  private createTLSCertificate(
    deployDomain: string,
    hostedZone: route53.IHostedZone,
  ) {
    return new certManager.DnsValidatedCertificate(
      this,
      `${deployDomain}-certificate`,
      {
        domainName: deployDomain,
        hostedZone, // DNS 認証に Route 53 のホストゾーンを使う
        region: 'us-east-1', // 必ず us-east-1 リージョン
        validation: certManager.CertificateValidation.fromDns(),
      },
    )
  }

  private addRoute53Records(
    zone: route53.IHostedZone,
    deployDomain: string,
    cf: cf.Distribution,
  ) {
    const propsForRoute53Records = {
      zone,
      recordName: deployDomain,
      target: route53.RecordTarget.fromAlias(
        new route53Targets.CloudFrontTarget(cf),
      ),
    }
    new route53.ARecord(this, 'ARecord', propsForRoute53Records)
    new route53.AaaaRecord(this, 'AaaaRecord', propsForRoute53Records)
  }
}

トラブルシューティング

503 エラー

image.png

今回の場合、Functionで起きている可能性が高い。
コンソールから簡単に治せるので、修正して実行してみるとよい。(なお、es5のみ対応のため、let や constを使うとエラーになるので注意。)

image.png

修正は発行しないと意味がないので注意。反映までは数分時間がかかる。

image.png

this xml file does not appear to have any style information associated with it.

image.png

ファイルのない場所を指してしまう状態。ルーティング設定のFunctionsが失敗して別のアドレスになっている、または、FunctionsとCloudFrontが紐づいておらず、ルーティングされていないなど。

参考

aws-cdkのv2が来たのでドキュメントを読んでみたメモ
Route53とCloudFrontの紐づけとSSL証明書の発行をCDKを使って行ったメモ
VueのSPAでの動的OGPをS3+Cloudfront+Lambda@Edgeの構成でcdkを使って実現したメモ
[aws] CDKでS3のバケットを削除時にBucketNotEmptyエラーが出る時

0
0
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
0
0