ちょっと話題の記事

AWS CDK が GA! さっそく TypeScript でサーバーレスアプリケーションを構築するぜ【 Cloud Development Kit 】

2019.07.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

やってみた感想から。これが本当の Infrastructure as Code かなと思いました。アプリケーションの開発者がインフラの構築も一緒にやっていて、 インフラ用のテンプレートファイルと格闘している 場合、AWS CDKに移行すると恩恵を得られそうです。一方インフラ構築をメイン業務として、CloudFormation テンプレートに慣れている場合、現時点ではまだ移行コストが大きいと考えています。

AWS CDK (Cloud Development Kit) とは

AWS リソースを 構成要素(construct) としてプログラムで書き、それらを組み合わせて実行するとデプロイできるというツールキットです。開発者視点では、AWSのインフラを TypeScript などのプログラミング言語を使って定義・デプロイできます。裏では、CDKプログラムを実行することで CloudFormation テンプレートを生成、そのテンプレートを使ってデプロイすることになります。これにより、プログラミング言語で実装することによるIDEや型補完の恩恵と、 CloudFormation でデプロイすることによるバリデーションや ChangeSet レビューの恩恵をいいとこどりできます。

2019年7月11日 Generally Available

2018年から GitHub でなにやら開発していた様子はありましたが、ついに AWS CDK がGAとなりました。GA対象の実装言語は Python と TypeScript です。AWS CDK はいろいろと目的やコンセプトがありますが、私が使うとしたらこの理由が大きいです。

Personally I really like that by using the AWS CDK, you can build your application, including the infrastructure, in your IDE, using the same programming language and with the support of autocompletion and parameter suggestion that modern IDEs have built in, without having to do a mental switch between one tool, or technology, and another. The AWS CDK makes it really fun to quickly code up your AWS infrastructure, configure it, and tie it together with your application code!

要するに、アプリケーション開発と同じようにインフラも構築できるよ。IDEの恩恵受けられて楽だし、楽しそうだよね、 という話で、まったくそのとおりですね。私たちのようなアプリケーション開発をメインで請ける開発者は、インフラ構築作業を簡略化、高速化し、ロジックに集中したいものです。 AWS SAM や CloudFormation もインフラ構築の手助けをしてくれる点は CDK と同じですが、CDKで独特なのはインフラ構築作業をアプリケーション開発のコンテキストに組み込んだ点です。これにより、IDE、テストツール、差分検証、コードレビューなど、アプリケーション開発のエコシステムがそのままインフラのコードにも適用できます。

参考: CDK の利用例自体はすでにあります

特に、株式会社はてな様がヘビーユースしているようです。資料もありますね。

また弊社ブログでもすでにいくつか例があります。ユースケース別で参考にしてみてください。

API + Lambda + DynamoDB のサーバーレスアプリを構築

さっそく使ってみましょう。本記事では典型的なAWSのサーバーレスアプリケーションを構築してみます。AWS CDK を使わないのであれば、AWS SAM で作成するようなアプリケーションです。インフラをプログラムで組めるメリットを最大限活かすために、AWS CDK の言語を TypeScript にして型の恩恵にあずかりましょう。さらに、 Lambda Function の実装言語も TypeScript とし、統一して試します。

次の手順でやっていきます。

  1. CDK のインストール
  2. Lambda Function の準備
  3. インフラコードをCDKで書く
  4. デプロイ、実行

主にこれらのドキュメントを見ながら進めました。

CDK のインストール

$ npm install -g aws-cdk
$ mkdir hello-cdk
$ cd hello-cdk
$ cdk init app --language=typescript
Applying project template app for typescript
Initializing a new git repository...
Executing npm install...
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN hello-cdk@0.1.0 No repository field.
npm WARN hello-cdk@0.1.0 No license field.

