AWS CDKとCDK Pipelinesでつくるサーバレスアプリケーション

# この記事について
AWS CDKv2でCDK PipelinesがGAになって大分たちますが、非常にラクにパイプラインが作成できて便利に感じました。  
特に、サーバレスアプリケーションの場合は、Lambda関数を修正したのですぐに配備したい、というときに便利ですね。  
この記事では、AWS CDKとCDK Pipelinesを用いて、以下のことを試してみます。

1. AWS CDKを利用してAPI Gateway＋Lambdaでサーバレスアプリケーションを配備します。  
合わせて「開発面」「ステージング面」・・・なども意識して、１つのコードから複数面の作成を行います。
1. CDK Pipelinesを用いて、上記スタックをパイプライン化してみます。
1. CDK Pipelinesのself-mutatingを試してみます。

すでにCDKでスタック作成している方も、これを参考にCDK Pipelinesにトライしてもらえると幸いです。

# この記事の対象者
- AWS CDKに興味がある方
- CDK Pipelinesに興味がある方

# CDK Pipelinesとは
- マニュアル  
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/cdk_pipeline.html  
`CDK Pipelines are self-updating. If you add application stages or stacks, the pipeline automatically reconfigures itself to deploy those new stages or stacks.`  
self-updatingとか、self-mutatingなどと表現されておりましたが、CodeCommitやGitHubにコードをpushすると、CDK Pipelinesがパイプラインそのものを変更してくれる、という動きをします。このあと、実際に試してみたいと思います。

# 事前準備
1. あらかじめご準備いただきたいもの  
AWSアカウントと、IAMユーザはあらかじめ準備いただいている前提でお話いたします。
1. CDK実行環境について  
一番手軽に準備ができますので、筆者はいつもCloudShell上でCDKを実行しております。  
CloudShellを起動して、`sudo npm install -g aws-cdk`を実行してください。  
CloudShellは放置しているとセッションが切れて、/homeディレクトリ以外のデータは削除されてしまいますのでご注意ください。（消えた場合はまたインストールすれば利用できます）
1. cdk bootstrap実行  
AWSアカウント＞リージョン毎に、CDKを初めて利用する際に１回だけ実行が必要になります。  
`cdk bootstrap aws://ACCOUNT-NUMBER/REGION`

# 本手順を通じて作成するもの
![aws_arch2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/d2ffb276-34de-da5d-cfbf-145ade77abc3.jpeg)
API Gatewayと、その背後で動くLambdaを作成します。  
作成を容易に行うために、API GatewayもLambdaもVPC外に作成しています。  
面毎にパイプラインを作成するために、面に対応したブランチを作成します。

