ABEJA Tech Blog

中の人の興味のある情報を発信していきます

使い慣れたプログラミング言語でAWSのインフラ管理をする ~AWS CDKのススメ~

ABEJAでプロダクト開発をしている平原です。ABEJAアドベントカレンダー2023の6日目の記事です。皆さんはAWSでIaCを利用する時には何を利用しているでしょうか? AWSではAWS CloudFormationがIaCとして提供されており、それをラップするような形でAWS SAM、AWS CDK、AWS Amplify、AWS Elastic Beanstalkなどのツールが提供されています。その中でも、コードの読みやすさ、カスタマイズ性の高さからAWS CDKを使ってみてとても良かったので今更ですが紹介したいと思います。

1. AWS CDKとは

一言で言うとAWS CDKとはCloudFormationのリソース定義を、テンプレート(JSON, YAML)の代わりにプログラミング言語を使って行うことができるようにするツールです。イカツイYAMLファイルに圧倒されてしまってもこのCDKなら入門できると思います。

まず、開発者はプログラミング言語を使用して、必要なAWSリソースを定義します。そのあとは、CDKのデプロイコマンドを実行することによって、次のことを自動で行ってくれます。

  1. デプロイ前に必要なビルドを行う(フロントエンドのビルド、Lambda関数のビルド、Docker Imageなど)
  2. 定義からCloudFormationのテンプレートを自動作成する
  3. CloudFormationのテンプレートやビルドの成果物をS3に配置してテンプレートをCloudFormationでデプロイする

CDKを使ってCloudFormationを操作することのメリットは色々ありますが、

  • より少ないコードでインフラを定義できること
  • リソースの参照の書き方が直感的でわかりやすいこと
  • プログラミング言語(+エディタ)ならではの型安全性・コード補完などの恩恵を得られること
  • 定数や条件分岐、ループ、メソッド、クラスなど言語機能を使って記述することができるため、構成やその意図をより簡潔に示すことができること

などがあるかなと思います。

2. AWS CDKを触ってみる

2.1 環境構築

CDK自体はnpmパッケージなので、必要なものはCDK、Node.js、バージョン管理ツールの3つのみです。 CDKは次のコマンドでインストールします。

$ npm install -g aws-cdk

▼ 私の環境(クリックして開く)

Volta

Node.jsのバージョン管理に使えるツールです。 WindowsやMac、Linuxを行ったり来たりする方には便利だと思います。 windows以外の方は、

$ curl https://get.volta.sh | bash

で入ります。

windowsの方は公式からインストーラーをダウンロードしてインストールします。 公式ドキュメントにあるようにWindowsの開発者モードをONにします。 私の環境はWindowsですが簡単に入りました。 docs.volta.sh

Node.js

それでは、最新のLTSバージョンを入れましょう。

$ volta install node
success: installed and set node@20.9.0 (with npm@10.1.0) as default

$ node --version
v20.9.0

CDK

npmでCDKを入手します。

$ npm install -g aws-cdk

added 1 package in 21s

$ cdk --version
2.105.0 (build 04cb52d)

2.2. とりあえずLambdaを作成するところまでやってみる

2.2.1. プロジェクト作成

CDKプロジェクトを記述する言語はいろいろ選べて、現在はTypeScript、JavaScript、Python、Java、C#、Goが選べますが、お勧めはTypeScriptです。情報が多いのでトラブルを解消しやすいですし、型安全でもあります。プロジェクトによってはインフラをTypeScriptで記述することで、フロントエンド・バックエンド・インフラをTypeScriptで統一することも可能です。

CDKプロジェクトの開発は、次のコマンドで行います。

$ mkdir cdk-sample
$ cd cdk-sample
$ cdk init app --language typescript

▼ できたよって言われる(クリックして開く)

Applying project template app for typescript
# Welcome to your CDK TypeScript project

This is a blank project for CDK development with TypeScript.

The `cdk.json` file tells the CDK Toolkit how to execute your app.

## Useful commands

* `npm run build`   compile typescript to js
* `npm run watch`   watch for changes and compile
* `npm run test`    perform the jest unit tests
* `cdk deploy`      deploy this stack to your default AWS account/region
* `cdk diff`        compare deployed stack with current state
* `cdk synth`       emits the synthesized CloudFormation template

Initializing a new git repository...
Executing npm install...
✅ All done!

ここで作成されたファイルを確認してみます。大事なファイルは、

  • bin/cdk-sample.ts
  • lib/cdk-sample-stack.ts

の2つです。

bin/cdk-sample.tsはデプロイする時に呼ばれるスクリプトです。ここで作成したStackオブジェクトが、CloudFormationのStackに対応します。複数のStackを定義して、Stack間でリソースを参照することもできます。

Stackオブジェクトの第二引数はスタック名として使用されます。環境変数などを利用して、dev, stg, prod環境でスタックを分けるといったことも可能です。第三引数はオプションを設定する箇所で、リージョン間で参照するときなどデフォルトではできないことを有効かする時に指定したりします。また、スタック間でリソースを参照する時に、スタック自体やスタック内のリソースを受け渡すためにも使われます。コメントによるとデフォルトのAWSアカウントを指定したりすることもできるみたいです。(指定しない場合はデフォルトのAWSプロファイルが使われます。)

/* bin/cdk-sample.ts */
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CdkSampleStack } from '../lib/cdk-sample-stack';

const app = new cdk.App();
new CdkSampleStack(app, 'CdkSampleStack', {
  /* If you don't specify 'env', this stack will be environment-agnostic.
   * Account/Region-dependent features and context lookups will not work,
   * but a single synthesized template can be deployed anywhere. */

  /* Uncomment the next line to specialize this stack for the AWS Account
   * and Region that are implied by the current CLI configuration. */
  // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

  /* Uncomment the next line if you know exactly what Account and Region you
   * want to deploy the stack to. */
  // env: { account: '123456789012', region: 'us-east-1' },

  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});

