AWS-CDKによる複数環境の管理を楽にするツールを作った

# 概要

aws-cdkをさらに便利にするツールをnpmに公開しました！！
本番環境、検証環境など複数環境を管理するのに便利かと思います。お試しください。

https://www.npmjs.com/package/cdk-env-manager
https://github.com/masahirompp/cdk-env-manager

## 概要図

![cdk-env-manager-image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/5d11fcd1-fa51-48b2-1155-5a537b2aaf2a.png)

## 利用イメージ

![cdk.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/5372e723-4239-629f-4957-4722ef21aea8.gif)

それでは以下詳細。

# aws-cdkの考慮事項

aws-cdk（またはCloudFormation）でAWS環境を管理する場合、以下のようなことを考慮すると思います。

- **A**. 複数の環境（production, staging, developmentなど）を管理したい
    - 環境は無限に増える前提でコーディングすべき
    - （ケース・バイ・ケースだが）１つのAWSアカウント上で、複数環境を管理したい
- **B**. デプロイ時のパラメータ（URL, InstanceType, RemovalPolicyなど）を環境毎に管理したい
    - パラメータの数が増えるとContextや環境変数で渡すのつらい
    - パラメータを開発者で共有したいけれど、ローカルのJSONファイルなどにハードコーディングはやめたい、Gitの管理下にも置きたくない
- **C**. StackのOutputs（ApiGatewayのEndpointUrlなど）を環境毎に管理したい
    - Outputsを開発者で共有したいけれど、ローカルのJSONファイルなどにハードコーディングはやめたい、Gitの管理下にも置きたくない
- **D**. デプロイ時の手順を統一したい
    - cdk diffで差分を確認してからデプロイするなど
- **E**. Stackを適切に分けたい

これらをうまく解決するには、

- **B, C**の各環境のパラメータやOutputsについては、SSMのパラメータストアで管理することで、ローカルファイルを使わず、開発者間で共有できそうです。
- **A**もパラメータストアのパスを上手く区切ればできそうです。
- **D**は、aws-cdkのラッパーを作ればできそう。

ということで、これらを解決するツール作りました！！

# ツールの使い方

## 事前準備

aws-cdkなどインストールして、`cdk init --language typescript`等の初期設定は済ましておいてください。

（本ツールはTypeScriptのみ対応です）

## cdk-env-managerをインストール

```shell
yarn add -D cdk-env-manager
# npm install -D cdk-env-manager
```

## cdk Stackの作成

各Stackのコードを作成します。

- cdk-env-managerからCdkStackBaseをimportして継承してください。
- createResourcesメソッドを実装してください。

```typescript
// S3Stack.ts
import * as cdk from '@aws-cdk/core'
import * as s3 from '@aws-cdk/aws-s3'
import { CdkStackBase } from 'cdk-env-manager'

type Input = { removalPolicy: cdk.RemovalPolicy }
type Output = { myBucketArn: string }

export class S3Stack extends CdkStackBase<Input, Output> {
  createResources() {
    const myBucket = new s3.Bucket(this, this.name('MyBucket'), {
      removalPolicy: this.props.removalPolicy
    })

    this.createOutputsSsmParameters({ myBucketName: myBucket.bucketName })

    return {
      myBucketArn: myBucket.bucketArn
    }
  }
}
```

補足として

- InputはこのStackが受け取るパラメータです。
- OutputはこのStackが出力するパラメータです。他のStackに渡したい場合に使います。
- `createOutputsSsmParameters(...)`は値をSSMのパラメータストアに書き出します。アプリ側から参照したい設定値などを、SSMに書き出してください。（アプリ側からの参照方法は後述）
- `this.name("MyBucket")`は、"DevMyBucket"のように環境名のprefixをつけてくれるヘルパーメソッドです。

同様にここではもう１つStackを作成。

```typescript
// RoleStack.ts
import * as iam from '@aws-cdk/aws-iam'
import { CdkStackBase } from 'cdk-env-manager'

type Input = { bucketArn: string }
type Output = { myRoleArn: string }

export class RoleStack extends CdkStackBase<Input, Output> {
  createResources() {
    const myManagedPolicy = new iam.ManagedPolicy(this, this.name('MyManagedRole'), {
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ['s3:GetObject'],
          resources: [this.props.bucketArn]
        })
      ]
    })

    const myRole = new iam.Role(this, this.name('MyRole2'), {
      assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com'),
      managedPolicies: [myManagedPolicy]
    })

    return { myRoleArn: myRole.roleArn }
  }
}
```

## cdk appの作成

複数のStackをまとめるcdk appを作成します。

- cdk-env-managerからCdkAppBaseをimportして継承してください。
- createStacksメソッドを実装してください。
- 末尾２行は必須です。

```typescript
// MyApp.ts
import * as cdk from '@aws-cdk/core'
import { CdkAppBase } from 'cdk-env-manager'
import { RoleStack } from './stacks/RoleStack'
import { S3Stack } from './stacks/S3Stack'

type Parameter = { removalPolicy: cdk.RemovalPolicy }

export class MyApp extends CdkAppBase<Parameter> {
  async createStacks() {
    const s3Stack = new S3Stack(this, 'S3Stack', {
      removalPolicy: this.deployParameters.removalPolicy
    })

    new RoleStack(this, 'RoleStack', {
      bucketArn: s3Stack.exports.myBucketArn
    })
  }
}

const app = new MyApp()
app.synth()
```

補足として