# １段階目：API Gateway＋Lambdaを作る
## CDKにおける内部処理について
AWS CDKはコードからCloudFormationテンプレート（CFn Template）を生成し、それを用いてCloudFormationスタックを起動します。  
CDKコードに環境に依存する情報（AWSアカウントIDやリージョン情報など）をハードコードしてしまうと再利用性が損なわれます。その時に役に立つのが「Context」という機能になります。Contextという機能により、外部の環境情報を読み込み、CDKコードと「合成」してCFnテンプレートを生成します。今回は、1つのコードから複数面を作成するために、この「Context」機能を応用してみました。環境を配備する際に環境面を指定したいので、コマンドライン引数でContext情報を指定します。
## CDKを利用した開発の流れについて
以下の流れで開発を行います。
1. 開発プロジェクトの作成  
`cdk init app --language=typescript` ※TypeScript、Python、Java、C#、Goが利用可能
1. CDKコードの実装  
お好きなエディタを使ってCDKコードを実装してください。
1. CDKコードと環境情報の合成（CFnテンプレートの作成）  
`cdk synth`
Context情報は以下のように指定してください。（-cオプションで指定）  
`cdk synth -c ENV=DEV1`
1. AWS環境への配備
`cdk deploy -c ENV=DEV1`  
Context情報は以下のように指定してください。（-cオプションで指定）
## 実装前の準備
以下のコマンドを実行して、CDKの開発プロジェクトを作成します。  
`mkdir cp_sample && cd cp_sample && cdk init app --language=typescript`  
後で出てまいりますが、Node.jsを実行環境とするLambdaをCDKで配備する際にts-nodeが必要でしたのでここでインストールしておきます。  
`npm install ts-node --save-dev`
## API Gateway + Lambdaを配備するCDKを実装してみましょう
cdk initで作成したテンプレートに以下のコードを実装していきます。  
```:ディレクトリ構成
bin
└── cp_sample.ts            CDKエントリポイント
env
└── cp-env.ts               複数面作成するために細工した環境変数を保持するインタフェース
lambda
└── hello-world.ts          CDK内で配備するLambda関数
lib
└── apigateway-stack.ts     API Gateway＋Lambdaを配備するスタック
```
```typescript:bin/cp_sample.ts
  #!/usr/bin/env node
  import 'source-map-support/register';
  import * as cdk from 'aws-cdk-lib';
  import ApiGatewayStack from '../lib/apigateway-stack';
  import * as cp from '../env/cp-env';
  
  const app = new cdk.App();
  const env = cp.getEnv(app.node);
  new ApiGatewayStack(app, `ApiGatewayStack-${env.side}`, {});
```
作成する「面」の情報を取得するために`cp.getEnv(app.node)`を実行していますが、これは`cp-env.ts`の関数として実装しています。  
`ApiGatewayStack()`の第二引数がCFnスタックの名前になります。この名前に「面」の情報を連結して、面毎に名前が変わるようにしてあります。
```typescript:env/cp-env.ts
  import { Node } from 'constructs';
  
  export interface IEnvironment {
      side: string,
      message: string,
  }
  
  export interface CPEnvironment {
      [env: string]: IEnvironment,
  };
  
  // 環境「面」を追加したい場合は、以下のインデックスを追加する。
  export const env: CPEnvironment = {
      ['DEV1']: {
          side: 'DEV1',
          message: 'I am DEV1.',
      },
      ['DEV2']: {
          side: 'DEV2',
          message: 'I am DEV2.',
      },
  }
  
  export function getEnv(node: Node): IEnvironment{
      // cdk deploy -c ENV=DEV1 といった形で-cで指定された値を取得する。
      const envContext = node.tryGetContext('ENV');
      if(!envContext){
          throw new Error('ENV context is required.');
      } else if (!(envContext in env)){
          throw new Error(`The specified value ${envContext} does not exist in the environment variables.`);
      }
      return env[envContext];
  }
```
cdk実行時に`c- ENV=DEV1`といった形で指定する想定で上記実装をしています。  
`-c`の指定を忘れた場合や、想定外の値が指定されたことも踏まえてチェック処理も入れてみました。  
環境毎に設定を変更したいものを`CPEnvironment`に追加していく使い方です。
```typescript:lib/apigateway-stack.ts
  import * as cdk from 'aws-cdk-lib';
  import * as lambda from 'aws-cdk-lib/aws-lambda';
  import * as lambdaNodeJS from 'aws-cdk-lib/aws-lambda-nodejs';
  import * as apigateway from 'aws-cdk-lib/aws-apigateway';
  import { Construct } from 'constructs';
  import * as cp from '../env/cp-env';
  
  export interface ApiGatewayProps extends cdk.StackProps {
  }
  
  export default class ApiGatewayStack extends cdk.Stack {
      public readonly url: cdk.CfnOutput;
      constructor(scope: Construct, id: string, props: ApiGatewayProps) {
          super(scope, id, props);
  
          const env = cp.getEnv(this.node);
  
          const helloWorldLambda = new lambdaNodeJS.NodejsFunction(this, `MyLambda-${env.side}`, {
              entry: 'lambda/hello-world.ts',
              handler: 'handler',
              runtime: lambda.Runtime.NODEJS_16_X,
              functionName: `HelloWorldLambda-${env.side}`,
              environment: { "MY_MESSAGE": env.message },
          });
  
          const helloWorldGateway = new apigateway.LambdaRestApi(this, `MyApiGateway-${env.side}`, {
              handler: helloWorldLambda,
              restApiName: `HelloWorldGateway-${env.side}`,
              proxy: false,
          });
          const hoge = helloWorldGateway.root.addResource('hoge');
          hoge.addMethod('GET', new apigateway.LambdaIntegration(helloWorldLambda));
          const fuga = helloWorldGateway.root.addResource('fuga');
          fuga.addMethod('GET', new apigateway.LambdaIntegration(helloWorldLambda));
          const bar = helloWorldGateway.root.addResource('bar');
          bar.addMethod('GET', new apigateway.LambdaIntegration(helloWorldLambda));
          
          this.url = new cdk.CfnOutput(this, "API_GATEWAY_ADDRESS", {value: helloWorldGateway.url});
      }
  }
```
本コードの中で、Lambda関数の配備と、API Gatewayの配備を行っています。  
ここでも`getEnv()`から「面」情報を取り出して、各リソースの名前組み立てに使っています。  
面毎にふるまいを変更する使い方をイメージして、Lambda関数の環境変数に面毎に切り替えたい値をいれてみました。  
３段階目の実装時に利用するので、API GatewayのURLをCfnOutputしてあります。  
```typescript:lambda/hello-world.ts
  import * as lambda from 'aws-lambda';
  
  export const handler = async (event: lambda.APIGatewayProxyEventV2, context: lambda.APIGatewayEventRequestContextV2): Promise<lambda.APIGatewayProxyResultV2> => {
  
      const message = process.env.MY_MESSAGE;
      const responseBody = { message: `Hello! ${message}`};
      return {
          statusCode: 200,
          headers: { "Content-Type": "application/json"},
          body: JSON.stringify(responseBody),
      }
  
  }
```  
Lambda環境変数から値を受け取り、それをJSONとして応答しています。  
## 配備してみましょう
`npx cdk deploy -c ENV=DEV1`  
`npx cdk deploy -c ENV=DEV2`  
上記コマンドを実行すると、２面分作成できます。  
- CloudFormation画面
![cfn1.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/931cfaa7-78b9-d7c7-6e58-3750dc6ced86.jpeg)
- API Gateway画面  
![apigateway1.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/246aea20-3dad-e043-c378-13ddf716405f.jpeg)
- Lambda画面  
![lambda1.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/26931731-303c-54c4-ba85-c30ec7eda9b2.jpeg)
API Gateway＞どちらかの面のAPIを選択＞ステージから任意のURLにアクセスしていただき、  
`{"message":"Hello! I am DEV1."}`が表示されれば成功です。（messageの内容は面により変わります）  
確認ができたら、削除しておきましょう。以下のコマンドで削除できます。  
`npx cdk destroy -c ENV=DEV1`  
`npx cdk destroy -c ENV=DEV2`  