# Useful commands

 * `npm run build`   compile typescript to js
 * `npm run watch`   watch for changes and compile
 * `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
$ tree -L 3
.
├── node_modeules
├── README.md
├── bin
│   └── hello-cdk.ts
├── cdk.json
├── lib
│   └── hello-cdk-stack.ts
├── package-lock.json
├── package.json
└── tsconfig.json

インストールOKです。

Lambda Function の準備

ここに Lambda Function のソースコードを src/lambda/hello-cdk.ts として追加します。まずは Lambda Function のコードを書きましょう。こちらも TypeScript で書き、tsc でビルドする流れとします。

$ mkdir -p src/lambda
$ touch src/lambda/hello-cdk.ts

src/lambda/hello-cdk.ts

import { UpdateItemInput } from 'aws-sdk/clients/dynamodb';
import * as AWS from 'aws-sdk';

const EnvironmentVariableSample = process.env.GREETING_TABLE_NAME!;
const Region = process.env.REGION!;

const DYNAMO = new AWS.DynamoDB(
    {
        apiVersion: '2012-08-10',
        region: Region
    }
);

export async function handler(event: User): Promise<GreetingMessage> {
    return HelloWorldUseCase.hello(event);
}

export class HelloWorldUseCase {

    public static async hello(userInfo: User): Promise<GreetingMessage> {
        const message = HelloWorldUseCase.createMessage(userInfo);
        await DynamodbGreetingTable.greetingStore(message);
        return message;
    }

    private static createMessage(userInfo: User): GreetingMessage {
        return {
            title: `hello, ${userInfo.name}`,
            description: 'my first message.',
        }
    }
}

export class DynamodbGreetingTable {
    public static async greetingStore(greeting: GreetingMessage): Promise<void> {
        const params: UpdateItemInput = {
            TableName: EnvironmentVariableSample,
            Key: {greetingId: {S: 'hello-cdk-item'}},
            UpdateExpression: [
                'set title = :title',
                'description = :description'
            ].join(', '),
            ExpressionAttributeValues: {
                ':title': {S: greeting.title},
                ':description': {S: greeting.description}
            }
        };

        await DYNAMO.updateItem(params).promise()
    }
}
export interface User {
    name: string;
}
export interface GreetingMessage {
    title: string;
    description: string;
}

API Gateway から受け取ったデータをもとに挨拶文を生成し、それを DynamoDB へ保存する動きになっています。これで Lambda Function の準備は終わりました。

インフラコードをCDKで書く

CDK でインフラのリソースを定義していくにあたり、気になる課題ポイントをあらかじめ出しておきます。

  • あいさつアプリケーションのソースコードは src/lambda にある。Lambda Function にこのパスを教えるにはどうすればよいか
  • あいさつ Lambda Function は環境変数を利用している。 Lambda Function へ環境変数を設定するにはどうするか
  • あいさつ Lambda Function は API Gateway から入力を受け取っている。APIへの入力を Lambda Function へ渡すにはどうするか

これらに注目しながら書いていきましょう。CDKで個別のリソースを作るために、必要な追加ライブラリをインストールします。

npm install --save @aws-cdk/aws-dynamodb @aws-cdk/aws-lambda @aws-cdk/aws-apigateway

cdk init app で生成された lib/index.tsを編集します。

lib/index.ts

import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as lambda  from '@aws-cdk/aws-lambda';
import * as apigateway from '@aws-cdk/aws-apigateway';
import cdk = require('@aws-cdk/core');
import { Duration } from '@aws-cdk/core';

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

        // (1) DynamoDB
        const greetingTable = new dynamodb.Table(this, 'greeting', {
            partitionKey: {
                name: 'greetingId',
                type: dynamodb.AttributeType.STRING
            },
            tableName: 'greeting'
        });

        // (2) Lambda Function
        const putGreetingItemLambda = new lambda.Function(this, 'putGreetingItemLambda', {
            code: lambda.Code.asset('src/lambda'),  // (3)
            handler: 'hello-cdk.handler',
            runtime: lambda.Runtime.NODEJS_10_X,
            timeout: Duration.seconds(3),

            // (4) 
            environment: {
                GREETING_TABLE_NAME: greetingTable.tableName,
                REGION: props ? props.env!.region : 'ap-northeast-1'
            }
        });

        // (5) grant (maybe create iam role for lambda?)
        greetingTable.grantReadWriteData(putGreetingItemLambda);


        // (6) api gateway
        const api = new apigateway.RestApi(this, 'itemsApi', {
            restApiName: 'hello-cdk-greeting'
        });
        const greetingResource = api.root.addResource('greeting');

        // (7) request integration
        const putGreetingItemIntegration = new apigateway.LambdaIntegration(
            putGreetingItemLambda,
            {
                proxy: false,
                integrationResponses: [
                    {
                        statusCode: '200',
                        responseTemplates: {
                            'application/json': '$input.json("$")'
                        }
                    }
                ],
                passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
                requestTemplates: {
                    'application/json': '$input.json("$")'
                },
            }
        );
        greetingResource.addMethod(
            'POST',
            putGreetingItemIntegration,
            {methodResponses: [{statusCode: '200',}]});

    }
}

const app = new cdk.App();
new HelloCdkStack(app, 'HelloCdkApp');
app.synth();

思ったよりもずっと短い。先ほどあげた課題含め、どんなことを書いているか見ていきましょう。

  • (1) DynamoDBを定義: テーブル名やパーティションキーを指定することで DynamoDB を定義できます
  • (2) Lambda Function を定義: Lambda Function を定義します。
  • (3) ソースコードのパスを指定: lambda.Code.asset() で指定できるようです。アセット扱いなんですね。
  • (4) 環境変数を指定: オプションの environment キーで設定できるようです。わかりやすいですね。
  • (5) 権限設定: CloudFormation テンプレートとの違いその1。IAMリソースが個別であるのではなく、DynamoDBの構成要素が Lambda Function の構成要素に Grant するという書き方になります。
  • (6) API Gateway: CloudFormation テンプレートとの違いその2。API定義をつらつらと書いていくのではなく、APIのルートに addResource するという考え方です。
  • (7) 統合リクエストを定義: オプションでゴリゴリ定義することもよくあります。このあたりは CloudFormation をリスペクトしているのかもしれません。

型の恩恵は例えばつぎのように受けられます。

cdk_ide.png

Lambda Function でサポートしているランタイムを調べるのに、ドキュメントを検索する必要はありません。定義された候補をIDEで調べれば確定できます。さらに、EOLなどの理由で非推奨となるバージョンはプログラム上のDeprecatedとして表現できます。NodeJS4.3は打ち消し線で非推奨なのだとわかります。

インフラコードはこれで準備OKです。デプロイします。

デプロイ・実行

Lambda Function のコードも、CDKのコードも TypeScript で書いたので JavaScript へ変換します。

$ npm run build # 実体は tsc です
> hello-cdk@0.1.0 build /Users/wada.yusuke/Downloads/hello-cdk
> tsc

これでどちらのコードもJavaScriptが生成されました。次にデプロイするにあたり、最終的には CloudFormation テンプレートとなることを考えると、S3バケットが必要になるはずです。これは手で作るのか…というとそんなことはなく、cdkのコマンドが用意されています。cdk bootstrap を使います。スイッチロールを行い、デプロイ対象のAWSアカウントにアクセスできる状況で実施してください。私は fish shell の aws_swrole を使いました。

$ aws_swrole my-account
$ cdk bootstrap
 ⏳  Bootstrapping environment aws://xxxxxxxxxxxxx/ap-northeast-1...
CDKToolkit: creating CloudFormation changeset...
 0/2 | 10:50:31 | CREATE_IN_PROGRESS   | AWS::CloudFormation::Stack | CDKToolkit User Initiated
 0/2 | 10:50:37 | CREATE_IN_PROGRESS   | AWS::S3::Bucket | StagingBucket
 0/2 | 10:50:38 | CREATE_IN_PROGRESS   | AWS::S3::Bucket | StagingBucket Resource creation Initiated
 1/2 | 10:51:00 | CREATE_COMPLETE      | AWS::S3::Bucket | StagingBucket
 2/2 | 10:51:02 | CREATE_COMPLETE      | AWS::CloudFormation::Stack | CDKToolkit
 ✅  Environment aws://xxxxxxxxxxxxx/ap-northeast-1 bootstrapped.

デプロイ準備ができました。cdk deoployでデプロイします。

$ cdk deploy

すると変更内容が確認されます。テーブルフォーマットで整形してくれ、見やすいです。

cdk_deploy.png

OKであればそのままスタックの作成に入ります。

Do you wish to deploy these changes
HelloCdkStack: deploying...
Updated: asset.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
HelloCdkStack: creating CloudFormation changeset...
  0/16 | 10:52:09 | CREATE_IN_PROGRESS   | AWS::CloudFormation::Stack  | HelloCdkStack User Initiated
  0/16 | 10:52:38 | CREATE_IN_PROGRESS   | AWS::CDK::Metadata          | CDKMetadata
  0/16 | 10:52:38 | CREATE_IN_PROGRESS   | AWS::ApiGateway::RestApi    | itemsApi
  0/16 | 10:52:38 | CREATE_IN_PROGRESS   | AWS::IAM::Role              | itemsApi/CloudWatchRole
  0/16 | 10:52:38 | CREATE_IN_PROGRESS   | AWS::IAM::Role              | putGreetingItemLambda/ServiceRole
  0/16 | 10:52:38 | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table        | greeting
  0/16 | 10:52:39 | CREATE_IN_PROGRESS   | AWS::ApiGateway::RestApi    | itemsApi
  0/16 | 10:52:39 | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table        | greeting
  0/16 | 10:52:39 | CREATE_IN_PROGRESS   | AWS::IAM::Role              | putGreetingItemLambda/ServiceRole
  1/16 | 10:52:39 | CREATE_COMPLETE      | AWS::ApiGateway::RestApi    | itemsApi
  1/16 | 10:52:39 | CREATE_IN_PROGRESS   | AWS::IAM::Role              | itemsApi/CloudWatchRole
  1/16 | 10:52:41 | CREATE_IN_PROGRESS   | AWS::CDK::Metadata          | CDKMetadata Resource creation Initiated
  2/16 | 10:52:41 | CREATE_COMPLETE      | AWS::CDK::Metadata          | CDKMetadata
  2/16 | 10:52:43 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Resource   | itemsApi/Default/greeting
  2/16 | 10:52:43 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Resource   | itemsApi/Default/greeting
  3/16 | 10:52:43 | CREATE_COMPLETE      | AWS::ApiGateway::Resource   | itemsApi/Default/greeting
  3/16 | 10:52:46 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Method     | itemsApi/Default/greeting/OPTIONS
  3/16 | 10:52:46 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Method     | itemsApi/Default/greeting/OPTIONS
  4/16 | 10:52:48 | CREATE_COMPLETE      | AWS::ApiGateway::Method     | itemsApi/Default/greeting/OPTIONS
  5/16 | 10:52:58 | CREATE_COMPLETE      | AWS::IAM::Role              | itemsApi/CloudWatchRole
  6/16 | 10:52:58 | CREATE_COMPLETE      | AWS::IAM::Role              | putGreetingItemLambda/ServiceRole
  6/16 | 10:53:02 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Account    | itemsApi/Account
  6/16 | 10:53:03 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Account    | itemsApi/Account
  7/16 | 10:53:03 | CREATE_COMPLETE      | AWS::ApiGateway::Account    | itemsApi/Account
  8/16 | 10:53:10 | CREATE_COMPLETE      | AWS::DynamoDB::Table        | greeting
  8/16 | 10:53:13 | CREATE_IN_PROGRESS   | AWS::IAM::Policy            | putGreetingItemLambda/ServiceRole/DefaultPolicy
  8/16 | 10:53:14 | CREATE_IN_PROGRESS   | AWS::IAM::Policy            | putGreetingItemLambda/ServiceRole/DefaultPolicy
  9/16 | 10:53:23 | CREATE_COMPLETE      | AWS::IAM::Policy            | putGreetingItemLambda/ServiceRole/DefaultPolicy
  9/16 | 10:53:26 | CREATE_IN_PROGRESS   | AWS::Lambda::Function       | putGreetingItemLambda
  9/16 | 10:53:27 | CREATE_IN_PROGRESS   | AWS::Lambda::Function       | putGreetingItemLambda
 10/16 | 10:53:27 | CREATE_COMPLETE      | AWS::Lambda::Function       | putGreetingItemLambda
 10/16 | 10:53:30 | CREATE_IN_PROGRESS   | AWS::Lambda::Permission     | putGreetingItemLambda/ApiPermission.Test.POST..greeting
 10/16 | 10:53:30 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Method     | itemsApi/Default/greeting/POST
 10/16 | 10:53:30 | CREATE_IN_PROGRESS   | AWS::Lambda::Permission     | putGreetingItemLambda/ApiPermission.Test.POST..greeting
 10/16 | 10:53:30 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Method     | itemsApi/Default/greeting/POST
 11/16 | 10:53:31 | CREATE_COMPLETE      | AWS::ApiGateway::Method     | itemsApi/Default/greeting/POST
 11/16 | 10:53:34 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Deployment | itemsApi/Deployment
 11/16 | 10:53:35 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Deployment | itemsApi/Deployment
 12/16 | 10:53:35 | CREATE_COMPLETE      | AWS::ApiGateway::Deployment | itemsApi/Deployment
 12/16 | 10:53:38 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Stage      | itemsApi/DeploymentStage.prod
 12/16 | 10:53:39 | CREATE_IN_PROGRESS   | AWS::ApiGateway::Stage      | itemsApi/DeploymentStage.prod
 13/16 | 10:53:40 | CREATE_COMPLETE      | AWS::ApiGateway::Stage      | itemsApi/DeploymentStage.prod
 14/16 | 10:53:40 | CREATE_COMPLETE      | AWS::Lambda::Permission     | putGreetingItemLambda/ApiPermission.Test.POST..greeting
 14/16 | 10:53:43 | CREATE_IN_PROGRESS   | AWS::Lambda::Permission     | putGreetingItemLambda/ApiPermission.POST..greeting
 14/16 | 10:53:44 | CREATE_IN_PROGRESS   | AWS::Lambda::Permission     | putGreetingItemLambda/ApiPermission.POST..greeting
 15/16 | 10:53:54 | CREATE_COMPLETE      | AWS::Lambda::Permission     | putGreetingItemLambda/ApiPermission.POST..greeting

 ✅  HelloCdkStack

Outputs:
HelloCdkStack.itemsApiEndpointYYYYYY = https://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:XXXXXXXX:stack/HelloCdkStack/uuid-uuid-uuid-uuid-uuid

API Gateway の Endpoint まで出力されました。本当に動くのでしょうか。

$ curl -X POST -d '{"name":"wada"}' https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/greeting
{"title":"hello, wada","description":"my first message."}

$ aws dynamodb scan --table-name greeting
{
    "Items": [
        {
            "description": {
                "S": "my first message."
            },
            "greetingId": {
                "S": "hello-cdk-item"
            },
            "title": {
                "S": "hello, wada"
            }
        }
    ],
    "Count": 1,
    "ScannedCount": 1,
    "ConsumedCapacity": null
}

動きました。DynamoDBにもたしかにデータが保存されています。ここまで一切、AWSコンソールにログインしていません。 いい時代になりました。

まとめ

典型的なサーバーレスアプリケーションのインフラ構築を AWS CDK でやってみました。 CloudFormation テンプレートよりも少ない記述量でやりたいことができました。また、デプロイ経過や結果の出力内容も丁寧で、AWSコンソールといったりきたりがありませんでした。私のようにアプリケーション開発に従事している者からするとメリットが大きいと感じます。一方、コードで表現することからテンプレートで書くときと多少は考え方を変える必要があり、慣れも必要です。

Pros:

アプリケーション開発の一環で作業でき、作業者のコンテキストスイッチが少ない。YAMLテンプレートのかわりにプログラミング言語でインフラを構築できる。これにより、IDEなどを使って、アプリケーション開発の一環で作業できる。

Cons:

CDK を使いこなすためには、ある程度 CloudFormation テンプレートで努力した実績が必要。また、「各リソースは構成要素であり、構成要素同士が連携する」という考え方をプログラムに落とし込んで考えられると効率的に理解できるが、そのためにはアプリケーション構築の経験がそれなりに必要。本記事ではDynamoDBへのGrantが最たる例。

// これでIAMが生成される
greetingTable.grantReadWriteData(putGreetingItemLambda);

さらに実践的に使いこなすために

次のような内容についても検証していきます。

  • 環境ごとにリソース名を分けたいというケースに対応できるか (dev-greeting-tableなど)
  • Lambda Function AWS SDK 以外のライブラリを使う場合は、JavaScript への変換だけでなくバンドルが必要になりそう
  • これまで CloudFormation で書いていたものを、どこまでCDKで置き換えられるか

AWS CDK はオープンソースです。たくさん使ってどんどんフィードバックしていきましょう。