【AWS CDK入門】AWS CDKでAPI Gateway + Lambdaを構築してみた

# はじめに
AWS CDKってドキュメント見てもよくわからん...ってなったので、実際に動かしてみた。
すると、CDKすげええええぇってなったので、その感動を共有したくて記事を書いてみた。

### 対象とする読者
* AWS CDKとはなんぞやって人
* AWS CDKをお金をかけずに動かしてみたい人

# AWS CDK のこれだけ知っておけばOK

AWSのIaC (コードでインフラを管理)といえば、CloudFormation！
→ しかし、以下の課題が・・・
* YAML/JSONなので、ループや条件分岐などを用いたリソース定義が書けない
* 大規模なインフラだと、一から十まで書くの大変

これらを解決するのが、AWS CDK！
→ CloudFormation用のテンプレートをプログラム言語から生成しちゃおう

:::note
* TypeScriptやPythonなどでリソースを定義するのでループなど自由自在
* ライブラリでリソースが抽象化されているので記述量が大幅減
（抽象化 = REST APIを作るといったパターンが用意されている）
:::


# AWS CDK を触ってみよう
何はともあれ、触ってみよう

## やったこと
* AWS CDKの実行環境構築
* CDKが用意しているsample-app(SNS + SQS）の構築
* API Gateway + Lambdaの環境構築

## 筆者の環境
* macOS
* AWSアカウント、IAMユーザー作成済み
* Node.jsインストール済み

## 1. sample-app を読み解きそのままデプロイ

CDKが用意しているsample-appをそのままデプロイする。配備される資源は以下の通り。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3665927/a9585122-fc37-217c-732c-aadc7a98c3c5.png" width=600px>

### 1.1 事前準備
cdkコマンドが実行できるように、アクセスキーIDとシークレットアクセスキーを取得する。

1. IAMユーザでAWSマネジメントコンソールにログイン
1. IAMサービスに移動し、「自分の認証情報」を開く
1. 「アクセスキーを作成」から、アクセスキーIDとシークレットアクセスキーを作成
1. 以下内容のファイルを作成し、`~/.aws/credentials`として配置
```:~/.aws/credentials
[default]
aws_access_key_id = 【アクセスキーID】
aws_secret_access_key = 【シークレットアクセスキー】
```
[default]の部分は必要に応じて、profileを設定して使い分ける必要がある。

### 1.2 aws-cdkのインストール
```console
$ npm install -g aws-cdk
```
### 1.3 プロジェクト作成 (`cdk init`)
以下を実行して、プロジェクトを作成する。
```console
$ mkdir cdk-workshop && cd cdk-workshop
$ cdk init sample-app --language typescript
```
上記を実行すると、いくつかのディレクトリとファイルが作成される。後述するcdk synthコマンドでテンプレートを作成する際に実行されるコマンドは、`cdk.json`の以下の行である。
```:cdk.json
"app": "npx ts-node --prefer-ts-exts bin/cdk-workshop.ts",
```
したがって、まずは`bin/cdk-workshop.ts`を確認してみる。
```typescript:bin/cdk-workshop.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { CdkWorkshopStack } from '../lib/cdk-workshop-stack';

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

これを見ると、デプロイするスタックの内容は`lib/cdk-workshop-stack.ts`に定義されていることがわかる。そこで、次に`lib/cdk-workshop-stack.ts`を見てみる。
```typescript:lib/cdk-workshop-stack.ts
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subs from 'aws-cdk-lib/aws-sns-subscriptions';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { Construct } from 'constructs';

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

    const queue = new sqs.Queue(this, 'CdkWorkshopQueue', {
      visibilityTimeout: Duration.seconds(300)
    });

    const topic = new sns.Topic(this, 'CdkWorkshopTopic');

    topic.addSubscription(new subs.SqsSubscription(queue));
  }
}
```
これを見ると、デプロイ時に以下が実施されるようだ。
* 可視性タイムアウト300秒の「CdkWorkshopQueue」というキューの作成
* 「CdkWorkshopTopic」というSNSトピックの作成
* 「CdkWorkshopTopic」のサブスクリプションとして「CdkWorkshopQueue」の追加

### 1.4 CloudFormationテンプレート作成 (`cdk synth`)
次に以下コマンドを実行して、CloudFormaitonテンプレートを作成する。
```console
$ cdk synth
```
すると、以下のファイルが作成される。
```
cdk.out/
├── CdkWorkshopStack.assets.json
├── CdkWorkshopStack.template.json  <- CloudFormationテンプレート
├── cdk.out
├── manifest.json
└── tree.json
```

### 1.5 ブートストラップの実行 (`cdk bootstrap`)
cdkのデプロイに必要な、S3バケット、IAMロール、SSMパラメータ等を作成する。
**※cdkによるデプロイの初回のみ行う。**
```
$ cdk bootstrap
```

### 1.6 デプロイ (`cdk deploy`)
最後に以下コマンドを実行し、資源をデプロイする。
```console
$ cdk deploy
```
以下のようにIAMポリシーの変更内容について確認されるので、「y」と入力する。
```
IAM Statement Changes
┌───┬─────────────────────────────┬────────┬─────────────────────────────┬─────────────────────────────┬────────────────────────────────┐
│   │ Resource                    │ Effect │ Action                      │ Principal                   │ Condition                      │
├───┼─────────────────────────────┼────────┼─────────────────────────────┼─────────────────────────────┼────────────────────────────────┤
│ + │ ${CdkWorkshopQueue.Arn}     │ Allow  │ sqs:SendMessage             │ Service:sns.amazonaws.com   │ "ArnEquals": {                 │
│   │                             │        │                             │                             │   "aws:SourceArn": "${CdkWorks │
│   │                             │        │                             │                             │ hopTopic}"                     │
│   │                             │        │                             │                             │ }                              │
└───┴─────────────────────────────┴────────┴─────────────────────────────┴─────────────────────────────┴────────────────────────────────┘
(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)? 
```
:::note
**AWS CDK のここがすごい！！**
SNSからSQSキューへ通知するには、SQSキューのアクセスポリシーにそれを許可するIAMポリシーが必要であるが、CDKが自動で作成してくれていた！！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3665927/144f1666-0235-2832-92b4-b3257c1f4138.png" width=700px>
:::

## 2. API Gateway+Lambda環境を構築

### 2.1 スタック変更

以下のような環境を構築する。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3665927/eda34635-1334-83a5-6b41-c2868b392eae.png" width=600px>


**ただし、そのままだとAPIを誰でも叩けてしまうので、APIキーと使用量プランによる利用制限をかける。**

スタックの内容を以下のように変更する。
* 参考：[LambdaRestApiの公式ドキュメント](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_apigateway.LambdaRestApi.html)
```typescript:lib/cdk-workshop-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Runtime, Code } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { LambdaRestApi } from 'aws-cdk-lib/aws-apigateway';

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

    const helloLambda = new NodejsFunction(this, 'HelloHandler', {
      runtime: Runtime.NODEJS_18_X,
      entry: 'src/hello.ts',
      handler: 'handler'
    });
    const helloApi = new LambdaRestApi(this, 'hello-endpoint', {
      handler: helloLambda,
      defaultMethodOptions: { apiKeyRequired: true },
      proxy: false
    });
    const helloEndPoint = helloApi.root.addResource('hello');
    helloEndPoint.addMethod('GET');

    const apiKey = helloApi.addApiKey('ApiKey', {
      apiKeyName: 'hello-api-key'
    });
    const plan = helloApi.addUsagePlan('UsagePlan', {
      name: 'hello-api-usageplan'
    });
    plan.addApiKey(apiKey);
    plan.addApiStage({ stage: helloApi.deploymentStage });
  }
}
```
Lambda関数にデプロイするソースコードは以下の通り。
```typescript:src/hello.ts
import {
  APIGatewayEventRequestContextV2,
  APIGatewayProxyEventV2,
  APIGatewayProxyResultV2
} from 'aws-lambda';