- Parameterには環境ごとのパラメータを定義します。詳細は次へ。

## パラメータファイルの定義

デプロイ時にパラメータを渡すことができます。パラメータは環境毎に設定できます。
パラメータの既定値をファイルに記載しておく必要があります。
プロジェクトのルートに`cdk.parameters.default.env`を作成し、
デフォルトのパラメータを`key=value`形式で指定してください。

```dot
# cdk.parameters.default.env
REMOVAL_POLICY=retain
```

## 環境変数の設定

デプロイ時に必要な以下の環境変数を設定してください。

- AWS_DEFAULT_REGION
- AWS_ACCESS_KEY_IDとAWS_SECRET_ACCESS_KEYのペア、またはAWS_PROFILE

ここでは`.env`ファイルに記載している体で進めます。

```dot
# .env
AWS_DEFAULT_REGION=ap-northeast-1
AWS_PROFILE=my-profile
```

## デプロイ方法

ここまで来たら準備完了です。デプロイをしてみます。

### 1. ツールの実行

ここでは、環境変数を`.env`から読み込んで実行します。

```shell
npx -n '-r dotenv/config' cdk-env-manager
```

### 2. デプロイする環境の選択

CdkEnvKeyは環境を識別するキーです。CdkEnvKeyを選択してください。
初回実行時は既存の環境はありませんので、「create new Stacks」を選択します。

<img width="386" alt="Screen Shot 2019-12-03 at 2.07.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/7f576a0a-578e-7308-d2af-b062e84762ab.png">

### 3. パラメータの確認と設定

デプロイ時のパラメータを、確認するか、変更するか、そのまま続行するか選んでください。

<img width="385" alt="Screen Shot 2019-12-03 at 2.10.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/6e849765-bbcb-f707-557a-032c298acdfd.png">

新規作成時や変更を選択した場合は、パラメータの編集ができます。

<img width="400" alt="Screen Shot 2019-12-03 at 2.12.05.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/b4260c74-fa10-4b5f-1f42-50675561ea89.png">

`cdk.parameters.default.env`のデフォルトパラメータを増やすと、このプロンプトで編集できるパラメータも増えます。

### 4. 差分の確認

パラメータが決まったら、ツールが内部的に`cdk diff`を実行します。
デプロイ時の差分を確認してください。

<img width="408" alt="Screen Shot 2019-12-03 at 2.13.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/98d639c6-9fd0-db21-0a5d-d6ec7a10991b.png">

### 5. デプロイするStackの選択

diffを確認を受けて、デプロイするStackを選択します。１つでも複数でもOKです。

<img width="389" alt="Screen Shot 2019-12-03 at 2.17.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/8634820a-070b-2204-d6fc-8660b4bc67c0.png">

### 6. デプロイ実行

5で選択したStackをデプロイします。内部的には`cdk deploy`を呼び出しているだけです。

<img width="421" alt="Screen Shot 2019-12-03 at 2.18.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/06514719-cad6-64d6-76c6-8608db114138.png">

cdkのデプロイが終わるのをまって完了！！

## アプリ側からStackの出力を参照

SSMに書き出された設定値をアプリ側から読み込みます。ここでは以下の手法をサンプルとします。

- アプリ側で利用する環境を、環境変数（CDK_ENV_KEY）で指定
- webpackのビルド時に設定値をSSMから読み込み、DefinePluginでアプリに渡す

```typescript
import webpack from 'webpack'
import { loadStackParameters } from 'cdk-env-manager'

const configFunction: () => Promise<webpack.Configuration> = async () => {
  // load stack parameters from ssm
  const params = await loadStackParameters()

  return {
    entry: 'src/index.ts',
    // ...省略
    plugins: [
      new webpack.DefinePlugin({
        ...Object.keys(params).reduce(
          (payload, key) => ({ ...payload, [key]: JSON.stringify(params[key]) }),
          {}
        )
      })
    ]
  }
}

export default configFunction
```

SSMからパラメータをまとめて取得するには、`getStackParameters`を使えばOKです。

# 最後に

駆け足になってしまってわかりにくいところもあるかもです。
Github側のREADME.mdなどをこれから整理し、わかりやすくしていく予定です。
手伝ってくれる方募集中です！！

# （おまけ）Stackを適切にわけたい

Stack間でパラメータを参照する場合、いくつか方法がありますが、
aws-cdkでStack間でパラメータの受け渡しをすると、自動的にクロススタック参照になります。

クロススタック参照は便利ですが、致命傷を避ける必要があります。
致命傷の詳細は、いつもお世話になっているクラスメソッドさんのブログをご参照ください。
https://dev.classmethod.jp/cloud/aws/aws-all-iac/

私が実施している致命傷を避ける方法として、
運用上削除してはいけないユーザデータを含むリソース（RDS Instance, Cognito UserPool, DynamoDB Tableなど）を１つのStackにまとめ（以下UserDataStackと呼びます）

- UserDataStackを他のStackが参照する（依存する）
- UserDataStackは他のStackを参照してはならない（依存してはならない）。ただし、UserDataStackのリソースを作るためにどうしても必要なリソースのみ（例えば、RDS Instanceに対するVPCなど）、仕方ないので依存を許可。

というルールで構築しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322301/ac7da9e3-293a-0f77-bdfa-4f19943eedb4.png" width=75%>

こうしておけば、**Stack1〜4をすべて作り直し！！**とかできるので、致命傷になりにくいです。

おわり。