Route53とCloudFrontの紐づけとSSL証明書の発行をCDKを使って行ったメモ

## 概要
CloudFrontで公開したページのドメインを自分で取得したドメインのサブドメインに変更したい。
今回は、[freenom](https://www.freenom.com/ja/index.html)で取得した`hoge.tk`ドメインを使って`www.fuga.hoge.tk`ドメインを設定する。
FreenomとRoute53のネームサーバの紐づけは手動で行い、
Route53とCloudFrontの紐づけおよびSSL証明書の発行をCDKで行う。

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

## 作業方針

[CDKでCloudFront+S3な静的Webサイトを作る方法［AWS CDK入門］](https://qiita.com/satackey/items/bc80a586da05e855cb85)では`CloudFrontWebDistribution`クラスを使った例が紹介されているので、今回はそれを参考に`Distribution`クラスを使って設定する。([※どちらも利用できるがDistributionのほうが後発で機能豊富](https://dev.classmethod.jp/articles/build-a-static-site-hosting-delivery-with-amaozn-s3-using-cloudfront-distributions-new-cdk-constrain/))
また、今回Route53のhostedZoneは手動で作成する。Route53をcdkで作る場合は[API Gatewayにカスタムドメインを設定するためのリソースを全てAWS CDKでつくってみた](https://dev.classmethod.jp/articles/aws-cdk-all-resources-for-api-gateway/)が参考となる。

## 事前準備

### ドメインの取得
[freenom](https://www.freenom.com/ja/index.html) で新規ドメインを取得。
今回は`hoge.tk`が取得できたこととする。

### ネームサーバの作成
freenomで取得したドメインのネームサーバーをRoute53で作成する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/1e2d0124-b017-a9fe-7c0a-734f6e65792b.png)

ホストゾーンをfreenomで取得したドメイン`hoge.tk`で作成する。
作成後、ネームサーバが4つ作成されるので控えておく

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/42d982c2-2888-d515-1047-80f3ff638f92.png)

### ネームサーバとドメインの紐づけ

FreenomのServices > My Domains > Manage Domain を選択。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/c3f4af7d-972e-1f49-254b-60fc43628823.png)

ManagementTools > Nameservers を選択。
Use custom nameservers を選択し、ネームサーバをRoute53で作成した4つ分入力する。
Change Nameserversを押して設定完了。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/7673ae8f-da35-9458-2d9d-4337fe1838ff.png)

## 設定

### 利用パッケージ
* `aws cdk 1.121.0`
* `dotenv 10.0.0`
  * .envファイルで環境変数を設定している。

<details><summary>package.json</summary><div>

```jsono:package.json
{
  "name": "cdk",
  "version": "0.1.0",
  "bin": {
    "cdk": "bin/cdk.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "bundle": "cdk synth > cdk.out/template.yml",
    "deploy": "cdk deploy --profile produser --require-approval never --all"
  },
  "devDependencies": {
    "@aws-cdk/assert": "1.121.0",
    "@types/aws-lambda": "^8.10.83",
    "@types/node": "16.7.10",
    "aws-cdk": "1.121.0",
    "dotenv": "^10.0.0",
    "esbuild": "^0.12.25",
    "npm-check-updates": "^11.8.5",
    "ts-node": "^10.2.1",
    "typescript": "~4.4.2"
  },
  "dependencies": {
    "@aws-cdk/aws-certificatemanager": "^1.121.0",
    "@aws-cdk/aws-cloudfront": "^1.121.0",
    "@aws-cdk/aws-cloudfront-origins": "^1.121.0",
    "@aws-cdk/aws-iam": "^1.121.0",
    "@aws-cdk/aws-route53": "^1.121.0",
    "@aws-cdk/aws-route53-targets": "^1.121.0",
    "@aws-cdk/aws-s3": "^1.121.0",
    "@aws-cdk/aws-s3-deployment": "^1.121.0",
    "@aws-cdk/core": "1.121.0",
    "source-map-support": "^0.5.19"
  }
}
```

```json:cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
  "versionReporting": false
}
```

</div></details>


### 実行ファイル

* 環境変数を設定

<details><summary>bin/cdk.ts</summary><div>

```bin/cdk.ts
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from '@aws-cdk/core'
import { AWSHogeClientStack } from '../lib/cdk-stack'
import * as dotenv from 'dotenv'

// .envファイルから環境変数読込
dotenv.config()

// 環境変数の型をstring | undefined から stringにするためのチェック

if (!process.env.PROJECT_ID) throw new Error(`please add PROJECT_ID to .env`)
if (!process.env.ROOT_DOMAIN) throw new Error(`please add ROOT_DOMAIN to .env`)
if (!process.env.DEPLOY_DOMAIN)
  throw new Error(`please add DEPLOY_DOMAIN to .env`)
if (!process.env.TAG_PROJECT_NAME)
  throw new Error(`please add TAG_PROJECT_NAME to .env`)
if (!process.env.CF_COMMENT)
  throw new Error(`please add CF_COMMENT to .env`)


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

new AWSLosTRPGClientStack(app, `${projectId}-stack`, {
  bucketName: `${projectId}-s3-bucket`,
  identityName: `${projectId}-origin-access-identity-to-s3-bucket`,
  defaultCachePolicyName: `${projectId}-cache-policy-default`,
  distributionName: `${projectId}-distribution-cloudfront`,
  rootDomain: process.env.ROOT_DOMAIN,
  deployDomain: process.env.DEPLOY_DOMAIN,
  projectNameTag: process.env.TAG_PROJECT_NAME,
  aRecordName: `${projectId}-a-record`,
  aaaaRecordName: `${projectId}-aaaa-record`,
  cfComment: process.env.CF_COMMENT
  env,
})

```


