AWS CDKの'aws-s3-deployment'を使ってクライアントサイドも一緒にデプロイする

## 動機
SPAでアプリケーション作ってS3+CloudFrontで公開することってよくありますよね？

どうせならSPA側のリソースとCloudFormationのスタックを別々に管理するのではなく、例えばスタックをdeployしたらSPA側（つまりS3の中身）も同時に更新したいものです。

今までそれをやろうと思うと例えば...

* SPA側の最新のビルドを（CodePipelineなどを使って）どこかのバケット(ビルドバケットと呼ぶ)に常に置いておく
* CloudFormation側にCustom Resourceを定義して、ビルドバケットからデプロイ用のバケットにsyncする
    * しかもLambda側からは普通にaws s3 syncが呼べないためawsclidriverのwrapperとか用意しなきゃいけない...


ということで、面倒で仕方無いわけです。

そんなことを思いながら、先日（もう古い？）GAになったAWS-CDKの[リファレンス](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-construct-library.html)を眺めていると...

> aws-s3-deployment

...ん？何これ?

しかも[サンプルソース](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-s3-deployment-readme.html)を見ると、

```typescript
const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
  websiteIndexDocument: 'index.html',
  publicReadAccess: true
});

new s3deploy.BucketDeployment(this, 'DeployWebsite', {
  source: s3deploy.Source.asset('./website-dist'),
  destinationBucket: websiteBucket,
  destinationKeyPrefix: 'web/static' // optional prefix in destination bucket
});
```

./website-distにあるソースをWebsiteBucketにデプロイしてくれるっていうのかい!?

これは試すしか無いってことでやってみました。

## 環境周り
### awscliのセットアップ

もう色々な方が書いているので省略。[公式](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-configure.html)を見て頑張ってください。

当然、ACCESS KEY IDとSECRET KEYを発行するユーザーにはS3やCloudFrontなどを作ることができるポリシーやCloudFormationをいじることができるポリシーが当たっていないとダメです。私はAdminロールが当たってるユーザーでやりました。

### node.js,npmのインストール

これもよしなに頑張ってください。グーグル先生に訊いた方が早いです。

### aws-cdkのインストール

```shell
$ npm install -g aws-cdk

$ cdk --version
1.4.0 (build 175471f)
```

### cdk bootstrap

aws-cdkを動作させるために必要な環境(zipしたソースを置くためのバケットなど)をよしなに作ってくれるみたいです。

```shell
$ cdk bootstrap
```

これがエラーになるようであればawscliのprofileに紐づいているIAMの権限周りを見直してみてください。

## やりたいこと
### フォルダ構成
![スクリーンショット 2019-08-15 11.03.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290471/6482a05c-b0ca-6297-00f7-ff47b04870a2.png)

こんな感じで、web以外はcdk initで自動的に作成される構成。
webディレクトリにデプロイしたいリソースを入れて置いて、cdk buildで一気にデプロイしたい。

ちなみに今回はvue-cliを使ってtypescript + vueをcreateした際の初期のサンプルを入れています。