export const handler = async (
  event: APIGatewayProxyEventV2,
  context: APIGatewayEventRequestContextV2
): Promise<APIGatewayProxyResultV2> => {
  console.log({ event, context });
  const responseBody = {
    message: 'Hello World!'
  };
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(responseBody)
  };
};
```

### 2.2 cdk synth & cdk deploy

→ デプロイすると、先ほど構築したSNS+SQS環境が削除され、新しくAPI Gateway+Lambda環境が構築される。

<details>
<summary>cdk synthコマンドを実行すると Error: spawnSync docker ENOENT が出る場合</summary>

```console
$ cdk synth
Error: spawnSync docker ENOENT
    at Object.spawnSync (node:internal/child_process:1117:20)
    at spawnSync (node:child_process:876:24)
    at dockerExec (/Path/to/directory/cdk-workshop/node_modules/aws-cdk-lib/core/lib/private/asset-staging.js:1:3585)
    at Function.fromBuild (/Path/to/directory/cdk-workshop/node_modules/aws-cdk-lib/core/lib/bundling.js:1:4718)
    at new Bundling (/Path/to/directory/cdk-workshop/node_modules/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.js:1:3776)
    at Function.bundle (/Path/to/directory/cdk-workshop/node_modules/aws-cdk-lib/aws-lambda-nodejs/lib/bundling.js:1:960)
    at new NodejsFunction (/Path/to/directory/cdk-workshop/node_modules/aws-cdk-lib/aws-lambda-nodejs/lib/function.js:1:1729)
    at new CdkWorkshopStack (/Path/to/directory/cdk-workshop/lib/cdk-workshop-stack.ts:11:25)
    at Object.<anonymous> (/Path/to/directory/cdk-workshop/bin/cdk-workshop.ts:6:1)
    at Module._compile (node:internal/modules/cjs/loader:1356:14) {
  errno: -2,
  code: 'ENOENT',
  syscall: 'spawnSync docker',
  path: 'docker',
  spawnargs: [
    'build',
    '-t',
    'cdk-805ab1d42744d346e7ee81b097a37bd764cf7a67af41bcccaad6ccee384363e2',
    '--platform',
    'linux/amd64',
    '--build-arg',
    'IMAGE=public.ecr.aws/sam/build-nodejs18.x',
    '--build-arg',
    'ESBUILD_VERSION=0',
    '/Path/to/directory/cdk-workshop/node_modules/aws-cdk-lib/aws-lambda-nodejs/lib'
  ]
}
```
NodejsFunctionでは、esbuildを用いてTypeScriptをビルドする。
macOSでは手動でインストールする方が良いらしく、以下を実施すると解消した。
参考：[[備忘録] Error: spawnSync docker ENOENTの解消](https://qiita.com/m-chan/items/1a3f4d53d3470c32405c)
```console
$ npm i -D esbuild
```
</details>

### 2.3 APIのテスト

URLをコンソールから確認
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3665927/d4ac21a5-2f04-81d4-4a34-105e863fa03d.png" width=700px>

APIキーをコンソールから確認
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3665927/58387da8-7a1e-8e69-e633-1b5735a2430d.png" width=700px>

APIキーをヘッダーに渡して以下を実行
```console
$ curl -X GET -H "x-api-key: APIキー" https://xxxx.execute-api.us-east-1.amazonaws.com/prod/hello
{"message":"Hello World!"} 
```
ちなみに誤ったAPIキーを指定すると以下のようになる
```console
$ curl -X GET -H "x-api-key: 誤ったAPIキー" https://xxxx.execute-api.us-east-1.amazonaws.com/prod/hello
{"message":"Forbidden"} 
```

# まとめ・所感

* AWS CDKはすごい！！ちょっとした検証を行うとき、ささっと環境構築できそう！！
* リソースの定義・連携の記述方法は、慣れないとちょっと難しい。
→ 結局公式のドキュメントが一番わかりやすいかも？

# 参考文献
以下の記事を参考にさせていただきました。ありがとうございます。

https://frontse.hatenablog.jp/entry/cdk_workshop1

https://frontse.hatenablog.jp/entry/cdk_workshop2

https://qiita.com/the_red/items/7ec40e078e042a3629ec