# ２段階目：CDK Pipelines化してみる
## 実装前の準備
パイプラインを作成しますので、パイプライン起動の起点となるgitリポジトリとブランチが必要です。  
今回は、CodeCommitに「CPSample」という名前のリポジトリを準備します。（AWSマネジメントコンソール上で、手で作成してください）  
開発面毎にパイプラインを動かすことを模擬して、以下の形でブランチを作成します。  
- DEV1 -> DEV1面向けブランチ
- DEV2 -> DEV2面向けブランチ
## API Gateway + LambdaのCDKコードをパイプライン化してみましょう
前に作成したAPI GatewayやLambda作成のプロジェクトをそのまま利用します。  
lib/pipeline-stack.tsを新規で実装し、bin/cp_sample.tsを修正します。  
API Gateway+Lambdaを作成するlib/apigateway-stack.tsは修正不要です。  
```:ディレクトリ構成
bin
└── cp_sample.ts            CDKエントリポイント　★修正★
env
└── cp-env.ts               複数面作成するために細工した環境変数を保持するインタフェース
lambda
└── hello-world.ts          CDK内で配備するLambda関数
lib
└── apigateway-stack.ts     API Gateway＋Lambdaを配備するスタック
└── pipeline-stack.ts       CDK Pipelinesを配備するスタック　★追加★
```
```typescript:lib/pipeline-stack.ts
  import * as cdk from 'aws-cdk-lib';
  import * as pipelines from 'aws-cdk-lib/pipelines';
  import * as codecommit from 'aws-cdk-lib/aws-codecommit';
  import { Construct } from 'constructs';
  import * as cp from '../env/cp-env';
  import ApiGatewayStack from '../lib/apigateway-stack';
  
  export default class MyPipelineStack extends cdk.Stack {
  
      constructor(scope: Construct, id: string, props?: cdk.StackProps) {
          super(scope, id, props);
  
          const env = cp.getEnv(this.node);
  
          // ポイント１
          const pipeline = new pipelines.CodePipeline(this, `MyPipeline-${env.side}`, {
              pipelineName: `MyPipeline-${env.side}`,
              synth: new pipelines.ShellStep('Synth', {
                  input: pipelines.CodePipelineSource.codeCommit(
                      codecommit.Repository.fromRepositoryName(this, `MyCodeCommit-${env.side}`, 'CPSample'),
                      `${env.side}`, {}),
                  commands: [
                      'npm ci',
                      'npm run build',
                      `npx cdk synth -c ENV=${env.side}`,  // ポイント３
                  ],
              }),
          });
  
          // ポイント２
          const myApp = new MyApplication(this, `MyAppStage-${env.side}`, {});
          const apiGatewayStage = pipeline.addStage(myApp);
      }
  
  }
  
  export interface MyAppProps extends cdk.StackProps {
  }
  
  // ポイント２
  class MyApplication extends cdk.Stage {
      constructor(scope: Construct, id: string, props: MyAppProps) {
          super(scope, id, props);
          const env = cp.getEnv(this.node);
          const apiGatewayStack = new ApiGatewayStack(this, `MyGateway-${env.side}`, {});
      }
  }
```
上記コード内コメントの「ポイント」と合わせて解説します。
- ポイント１  
パイプラインを作成します。パイプラインの起点となるCodeCommitリポジトリ（CPSample）を指定しています。  
プロパティとして指定している「synth」は必須です。ここでCDKコードをsynthして、CFnテンプレートを作成するイメージです。  
- ポイント２  
１段階目で作成したAPI GatewayスタックをStageとしてpipelineに登録します。  
前にも記載しましたが、既存のコード（ApiGatewayStack）に手を加える必要はないので便利ですね。
- ポイント３  
環境「面」毎にパイプラインを作成するポイントになります。  
DEV1ブランチにコードがpushされると、それに対応したパイプラインが稼働します。  
内部で作成されるAPI GatewayスタックにContext情報を伝えるため、`-c ENV=DEV1`となるようにここでオプション指定を行っています。
```diff_typescript:bin/cp_sample.ts
  #!/usr/bin/env node
  import 'source-map-support/register';
  import * as cdk from 'aws-cdk-lib';
- import ApiGatewayStack from '../lib/apigateway-stack';
+ import MyPipelineStack from '../lib/pipeline-stack';
  import * as cp from '../env/cp-env';
  
  const app = new cdk.App();
  const env = cp.getEnv(app.node);
- new ApiGatewayStack(app, `ApiGatewayStack-${env.side}`, {});
+ new MyPipelineStack(app, `MyPipelineStack-${env.side}`, {});
```
MyPipelineStackを実行するように修正します。ApiGatewayStackの実行は不要になりますので削除しましょう。
## 配備してみましょう
１段階目と同様、下記コマンドで配備することができます。  
`npx cdk deploy -c ENV=DEV1` ※DEV1ブランチをcheckoutした状態で実行  
`npx cdk deploy -c ENV=DEV2` ※DEV2ブランチをcheckoutした状態で実行  
１つのパイプラインが終わるまで10分程度かかるので少々お待ちください。  
実行が終わると以下のような形で、CFnスタックがそれぞれ２つずつ作成されます。  
- CloudFormation画面
![cfn2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/aae687ed-c931-147b-0d83-c60fcb7e3444.jpeg)
MyPipelineStack-DEV1はCDK Pipelinesを配備した際に起動されるスタックです。  
MyAppStage-DEV1-MyGateway-DEV1はCDK Pipelinesが作成したスタックです。  
- CodePipeline画面  
![codepipeline1.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/acfc2791-d116-8021-80d5-58f298e6d67d.jpeg)