lib/cdk-sample-stack.tsではスタックの定義を行っています。 初期状態なので空っぽですが、コメントでSQSの定義の仕方がサンプルとして書いてあります。おもむろに、コメントアウトを外してデプロイ(cdk deploy)するとSQSが作成されます。(cdk destroyで消せます。初めてCDKを使う場合はcdk bootstrapを事前に実行する必要があります。)

SQSの例にあるように、第二引数にリソースを識別するためのID(スタック内でユニーク)、第三引数にリソース作成に使用するパラメータを指定します。

/* lib/cdk-sample-stack.ts */
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
// import * as sqs from 'aws-cdk-lib/aws-sqs';

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

    // The code that defines your stack goes here

    // example resource
    // const queue = new sqs.Queue(this, 'CdkSampleQueue', {
    //   visibilityTimeout: cdk.Duration.seconds(300)
    // });
  }
}

2.2.2. デプロイ用のLambda関数を書く

TypeScriptのプロジェクトを作成して、HelloWorld的な関数を作成します。

$ mkdir assets
$ mkdir assets/lambda
$ cd assets/lambda
$ npm init -y
$ npm install -D typescript @types/node @types/aws-lambda
$ npx tsc --init
/* assets/lambda/index.ts */
import { Handler } from 'aws-lambda';

export const handler: Handler<unknown, string> = async (event, context) => {
    if (typeof event === 'object' && event !== null) {
        console.log('EVENT: \n' + JSON.stringify(event, null, 2));
    }
    return context.logStreamName;
};

2.2.3. CDKのStackにLambda関数を記載する

いよいよCDKを書いていくのですが、何にもわからないのでドキュメントを見たいです。 そんな時は、次のコマンドでドキュメントのページに飛ぶことができます。 検索しなくても良いのはちょっと嬉しかったりします。

$ cdk docs

cdk docsでこのページに飛ぶ

API Referenceのページに飛ぶと各APIの一覧を見ることができます。今回はLambda関数をデプロイしたいので、lambdaを探しに行きます。aws_lambda_nodejsというのがあるのでこちらを利用したいと思います。

API Referenceのページでlambdaの項目を探す

Overviewを見ていくと色々書いてあるので、とりあえず参考にして書き換えてみます。

/* lib/cdk-sample-stack.ts */
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as path from 'path';


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

    const funcHello = new NodejsFunction(this, 'funcHello', {
      entry: path.join(__dirname, '..', 'assets', 'lambda', 'index.ts'),
      runtime: Runtime.NODEJS_18_X,
      handler: 'handler',
    });
  }
}

2.2.4. デプロイしてみる

※ CDKを実行している時のAWSプロファイルの持つ情報や権限がデプロイに使用されます。IAMロールやS3、Lambdaなどのリソースが作れる権限であることを確認してください。また、デプロイ先のAWSアカウントが正しいことを確認してください。

CDKをAWS Organization、リージョンで初めて利用する前にbootstrapを実行する必要があります。これを実行するとCDKToolkitというCloudFormationのスタックが作成されて、デプロイに使用するためのS3バケットとIAMロールを作成します。実行は次のコマンドでできます。

$ cdk bootstrap
# AWSプロファイルを指定したい時
# cdk bootstrap --profile <profile>

▼ IAMとS3が作られているのが見える(クリックして開く)

 ⏳  Bootstrapping environment aws://123456789012/ap-northeast-1...
