こんにちは、大石(@se-o-chan)です。
第1回に引き続き、TypeScriptでAWS CDK Workshopを進めてみて、AWS CDKとはどんなものかを紹介していきます。この手のWorkshopは「手順に沿ってやってみたけど、結局何やっているか分からない」となりがちなので、一段踏み込んで内容を解説します。
第1回の記事はこちらです。
AWS CDK Workshop
今回のWorkshopは下記です。
Hello, CDK!
では第1回の続きで、今度はCDKを使ってAPI GatewayとLambda関数を構築します。
(出典) AWS CDK Workshop
CDKではAWSリソース定義だけでなく、Lambda関数のソースコードも一緒に管理できます。プロジェクトディレクトリ(cdk-workshopフォルダ)直下にlambda/hello.js
ファイルを作成し、次のコードを追記します。
exports.handler = async function(event) { console.log("request:", JSON.stringify(event, undefined, 2)); return { statusCode: 200, headers: { "Content-Type": "text/plain" }, body: `Hello, CDK! You've hit ${event.path}\n` }; };
次に第1回の記事でSQS、SNSの定義を記載していたlib/cdk-workshop-stack.ts
において、SQS・SNSの定義を削除し、Lambda関数の定義に差し替えます。
import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; export class CdkWorkshopStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); // defines an AWS Lambda resource const hello = new lambda.Function(this, 'HelloHandler', { runtime: lambda.Runtime.NODEJS_16_X, // execution environment code: lambda.Code.fromAsset('lambda'), // code loaded from "lambda" directory handler: 'hello.handler' // file is "hello", function is "handler" }); } }
(注意)ワークショップではruntime: lambda.Runtime.NODEJS_14_X
と定義されていますが、ランタイムであるNode.jsのバージョン14は2023/4/30にサポート終了しており、デプロイするとエラーになるため、16や18に差し替えてください。
ここにはCDKの肝となるソースが含まれていますが、先にCDKのコアコンセプトに触れておきます。
(出典) AWS Black Belt「AWS CDK Basic #1 概要」
上の絵において、Appは今作成しているCDKアプリケーション全体を指し、StackはAppから生成されるCloudFormationスタックと同義です。1つのAppに複数のStackが存在し得ます。Stackの中には1つ以上のConstruct(以下、コンストラクトと表記)が含まれます。このコンストラクトこそがCDKの肝です。CDKにおけるAWSリソースを定義するテンプレートとイメージすればよいかと思います。
コンストラクトは自分で定義もできますが、AWS Construct Libraryというコンストラクト集が既に用意されています。コンストラクトには大きく3つの種類があります。
(出典) AWS Black Belt「AWS CDK Basic #1 概要」
CDKの特徴の1つが「抽象化されたコンストラクトが用意されている」ことです。Low-level constructs (L1)だとCloudFormationテンプレートを作る労力とCDKアプリを作る労力は変わらないのですが、High-level constructs (L2)だと少量のコードでAWSリソースをまとめて作成してくれます。
ここで最初にエントリーポイントとして見たbin/cdk-workshop.ts
を振り返ってみると、const app = new cdk.App();
で作成していたのがAppであり、次の行のnew CdkWorkshopStack
でインスタンス化したのがStackです。
#!/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
に戻ります。クラスCdkWorkshopStack
の途中でインスタンス化されているlambda.Function
がLambda関数向けのL2コンストラクトです。L2のためAWSリソース定義がかなり抽象化されており、これを使うと
const hello = new lambda.Function(this, 'HelloHandler', { runtime: lambda.Runtime.NODEJS_16_X, // execution environment code: lambda.Code.fromAsset('lambda'), // code loaded from "lambda" directory handler: 'hello.handler' // file is "hello", function is "handler" });
というたった5行のソースコードで、Lambda関数のソースコードを格納するS3バケットや関数実行用のIAMロールまでまとめて作成してくれます。S3やIAMのことなんて一文字も書いていないのに、上手くフォローしてくれるのがCDKの強みであり、単なるIaaSツールとは違うポイントです。
コンストラクトをインスタンス化する際には3つのパラメータを渡します。
- scope:対象のコンストラクトを呼び出す親コンストラクトを指定。基本は
this
をパラメータとして渡すことがほとんど。 - id :任意の文字列。StackがS3バケットなどを作成する際に一意となるようリソース名にこのidが埋め込まれる。上記scope内で重複しないよう定義が必要。
- props:コンストラクタごとに必要なパラメータ群。中括弧で括って複数のパラメータを指定。例えばLambda.Functionならruntime、code、handlerの指定が必要。
少し脇道にそれてしまいましたが、ここでcdk deploy
をすればLambda関数が作成されます。ワークショップではその後、API GatewayをLambda関数を前に追加し、REST APIとして定義するところまで実施します。
コンストラクトを書く
この章では自分でコンストラクトを定義します。このコンストラクトでは、新たなLambda関数を定義し、API Gatewayからの呼び出し(invoke)を受け付けた後、DyanamoDBで呼び出し回数をカウントアップしてから後続の関数(先ほど作成したHelloHandler関数)を呼び出す、という構成を定義します。
(出典) AWS CDK Workshop
lib/hitcounter.ts
ファイルを新規作成し、以下を追記します。
import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { Construct } from 'constructs'; export interface HitCounterProps { /** the function for which we want to count url hits **/ downstream: lambda.IFunction; } export class HitCounter extends Construct { /** allows accessing the counter function */ public readonly handler: lambda.Function; constructor(scope: Construct, id: string, props: HitCounterProps) { super(scope, id); const table = new dynamodb.Table(this, 'Hits', { partitionKey: { name: 'path', type: dynamodb.AttributeType.STRING } }); this.handler = new lambda.Function(this, 'HitCounterHandler', { runtime: lambda.Runtime.NODEJS_16_X, handler: 'hitcounter.handler', code: lambda.Code.fromAsset('lambda'), environment: { DOWNSTREAM_FUNCTION_NAME: props.downstream.functionName, HITS_TABLE_NAME: table.tableName } }); // grant the lambda role read/write permissions to our table table.grantReadWriteData(this.handler); // grant the lambda role invoke permissions to the downstream function props.downstream.grantInvoke(this.handler); } }
Constructクラスを継承して定義されたHitCounter
クラスが、今回定義しているコンストラクトです。コンストラクトにはscope、id、propsのパラメータが必要でしたが、今回propsとしてHitCounterProps
というインターフェースが定義されています。中身はdownstream
という名前で、後続のLambda関数を定義するようにしています。lambda.IFunction
として記述されているのは、CDKが定義しているインターフェースでLambda関数オブジェクトを指します。詳細定義はAWS CDK Referenceに記述されています。
このコンストラクトの内部には、DynamoDBテーブルを定義するdynamodb.Table
コンストラクトと、Lambda関数を定義するlambda.Function
コンストラクトがあります。つまり、コンストラクトは入れ子構造にすることができます。
最後のtable.grantReadWriteData
は、dynamodb.Table
コンストラクトに定義されているメソッドで、このテーブルの全データに読み書きができるIAMロールをメソッドの引数に指定したリソース(今回だとHitCounterHandler関数)に付与してくれます。同じくprops.downstream.grantInvoke
は、lambda.Function
コンストラクトのメソッドで、このLambda関数を実行する権限を引数のリソースに付与します。
このように面倒なIAM設計・実装をメソッド呼び出し1つで解決できるのも、CDKのL2コンストラクトの抽象化の威力ですね。
次にStackを定義しているlib/cdk-workshop-stack.ts
に戻り、今回作ったHitCounterコンストラクトをnew
するよう修正します。
import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; import { HitCounter } from './hitcounter'; export class CdkWorkshopStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const hello = new lambda.Function(this, 'HelloHandler', { runtime: lambda.Runtime.NODEJS_16_X, code: lambda.Code.fromAsset('lambda'), handler: 'hello.handler' }); const helloWithCounter = new HitCounter(this, 'HelloHitCounter', { downstream: hello }); // defines an API Gateway REST API resource backed by our "hello" function. new apigw.LambdaRestApi(this, 'Endpoint', { handler: helloWithCounter.handler }); } }
この後cdk deploy
をすればLambda関数やDynamoDBテーブル、IAMロールが作成され、API GatewayからREST APIでそれらを実行することが出来ます。
コンストラクトライブラリの利用
コンストラクトは「CDKがあらかじめ定義しているもの」、「自分で定義したもの」の他に、「一般に公開されているもの」も使用することができます。一般に公開されているものとは、npmのリポジトリ(npmjs.com)内に定義されたモジュールやConstruct Hubで公開されているコンストラクトを指します。この章ではnpmからモジュールをインストールし、その中のコンストラクトを使ってみます。
使い方はシンプルで、まずnpm install cdk-dynamo-table-viewer@0.2.488
でモジュールをインストールします。このモジュールにはTableViewer
というコンストラクトが定義されていて、これを使うとDynamoDBテーブルから取得した全データをHTMLに埋込んで返却するLambda関数とAPI Gatewayが構築されます。
cdk-workshop-stack.ts
にて、TableViewer
コンストラクトをnew
します。
import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; import { HitCounter } from './hitcounter'; import { TableViewer } from 'cdk-dynamo-table-viewer'; export class CdkWorkshopStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const hello = new lambda.Function(this, 'HelloHandler', { runtime: lambda.Runtime.NODEJS_14_X, code: lambda.Code.fromAsset('lambda'), handler: 'hello.handler' }); const helloWithCounter = new HitCounter(this, 'HelloHitCounter', { downstream: hello }); // defines an API Gateway REST API resource backed by our "hello" function. new apigw.LambdaRestApi(this, 'Endpoint', { handler: helloWithCounter.handler }); new TableViewer(this, 'ViewHitCounter', { title: 'Hello Hits', table: helloWithCounter.table }); } }
TableViewerの引数の中にあるhelloWithCounter.table
は、HitCounter
コンストラクトで作成したDynamoDBテーブルです。これだけで先ほど作ったDynamoDBテーブルのデータをWeb画面で描写することができます。
※そのインスタンスであるhelloWithCounterから変数tableを呼び出せるように、hitcounter.ts
においてpublic指定した変数tableにDynamoDBテーブルを代入しておく必要があります。
このWorkshopの最後に「追加課題」としてテーブルデータをhitsの降順にソートするよう指示されます。TableViewer
コンストラクトが定義されているnode_modules/cdk-dynamo-table-viewer/lib/table-viewer.d.ts
を見てみると、どうやらこのコンストラクトのpropsにはsortBy
というパラメータを指定できそうだと分かります。さらに、どうやら"-"を頭につけると降順になりそうです。
import { aws_apigateway as apigw, aws_dynamodb as dynamodb } from 'aws-cdk-lib'; import { Construct } from 'constructs'; export interface TableViewerProps { /** * The endpoint type of the * [LambdaRestApi](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-apigateway.LambdaRestApi.html) * that will be created * @default - EDGE */ readonly endpointType?: apigw.EndpointType; /** * The DynamoDB table to view. Note that all contents of this table will be * visible to the public. */ readonly table: dynamodb.ITable; /** * The web page title. * @default - No title */ readonly title?: string; /** * Name of the column to sort by, prefix with "-" for descending order. * @default - No sort */ readonly sortBy?: string; } /** * Installs an endpoint in your stack that allows users to view the contents * of a DynamoDB table through their browser. */ export declare class TableViewer extends Construct { readonly endpoint: string; constructor(parent: Construct, id: string, props: TableViewerProps); }
ということで、cdk-workshop-stack.ts
の末尾にあるTableViewerをインスタンス化している箇所を次のように差し替えます。
new TableViewer(this, 'ViewHitCounter', { title: 'Hello Hits', sortBy: '-hits', table: helloWithCounter.table })
これでcdk deploy
してみると、無事hitsの降順でテーブルが表示されているはずです。このように公開されているコンストラクトがどのような機能を持ち、どんな実装を行うのかを時にソースまで追って確認するのはコンストラクト利用者の責務であり、とても重要なポイントとなります。
まとめ
CDKのコアコンセプトを整理し、AWS CDK標準やnpmで公開されているコンストラクトの使い方、コンストラクトの作り方を確認しました。Workshop実施前と比べると、だいぶCDKに対する解像度が上がったのではないでしょうか。
このWorkshopにはアドバンスドトピックがあります。ここではCDKアプリのテスト手法やCodePipelineによるCI/CD手法が紹介されています。そこは第3回で触れていきます。