# ３段階目：self-updatingを体験してみる
パイプラインの最後に、API Gatewayがきちんと配備できたかどうかを確認するために、テストを行うコードを追加してみましょう。
```diff_typescript:lib/pipeline-stack.ts
  import * as cdk from 'aws-cdk-lib';
  import * as pipelines from 'aws-cdk-lib/pipelines';
  import * as codecommit from 'aws-cdk-lib/aws-codecommit';
  import { Construct } from 'constructs';
  import * as cp from '../env/cp-env';
  import ApiGatewayStack from '../lib/apigateway-stack';
  
  export default class MyPipelineStack extends cdk.Stack {
  
      constructor(scope: Construct, id: string, props?: cdk.StackProps) {
          super(scope, id, props);
  
          const env = cp.getEnv(this.node);
  
          // ポイント１
          const pipeline = new pipelines.CodePipeline(this, `MyPipeline-${env.side}`, {
              pipelineName: `MyPipeline-${env.side}`,
              synth: new pipelines.ShellStep('Synth', {
                  input: pipelines.CodePipelineSource.codeCommit(
                      codecommit.Repository.fromRepositoryName(this, `MyCodeCommit-${env.side}`, 'CPSample'),
                      `${env.side}`, {}),
                  commands: [
                      'npm ci',
                      'npm run build',
                      `npx cdk synth -c ENV=${env.side}`,  // ポイント３
                  ],
              }),
          });
  
          // ポイント２
          const myApp = new MyApplication(this, `MyAppStage-${env.side}`, {});
          const apiGatewayStage = pipeline.addStage(myApp);
  
+         // ポイント４
+        apiGatewayStage.addPost(new pipelines.ShellStep(`GET-TEST-${env.side}`, {
+             envFromCfnOutputs: {
+                 URL: myApp.apiGatewayAddress,
+             },
+             commands: [`curl -Ssf $URL/bar`],
+        }));
      }
  
  }
  
  export interface MyAppProps extends cdk.StackProps {
  }
  
  // ポイント２
  class MyApplication extends cdk.Stage {
+     public readonly apiGatewayAddress: cdk.CfnOutput;  // ★ポイント４★
      constructor(scope: Construct, id: string, props: MyAppProps) {
          super(scope, id, props);
          const env = cp.getEnv(this.node);
          const apiGatewayStack = new ApiGatewayStack(this, `MyGateway-${env.side}`, {});
+         this.apiGatewayAddress = apiGatewayStack.url;  // ★ポイント４★
      }
  }
```
追加した箇所について解説します。コードを追加したのはlib/pipeline-stack.tsのみ。  
- ポイント４  
`MyAppStage-${env.side}`の配備が完了した後にステップを１つ追加しています（`addPost()`が該当）。  
ShellStepはパイプラインを作成する際にも登場しておりますが、CodeBuild上でシェルコマンドを実行する仕組みです。今回は、curlでAPI GatewayのURLにGETアクセスしています。curlの宛先URLが`$URL`となっておりますが、これは、`envFromCfnOutputs`プロパティで指定しています。CfnOutputの値を、CodeBuild内で環境変数として引き継いでくれる仕組みです。CfnOutputの値そのものは、`MyApplication`クラス内で引継ぎを行っています。（// ★ポイント４★ の箇所）
## パイプラインを動かしてself-updatingを体感してみましょう
実装が終わったらCodeCommitにpushしてみましょう。数十秒ほどでpushした方のブランチに紐づくCodePipelineが動き始めます。  
自明ですが、`npx cdk deploy・・・`は実行不要です。  
最初に配備した時のパイプラインは以下のイメージですが、  
![codepipeline2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/79a49e0d-d0bb-e7fe-ae65-5f09d6569e06.jpeg)  
DEV1リポジトリにコードをpushした後のパイプラインはこのような形になります。`GET-TEST-DEV1`ステップを追加できたことが分かるかと思います。  
![codepipeline3.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988239/6873ef39-428d-ec5a-2585-1f1acba6c4dc.jpeg)
なお、`GET-TEST-DEV1`の「詳細」をクリックするとCodeBuildのログが確認できます。
```
・・・(略)・・・
[Container] 2022/11/27 07:27:41 Entering phase BUILD
[Container] 2022/11/27 07:27:41 Running command curl -Ssf $URL/bar
{"message":"Hello! I am DEV1."}
[Container] 2022/11/27 07:27:43 Phase complete: BUILD State: SUCCEEDED
[Container] 2022/11/27 07:27:43 Phase context status code:  Message: 
・・・(略)・・・
```
テストも成功していますね。なお、`$URL`の値はCodeBuil画面の「環境変数」タブで確認できます。
## 確認が終わったら削除しましょう
以下のコマンドで削除できます。  
`npx cdk destroy -c ENV=DEV1` ※DEV1ブランチをcheckoutした状態で実行  
`npx cdk destroy -c ENV=DEV2` ※DEV2ブランチをcheckoutした状態で実行  
CDK Pipelines内で作成されたスタックは上記コマンドでは削除されないようです。こちらについては、CloudFormation画面から削除するようにしてください。  