Trusted accounts for deployment: (none)
Trusted accounts for lookup: (none)
Using default execution policy of 'arn:aws:iam::aws:policy/AdministratorAccess'. Pass '--cloudformation-execution-policies' to customize.
CDKToolkit: creating CloudFormation changeset...
CDKToolkit |  0/12 | 23:11:05 | CREATE_IN_PROGRESS   | AWS::SSM::Parameter     | CdkBootstrapVersion Resource creation Initiated
CDKToolkit |  0/12 | 23:10:53 | REVIEW_IN_PROGRESS   | AWS::CloudFormation::Stack | CDKToolkit User Initiated
CDKToolkit |  0/12 | 23:10:59 | CREATE_IN_PROGRESS   | AWS::CloudFormation::Stack | CDKToolkit User Initiated
CDKToolkit |  0/12 | 23:11:04 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | FilePublishingRole
CDKToolkit |  0/12 | 23:11:04 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | ImagePublishingRole
CDKToolkit |  0/12 | 23:11:04 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | LookupRole
CDKToolkit |  0/12 | 23:11:04 | CREATE_IN_PROGRESS   | AWS::S3::Bucket         | StagingBucket
CDKToolkit |  0/12 | 23:11:04 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | CloudFormationExecutionRole
CDKToolkit |  0/12 | 23:11:04 | CREATE_IN_PROGRESS   | AWS::SSM::Parameter     | CdkBootstrapVersion
CDKToolkit |  0/12 | 23:11:04 | CREATE_IN_PROGRESS   | AWS::ECR::Repository    | ContainerAssetsRepository
CDKToolkit |  0/12 | 23:11:05 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | CloudFormationExecutionRole Resource creation Initiated
CDKToolkit |  0/12 | 23:11:05 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | ImagePublishingRole Resource creation Initiated
CDKToolkit |  0/12 | 23:11:05 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | FilePublishingRole Resource creation Initiated
CDKToolkit |  1/12 | 23:11:05 | CREATE_COMPLETE      | AWS::SSM::Parameter     | CdkBootstrapVersion
CDKToolkit |  1/12 | 23:11:05 | CREATE_IN_PROGRESS   | AWS::ECR::Repository    | ContainerAssetsRepository Resourcle Resource creation Initiated
CDKToolkit |  3/12 | 23:11:23 | CREATE_COMPLETE      | AWS::IAM::Role          | ImagePublishingRole
CDKToolkit |  4/12 | 23:11:23 | CREATE_COMPLETE      | AWS::IAM::Role          | FilePublishingRole
CDKToolkit |  5/12 | 23:11:23 | CREATE_COMPLETE      | AWS::IAM::Role          | CloudFormationExecutionRole      
CDKToolkit |  5/12 | 23:11:24 | CREATE_IN_PROGRESS   | AWS::IAM::Policy        | ImagePublishingRoleDefaultPolicy 
CDKToolkit |  6/12 | 23:11:24 | CREATE_COMPLETE      | AWS::IAM::Role          | LookupRole
CDKToolkit |  6/12 | 23:11:26 | CREATE_IN_PROGRESS   | AWS::IAM::Policy        | ImagePublishingRoleDefaultPolicy Resource creation Initiated
CDKToolkit |  7/12 | 23:11:28 | CREATE_COMPLETE      | AWS::S3::Bucket         | StagingBucket 
CDKToolkit |  7/12 | 23:11:29 | CREATE_IN_PROGRESS   | AWS::IAM::Policy        | FilePublishingRoleDefaultPolicy 
CDKToolkit |  7/12 | 23:11:29 | CREATE_IN_PROGRESS   | AWS::S3::BucketPolicy   | StagingBucketPolicy 
CDKToolkit |  7/12 | 23:11:30 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | DeploymentActionRole 
CDKToolkit |  7/12 | 23:11:31 | CREATE_IN_PROGRESS   | AWS::S3::BucketPolicy   | StagingBucketPolicy Resource creation Initiated
CDKToolkit |  7/12 | 23:11:31 | CREATE_IN_PROGRESS   | AWS::IAM::Policy        | FilePublishingRoleDefaultPolicy Resource creation Initiated
CDKToolkit |  8/12 | 23:11:31 | CREATE_COMPLETE      | AWS::S3::BucketPolicy   | StagingBucketPolicy
CDKToolkit |  8/12 | 23:11:32 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | DeploymentActionRole Resource creation Initiated
CDKToolkit |  9/12 | 23:11:42 | CREATE_COMPLETE      | AWS::IAM::Policy        | ImagePublishingRoleDefaultPolicy 
CDKToolkit | 10/12 | 23:11:47 | CREATE_COMPLETE      | AWS::IAM::Policy        | FilePublishingRoleDefaultPolicy 
CDKToolkit | 11/12 | 23:11:50 | CREATE_COMPLETE      | AWS::IAM::Role          | DeploymentActionRole 
CDKToolkit | 12/12 | 23:11:52 | CREATE_COMPLETE      | AWS::CloudFormation::Stack | CDKToolkit
 ✅  Environment aws://123456789012/ap-northeast-1 bootstrapped.

bootstrapが完了したらいよいよデプロイをしていきます。 準備は整ったので次のコマンドを打ち込むだけです。

$ npx cdk deploy
# AWSプロファイルを指定したい時
# npx cdk deploy --profile <profile>

▼ tsのビルドが走ってLambdaとIAMが作られていく(クリックして開く)

