CDKでCloudFront+S3な静的Webサイトを作る方法［AWS CDK入門］

## AWS Cloud Development Kit とは?

>> AWS クラウド開発キット (AWS CDK) は、使い慣れたプログラミング言語を使用してクラウドアプリケーションリソースをモデル化およびプロビジョニングするためのオープンソースのソフトウェア開発フレームワークです。

> https://aws.amazon.com/jp/cdk/ - AWS クラウド開発キット

いわゆる Infrastructure as Code(IaC) と呼ばれるものの一つで、AWS のリソースの作成をコードで書けるものです。同類の YAML/JSON で書く CloudFormation に比べ、TypeScript などで通常のプログラムのようにより簡単に書くことができます。 

CDK で記述したコードは CloudFormation の YAML に変換された上でデプロイされるため、AWS Console では　CloudFormation で作ったものと表示されます。

この記事では静的なWebサイトを作っていく例ですが、実際のところ、`静的なサイトを作りたい` & `CDNもほしい`という目的ならば**[Netlify](https://netlify.com)**をおすすめします！！
**CDKを使ってみる**ことを目的に読んで頂ければと思います。

なお、この記事では**TypeScript**を使って実装します。
_CDKはTypeScriptのほか、JavaScript、Python、Java、C#にも対応しています。_

## 目次
- [ここまで終わっている前提で始めます](#%E3%81%93%E3%81%93%E3%81%BE%E3%81%A7%E7%B5%82%E3%82%8F%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E5%89%8D%E6%8F%90%E3%81%A7%E5%A7%8B%E3%82%81%E3%81%BE%E3%81%99)
- [準備](#%E6%BA%96%E5%82%99)
    - [CDKのインストール](#cdk%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)
    - [プロジェクトの作成](#%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)
- [コード](#%E3%82%B3%E3%83%BC%E3%83%89)
    - [最初の状態](#%E6%9C%80%E5%88%9D%E3%81%AE%E7%8A%B6%E6%85%8B)
    - [`bin/cdk_files.ts `](#bincdk_filests)
    - [`lib/cdk_files-stack.ts`](#libcdk_files-stackts)
    - [アカウント・リージョンの設定](#%E3%82%A2%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E3%83%AA%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E8%A8%AD%E5%AE%9A)
    - [パッケージのインポート](#%E3%83%91%E3%83%83%E3%82%B1%E3%83%BC%E3%82%B8%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88)
    - [使用する Route 53 ホストゾーンの定義](#%E4%BD%BF%E7%94%A8%E3%81%99%E3%82%8B-route-53-%E3%83%9B%E3%82%B9%E3%83%88%E3%82%BE%E3%83%BC%E3%83%B3%E3%81%AE%E5%AE%9A%E7%BE%A9)
    - [TLS証明書を作る](#tls%E8%A8%BC%E6%98%8E%E6%9B%B8%E3%82%92%E4%BD%9C%E3%82%8B)
    - [S3 のバケットを作る](#s3-%E3%81%AE%E3%83%90%E3%82%B1%E3%83%83%E3%83%88%E3%82%92%E4%BD%9C%E3%82%8B)
    - [S3 へWebサイトのデータをアップロードする](#s3-%E3%81%B8web%E3%82%B5%E3%82%A4%E3%83%88%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89%E3%81%99%E3%82%8B)
    - [CloudFront origin access identity を作る](#cloudfront-origin-access-identity-%E3%82%92%E4%BD%9C%E3%82%8B)
    - [CloudFront ディストリビューションを作る](#cloudfront-%E3%83%87%E3%82%A3%E3%82%B9%E3%83%88%E3%83%AA%E3%83%93%E3%83%A5%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E4%BD%9C%E3%82%8B)
    - [Route 53 でレコードを追加](#route-53-%E3%81%A7%E3%83%AC%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E8%BF%BD%E5%8A%A0)
    - [cdk_files-stack.ts全体のコード](#cdk_files-stackts%E5%85%A8%E4%BD%93%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89)
- [デプロイ](#%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4)
- [参考資料](#%E5%8F%82%E8%80%83%E8%B3%87%E6%96%99)


## ここまで終わっている前提で始めます
- HTML/CSS、JavaScriptなどのアップロードするファイルがディレクトリにまとめられている
    - この記事では`workspace/web/build`として説明します
        - `workspace/web` で `$ npm run build` したイメージです
        - `workspace/cdk_files/` に CDK のファイルを置きます。お好みの場所に変えても大丈夫です。
- Node.js をインストール済み
- 次の権限を持ったIAMユーザ/ロールを作成済み
    - `AWSCloudFormationFullAccess`
    - `AmazonRoute53FullAccess`
    - `AWSCertificateManagerFullAccess`
    - `AmazonS3FullAccess`
    - `CloudFrontFullAccess`
        - 全部 `**FullAccess` なのは「権限なにもわからない」なのでお許しください＞＜
- Route 53にドメインを追加済み(ホストゾーンの作成)
    - `example.com`を追加したものとして説明します

## 準備
### CDKのインストール
`cdk`コマンドを使えるようにnpmでインストールします

```shell
$ npm i -g aws-cdk
$ # OR
$ yarn global add aws-cdk

$ cdk --version
1.27.0 (build a98c0b3)
```

### プロジェクトの作成

`$ cdk init` でできるプロジェクトの作成は、空のディレクトリでないとエラーになります

```shell-session
[workspace] $ # cdk_files ディレクトリを作ります
[workspace] $ mkdir cdk_files && cd $_

[workspace/cdk_files] $ # CDK プロジェクトを作ります
[workspace/cdk_files] $ cdk init app --language=typescript 
Applying project template app for typescript
Initializing a new git repository...
Executing npm install...
npm WARN deprecated request@2.88.2: request has been deprecated, see https://github.com/request/request/issues/3142
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN cdk_files@0.1.0 No repository field.
npm WARN cdk_files@0.1.0 No license field.

# Welcome to your CDK TypeScript project!

This is a blank project for TypeScript development with CDK.

The `cdk.json` file tells the CDK Toolkit how to execute your app.

## Useful commands

 * `npm run build`   compile typescript to js
 * `npm run watch`   watch for changes and compile
 * `npm run test`    perform the jest unit tests
 * `cdk deploy`      deploy this stack to your default AWS account/region
 * `cdk diff`        compare deployed stack with current state
 * `cdk synth`       emits the synthesized CloudFormation template
```

このようにファイルが出来上がります

```shell-session
[workspace/cdk_files] $ # ↓ .git と node_modules は表示しません
[workspace/cdk_files] $ tree -aI "\.git|node_modules"
.
├── .gitignore
├── .npmignore
├── README.md
├── bin
│   └── cdk_files.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── cdk_files-stack.ts
├── package-lock.json
├── package.json
├── test
│   └── cdk_files.test.ts
└── tsconfig.json

3 directories, 11 files
```

リソースの作成に必要なパッケージをインストールします

```shell-session
[workspace/cdk_files] $ npm i \
    @aws-cdk/aws-route53 \
    @aws-cdk/aws-route53-targets \
    @aws-cdk/aws-certificatemanager \
    @aws-cdk/aws-s3 \
    @aws-cdk/aws-s3-deployment \
    @aws-cdk/aws-cloudfront \
    @aws-cdk/aws-iam
```

## コード
いよいよコードを書いていきます！！

CDK で書いたコードは CloudFormation テンプレートへ変換された後、CloudFomationのスタックとしてデプロイされます。以下の説明では「作成する」などと書いていますが、実際には「『作成する』という内容を CloudFomation テンプレートに定義する」という意味なので注意してください。

主に使うのは CloudFront と S3 ですが、HTTPS のために独自ドメインの SSL 証明書を発行できる AWS Certificate Manager(ACM) を使います。ACM の利用は無料[^ACM]ですが、実際に証明書を利用する AWS リソースの料金が必要になりますので注意してください。

今回の場合は、ACMを用いてCloudFrontから独自ドメインで配信を行うため、CloudFrontの料金は払わねばならないという仕組みです。

[^ACM]: 2020年4月現在。また ACM で無料となるのは、パブリック向けの SSL/TLS 証明書のみです。今回は使っていませんが、ACM のプライベート CA というサービスでは料金がかかるので注意してくださいね（個人で使うことなさそうですが）。

また、CloudFront と ACM の DNS の設定を自動化に Route 53 を使います。手動になりますが他の DNS サーバを使っても構いません。その場合は Route 53 の設定部分を読み飛ばしてください。事前に ACM の認証を、デプロイ後に CNAME の設定が必要です。

### 最初の状態

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

const app = new cdk.App();
new CdkFilesStack(app, 'CdkFilesStack');
```

#### `lib/cdk_files-stack.ts` 
```lib/cdk_files-stack.ts
import * as cdk from '@aws-cdk/core';

export class CdkFilesStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here
  }
}
```

> セミコロンなし派なので、以降のコードにセミコロンはありません。お好みでセミコロンを;)


### アカウント・リージョンの設定
最初に `bin/cdk_files.ts` を編集します

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

const app = new cdk.App()
new CdkFilesStack(app, 'CdkFilesStack', {
  env: {
    // アカウント・リージョンは環境変数で設定することにします
    // もちろん、アカウントID・リージョンをハードコードしても構いません
    account: process.env.CDK_DEPLOY_ACCOUNT || process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEPLOY_REGION || process.env.CDK_DEFAULT_REGION,
  },
})
```

### パッケージのインポート
以降では `lib/cdk_files-stack.ts` を編集します
先頭でパッケージのインポートを行います。`@aws-cdk/core`に続いて書いてください。

```lib/cdk_files-stack.ts
import * as cdk from '@aws-cdk/core'
// ↓を追加
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'
import * as s3 from '@aws-cdk/aws-s3'
import * as s3Deploy from '@aws-cdk/aws-s3-deployment'
import * as iam from '@aws-cdk/aws-iam'
import * as cloudfront from '@aws-cdk/aws-cloudfront'
// ここまで

export class CdkFilesStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    // 以降はここに追記してください。 class の部分等は省略します。
  }
}
```

> 最初から全部見たいという~~よくばりな~~人のために、[全体のコード](#%E5%85%A8%E4%BD%93%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89)を用意しました

### 使用する Route 53 ホストゾーンの定義
```ts:CdkFilesStack#constructor
// 下2つの名前は Route 53 に登録しているものに変更してください
// Route 53 に登録してあるドメイン名
const rootDomain = 'example.com'
// サイトをデプロイするドメイン名
// Zone APEX にデプロイするときは rootDomain をそのまま代入して大丈夫です
const deployDomain = `yourdomain.${rootDomain}`

// ドメイン名からデプロイに使う Route 53 のホストゾーンを定義します
const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
  domainName: `${rootDomain}.`,
})
``` 

### TLS証明書を作る
```ts:CdkFilesStack#constructor
const cert = new certManager.DnsValidatedCertificate(this, 'Certificate', {
  // なんでも無料なので、とりあえず example.com と *.example.com の証明書を取得します
  // a.b.example.com のようなドメインを使うときには変えてください
  domainName: rootDomain,
  subjectAlternativeNames: [`*.${rootDomain}`],
  // DNS 認証に Route 53 のホストゾーンを使います
  hostedZone,
  // CloudFront で使う証明書では props で指定された region を上書きし
  // 必ず us-east-1 リージョンを指定します
  // ap-northeast-1 など他ではエラーになるので気をつけましょう
  region: 'us-east-1',
})
```

### S3 のバケットを作る
```ts:CdkFilesStack#constructor
const staticSiteBucket = new s3.Bucket(this, 'StaticSiteBucket', {
  // 「.」→「-」と置き換えます
  // deployDomain が yourdomain.example.com であれば yourdomain-example-com がバケット名になります
  bucketName: deployDomain.replace('.', '-'),
  // `$ cdk destroy` でデプロイを削除するときに、S3 のバケットも削除するようにします
  removalPolicy: cdk.RemovalPolicy.DESTROY,
})
```

### S3 へWebサイトのデータをアップロードする
```ts:CdkFilesStack#constructor
new s3Deploy.BucketDeployment(this, 'StaticSiteDeploy', {
  // アップロードするデータの場所を指定します
  sources: [s3Deploy.Source.asset('../web/build')],
      // ファイルが指定されなかったときは index.html を返します
      // Apache でいう `DirectoryIndex index.html`、NGINX でいう `index index.html` です
      websiteIndexDocument: 'index.html',
  // アップロード先のバケットは一つ前で作ったものです
  destinationBucket: staticSiteBucket,
})
```

### CloudFront origin access identity を作る

S3 のバケットは非公開ですから、CloudFront が S3 にアクセスするための権限を作成します

```ts:CdkFilesStack#constructor
// Origin access identity を作ります
const cfOai = new cloudfront.OriginAccessIdentity(this, `CfOai`, {
  comment: 'Access identity for S3 bucket',
})

// 作成した S3 バケットへアクセスを許可する IAM ポリシーを作ります
const cfS3Access = new iam.PolicyStatement({
  // S3 の読み取りを許可
  effect: iam.Effect.ALLOW,
  actions: ['s3:GetObject'],
  // 上で作った OAI へ、このポリシーを適用します
  principals: [new iam.CanonicalUserPrincipal(cfOai.cloudFrontOriginAccessIdentityS3CanonicalUserId)],
  // 許可する S3 のバケットを指定します
  resources: [`${staticSiteBucket.bucketArn}/*`]
})

// S3 へバケットポリシーを追加
staticSiteBucket.addToResourcePolicy(cfS3Access)
```

### CloudFront ディストリビューションを作る

```ts:CdkFilesStack#constructor
const cfDist = new cloudfront.CloudFrontWebDistribution(this, 'CfDistribution', {
  originConfigs: [{
    // origin を S3 に設定します
    s3OriginSource: {
      // サイトのファイルがアップロードされた S3 バケットを指定します
      s3BucketSource: staticSiteBucket,
      // 先ほど作成した権限を使い S3 へアクセスします
      originAccessIdentity: cfOai,
    },
    // キャッシュの動作はデフォルトのみに指定します
    // 設定を追加してもOKです
    behaviors: [
      { isDefaultBehavior: true },
    ],
  }],
  // 作成した ACM の証明書を配信に使います
  viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(cert, {
    // デフォルトで作成されるエンドポイントで
    // HTTPS アクセスできる *.cloudfront.net に加えて
    // yourdomain.example.com の HTTPS リクエストを受け付けるようにします
    aliases: [deployDomain],
    // HTTPS 通信には TLS V1 以上のプロトコルを使います
    securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1,
    // SNI (ドメイン名ベースの Virtual host) で SSL(TLS) を利用します
    // SNI をサポートしていない、いにしえの端末では使えません
    //   (専用 IP にするといにしえでも使えますが、めちゃめちゃ高いので気をつけましょう)
    sslMethod: cloudfront.SSLMethod.SNI,
  }),
})
```

### Route 53 でレコードを追加

```ts:CdkFilesStack#constructor
// IPv4 と IPv6 の両方に対応させるため、A と AAAA の両方のレコード追加します
// route53.ARecord と route53.AaaaRecord の
//   引数は共通のため route53RecordProps に定義しています
const propsForRoute53Records = {
  // example.com ゾーンで
  zone: hostedZone,
  // yourdomain.example.com に対して
  recordName: deployDomain,
  // ALIAS ramdomstr.cloudfront.net を追加します
  target: route53.AddressRecordTarget.fromAlias(
    new route53Targets.CloudFrontTarget(cfDist)
  ),
}

new route53.ARecord(this, 'ARecord', propsForRoute53Records)
new route53.AaaaRecord(this, 'AaaaRecord', propsForRoute53Records)
```

こちらで設定している ALIAS レコードは、CNAME に変わって AWS のサービスを Target にするときに使えるもので、実際にレコードの応答は A や AAAA レコードになるという「すぐれもの」です。Route 53 以外の DNS を使う際には CNAME に _randomstr_.cloudfront.net を設定してくださいね。  

### `cdk_files-stack.ts`全体のコード

```cdk_files-stack.ts
import * as cdk from '@aws-cdk/core'
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'
import * as s3 from '@aws-cdk/aws-s3'
import * as s3Deploy from '@aws-cdk/aws-s3-deployment'
import * as cloudfront from '@aws-cdk/aws-cloudfront'
import * as iam from '@aws-cdk/aws-iam'

export class CdkFilesStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 使用する Route 53 ホストゾーンの定義
    const rootDomain = 'example.com'
    const deployDomain = `yourdomain.${rootDomain}`

    const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
      domainName: `${rootDomain}.`,
    })

    // TLS証明書を作る
    const cert = new certManager.DnsValidatedCertificate(this, 'Certificate', {
      domainName: rootDomain,
      subjectAlternativeNames: [`*.${rootDomain}`],
      hostedZone,
      region: 'us-east-1',
    })

    // S3 のバケットを作る
    const staticSiteBucket = new s3.Bucket(this, 'StaticSiteBucket', {
      bucketName: deployDomain.replace('.', '-'),
      websiteIndexDocument: 'index.html',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    })

    // S3 へWebサイトのデータをアップロードする
    new s3Deploy.BucketDeployment(this, 'StaticSiteDeploy', {
      sources: [s3Deploy.Source.asset('../web/build')],
      destinationBucket: staticSiteBucket,
    })

    // CloudFront origin access identity を作る
    const cfOai = new cloudfront.OriginAccessIdentity(this, `CfOai`, {
      comment: 'Access identity for S3 bucket',
    })

    const cfS3Access = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:GetObject'],
      principals: [new iam.CanonicalUserPrincipal(cfOai.cloudFrontOriginAccessIdentityS3CanonicalUserId)],
      resources: [`${staticSiteBucket.bucketArn}/*`]
    })

    staticSiteBucket.addToResourcePolicy(cfS3Access)

    // CloudFront ディストリビューションを作る
    const cfDist = new cloudfront.CloudFrontWebDistribution(this, 'CfDistribution', {
      originConfigs: [{
        s3OriginSource: {
          s3BucketSource: staticSiteBucket,
          originAccessIdentity: cfOai,
        },
        behaviors: [
          { isDefaultBehavior: true },
        ],
      }],
      viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(cert, {
        aliases: [deployDomain],
        securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1,
        sslMethod: cloudfront.SSLMethod.SNI,
      }),
    })

    // Route 53 でレコードを追加
    const propsForRoute53Records = {
      zone: hostedZone,
      recordName: deployDomain,
      target: route53.AddressRecordTarget.fromAlias(
        new route53Targets.CloudFrontTarget(cfDist)
      ),
    }

    new route53.ARecord(this, 'ARecord', propsForRoute53Records)
    new route53.AaaaRecord(this, 'AaaaRecord', propsForRoute53Records)
  }
}
```

## デプロイ

```shell-session
[workspace/cdk_files] $ # シェル変数としていますが、export せずに
[workspace/cdk_files] $ # 最初から cdk deploy するワンライナーでももちろん大丈夫です。
[workspace/cdk_files] $ export CDK_DEPLOY_REGION=ap-northeast-1 CDK_DEPLOY_ACCOUNT=111122223333

[workspace/cdk_files] $ # 何も指定しなければ default の AWS profile を用いてデプロイされます
[workspace/cdk_files] $ cdk deploy # --profile PROFILE_NAME で AWS profile を指定できます
## デプロイされるもの一覧が表示されます
## 確認して、yを押してデプロイを実行します
Do you wish to deploy these changes (y/n)? y
## デプロイの進捗が表示されます
 ✅  CdkFilesStack
```

特にエラーが表示されていなければデプロイ成功です:tada:

もし、書き間違いや権限設定にミスがあってデプロイに失敗しても自動的にロールバックされるので、0から簡単にやり直す事ができます。`ROLLBACK_COMPLETE` と表示されます。(ただし `ROLLBACK_FAILED` となれば自力で削除を行う必要があります)

また、コードを部分的に更新すると、新規と同様に CloudFomation のテンプレートが作成されますが、CloudFomation のスタックで現状との差分が取られ、更新すべき部分が上記の「デプロイされるもの一覧」で確認できます。同様に `y` でデプロイされます。冪等性があってかしこいですね！デプロイが捗って助かります。

## 参考資料

[AWS CDKを使って ほぼ一撃で静的サイトを構築する - Qiita](https://qiita.com/shootacean/items/6042afb6b1280bafae9e)
[aws-certificatemanager module · AWS CDK](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-certificatemanager-readme.html)
[aws-cloudfront module · AWS CDK](
https://docs.aws.amazon.com/cdk/api/latest/docs/aws-cloudfront-readme.html)
[Static web in Cloudfront with AWS CDK - Ruben J Garcia](https://rubenjgarcia.es/static-web-in-cloudfront-with-aws-cdk/)
[Deploy a SPA Website to AWS S3 with CloudFront CDN in 40 lines of TypeScript using AWS CDK](https://medium.com/@david.sandor/deploy-a-spa-website-to-aws-s3-with-cloudfront-cdn-in-40-lines-of-typescript-using-aws-cdk-ff800b6c1bb8)