[AWS CDK超入門] DynamoDB + Lambda + API GatewayでAPIを作ってみた

CDKは楽しいぞっ
2020.05.27

AWS CDK とは

AWS Cloud Development Kit (AWS CDK)は AWS のリソースを Typescript や Python 等のコードで定義するフレームワークです。コードで定義したリソースは CloudFormation テンプレートに変換され、デプロイされます。

CDK の対応言語は以下の5つです。(2020 年 5 月 現在)

  • Typescript
  • JavaScript
  • Python
  • Java
  • C#

AWS CDK のメリットとデメリット

CDK を使うメリットとして主に上げられるのは以下です。

  • CloudFormation で書くよりもはるかに少量のコードの記述で済む
  • 使い慣れたプログラミング言語が使える
  • if 文やループなどのプログラミングロジックが使える
  • ライブラリとして切り出し、共有できる
  • テストが書ける

デメリットとしては以下が挙げられます。

  • プログラミングに慣れていない場合の高い学習コスト

作るもの

AWS Lambda + API Gateway + DynamoDB の構成 で DB からデータを取得する API を AWS CDK で作成してデプロイしてみます。

言語は Typescript を使います。

tsc --v
Version 3.7.4

インストール

AWS CDK を利用するにはNode.js >= 10.3.0が必要です。

npm install -g aws-cdk
cdk --version
1.32.2 

下準備

新しくフォルダを作成します。

mkdir hello-cdk
cd hello-cdk

言語を指定して雛形を作成します。

cdk init --language typescript

今回作成するリソースのモジュールもインストールしておきましょう。

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

数が多くなると package.json に記述してnpm installする方が楽かもしれません。

package.json 記述例

package.json

"dependencies": {
  "@aws-cdk/aws-apigateway": "_",
  "@aws-cdk/aws-dynamodb": "_",
  "@aws-cdk/aws-lambda": "\*",
  // more dependencies
}

cdk bootstrap

cdk bootstrap

AWS アカウント、リージョン単位で一度だけ実行するコマンドです。 CDKで利用するリソースを置いておく S3 バケットを作成してくれます。

Watch Mode

npm run watch


Starting compilation in watch mode...
Found 0 errors. Watching for file changes.
...

上記コマンドで Watch モードを起動します。これで Typescript のトランスパイラが随時 Typescript コードを Javascript にコンパイルしてくれます。オンにしておくことでコードを変更するごとにエラーチェックできるので便利ですし、デプロイする際にコンパイルし忘れるのも防げます。

これで準備は完了です。 早速lib/hello-cdk-stack.tsに リソース定義を記述してゆきます。

Dynamo DB

まずはじめに DynamoDB のテーブルを定義します。

lib/hello-cdk-stack.ts

import \* as cdk from "@aws-cdk/core";
import {Table, AttributeType, } as dynamodb from "@aws-cdk/aws-dynamodb";

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

    new Table(this, "items", {
      partitionKey: {
        name: "itemId",
        type: AttributeType.STRING,
      },
      tableName: "items",
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });

}
}

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

試しにこの状態で cdk deploy してみると、以下の結果がかえります。

cdk deploy

HelloCdkStack: deploying...
HelloCdkStack: creating CloudFormation changeset...
 0/3 | 2:24:38 AM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata   | CDKMetadata
 0/3 | 2:24:38 AM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table | items (items07D08F4B)
 0/3 | 2:24:39 AM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table | items (items07D08F4B) Resource creation Initiated
 0/3 | 2:24:39 AM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata   | CDKMetadata Resource creation Initiated
 1/3 | 2:24:39 AM | CREATE_COMPLETE      | AWS::CDK::Metadata   | CDKMetadata
 2/3 | 2:25:09 AM | CREATE_COMPLETE      | AWS::DynamoDB::Table | items (items07D08F4B)
 3/3 | 2:25:11 AM | CREATE_COMPLETE      | AWS::CloudFormation::Stack | HelloCdkStack

マネジメントコンソールを覗くとitemsテーブルが作成されています。

DynamoDB removalPolicy について

DynamoDB テーブルにデータが入っている状態でスタックを削除しようとした場合、以下のオプションで挙動が変わります。

  • RETAIN: テーブルにデータが入っている状態でcdk destroyが実行された時、テーブルを削除しない
  • DESTROY: テーブルにデータが入っている状態でcdk destroyが実行された時、データごとテーブルを削除する

テーブル内のデータを意図せずに削除することがないよう本番環境では removalPolicy をRETAINに設定することが推奨されています。

Lambda

次に DynamoDB からデータを取得する Lambda Function を定義します。 Lambda のソースコードは/lambda/というフォルダを新規に作成し、そこに記述することにします。