# 補足：CDKコマンドの実行方法について
cdkコマンドを実行する際に、`npx`を利用しておりました。こちらについて補足しておきます。  
CDK開発プロジェクトを作成する際（`cdk init app --language=typescript`）は、グローバル環境にインストールされたcdkコマンドを実行しております。  
その後、CDK開発プロジェクトの中でcdkコマンドを発行するときには、一貫して`npx cdk deploy`といった形で**npx～**で実行しています。  
実際の運用では、CDK開発プロジェクトごとgitで管理することになると思いますが、npxでcdkコマンドを実行することにより、gitでコードを共有された開発者がみな同じバージョンのcdkコマンドを実行することができます。（cdk initで開発プロジェクトを作成した際に、node_module内にcdkもインストールされます。）  
CDKは非常にバージョンアップの速度が速いため（体感的には１か月程度でマイナーバージョンが上がっていく）、運用上もこの点は意識しておいたほうがよいと思います。

# まとめ
- AWS CDKを用いて、API GatewayとLambda関数を配備してみました。
- CDKのContextを活用して、同一のコードから複数面作成することができました。
- API GatewayとLambda関数を作成するコードをそのまま利用し、CDK Pipelinesを用いてパイプライン化してみました。
- パイプライン化する際にも、環境面を意識して、ブランチとパイプラインの紐づけ実装ができることを確認しました。

Lambdaをはじめとするサーバレスアプリケーションを配備する際、コードをpushするだけで実行環境に配備までしてくれるのでだいぶ便利に感じました。  
また、同様の仕組みをCodeBuildとCodePipelineと連携させて自作ことも可能ですが、同じCDKで実装するにしても、こちらのほうがラクできるのではないでしょうか。

# 参考
- AWS CDKマニュアル  
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/home.html  
- CDK Pipelinesマニュアル（その１）  
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/cdk_pipeline.html  
- CDK Pipelinesマニュアル（その２）  
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.pipelines-readme.html  
こちらのほうが詳しく利用方法が書かれています。