今更ながらAWS CDKを使ってみた

# はじめに

今更ですがAWS CDKを使ってみました。
CDKはCloud Development Kitの略で、これを使うとインフラをコードで表現することができます。
CloudFormationやTerraformでも同じようなことができますが、大きな違いは次の点となります。

* 高レベルAPIが用意されていて少ない行数で書ける
* エディタの補完が効く
* テストがかける
* CLIが用意されていて、デプロイやdiff等の操作が簡単に行える
* プログラムとして書けるのでforやifなども思いのまま

公式: [AWS クラウド開発キット – アマゾン ウェブ サービス](https://aws.amazon.com/jp/cdk/)

なお内部的にはCloudFormationが用いられますが、CDKがラップしているのでCloudFormationには一切手を触れることはありません。

# 何を作ったのか

構成はこんな感じです。図中のAWS SDK部分は既存のサービスで、それと連携するAWSサービスを今回CDKを使って構築してみました。
図には書いていませんが、LambdaではDynamoDBに記録されている日時とEC2インスタンスIDのレコードを取得して、次に実行している日時と一致した場合そのEC2インスタンスを停止させ、最後にレコードを削除するという処理をしています。

![4b89e2e2-bd60-479d-aa93-45c38fc4e157.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27674/dd7dcc5e-d3fb-967f-943e-72cafdf54f57.png)

# CDKインストール

このコマンドを実行するとCDKのインストールと初期化が行われコーディングに入れます。

```bash
npm install -g aws-cdk
mkdir sample
cd $_
cdk init --language typescript
yarn add '@aws-cdk/aws-dynamodb' '@aws-cdk/aws-lambda' '@aws-cdk/aws-iam' '@aws-cdk/aws-events' '@aws-cdk/aws-events-targets'
```

# コーディング

実行の起点となるファイルはbinディレクトリ内にあるファイルです。
ファイル名やクラス名などは親ディレクトリのものが使われます。初期状態ではこんな感じになっています。
※なので親ディレクトリ名はよく考えたほうがいいです

```typescript
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { SampleStack } from '../lib/sample-stack';
const app = new cdk.App();
new SampleStack(app, 'SampleStack');
```

lib/sample-stack.tsにコードを書いていきます。
LambdaでEC2を停止させる処理があるのでその権限も付けています。

特筆するべきはLambdaにDynamoDBへのアクセス権を付与している箇所です。
コードを抜粋すると次の一行です。すごくないですか？私は感動しました。

```
    table.grantFullAccess(lambda_function);
```

```typescript
import * as cdk from '@aws-cdk/core';
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { Function, AssetCode, Runtime } from '@aws-cdk/aws-lambda';
import { PolicyStatement } from "@aws-cdk/aws-iam";
import { Rule, Schedule } from '@aws-cdk/aws-events';
import { LambdaFunction } from '@aws-cdk/aws-events-targets';

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

    const table: Table = new Table(this, "Ec2ManagerTable", {
      partitionKey: {
        name: "scheduled_at",
        type: AttributeType.STRING
      },
      sortKey: {
        name: "instance_id",
        type: AttributeType.STRING
      },
      readCapacity: 1,
      writeCapacity: 1,
      tableName: 'ec2-manager'
    });

    const lambda_function: Function = new Function(this, 'Ec2Operator', {
      runtime: Runtime.NODEJS_10_X,
      code: AssetCode.fromAsset('src/lambda'),
      handler: 'index.handler',
      timeout: cdk.Duration.seconds(30),
      environment: {
        TZ: "Asia/Tokyo",
        TABLE_NAME: table.tableName
      }
    });

    table.grantFullAccess(lambda_function);

    lambda_function.addToRolePolicy(
        new PolicyStatement({
          resources: [`arn:aws:ec2:*:*:*`],
          actions: [
            "ec2:stopInstances",
          ],
        })
    );

    const rule = new Rule(this, 'Rule', {
      schedule: Schedule.expression('cron(0 * * * ? *)')
    });

    rule.addTarget(new LambdaFunction(lambda_function));
  }
}
```

次にLambdaのコードを書いていきます。
`AssetCode.fromAsset('src/lambda')`としたのでsrc/lambda配下にindex.jsとして書いていきます。

```javascript
exports.handler = async function(event, context) {
    var AWS = require("aws-sdk");
    var dynamo = new AWS.DynamoDB.DocumentClient();
    var ec2 = new AWS.EC2();

    const today = new Date();
    const month = today.getMonth() + 1; // 0 start
    const day = today.getDate();
    const hour = today.getHours();
    const targetDate = month + '/' + day + ' ' + hour;

    const dynamo_param = {
        TableName: process.env.TABLE_NAME,
        KeyConditionExpression: "#k = :val",
        ExpressionAttributeValues: { ":val": targetDate },
        ExpressionAttributeNames: { "#k": "scheduled_at" }
    };

    let ec2_params = {
        InstanceIds: []
    };

    const query_result = await dynamo.query(dynamo_param, function(err, data) {
        if (err) {
            console.log(err, err.stack);
        } else {
            return data;
        }
    }).promise();

    query_result.Items.forEach(function(data) {
        ec2_params.InstanceIds.push(data.instance_id);
    });

    const ec2_result = await ec2.stopInstances(ec2_params, function(err, data) {
        if (err) {
            console.log(err);
        } else {
            return data;
        }
    }).promise();

    for (let instance of ec2_result.StoppingInstances) {
        const params = {
            TableName: process.env.TABLE_NAME,
            Key: {
                "scheduled_at": targetDate,
                "instance_id": instance.InstanceId
            }
        };
        await dynamo.delete(params, function(err, data) {
            console.log('DELETE: ' + JSON.stringify(data));
            if (err) {
                console.log(err);
            } else {
                console.log(JSON.stringify(data));
            }
        }).promise();
    };
};
```

# デプロイ

いよいよデプロイです。
デプロイする前に、初めてCDKを使う場合は次のコマンドを実行する必要があります。
これにより`CDKToolkit`という名前でCloudFormationスタックが作成され、CDK用のS3バケットが作成されます。
なおAWSクレデンシャルのプロファイルを切り替えたい場合は普通に`--profile (profile名)`とすれば切り替えられます。

```bash
cdk bootstrap
```

一度TypeScriptからJavaScriptのコードを出力する必要があるので次のコマンドを実行します。

```bash
npm run build
```

もしくは次のコマンドを実行すると、コードに更新が発生次第自動で出力してくれます。

```bash
npm run watch
```

最後に次のコマンドを実行するとデプロイが行われます。

```bash
cdk deploy
```

実行するとログが出力されながら作成されてゆきます。
途中IAMリソースの作成確認がされますが`y`と回答します。

```bash
(node:3699) ExperimentalWarning: The fs.promises API is experimental
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬─────────────────────────────────┬────────┬───────────────────────┬─────────────────────────────────┬───────────────────────────────────────────────┐
│   │ Resource                        │ Effect │ Action                │ Principal                       │ Condition                                     │
├───┼─────────────────────────────────┼────────┼───────────────────────┼─────────────────────────────────┼───────────────────────────────────────────────┤
│ + │ ${SampleLambda.Arn}             │ Allow  │ lambda:InvokeFunction │ Service:events.amazonaws.com    │ "ArnLike": {                                  │
│   │                                 │        │                       │                                 │   "AWS:SourceArn": "${Rule.Arn}"              │
│   │                                 │        │                       │                                 │ }                                             │
├───┼─────────────────────────────────┼────────┼───────────────────────┼─────────────────────────────────┼───────────────────────────────────────────────┤
│ + │ ${SampleLambda/ServiceRole.Arn} │ Allow  │ sts:AssumeRole        │ Service:lambda.amazonaws.com    │                                               │
├───┼─────────────────────────────────┼────────┼───────────────────────┼─────────────────────────────────┼───────────────────────────────────────────────┤
│ + │ ${SampleTable.Arn}              │ Allow  │ dynamodb:*            │ AWS:${SampleLambda/ServiceRole} │                                               │
├───┼─────────────────────────────────┼────────┼───────────────────────┼─────────────────────────────────┼───────────────────────────────────────────────┤
│ + │ arn:aws:ec2:*:*:*               │ Allow  │ ec2:stopInstances     │ AWS:${SampleLambda/ServiceRole} │                                               │
└───┴─────────────────────────────────┴────────┴───────────────────────┴─────────────────────────────────┴───────────────────────────────────────────────┘
IAM Policy Changes
┌───┬─────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                    │ Managed Policy ARN                                                             │
├───┼─────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${SampleLambda/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴─────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
SampleStack: deploying...
[0%] start: Publishing 42294a6a15b0d7af1c80cfb86028837abdd1bcfe8ac996fcb6a197311e11f113:current
[100%] success: Published 42294a6a15b0d7af1c80cfb86028837abdd1bcfe8ac996fcb6a197311e11f113:current
SampleStack: creating CloudFormation changeset...
 0/8 | 12:58:47 PM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table    | SampleTable (SampleTable08FBB2D0)
 0/8 | 12:58:47 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata      | CDKMetadata
 0/8 | 12:58:47 PM | CREATE_IN_PROGRESS   | AWS::IAM::Role          | SampleLambda/ServiceRole (SampleLambdaServiceRoleB1A8618F)
 0/8 | 12:58:47 PM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table    | SampleTable (SampleTable08FBB2D0) Resource creation Initiated
 0/8 | 12:58:48 PM | CREATE_IN_PROGRESS   | AWS::IAM::Role          | SampleLambda/ServiceRole (SampleLambdaServiceRoleB1A8618F) Resource creation Initiated
 0/8 | 12:58:49 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata      | CDKMetadata Resource creation Initiated
 1/8 | 12:58:49 PM | CREATE_COMPLETE      | AWS::CDK::Metadata      | CDKMetadata
 2/8 | 12:59:03 PM | CREATE_COMPLETE      | AWS::IAM::Role          | SampleLambda/ServiceRole (SampleLambdaServiceRoleB1A8618F)
 3/8 | 12:59:18 PM | CREATE_COMPLETE      | AWS::DynamoDB::Table    | SampleTable (SampleTable08FBB2D0)
 3/8 | 12:59:20 PM | CREATE_IN_PROGRESS   | AWS::IAM::Policy        | SampleLambda/ServiceRole/DefaultPolicy (SampleLambdaServiceRoleDefaultPolicy7CCBB3B5)
 3/8 | 12:59:22 PM | CREATE_IN_PROGRESS   | AWS::IAM::Policy        | SampleLambda/ServiceRole/DefaultPolicy (SampleLambdaServiceRoleDefaultPolicy7CCBB3B5) Resource creation Initiated
 4/8 | 12:59:36 PM | CREATE_COMPLETE      | AWS::IAM::Policy        | SampleLambda/ServiceRole/DefaultPolicy (SampleLambdaServiceRoleDefaultPolicy7CCBB3B5)
 4/8 | 12:59:39 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Function   | SampleLambda (SampleLambdaB2FF4FA1)
 4/8 | 12:59:39 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Function   | SampleLambda (SampleLambdaB2FF4FA1) Resource creation Initiated
 5/8 | 12:59:40 PM | CREATE_COMPLETE      | AWS::Lambda::Function   | SampleLambda (SampleLambdaB2FF4FA1)
 5/8 | 12:59:42 PM | CREATE_IN_PROGRESS   | AWS::Events::Rule       | Rule (Rule4C995B7F)
 5/8 | 12:59:43 PM | CREATE_IN_PROGRESS   | AWS::Events::Rule       | Rule (Rule4C995B7F) Resource creation Initiated
5/8 Currently in progress: Rule4C995B7F
 6/8 | 1:00:43 PM | CREATE_COMPLETE      | AWS::Events::Rule       | Rule (Rule4C995B7F)
 6/8 | 1:00:45 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Permission | SampleLambda/AllowEventRuleSampleStackRuleB7F44284 (SampleLambdaAllowEventRuleSampleStackRuleB7F44284B9B1AC02)
 6/8 | 1:00:46 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Permission | SampleLambda/AllowEventRuleSampleStackRuleB7F44284 (SampleLambdaAllowEventRuleSampleStackRuleB7F44284B9B1AC02) Resource creation Initiated

 ✅  SampleStack
```

これでデプロイが完了しました！CLIが用意されているので簡単ですね。

CloudFormationを確認してもしっかり作成されています。

<img width="840" alt="スクリーンショット 2020-04-18 13.04.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27674/18836295-4532-2b2a-c414-e194d5db9145.png">

次のコマンドでコードと実際のインフラの差分を見ることができます。

```bash
cdk diff
```

インフラの削除は次のコマンドで行なえます。

```bash
cdk destroy
```

# 最後に

AWS CDKはプログラミングの知識が必要となるので、今までなんとなくYAMLでいいか…などと考えていましたが、実際に使ってみると便利で使いやすく感じました。

なんといってもエディタ（IDE）の補完が効くのがいいです。
コーディングの最中に必須のパラメーターが足りないよ、追加しようか？などIDEが聞いてくるのでとても捗りました。

これからはAWS CDKを使ってバシバシデプロイしていきます！