get-item.tsに DB から Item を一つ取得する処理を記述します。

lib/lambda/get-item.ts

const AWS = require("aws-sdk");
const db = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.TABLE_NAME || "";
const PRIMARY_KEY = process.env.PRIMARY_KEY || "";

export const handler = async (event: any = {}): Promise<any> => {
  const requestedItemId = event.pathParameters.id;
    if (!requestedItemId) {
      return {
        statusCode: 400,
        body: `Error: You are missing the path parameter id`,
      };
    }

  const params = {
    TableName: TABLE_NAME,
      Key: {
        [PRIMARY_KEY]: requestedItemId,
      },
    };

  try {
    const response = await db.get(params).promise();
      return { statusCode: 200, body: JSON.stringify(response.Item) };
    } catch (dbError) {
      return { statusCode: 500, body: JSON.stringify(dbError) };
  }
};

lib/hello-cdk-stack.tsに Lambda を追加します。 Lambda から DynamoDB へアクセスするため、IAM Role も作成します。

lib/hello-cdk-stack.ts

import * as cdk from "@aws-cdk/core";
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { AssetCode, Function, Runtime } from "@aws-cdk/aws-lambda";

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

      const dynamoTable = new Table(this, "items", {
        partitionKey: {
          name: "itemId",
          type: AttributeType.STRING,
        },
        tableName: "items",
        removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
      });

      const getItemLambda = new Function(this, "getOneItemFunction", {
        code: new AssetCode("lib/lambda"),
        handler: "get-item.handler",
        runtime: Runtime.NODEJS_10_X,
        environment: {
          TABLE_NAME: dynamoTable.tableName,
          PRIMARY_KEY: "itemId",
        },
      });

      // dynamodb読み取り権限をLambdaに付与
      dynamoTable.grantReadData(getItemLambda);

      const api = new RestApi(this, "itemsApi", {
        restApiName: "Items Service",
      });

  }
}

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

cdk diff

ここまで記述した時点でcdk diffを実行してみると、既存の Stack との差分をみてくれます。 Lambda、Lambda ServiceRole が表示されることをデプロイする前に一度確認してみましょう。

API Gateway

最後に API Gateway を定義します。

lib/hello-cdk-stack.ts

import \* as cdk from "@aws-cdk/core";
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { AssetCode, Function, Runtime } from "@aws-cdk/aws-lambda";
import {
RestApi,
LambdaIntegration,
IResource,
MockIntegration,
PassthroughBehavior,
} from "@aws-cdk/aws-apigateway";

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

    const dynamoTable = new Table(this, "items", {
      partitionKey: {
        name: "itemId",
        type: AttributeType.STRING,
      },
      tableName: "items",
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });

    const getItemLambda = new Function(this, "getOneItemFunction", {
      code: new AssetCode("lib/lambda"),
      handler: "get-item.handler",
      runtime: Runtime.NODEJS_10_X,
      environment: {
        TABLE_NAME: dynamoTable.tableName,
        PRIMARY_KEY: "itemId",
      },
    });

    // dynamodb読み取り権限をLambdaに付与
    dynamoTable.grantReadData(getItemLambda);

    // ApiGateway
    const api = new RestApi(this, "sampleApi", {
      restApiName: "Sample API",
    });
    const items = api.root.addResource("items");

    const singleItem = items.addResource("{id}");
    const getItemIntegration = new LambdaIntegration(getItemLambda);
    singleItem.addMethod("GET", getItemIntegration);
    addCorsOptions(items);

}
}
export function addCorsOptions(apiResource: IResource) {
apiResource.addMethod(
  "OPTIONS",
new MockIntegration({
integrationResponses: [
{
statusCode: "200",
responseParameters: {
  "method.response.header.Access-Control-Allow-Headers":
  "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'",
  "method.response.header.Access-Control-Allow-Origin": "'*'",
  "method.response.header.Access-Control-Allow-Credentials":
  "'false'",
  "method.response.header.Access-Control-Allow-Methods":
  "'OPTIONS,GET,PUT,POST,DELETE'",
},
},
],
passthroughBehavior: PassthroughBehavior.NEVER,
requestTemplates: {
  "application/json": '{"statusCode": 200}',
},
}),
  {
  methodResponses: [
    {
    statusCode: "200",
      responseParameters: {
      "method.response.header.Access-Control-Allow-Headers": true,
      "method.response.header.Access-Control-Allow-Methods": true,
      "method.response.header.Access-Control-Allow-Credentials": true,
      "method.response.header.Access-Control-Allow-Origin": true,
      },
    },
  ],
  }
);
}

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

API Gateway CORS(Cross Origin Resource Sharing)対応