詳しく知りたい方は[こちら](https://qiita.com/TakenoriHirao/items/dedbc184845b6b58541a)など参考にされるといいんじゃないでしょうか。

## やってみた
### aws cdkプロジェクトの作成
```shell
$ mkdir s3-deployment-inspection
$ cd s3-deployment-inspection
$ cdk init --language typescript
```

うまく行けば上記フォルダ構成の"web"以外ができるはず。
なお、今回はフォルダ名を"s3-deployment-inspection"としているため、作成されるテンプレートは

```lib/s3-deployment-inspection-stack.ts
import cdk = require('@aws-cdk/core');

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

    // The code that defines your stack goes here
  }
}
```
と行った命名になっている。設定より規約。素晴らしい。

### vueプロジェクトの作成
```shell
$ vue create web
# 色々質問されるので答える。今回はtypescriptのプロジェクトを選びました。
```

### vueプロジェクトのビルド
今回はvue createした際に出来るサンプルをそのままビルドしました。

```shell
$ cd web
$ npm run build #./distにindex.htmlを含むリソースがデプロイされる
```

### (/tsconfig.jsonの修正)
さて、Vue側のビルドが終わったのでプロジェクトルートに戻って、

```shell
$ cd ../
$ pwd
 # ${your root}/s3-deployment-inspection
```

まだ何も書いてないけれどcdkの方もビルドしてみようかとしたところ、

```shell
$ npm run build #実質$ tsc 叩いてるのと一緒
```

ビルドエラーが発生。
よくよく考えると、webの中にも*.tsがあって、別のnode_modulesに依存しているにも関わらずそれも一緒にビルドしようとするのでエラーが発生してしまう。

ということで、ルート側のtsconfig.jsonを修正。excludeに"web/*"を追加。

```tsconfig.json
{
  "compilerOptions": {
    "target":"ES2018",
    "module": "commonjs",
    "lib": ["es2016", "es2017.object", "es2017.string"],
    "declaration": true,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": false,
    "inlineSourceMap": true,
    "inlineSources": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization":false
  },
  "exclude": ["cdk.out","web/*"]
}
```

### 必要なライブラリをnpm install
cdk initしただけでは@aws-cdk/coreしかインストールされていません。
例えば、スタックの中でs3バケットを作りたいのであれば@aws-cdk/aws-s3などそれぞれのリソースに応じたライブラリが必要です。

```shell
$ pwd
 # ${your root}/s3-deployment-inspection
 # 間違えてweb下でやらないように!!

$ npm install --save @aws-cdk/aws-s3 @aws-cdk/aws-s3-deployment @aws-cdk/aws-cloudfront @aws-cdk/aws-iam
```

#### 2019/08/15時点 最新版(1.4.0)の@aws-cdk/aws-s3-deploymentが動かない問題
みなさんが見られる頃には解消しているといいのですが。。。
[こちら](https://github.com/aws/aws-cdk/issues/3656)の通りうまく動かないみたいです。

解消しない場合は、前バージョン(@1.3.0)をnpm installしましょう。

```shell
$ npm install --save @aws-cdk/aws-s3-deployment@1.3.0
```

### スタックの定義

ということでここから本番。CDKを使ってスタックを定義していく。

```lib/s3-deployment-inspection-stack.ts
import cdk = require('@aws-cdk/core');

import * as s3 from '@aws-cdk/aws-s3';
import * as iam from '@aws-cdk/aws-iam';
import * as s3Deploy from '@aws-cdk/aws-s3-deployment';
import * as cf from '@aws-cdk/aws-cloudfront';

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

    // Vueをデプロイする先のS3バケット
    const websiteBucket = new s3.Bucket(this, `WebsiteBucket-${this.stackName}`);

    // 今回のメイン
    // ./web/distに先ほどビルドしたビルド結果をwebsiteBucketにデプロイする
    // たった４行で済むなんて。。。
    new s3Deploy.BucketDeployment(this, 'DeployWebsite',{
      source: s3Deploy.Source.asset('./web/dist'),
      destinationBucket: websiteBucket
    });

    // CloudFrontからwebsiteBucketにアクセスする際のOriginAccessIdentity
    const OAI = new cf.CfnCloudFrontOriginAccessIdentity(this, `identity-${this.stackName}`,{
      cloudFrontOriginAccessIdentityConfig:{
        comment: `WebsiteBucket-${this.stackName}`
      }
    });

    // webSiteBucketのBucketPolicyのStatement
    // 先ほど作ったOAIにs3:GetObjectを許可する
    // websiteBucketはpublic access出来ない設定（デフォルト）になっているので
    // こうしておかないとCloudFrontからアクセス出来ない
    const webSiteBucketPolicyStatement = new iam.PolicyStatement({effect: iam.Effect.ALLOW});
    webSiteBucketPolicyStatement.addCanonicalUserPrincipal(OAI.attrS3CanonicalUserId);
    webSiteBucketPolicyStatement.addActions("s3:GetObject");
    webSiteBucketPolicyStatement.addResources(`${websiteBucket.bucketArn}/*`);
    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    // CloudFrontのdistribution
    // 先ほど作ったOAIを指定しておくのがポイント
    const distribution = new cf.CloudFrontWebDistribution(this, `Distribution-${this.stackName}`, {
      originConfigs:[
        {
          s3OriginSource: {
            s3BucketSource: websiteBucket,
            originAccessIdentityId: OAI.ref
          },
          behaviors: [{ isDefaultBehavior: true}]
        }
      ]
    });

    // CloudFrontのドメインを調べるのにいちいちAWS Consoleに入りたく無いので
    // URLに整形して出力しておく
    new cdk.CfnOutput(this, 'CFTopURL', {value: `https://${distribution.domainName}/`})
  }
}
```

### ビルド & デプロイ

```shell
$ npm run build
 #tsに構文エラー等がなければ何も出力されないはず
$ cdk build
 # CloudFrontのdistribution作ってるだけあって、やや待たされるのはやむなしか
 # なんやかんやあって最後は以下が出力されるはず

 ✅  S3DeploymentInspectionStack

Outputs:
S3DeploymentInspectionStack.CFTopURL = https://xxxxxxxxx.cloudfront.net/

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:xxxxxxxxxxx:stack/S3DeploymentInspectionStack/15c9fc40-bef3-11e9-a934-06ce06e5203e
```

### 確認
ということで、Outputsに出力されているURLをブラウザで叩いてみると...

![スクリーンショット 2019-08-16 6.42.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290471/b3723daa-68b4-fb06-54bc-af56dc78ed90.png)

ちゃんとVueのサンプルページが表示される。

### 追加確認 webだけ編集しても変更検知してくれるだろうか？

スタックの定義を変更したら変更検知して差分をデプロイしてくれそうだが、webのソースだけ変更したら検知して再デプロイしてくれるだろうか？

ということで実験。

#### Vueのサンプルソースを編集

```web/src/App.vue
<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <!--CHANGE: msgの末尾に!!を追加-->
    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App!!"/>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import HelloWorld from './components/HelloWorld.vue';

@Component({
  components: {
    HelloWorld,
  },
})
export default class App extends Vue {}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
```

#### webを再ビルドしてcdk deploy

```shell
$ cd web
$ npm run build
$ cd ../
$ cdk deploy

S3DeploymentInspectionStack: deploying...
S3DeploymentInspectionStack: creating CloudFormation changeset...
 0/2 | 6:51:44 | UPDATE_IN_PROGRESS   | Custom::CDKBucketDeployment                     | DeployWebsite/CustomResource/Default (DeployWebsiteCustomResourceD116527B) 
 1/2 | 6:52:08 | UPDATE_COMPLETE      | Custom::CDKBucketDeployment                     | DeployWebsite/CustomResource/Default (DeployWebsiteCustomResourceD116527B) 

 ✅  S3DeploymentInspectionStack

Outputs:
S3DeploymentInspectionStack.CFTopURL = https://xxxxxxxxxxx.cloudfront.net/

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:xxxxxxxxxx:stack/S3DeploymentInspectionStack/75086bf0-bf17-11e9-a934-06ce06e5203e
```

お、変更検知されたっぽい。

#### 確認

ブラウザをリロードしてみたが、変更されておらず。。。そうか、よく考えたらCloudFrontはデフォルトでキャッシュが効くな。。。TTLを0にしておくべきだった。

というわけでinvalidate。

```shell
$ aws cloudfront list-distributions
  # 長いので出力は貼らないがdistributionの一覧がjsonで出てくるので先ほど作ったdistributionを探してIDを取得する

$ aws cloudfront create-invalidation --distribution-id ${YOUR DISTRIBUTION ID} --paths '/*'
```

#### 再確認

![スクリーンショット 2019-08-16 7.04.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290471/2515abc7-d730-3504-af70-d197bd95c5c4.png)

ちゃんとTypeScript App!!になっている。

### 追加確認 具体的にどうやってwebのソースをデプロイしてるんだろう？

記事の冒頭に書いた通り、自分でやるときはCustom Resourceを作っていたが果たして...

ということで、CloudFormationのコンソールに入って、作成されたスタックをデザイナで見てみる。

![スクリーンショット 2019-08-16 7.13.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290471/b94f64ef-f3c3-2f31-b53a-db587c1c48c7.png)

やはりCustom Resource作ってるか。そうだよな、それしか無いよな。

でも**自分で作らなければいけないより一億倍楽!!**

### 後片付け

cdk destroyでスタックごと消してくれる。

```shell
$ cdk destroy
Are you sure you want to delete: S3DeploymentInspectionStack (y/n)? y
```

## 所感

### AWS CDKの魅力はカプセル化だと思う

一見、CloudFormationをTypeScriptやPythonで書けるだけのサービスに見えるけどさもあらず。今回検証したようなCustom Resourceを使ってS3にファイル群を転送するような動きはネイティブなCloudFormationでも出来るが、そこを**内部実装を隠匿してs3-deploymentというサービスに仕立て上げられているのが素晴らしい**。

私は興味があったので中身をみてCustom Resourceがあることを知りましたが、別にCustom Resourceという存在すら知らなくてもこの動きを実現出来るわけなので。

今回検証した以外にもそうやって上手くカプセル化されているAPIが色々ありそうなので、興味ある方は[リファレンス](https://docs.aws.amazon.com/cdk/api/latest/)読んでみてください。

### OAI周りが不満...まだまだ発展途上

せっかくファイルのデプロイまでカプセル化してるのに、CloudFrontのOriginAccessIdentityを作ったりBucketPolicyと紐付けたりといった工程はカプセル化出来んもんかね。。。

と思ったら、やっぱりIssueに上がってた。

[aws-cloudfront: easily support Origin Access Identity for S3 buckets](https://github.com/aws/aws-cdk/issues/941)

これからどんどん進化していくフレームワークだってことですね。楽しみ。

### 相当強力だが、学習コストはまだまた高そう

そもそもネイティブなCloudFormationの書き方を知らないと、世界観が把握しづらくてリファレンスとか読み解くのに苦労しそうな気がしてるのですがどうなんでしょう？？私は個人的にCloudFormation
地獄に片足を突っ込んでいたのでリファレンスもCDKのメリットも割とすんなり入ってきましたが。。。

もっとカプセル化が進めば、ネイティブなCfnを知らない人でもスッとInfrastructure As Codeが出来るようなフレームワークになるかも知れませんね。

このフレームワークのおかげで、Infrastructure as Codeに対するハードルが少しでも下がることを願っています。