failed to get console mode for stdin: The handle is invalid.
[+] Building 205.6s (14/14) FINISHED
 => [internal] load build definition from Dockerfile                                           0.4s
 => => transferring dockerfile: 1.30kB                                                         0.1s 
 => [internal] load .dockerignore                                                              0.3s
 => => transferring context: 2B                                                                0.0s 
 => [internal] load metadata for public.ecr.aws/sam/build-nodejs18.x:latest                    4.3s
 => [ 1/10] FROM public.ecr.aws/sam/build-nodejs18.x@sha256:bf6c24d47743a53257d982a12c2de9d  144.7s
 => => resolve public.ecr.aws/sam/build-nodejs18.x@sha256:bf6c24d47743a53257d982a12c2de9d8be0  0.1s 
 => => sha256:bf6c24d47743a53257d982a12c2de9d8be04e5fd3896a27eca80ddae5a5fd553 772B / 772B     0.0s
 => => sha256:9107274d5130223e96f3233d6809765338b8f2ac2df1170cf343dd4a00b98cd 8.00kB / 8.00kB  0.0s 
 => => sha256:4a623db433b7242335856444cbf95c7d8b24449f4ebf82562a2aba455278c7c 2.85kB / 2.85kB  0.0s
 => => sha256:8ff08ce843f9d48674a29e3be71de4f443f95b0d6121b7a10c8506dbec46b 88.36kB / 88.36kB  0.3s 
 => => sha256:242ba56a94f8ec206a187581948cfd6b3cb5f653507a7b31134660a8841ed599 418B / 418B     0.4s
 => => sha256:299668a79b8add698f8396eda6413971b0a555ba3ae46e38e2e70c0874 104.93MB / 104.93MB  22.2s 
 => => sha256:79a77e7c1be9a2c4f77ead609e8d8b7162377bb6905b2a244c7964d74d8c876 2.51MB / 2.51MB  2.0s 
 => => sha256:732063d06644ab6b4b2a678f23a171384a0a30e4c55d798c4908d1c4cce6 50.08MB / 50.08MB  13.9s 
 => => sha256:f3a628dafafef2424c60c8f342ae28ed4ac766c5bea971fc1ce75b6149913 19.78MB / 19.78MB  9.4s 
 => => sha256:da819f1a52faa44007d0383812aaf46d08c7137cc0b09cf53afce46085 344.72MB / 344.72MB  63.2s 
 => => sha256:e9c4238a9cdc29e78dc3b914a0c15364d7dbcb3698ba6595249720c1ab99 24.91MB / 24.91MB  22.5s
 => => sha256:7d9915c1f338cc7c59ff0de69945ca2bba973cb4f68ee70db9cff396108f 56.75MB / 56.75MB  37.8s 
 => => sha256:807da1b37691fc7427088e6b62a9587aa7379c92a3a2eb7948c45604c2b2 93.60MB / 93.60MB  45.6s 
 => => extracting sha256:299668a79b8add698f8396eda6413971b0a555ba3ae46e38e2e70c0874e09449     13.6s 
 => => sha256:62ef9f8b1ed6bc29ce6b4ed08b6690804b61bd54d44b5d4cd7b6e22926 136.76kB / 136.76kB  38.5s 
 => => extracting sha256:8ff08ce843f9d48674a29e3be71de4f443f95b0d6121b7a10c8506dbec46bf33      0.0s 
 => => sha256:fda99773e957a51bd50a05892f1a96aa0ee234e1369b5a12601af78ff2 122.18kB / 122.18kB  38.9s
 => => extracting sha256:242ba56a94f8ec206a187581948cfd6b3cb5f653507a7b31134660a8841ed599      0.0s 
 => => extracting sha256:79a77e7c1be9a2c4f77ead609e8d8b7162377bb6905b2a244c7964d74d8c8762      0.3s 
 => => extracting sha256:732063d06644ab6b4b2a678f23a171384a0a30e4c55d798c4908d1c4cce639bf      4.5s 
 => => extracting sha256:f3a628dafafef2424c60c8f342ae28ed4ac766c5bea971fc1ce75b6149913f92     16.8s 
 => => extracting sha256:da819f1a52faa44007d0383812aaf46d08c7137cc0b09cf53afce460858583a7     48.0s 
 => => extracting sha256:e9c4238a9cdc29e78dc3b914a0c15364d7dbcb3698ba6595249720c1ab99f629      3.0s 
 => => extracting sha256:7d9915c1f338cc7c59ff0de69945ca2bba973cb4f68ee70db9cff396108f611b     10.3s 
 => => extracting sha256:807da1b37691fc7427088e6b62a9587aa7379c92a3a2eb7948c45604c2b22a4d     13.9s 
 => => extracting sha256:62ef9f8b1ed6bc29ce6b4ed08b6690804b61bd54d44b5d4cd7b6e22926e03c67      0.0s 
 => => extracting sha256:fda99773e957a51bd50a05892f1a96aa0ee234e1369b5a12601af78ff2df9021      0.0s 
 => [ 2/10] RUN npm install --global yarn@1.22.5                                              11.9s 
 => [ 3/10] RUN npm install --global pnpm@7.30.5                                               8.7s
 => [ 4/10] RUN npm install --global typescript                                                9.0s 
 => [ 5/10] RUN npm install --global --unsafe-perm=true esbuild@0                             10.3s 
 => [ 6/10] RUN mkdir /tmp/npm-cache &&     chmod -R 777 /tmp/npm-cache &&     npm config --g  3.3s 
 => [ 7/10] RUN mkdir /tmp/yarn-cache &&     chmod -R 777 /tmp/yarn-cache &&     yarn config   2.7s 
 => [ 8/10] RUN mkdir /tmp/pnpm-cache &&     chmod -R 777 /tmp/pnpm-cache &&     pnpm config   4.0s 
 => [ 9/10] RUN npm config --global set update-notifier false                                  2.9s 
 => [10/10] RUN /sbin/useradd -u 1000 user && chmod 711 /                                      1.3s
 => exporting to image                                                                         1.4s 
 => => exporting layers                                                                        1.3s 
 => => writing image sha256:965cf15e084a25b66fa3751520ef597cfc155343ebd308e6b958eaaafb697f6d   0.0s 
 => => naming to docker.io/library/cdk-9a9d7191dc9fe5538d46d435ff1dfa0a221bc83acedc27d7dca540  0.0s 
Bundling asset CdkSampleStack/funcHello/Code/Stage...
esbuild cannot run locally. Switching to Docker bundling.

  asset-output/index.js  1.2kb

⚡ Done in 334ms

✨  Synthesis time: 298.08s