```bash:.env
PROJECT_ID=hoge-proj-01
ROOT_DOMAIN=hoge.tk
DEPLOY_DOMAIN=www.fuga.hoge.tk
TAG_PROJECT_NAME=hogehoge
CF_COMMENT=CloudFront の Distributeion の説明
```


</div></details>

## Stack

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


+    const zone = this.findRoute53HostedZone(props.rootDomain) // 利用するホストゾーンをドメイン名で取得
+    const cert = this.createTLSCertificate(props.deployDomain, zone) // ホストゾーンにSSL証明書の発行

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


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

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


<details><summary>lib/cdk-stack.ts</summary><div>

```ts:lib/cdk-stack.ts
import * as core from '@aws-cdk/core'
import * as s3 from '@aws-cdk/aws-s3'
import * as cf from '@aws-cdk/aws-cloudfront'
import * as iam from '@aws-cdk/aws-iam'
import * as s3deploy from '@aws-cdk/aws-s3-deployment'
import * as lambda from '@aws-cdk/aws-lambda'
import * as origins from '@aws-cdk/aws-cloudfront-origins'
import * as route53 from '@aws-cdk/aws-route53'
import * as route53Targets from '@aws-cdk/aws-route53-targets'
import * as certManager from '@aws-cdk/aws-certificatemanager'

interface Props extends core.StackProps {
  bucketName: string
  identityName: string
  defaultCachePolicyName: string
  distributionName: string
  rootDomain: string
  deployDomain: string
  aRecordName: string
  aaaaRecordName: string
  cfComment: string
}

export class AWSHogeClientStack extends core.Stack {
  constructor(scope: core.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
    )
    // 指定したディレクトリをデプロイ
    this.deployS3(bucket, distribution, '../client/build', props.bucketName)

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

    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,
      cors: [
        {
          allowedMethods: [s3.HttpMethods.GET],
          allowedOrigins: ['*'],
          allowedHeaders: ['*'],
        },
      ],
    })
    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,
    props: Props
  ) {
    const defaultPolicyOption = {
      cachePolicyName: props.defaultCachePolicyName,
      comment: 'A default policy',
      defaultTtl: core.Duration.days(2),
      minTtl: core.Duration.seconds(0), 
      maxTtl: core.Duration.days(10),
      cookieBehavior: cf.CacheCookieBehavior.all(),
      headerBehavior: cf.CacheHeaderBehavior.none(),
      queryStringBehavior: cf.CacheQueryStringBehavior.none(),
      enableAcceptEncodingGzip: true,
      enableAcceptEncodingBrotli: true,
    }
    const myCachePolicy = new cf.CachePolicy(
      this,
      props.defaultCachePolicyName,
      defaultPolicyOption,
    )

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

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

      priceClass: cf.PriceClass.PRICE_CLASS_200,
      defaultBehavior: {
        origin,
        allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cf.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: myCachePolicy,
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      // 2021.09.05 GUIコンソール上の推奨とCDKのデフォルト値がずれていたので明示
      minimumProtocolVersion: cf.SecurityPolicyProtocol.TLS_V1_2_2021,
      // Route53と連携するためのカスタムドメイン
      certificate: cert,
      domainNames: [props.deployDomain],
    })
    core.Tags.of(d).add('Service', 'Cloud Front')

    return d
  }

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

  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,
    cf: cf.Distribution,
    props: Props
  ) {
    const propsForRoute53Records = {
      zone,
      recordName: props.deployDomain,
      target: route53.RecordTarget.fromAlias(
        new route53Targets.CloudFrontTarget(cf),
      ),
    }

    new route53.ARecord(this, props.aRecordName, propsForRoute53Records)
    new route53.AaaaRecord(this, props.aaaaRecordName, propsForRoute53Records)
  }
}

```


</div></details>


## 参考

[Freenom](https://www.freenom.com/ja/index.html)
[aws - route53 - ドメイン名を使用したトラフィックの Amazon CloudFront ウェブディストリビューションへのルーティング](https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/routing-to-cloudfront-distribution.html)
[aws - cloudfront - 代替ドメイン名 (CNAME) を追加することによるカスタム URL の使用](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html)
[CDKでCloudFront+S3な静的Webサイトを作る方法［AWS CDK入門］](https://qiita.com/satackey/items/bc80a586da05e855cb85)
[API Gatewayにカスタムドメインを設定するためのリソースを全てAWS CDKでつくってみた](https://dev.classmethod.jp/articles/aws-cdk-all-resources-for-api-gateway/)