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

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

**2023/05/20 追記** deprecatedのクラスを使っている。[修正版はこちら](https://qiita.com/hibohiboo/items/82e5ed43754a41f60108)

## 環境
- Windows 10
- node v16.14.0
- aws-cdk: 2.15.0
- aws-cdk-lib: 2.15.0
- constructs: 10.0.79

## フォルダ構造

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

## CDKの設定確認

### S3
CloudFront用ファイル置き場。

[cdk-docs](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3.Bucket.html#accesscontrol)

設定項目|設定値|解説
--|--|--
blockPublicAccess|`BLOCK_ALL`|[パブリックアクセス](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/access-control-block-public-access.html)はできないようにしておく
removalPolicy|`DESTROY`|スタック削除したときにゴミが残らないようにしておく
autoDeleteObjects|`true`|スタック削除したときにゴミが残らないようにしておく(`removalPolicy`のみだと空でないときに`BucketNotEmpty;`のエラーが発生

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

**オリジンアクセスアイデンティティ(OAI)**
[cdk-docs](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront.OriginAccessIdentity.html)
[S3への制限](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)



**ポリシー**
[オリジンアクセスアイデンティティ (OAI) を使用して Amazon S3 コンテンツへのアクセスを制限する](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)
[OAI](https://docs.aws.amazon.com/ja_jp/general/latest/gr/acct-identifiers.html)


プロパティ|設定値|説明
--|--|--
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~。[*](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html)
minimumProtocolVersion|-|`TLS_V1_2_2021 `|TLSバージョン
domainNames|ドメイン`|ランダム値|今回は[freenom](https://www.freenom.com/ja/index.html)で取得したドメイン
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`へのリダイレクトを行う

```js
// ルーティング用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を利用する。[*](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html)

**参考**
[公式](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html)
[SPA を AWS CloudFront で動かすときにカスタムエラーレスポンスを使わない](https://zenn.dev/chatii/articles/34450e46481846)
[AWS CloudFront FunctionsでSPAのルーティング処理](https://qiita.com/seapolis/items/0afd49d24b12749d93ab)



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

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

**参考**
[参考](https://qiita.com/hibohiboo/items/ddae8b4740952e9ac373)
[qiita - CloudFront の Cache Policy と Origin Request Policy を理解する](https://qiita.com/t-kigi/items/6d7cfccdb629690b8d29)
[aws - 署名付き Cookie の使用](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html)
[aws - リクエストヘッダーに基づくコンテンツのキャッシュ](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/header-caching.html)
[aws - コンテンツがキャッシュに保持される期間 (有効期限) の管理](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Expiration.html)
[aws-圧縮ファイルの供給](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/ServingCompressedFiles.html)


#### ファイル配置設定


**ファイルデプロイ設定**

設定項目|設定値|解説
--|--|--
sources|`../client/build`|デプロイ元フォルダ
destinationBucket|作成したS3
distribution|作成したCloudFront
distributionPaths|`/デプロイ先フォルダ/*`|有効期限前にキャッシュの削除。月ごとに一定数まで無料。[*](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html)
destinationKeyPrefix|`デプロイ先フォルダ`|デプロイ先フォルダ

フォルダ構成（再）
```
- cdk
  - package.json
- client
  + build
  + src
```

[cdk - BucketDeployment ](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment.BucketDeployment.html)

### ルーティング設定
route53でAレコードとAaaaレコードを指定。


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

## ソースコード

```json: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"
  }
}
```

```json: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
```
```ts: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,
})
```

```ts: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/df25220c-2a61-eee1-faf0-8b4edf238aa7.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/2fdc4ea6-9c65-2a55-09f0-4db434e81d58.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/817a34a5-2352-5448-2ea6-b7f92ac3755e.png)


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/c0886036-b8a0-4b95-aef3-d5535ebaaab6.png)

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

## 参考
[aws-cdkのv2が来たのでドキュメントを読んでみたメモ](https://qiita.com/hibohiboo/items/1a6dec3bbcf941ea4d9e)
[Route53とCloudFrontの紐づけとSSL証明書の発行をCDKを使って行ったメモ](https://qiita.com/hibohiboo/items/ddae8b4740952e9ac373)
[VueのSPAでの動的OGPをS3+Cloudfront+Lambda@Edgeの構成でcdkを使って実現したメモ](https://qiita.com/hibohiboo/items/244dfb4eec18be1cc285)
[[aws] CDKでS3のバケットを削除時にBucketNotEmptyエラーが出る時](https://akamist.com/blog/archives/4701)