CdkSampleStack:  start: Building ebb3ea119fe21890178a2ee9727ccde9aa9a0da720f00de80f15e5613e0ee687:current_account-current_region
CdkSampleStack:  success: Built ebb3ea119fe21890178a2ee9727ccde9aa9a0da720f00de80f15e5613e0ee687:current_account-current_region
CdkSampleStack:  start: Building 841ba80b39fe817404800f0a8dca2c45429326c4595a6482a5fe259586bba0fe:current_account-current_region
CdkSampleStack:  success: Built 841ba80b39fe817404800f0a8dca2c45429326c4595a6482a5fe259586bba0fe:current_account-current_region
CdkSampleStack:  start: Publishing ebb3ea119fe21890178a2ee9727ccde9aa9a0da720f00de80f15e5613e0ee687:current_account-current_region
CdkSampleStack:  start: Publishing 841ba80b39fe817404800f0a8dca2c45429326c4595a6482a5fe259586bba0fe:current_account-current_region
CdkSampleStack:  success: Published 841ba80b39fe817404800f0a8dca2c45429326c4595a6482a5fe259586bba0fe:current_account-current_region
CdkSampleStack:  success: Published ebb3ea119fe21890178a2ee9727ccde9aa9a0da720f00de80f15e5613e0ee687:current_account-current_region
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 │ 
├───┼───────────────────────┼────────┼───────────────────────┼────────────────────────┼───────────┤ 
│ + │ ${funcHello/ServiceRo │ Allow  │ sts:AssumeRole        │ Service:lambda.amazona │           │ 
│   │ le.Arn}               │        │                       │ ws.com                 │           │ 
└───┴───────────────────────┴────────┴───────────────────────┴────────────────────────┴───────────┘ 
IAM Policy Changes
┌───┬──────────────────────────┬──────────────────────────────────────────────────────────────────┐ 
│   │ Resource                 │ Managed Policy ARN                                               │ 
├───┼──────────────────────────┼──────────────────────────────────────────────────────────────────┤ 
│ + │ ${funcHello/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasi │ 
│   │                          │ cExecutionRole                                                   │ 
└───┴──────────────────────────┴──────────────────────────────────────────────────────────────────┘ 
(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
CdkSampleStack: deploying... [1/1]
CdkSampleStack: creating CloudFormation changeset...
CdkSampleStack | 0/5 | 23:48:44 | CREATE_IN_PROGRESS   | AWS::IAM::Role        | funcHello/ServiceRole (funcHelloServiceRole472CE3D3) Resource creation Initiated
CdkSampleStack | 0/5 | 23:48:39 | UPDATE_IN_PROGRESS   | AWS::CloudFormation::Stack | CdkSampleStack
 User Initiated
CdkSampleStack | 0/5 | 23:48:43 | CREATE_IN_PROGRESS   | AWS::IAM::Role        | funcHello/ServiceRole (funcHelloServiceRole472CE3D3)
CdkSampleStack | 0/5 | 23:48:43 | UPDATE_IN_PROGRESS   | AWS::CDK::Metadata    | CDKMetadata/Default (CDKMetadata)
CdkSampleStack | 1/5 | 23:48:44 | UPDATE_COMPLETE      | AWS::CDK::Metadata    | CDKMetadata/Default (CDKMetadata)
CdkSampleStack | 2/5 | 23:49:00 | CREATE_COMPLETE      | AWS::IAM::Role        | funcHello/ServiceRole (funcHelloServiceRole472CE3D3)
CdkSampleStack | 2/5 | 23:49:02 | CREATE_IN_PROGRESS   | AWS::Lambda::Function | funcHello (funcHello6218314B)
CdkSampleStack | 2/5 | 23:49:04 | CREATE_IN_PROGRESS   | AWS::Lambda::Function | funcHello (funcHello6218314B) Resource creation Initiated
CdkSampleStack | 3/5 | 23:49:09 | CREATE_COMPLETE      | AWS::Lambda::Function | funcHello (funcHello6218314B)
CdkSampleStack | 4/5 | 23:49:10 | UPDATE_COMPLETE_CLEA | AWS::CloudFormation::Stack | CdkSampleStack

CdkSampleStack | 5/5 | 23:49:11 | UPDATE_COMPLETE      | AWS::CloudFormation::Stack | CdkSampleStack


 ✅  CdkSampleStack

✨  Deployment time: 44.32s

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:123456789012:stack/CdkSampleStack/ee56d960-83c1-11ee-a5a9-0ed5236d03ad

✨  Total time: 342.41s

正常に作成できたら、ちょっとLambdaを動かしてみます。AWS マネジメントコンソールを開くとLambdaが作成されているので、画面上でテストボタンを押してみます。次のような出力が出てきてどうやら動いてそうであることがわかります。

2023-11-15T14:51:02.264Z   ead1f7eb-e6bd-4412-929e-5ddba338b8cb    INFO    EVENT: 
{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}
START RequestId: ead1f7eb-e6bd-4412-929e-5ddba338b8cb Version: $LATEST
END RequestId: ead1f7eb-e6bd-4412-929e-5ddba338b8cb
REPORT RequestId: ead1f7eb-e6bd-4412-929e-5ddba338b8cb  Duration: 9.14 ms Billed Duration: 10 ms Memory Size: 128 MB    Max Memory Used: 67 MB Init Duration: 147.18 ms

2.2.5. お片付け

一通り試してみて、今回の確認は達成したのでお片付けしていきます。 cdk deployで作成されたスタックは次のコマンドで削除することができます。

$ npx cdk destroy

▼ 先ほど作られたIAMとLambdaが消えていく(クリックして開く)

failed to get console mode for stdin: The handle is invalid.
[+] Building 6.3s (14/14) FINISHED
 => [internal] load build definition from Dockerfile                                           0.3s
 => => transferring dockerfile: 32B                                                            0.1s
 => [internal] load .dockerignore                                                              0.3s 
 => => transferring context: 2B                                                                0.1s 
 => [internal] load metadata for public.ecr.aws/sam/build-nodejs18.x:latest                    5.2s
 => [ 1/10] FROM public.ecr.aws/sam/build-nodejs18.x@sha256:bf6c24d47743a53257d982a12c2de9d8b  0.0s
 => CACHED [ 2/10] RUN npm install --global yarn@1.22.5                                        0.0s 
 => CACHED [ 3/10] RUN npm install --global pnpm@7.30.5                                        0.0s 
 => CACHED [ 4/10] RUN npm install --global typescript                                         0.0s 
 => CACHED [ 5/10] RUN npm install --global --unsafe-perm=true esbuild@0                       0.0s 
 => CACHED [ 6/10] RUN mkdir /tmp/npm-cache &&     chmod -R 777 /tmp/npm-cache &&     npm con  0.0s 
 => CACHED [ 7/10] RUN mkdir /tmp/yarn-cache &&     chmod -R 777 /tmp/yarn-cache &&     yarn   0.0s
 => CACHED [ 8/10] RUN mkdir /tmp/pnpm-cache &&     chmod -R 777 /tmp/pnpm-cache &&     pnpm   0.0s 
 => CACHED [ 9/10] RUN npm config --global set update-notifier false                           0.0s 
 => CACHED [10/10] RUN /sbin/useradd -u 1000 user && chmod 711 /                               0.0s 
 => exporting to image                                                                         0.4s 
 => => exporting layers                                                                        0.0s 
 => => writing image sha256:965cf15e084a25b66fa3751520ef597cfc155343ebd308e6b958eaaafb697f6d   0.1s 
 => => naming to docker.io/library/cdk-9a9d7191dc9fe5538d46d435ff1dfa0a221bc83acedc27d7dca540  0.0s 
Are you sure you want to delete: CdkSampleStack (y/n)? y
CdkSampleStack: destroying... [1/1]
CdkSampleStack |   0 | 23:57:39 | DELETE_IN_PROGRESS   | AWS::CloudFormation::Stack | CdkSampleStack
 User Initiated
CdkSampleStack |   0 | 23:57:42 | DELETE_IN_PROGRESS   | AWS::Lambda::Function | funcHello (funcHello6218314B)
CdkSampleStack |   0 | 23:57:42 | DELETE_IN_PROGRESS   | AWS::CDK::Metadata    | CDKMetadata/Default (CDKMetadata)
CdkSampleStack |   1 | 23:57:43 | DELETE_COMPLETE      | AWS::CDK::Metadata    | CDKMetadata/Default (CDKMetadata)
CdkSampleStack |   2 | 23:57:48 | DELETE_COMPLETE      | AWS::Lambda::Function | funcHello (funcHello6218314B)
CdkSampleStack |   2 | 23:57:48 | DELETE_IN_PROGRESS   | AWS::IAM::Role        | funcHello/ServiceRole (funcHelloServiceRole472CE3D3)

 ✅  CdkSampleStack: destroyed

cdk bootstrapで作成されたリソースを削除するには、 AWSマネジメントコンソールでCloudFormationのページを開いて、 Stackを削除する必要があります。 また、S3は自動削除されないのでこちらもS3のページを開いてから手動で削除する必要があります。

2.3. CRUDっぽいAPIをAPI Gatewayで公開してみる

少しアプリケーションに近づけて、 API GatewayとLambdaを連携してAPIを公開してみます。 サンプルとしてTodoのCRUDを公開することを想定して作ってみることで、 リソース間の連携を行うときや、似たようなリソースを複数作るときのやり方を見てみます。 本来ならDBを用意すると思うのですが、 使い方を見るだけなら実態がなくても十分だと思うのでDBはハリボテです。

2.3.1. Lambda関数を用意する

CRUDを行うハンドラを定義していきます。 実装詳細は今回の本質ではないので、 ここでは、ハンドラを公開しているところだけを記載します。

/* assets/lambda/todoHandler.ts */
import { Handler, APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { TodoService } from './application/todoService';
import { ITodoRepository, Todo, TodoBody, TodoId, TodoTitle } from './model/todo';
import { InMemoryTodoRepository } from './repository/todo/mock';
import { BadRequestError, ServiceError } from './util/errors';
import { Buffer } from 'buffer';

/** インメモリで動くハリボテのTodoリポジトリ. 引数で初期値をセットしています */
const todoRepository: ITodoRepository = new InMemoryTodoRepository([
    new Todo(new TodoId("id1"), new TodoTitle("title1"), new TodoBody("body1")),
    new Todo(new TodoId("id2"), new TodoTitle("title2"), new TodoBody("body2")),
    new Todo(new TodoId("id3"), new TodoTitle("title3"), new TodoBody("body3")),
]);
const todoService = new TodoService(todoRepository);


const parseJSON = (text: string): unknown => {
    try {
        return JSON.parse(Buffer.from(text, "base64").toString());
    } catch (e) {
        const err = new BadRequestError("text should be JSON format");
        if (e instanceof Error) {
            err.stack = e.stack;
        }
        throw err;
    }
}

/** 想定したエラーならServiceErrorがthrowされてくる */
const handleError = (e: unknown): APIGatewayProxyResult => {
    if (e instanceof ServiceError) {
        console.error({stacktrace: e.stack});
        return {statusCode: e.statusCode, body: e.message, headers: {"Content-Type": "text/plain"}};
    } else if (e instanceof Error) {
        console.error({stacktrace: e.stack});
        return {statusCode: 500, body: e.message === "" ? "unknown error" : e.message, headers: {"Content-Type": "text/plain"}};
    }
    return {statusCode: 500, body: "unknown exception", headers: {"Content-Type": "text/plain"}};
}


/** CRUDのC */
export const createTodo: Handler<APIGatewayProxyEvent, APIGatewayProxyResult> = async (event, context) => {
    try {
        const body = parseJSON(event.body ?? "{}");
        if (typeof body !== 'object' || body === null) {
            throw new BadRequestError(". should be object");
        }
        if (!("title" in body && typeof body.title === "string")) {
            throw new BadRequestError(".title should be string");
        }
        if (!("body" in body && typeof body.body === "string")) {
            throw new BadRequestError(".body should be string");
        }
        const todo = todoService.createTodo(body.title, body.body);
        return {statusCode: 200, body: JSON.stringify(todo), headers: {"Content-Type": "application/json"}};
    } catch (e) {
        return handleError(e);
    }
};

/** CRUDのR */
export const listTodo: Handler<APIGatewayProxyEvent, APIGatewayProxyResult> = async (event, context) => {
    try {
        const todos = todoService.listTodo();
        return {statusCode: 200, body: JSON.stringify({todos})};
    } catch (e) {
        return handleError(e);
    }
};

/** CRUDのU */
export const updateTodo: Handler<APIGatewayProxyEvent, APIGatewayProxyResult> = async (event, context) => {
    try {
        const body = parseJSON(event.body ?? "{}");
        if (typeof body !== 'object' || body === null) {
            throw new BadRequestError(". should be object");
        }
        if (!("id" in body && typeof body.id === "string")) {
            throw new BadRequestError(".id should be string");
        }
        if (!("title" in body && typeof body.title === "string")) {
            throw new BadRequestError(".title should be string");
        }
        if (!("body" in body && typeof body.body === "string")) {
            throw new BadRequestError(".body should be string");
        }
        todoService.updateTodo(body.id, body.title, body.body);
        return {statusCode: 200, body: ""};
    } catch (e) {
        return handleError(e);
    }
};

/** CRUDのD */
export const deleteTodo: Handler<APIGatewayProxyEvent, APIGatewayProxyResult> = async (event, context) => {
    try {
        const body = parseJSON(event.body ?? "{}");
        if (typeof body !== 'object' || body === null) {
            throw new BadRequestError(". should be object");
        }
        if (!("id" in body && typeof body.id === "string")) {
            throw new BadRequestError(".id should be string");
        }
        todoService.deleteTodo(body.id);
        return {statusCode: 200, body: ""};
    } catch (e) {
        return handleError(e);
    }
};

2.3.2. CDKを用意する

それではCDKを書いていきます。 注目していただきたいところは↓です。

  1. リソースの参照方法: 例えばCfnIntegrationでRole.roleArnやNodejsFunction.functionArnのような形式で参照していますが、CloudFormationに出力される時に自動でGetAttのような形式に変換されます。
  2. リソース作成をまとめる: プログラミング言語なので共通した処理を切り出すことができます。今回は、APIのルート作成の流れを別のクラスとして定義しています。
  3. CfnXXXの利用: CDKはNodejsFunctionのようにIAMロールの作成やバンドルなどもよしなにやってくれるものが用意されており、L2コンストラクタと呼ばれています。API Gateway v2はこのL2コンストラクタが用意されていないため、代わりにL1コンストラクタを利用します。L1コンストラクタはCfnの接頭辞がついており、設定内容はCloudFormationと対応しています。

また、このCDKのコードは102行ですが、CDKから出力されるCloudFormationテンプレートは946行ありました。 メタデータが付いていたり、JSONで出力されていたりで行数が増えている面もあると思いますが、かなり目に優しくなっていると思います。

/** lib/cdk-sample-stack.ts */
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { CfnApi, CfnStage, CfnRoute, CfnIntegration, CfnDeployment } from 'aws-cdk-lib/aws-apigatewayv2';
import * as path from 'path';
import * as iam from 'aws-cdk-lib/aws-iam';

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

    // API Gateway v2はCDKのL2コンストラクタが用意されていないので、L1コンストラクタを利用する
    const api = new CfnApi(this, 'api', {
      name: "myapi",
      protocolType: "HTTP",
    });

    // ルート作成のような似た作業は、別のクラスにまとめることもできる
    // まとめることで、APIにルートを作成してデプロイを行うという意図がわかりやすくなっており、
    // ドキュメントの役割を果たすようになっている
    const routeCreateTodo = new Route(this, api, "createTodo", "POST /todos")
    const routeListTodo = new Route(this, api, "listTodo", "Get /todos")
    const routeUpdateTodo = new Route(this, api, "updateTodo", "PUT /todos")
    const routeDeleteTodo = new Route(this, api, "deleteTodo", "DELETE /todos")

    const stage = new CfnStage(this, 'stage', {
      apiId: api.attrApiId,
      stageName: "v1",
    });

    const deploy = new CfnDeployment(this, 'deployment', {
      apiId: api.attrApiId,
      stageName: stage.stageName,
    });

    // CfnDeploymentはCfnRouteへの依存が見えないため依存を明示する
    deploy.addDependency(routeCreateTodo.route)
    deploy.addDependency(routeListTodo.route)
    deploy.addDependency(routeUpdateTodo.route)
    deploy.addDependency(routeDeleteTodo.route)

    // デプロイ完了した時に、API Gatewayのエンドポイントを出力させる
    new cdk.CfnOutput(this, "outputAPIEndoint", {value: api.attrApiEndpoint});
  }
}

class Route {
  public readonly route: CfnRoute;

  constructor(scope: Construct, api: CfnApi, funcName: string, routeKey: string) {
    const funcNameCap = capitalize(funcName)
    // 第二引数のidは被らないように注意する
    // モジュールをバンドルしてもらうため、設定を追加した
    const func = new NodejsFunction(scope, `func${funcNameCap}`, {
      entry: path.join(__dirname, '..', 'assets', 'lambda', 'todoHandler.ts'),
      depsLockFilePath: path.join(__dirname, '..', 'assets', 'lambda', 'package-lock.json'),
      runtime: Runtime.NODEJS_18_X,
      handler: funcName,
      bundling: {
        commandHooks: {
          beforeBundling: (i, o) => [`cd ${i} && npm ci`],
          afterBundling: (i, o) => [],
          beforeInstall: (i, o) => [],
        },
      }
    });

    // API GatewayがLambdaを呼び出すためのロール
    const role = new iam.Role(scope, `roleCall${funcNameCap}`, {
      assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
      inlinePolicies: {
        "invoke": new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: [
                "lambda:InvokeFunction",
              ],
              resources: [
                func.functionArn,
              ],
            })
          ]
        })
      }
    });

    // コンソールでLambda統合と呼ばれているもの
    const integration = new CfnIntegration(scope, `integration${funcNameCap}`, {
      apiId: api.attrApiId,
      credentialsArn: role.roleArn,
      integrationMethod: "POST",
      integrationType: "AWS_PROXY",
      integrationUri: func.functionArn,
      payloadFormatVersion: "2.0",
    });

    // ルートを作成
    const route = new CfnRoute(scope, `route${funcNameCap}`, {
      apiId: api.attrApiId,
      routeKey: routeKey,
      target: `integrations/${integration.ref}`,
    });

    this.route = route;
  }
}