API Gatewayに対して CORS を有効にするには、以下の2点を設定する必要があります。

  1. Lambda のレスポンスにheaders: {"Access-Control-Allow-Origin": "<アクセスを許可したいオリジン>"}を含める必要がある。
  2. Options メソッドを追加する

デプロイ&動作確認

お疲れ様でした。ここまで記述ができたらもう一度cdk deployを実行してリソースを作成しましょう。 Stackが作成されたことを確認できたら動作をみてみましょう。

DynamoDB へ適当なデータを入れて APIGateway へリクエストを投げてみます。

curl -v https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/items/123
*   Trying xx.xxx.xxx.xx...

...
< HTTP/2 200
< content-type: application/json
< content-length: 39
< date: Tue, 26 May 2020 12:00:31 GMT
< x-amzn-requestid: daab105f-f66e-44c6-a4ec-9cc9d80d8bd9
< x-amz-apigw-id: NI2y7GssNjMFpBw=
< x-amzn-trace-id: Root=1-5ecd04df-c068ca1ce325b22cc88a43f6;Sampled=0
< x-cache: Miss from cloudfront
< via: 1.1 xxxxxxxxx.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT51-C1
< x-amz-cf-id: nahVrJ04K6zfUzuoXTQcBhH6ywmhjTlyLdKw6cAyUczEW8nG_VfpcQ==
<
* Connection #0 to host xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com left intact
{"itemId":"123","itemName":"すあま"}

テンプレート全文

ここまで記述したCDKテンプレートはこちらです。

lib/hello-cdk-stack.ts

import * as cdk from "@aws-cdk/core";
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { AssetCode, Function, Runtime } from "@aws-cdk/aws-lambda";
import {
RestApi,
LambdaIntegration,
IResource,
MockIntegration,
PassthroughBehavior,
} from "@aws-cdk/aws-apigateway";

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

    // DynamoDB定義
    const dynamoTable = new Table(this, "items", {
      partitionKey: {
        name: "itemId",
        type: AttributeType.STRING,
      },
      tableName: "items",
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });

    // Lambda 関数定義
    const getItemLambda = new Function(this, "getOneItemFunction", {
      code: new AssetCode("lib/lambda"),
      handler: "get-item.handler",
      runtime: Runtime.NODEJS_10_X,
      environment: {
        TABLE_NAME: dynamoTable.tableName,
        PRIMARY_KEY: "itemId",
      },
    });

    // dynamodb読み取り権限をLambdaに付与
    dynamoTable.grantReadData(getItemLambda);

    // ApiGateway
    const api = new RestApi(this, "sampleApi", {
      restApiName: "Sample API",
    });
    const items = api.root.addResource("items");

    const singleItem = items.addResource("{id}");
    const getItemIntegration = new LambdaIntegration(getItemLambda);
    singleItem.addMethod("GET", getItemIntegration);
    addCorsOptions(items);

}
}
// Options Method を作成
export function addCorsOptions(apiResource: IResource) {
  apiResource.addMethod(
  "OPTIONS",
  new MockIntegration({
  integrationResponses: [
  {
  statusCode: "200",
  responseParameters: {
    "method.response.header.Access-Control-Allow-Headers":
    "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'",
    "method.response.header.Access-Control-Allow-Origin": "'*'",
    "method.response.header.Access-Control-Allow-Credentials":
    "'false'",
    "method.response.header.Access-Control-Allow-Methods":
    "'OPTIONS,GET,PUT,POST,DELETE'",
  },
  },
  ],
  passthroughBehavior: PassthroughBehavior.NEVER,
  requestTemplates: {
    "application/json": '{"statusCode": 200}',
  },
  }),
    {
    methodResponses: [
      {
        statusCode: "200",
          responseParameters: {
            "method.response.header.Access-Control-Allow-Headers": true,
            "method.response.header.Access-Control-Allow-Methods": true,
            "method.response.header.Access-Control-Allow-Credentials": true,
            "method.response.header.Access-Control-Allow-Origin": true,
          },
      },
    ],
    }
  );
}

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

後片付け

不要なリソースは忘れずに削除しましょう。

cdk destroy

Are you sure you want to delete: HelloCdkStack (y/n)? y
HelloCdkStack: destroying...

 ✅  HelloCdkStack: destroyed

CDK コマンド早見表

最後にCDKで実行できるコマンドの一覧をおさらいしてみましょう。

cdk init --language <LANGUAGE> 初期化
cdk bootstrap 最初に一回だけ実行するおまじない
cdk ls Stack のリストを表示する
cdk diff デプロイ済の Stack との差異を表示する
cdk synth 生成された Cfn テンプレートを出力する
cdk deploy Stack を AWS 環境へデプロイする
cdk destroy <STACK NAME> Stack を破棄する

CDK 参考