const capitalize = (text: string): string => {
    if (text.length === 0) return text;
    return text.charAt(0).toUpperCase() + text.slice(1);
};

2.3.3. デプロイして動かしてみる

それではデプロイしてみて動いていることを確認します。 (Update, Deleteをパスパラメータにした方が良かった…)

$ npx cdk deploy
# Createしてみる
$ curl -X POST https://6xscmp48de.execute-api.ap-northeast-1.amazonaws.com/v1/todos -i -s -d '{"title": "foo", "body": "baa"}'
# HTTP/2 200
# date: Sat, 25 Nov 2023 02:15:26 GMT
# content-type: application/json
# content-length: 72
# apigw-requestid: O7rNugS3tjMEMeA=
# 
# {"id":"72af3c0c-87c5-45cb-bff8-12df47053938","title":"foo","body":"baa"}

# Listしてみる
$ curl -X GET https://6xscmp48de.execute-api.ap-northeast-1.amazonaws.com/v1/todos -i -s
# HTTP/2 200
# date: Sat, 25 Nov 2023 02:17:17 GMT
# content-type: text/plain; charset=utf-8
# content-length: 146
# apigw-requestid: O7rfBj1VtjMEPNQ=
# 
# {"todos":[{"id":"id1","title":"title1","body":"body1"},{"id":"id2","title":"title2","body":"body2"},{"id":"id3","title":"title3","body":"body3"}]}

# Updateしてみる(成功)
$ curl -X PUT https://6xscmp48de.execute-api.ap-northeast-1.amazonaws.com/v1/todos -i -s -d '{"id": "id1", "title": "foo", "body": "baa"}'
# HTTP/2 200
# date: Sat, 25 Nov 2023 02:18:24 GMT
# content-length: 0
# apigw-requestid: O7rphiWTtjMEMNg=

# Updateしてみる(存在しないのでエラー)
$ curl -X PUT https://6xscmp48de.execute-api.ap-northeast-1.amazonaws.com/v1/todos -i -s -d '{"id": "id4", "title": "foo", "body": "baa"}'
# HTTP/2 404
# date: Sat, 25 Nov 2023 02:18:41 GMT
# content-type: text/plain
# content-length: 29
# apigw-requestid: O7rsOhKqtjMEM_A=
# 
# todos don't have this id: id4

# Deleteしてみる(成功)
$ curl -X DELETE https://6xscmp48de.execute-api.ap-northeast-1.amazonaws.com/v1/todos -i -s -d '{"id": "id1"}'
# HTTP/2 200
# date: Sat, 25 Nov 2023 02:22:47 GMT
# content-length: 0
# apigw-requestid: O7sSthHVtjMEJTA=

# Deleteしてみる(存在しないのでエラー)
$ curl -X DELETE https://6xscmp48de.execute-api.ap-northeast-1.amazonaws.com/v1/todos -i -s -d '{"id": "id1"}'
# HTTP/2 404
# date: Sat, 25 Nov 2023 02:22:50 GMT
# content-type: text/plain
# content-length: 29
# apigw-requestid: O7sTOiAzNjMEJ9w=
# 
# todos don't have this id: id1

2.3.4. お片付け

忘れずにリソースを削除します。

$ npx cdk destroy

2.3.5. 詰まったところ

CfnRouteを作るときにtargetを適当に設定すると、このようなエラーが出ました。

 ❌ Deployment failed: Error: The stack named CdkSampleStack failed to deploy: UPDATE_ROLLBACK_COMPLETE: Resource handler returned message: "Unexpected or malformed target in route uso1gdu. Correct format should be integrations/<integration_id>. (Service: AmazonApiGatewayV2; Status Code: 400; Error Code: BadRequestException; Request ID: 0e30026e-d1a8-4de9-a55d-967d95e10bc5; Proxy: null)" (RequestToken: 3a65c2c4-4cf4-d1a4-7587-7266f36a86b3, HandlerErrorCode: GeneralServiceException)

どうやらtargetにはintegrations/<integration_id>を指定する必要があるようです。CDKのドキュメントでは解決できなさそうだったので、CloudFormationのドキュメントを見にいきました。RouteのドキュメントにあるExampleを見るとそれっぽいですが、integration_idはどこでしょうか?Integrationのドキュメントを見るとRefで取得できるようです。探すのにちょっと時間がかかってしまいました。

3. 感想

最初にあげたメリットの通り、 CDKを使って開発を行うとCloudFormationテンプレートと比較してコード量が少なくなって、 言語機能による恩恵を受けられてとても書きやすかったです。 また、個人で何かしたい時にも開発しているエディタのターミナルから気軽にdestroy・deployできたり、 環境変数などで別スタックとしてデプロイできたりするのも、 嬉しいポイントです。

ネガティブな面として感じたことは、ビルドがデプロイのたびに走ってしまうことです。 ローカルで開発する分にはmakeコマンドを利用する、 ビルドが入るリソースはスタックを分割しておく、 ビルドはCDK内で行わない、 などの対応である程度回避できるのかなと思います。

プログラミング言語を使ってインフラを管理できるという体験がとても良かったので、 これからも使っていきたいと思っています。 CDK for Terraformというものも出てきているので、いつか触りたいです。

We Are Hiring!

ABEJAは、テクノロジーの社会実装に取り組んでいます。
技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください!
(新卒の方のエントリーもお待ちしております)
https://careers.abejainc.com/