サーバーレスをこれから始める方へ!「形で考えるサーバーレス設計」のCDKテンプレート(web/mBaaS編)を試して解説してみた

AWSから「形で考えるサーバーレス設計」という資料が公開されています。こちらの設計を実装したCDK・SAMテンプレートとソースが一部で公開されていますので紹介します。試すことでサーバーレスで実装する際の実装・テスト・CI/CDの流れを把握することができます。
2020.06.24

はじめに

CX事業本部@東京の佐藤智樹です。

サーバーレスでシステムを構築する場合にやりたいことから利用パターンを勉強するために、AWSから「形で考えるサーバーレス設計」という資料が公開されています。

最近見たところ一部のパターンで、実際に動かして試すためのSAM、CDKテンプレートとLambdaのソースが公開されていました。今回はこちらのアプリケーションをコンソールから構築した後、テンプレートの内容を解説します。

これからサーバーレスを始める方は、自分で0から組み立てるよりも始めやすく基本的なパターンが学べるのでおすすめです。AWSのインフラ構成からLambdaの実装・ユニットテスト・CI/CDまで含まれた内容なので、理解すれば効率的でモダンな開発スタイルを実践していくことが可能になります。

テンプレートの公開場所

まずどこからテンプレートを入手できるのか紹介します。 こちらのリンクから形で考えるサーバーレス設計のページへ移動してください。

開くと代表的な適応シーンの中で「動的 Web / モバイルバックエンド」の部分で「テンプレートから始める->こちら」からLambdaのサンプルアプリケーション作成画面に飛べます。リンク先はAWSコンソールへのログインが必要なのでログインしていない場合はログインしてください。(ソース自体はGitHubで公開されているので、実際に開かなくても問題ないです)

動作確認

Lambdaの画面で構築できるアプリケーションの概要が確認できます。API Gatewayがエンドポイントとなってリクエストを受けて、LambdaがDynamoDBのデータをRead/Writeするパターンです。CodeCommitとCodePipelineもサポートされているのでCI/CDの体験もできます。

このままデプロイして動作を試したい場合は、右下の次へボタンで試すこともできます。

アプリケーション名を適当に入力して言語はNode.js、テンプレートは形式はAWS CDKで選択します。

ソース管理サービスを今回はCodeCommitを使用して、「ロールとアクセス許可の境界を作成する」にチェックをいれて作成を押下します。作成は数分と書いていましたが体感で20〜30分ぐらいかかりました。

完了するとCodePipelineを通してソースがデプロイされます。LambdaやAPI Gateway、DynamoDB、CodePipelineなどを確認するとアプリケーション名のついたリソースが作成されていることを確認できます。

動作確認する場合は、API Gatewayのエンドポイントが画面の中盤あたりに表示されるのでこちらを使用します。もし確認できない場合はAPI Gatewayの画面のステージからでも確認できます。

ローカルで以下のようにcurlコマンドを打つことで動作を確認できます。この辺りの流れはReadmeにも後述するソースのReadmeにも記載されています。

$ ENDPOINT=<画面で確認したエンドポイントを指定>
// データの追加
$ curl -d '{"id":"1234ABCD", "name":"My item"}' -H "Content-Type: application/json" -X POST $ENDPOINT
{"id":"1234ABCD", "name":"My item"}% 

$ curl -d '{"id":"12345678", "name":"My item 2"}' -H "Content-Type: application/json" -X POST $ENDPOINT
{"id":"12345678", "name":"My item 2"}%  

// データの全件数参照
$ curl $ENDPOINT
[{"id":"1234ABCD","name":"My item"},{"id":"12345678","name":"My item 2"}]% 

// データを1件参照
curl $ENDPOINT/1234ABCD
{"id":"1234ABCD","name":"My item"}% 

上記のようにこれでデータ保存と参照なAPIが作成できました。作るだけであれば

ソースの内容解説

ただ動かしただけだとどのようにリソース定義やデプロイが行われているのか分からないので簡単に解説を入れます。 ソース自体は下記画像の赤線部分の「Node.js」からGitHubのページへ移動して確認できます。

GitHubリポジトリへのリンク

画像の通り、cdkとsamのテンプレート確認できます。今回はcdkの方を使用していきます。

まずはリポジトリをローカルにコピーし、先ほどみていたソースの階層へ移動します。

$ git clone https://github.com/aws-samples/aws-lambda-sample-applications.git
$ cd aws-lambda-sample-applications/nodejs10.x/serverless-api-backend/cdk

フォルダの階層は以下のようになっています。主にsrc配下のLambdaのソース、lib配下のcdkテンプレート、tests配下のLambda用テストコードが確認できます。

$ tree
.
├── LICENSE
├── README.md
├── __tests__        // テスト用コード
│   └── unit
│       └── handlers
│           ├── get-all-items.test.js
│           ├── get-by-id.test.js
│           └── put-item.test.js
├── buildspec.yml   // CI/CD設定
├── cdk
│   ├── README.md
│   ├── bin
│   │   └── cdk.ts
│   ├── cdk.json
│   ├── jest.config.js
│   ├── lib         // cdkのテンプレート記載
│   │   ├── cdk-stack.ts
│   │   └── permissions-boundary-aspect.ts
│   ├── package.json
│   └── tsconfig.json
├── env.json
├── events          // Lambdaに渡すイベント
│   ├── event-get-all-items.json
│   ├── event-get-by-id.json
│   └── event-post-item.json
├── package.json
└── src             // Lambdaのソース部分
    └── handlers
        ├── get-all-items.js
        ├── get-by-id.js
        └── put-item.js

コードを実際に確認する場合は、VSCodeなどをダウンロードしてcdk直下とcdk/cdk直下で以下のコマンドを実行してください。

$ npm install

まずインフラ構成を確認するためlib配下の内容を確認します。

AWS CDKの構成

lib配下で以下2件のファイルがテンプレートとして設定されています。

  • cdk-stack.ts … API Gateway+Lambda+DynamoDBのリソースを定義
  • permissions-boundary-aspect.ts … リソースアクセス許可の境界を定義

今回はcdk-stack.tsについて記載します。permissions-boundary-aspect.tsはLambdaのサンプルアプリケーションから作成する場合に必要なアクセス境界の設定で普段はあまり使用しないかと思うので省きます。Lambdaのアクセス境界に関する詳細はこちらのリンクを確認してください。

以下がcdk-stack.tsのソースです。適宜日本語のコメントで解説を追記しています。

cdk-stack.ts

import * as apigateway from '@aws-cdk/aws-apigateway';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as lambda from '@aws-cdk/aws-lambda';
import * as s3 from '@aws-cdk/aws-s3';
import { App, CfnParameter, Duration, Stack, StackProps } from '@aws-cdk/core';


export class CdkStack extends Stack {
    constructor(scope: App, id: string, props: StackProps) {
        super(scope, id, props);

        new CfnParameter(this, 'AppId');

        // DynamoDBテーブルの作成
        const table = new dynamodb.Table(this, 'SampleTable', {
            partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
            readCapacity: 2,
            writeCapacity: 2
        });

        const environment = { SAMPLE_TABLE: table.tableName };
        // CodePipeline内のCodeBuildでビルドされる際にS3バケットへソースがアップロードされます。
        const artifactBucket = s3.Bucket.fromBucketName(this, 'ArtifactBucket', process.env.S3_BUCKET!);
        const artifactKey = `${process.env.CODEBUILD_BUILD_ID}/function-code.zip`;
        const code = lambda.Code.fromBucket(artifactBucket, artifactKey);

        // Lambdaを作成: get-all-items.js
        const getAllItemsFunction = new lambda.Function(this, 'getAllItems', {
            description: 'A simple example includes a HTTP get method to get all items from a DynamoDB table.',
            handler: 'src/handlers/get-all-items.getAllItemsHandler',
            runtime: lambda.Runtime.NODEJS_10_X,
            code,
            environment,
            timeout: Duration.seconds(60),
        });
        // Lambda(getAllItemsFunction)にテーブルのRead権限を付与
        table.grantReadData(getAllItemsFunction);

        // Lambdaを作成: get-by-id.js
        const getByIdFunction = new lambda.Function(this, 'getById', {
            description: 'A simple example includes a HTTP get method to get one item by id from a DynamoDB table.',
            handler: 'src/handlers/get-by-id.getByIdHandler',
            runtime: lambda.Runtime.NODEJS_10_X,
            code,
            timeout: Duration.seconds(60),
            environment,
        });
        // Lambda(getByIdFunction)にテーブルのRead権限を付与
        table.grantReadData(getByIdFunction);

        // Lambdaを作成: put-item.js
        const putItemFunction = new lambda.Function(this, 'putItem', {
            description: 'A simple example includes a HTTP post method to add one item to a DynamoDB table.',
            handler: 'src/handlers/put-item.putItemHandler',
            runtime: lambda.Runtime.NODEJS_10_X,
            code,
            timeout: Duration.seconds(60),
            environment,
        });
        // Lambda(getByIdFunction)にテーブルのRead/Write権限を付与
        table.grantReadWriteData(putItemFunction);

        // API Gatewayを作成
        const api = new apigateway.RestApi(this, 'ServerlessRestApi', { cloudWatchRole: false });
        // メソッド/階層を追加し、各メソッドにLambdaを統合リクエストとして紐付け
        api.root.addMethod('GET', new apigateway.LambdaIntegration(getAllItemsFunction));
        api.root.addMethod('POST', new apigateway.LambdaIntegration(putItemFunction));
        api.root.addResource('{id}').addMethod('GET', new apigateway.LambdaIntegration(getByIdFunction));
    }
}

Lambdaの構成

src配下のLambdaの実装について確認します。ソースとしては以下の3ファイルが用意されています。DynamoDBへのデータの追加とデータの単体抽出、全体抽出がそれぞれ実装されています。

  • get-all-items.js
  • get-by-id.js
  • put-item.js

ほとんど同様の内容なのでput-item.jsのみ軽く解説を記載します。JavaScriptなのでaync/awaitで非同期処理が簡潔に書かれています。async/awaitの詳細についてはこちらの記事がおすすめです。

put-item.js

const dynamodb = require('aws-sdk/clients/dynamodb');

const docClient = new dynamodb.DocumentClient();

// テーブル名を環境変数から取得
const tableName = process.env.SAMPLE_TABLE;

exports.putItemHandler = async (event) => { // 実務でやっている時はTypeScriptでeventの型をInterfaceで定義したりします。
    const { body, httpMethod, path } = event;
    if (httpMethod !== 'POST') {
        throw new Error(`postMethod only accepts POST method, you tried: ${httpMethod} method.`);
    }
    // 全てのログはCloudWatch Logsに出力されます。詳細は以下
    // https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-logging.html
    console.log('received:', JSON.stringify(event));

    // リクエストボディからid,nameを取得
    const { id, name } = JSON.parse(body);

    // DynamoDB Clientへ渡すパラメータを設定します。詳細は以下
    // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#put-property
    const params = {
        TableName: tableName,
        Item: { id, name },
    };
    // データを追加(putで条件無しなので、重複した場合は上書き)
    // await でdocClientの結果が返却されるまで待機
    await docClient.put(params).promise();

    const response = {
        statusCode: 200,
        body,
    };

    console.log(`response from: ${path} statusCode: ${response.statusCode} body: ${response.body}`);
    return response;
};

テストの構成

__test__/unit/handlers配下にLambdaのハンドラごとのテストが作成されています。1ファイルに対して1テストだけ実装されています。

  • get-all-items.test.js
  • get-by-id.test.js
  • put-item.test.js

3ファイルともほとんど同様の内容なのでこちらも「put-item.test.js」の解説を行います。テストはJestで書かれており、自分も普段開発で使用していてCDK自体のテストでも使用されています。

// aws-sdkからdynamodbをインポート
const dynamodb = require('aws-sdk/clients/dynamodb');

// put-item.jsをインポート
const lambda = require('../../../src/handlers/put-item.js');

// putItemHandlerのテスト
describe('Test putItemHandler', () => {
    let putSpy;

    // describe実行時最初に1回だけ実行
    // いくつかテスト前・後に実行できるメソッドがあり、優先度などの詳細は以下URLで確認できる
    // https://jestjs.io/docs/en/setup-teardown
    beforeAll(() => {
        // DynamoDBのputメソッドをspy化します。以下のURLはspyOnの詳細
        // https://jestjs.io/docs/en/jest-object.html#jestspyonobject-methodname
        putSpy = jest.spyOn(dynamodb.DocumentClient.prototype, 'put');
    });

    // describe実行時最後に1回だけ実行
    afterAll(() => {
        putSpy.mockRestore();
    });

    // putItemHandlerのテスト
    it('should add id to the table', async () => {
        // putSpyに指定したメソッドが特定の値を毎回返すように設定。
        putSpy.mockReturnValue({
            promise: () => Promise.resolve('data'),
        });

        const event = {
            httpMethod: 'POST',
            body: '{"id":"id1","name":"name1"}',
        };

        // Lambdaを実行
        const result = await lambda.putItemHandler(event);
        const expectedResult = {
            statusCode: 200,
            body: event.body,
        };

        // 実行結果と想定結果が一致するか確認
        expect(result).toEqual(expectedResult);
    });
});

今回のテストではJestのspyOnが使われていますが、自分は普段fnを使用しています。mockReturnValue無しでspyOnだけ設定した場合は、toHaveBeenCalledで関数が呼び出されたかどうかのテストだけ行えます。細かい使い分けは以下のブログで分かりやすく書かれています。

CI/CDの構成

ソース直下のbuildspec.ymlにCI/CDの設定が記載されています。サンプルアプリケーションはこちらの設定をCodeBuildで読み込んでビルド/テストしています。

version: 0.2
phases:
  install:
    commands:
      # テスト用に依存関係のインストール
      - npm install
      # CDKをインストール
      - cd cdk
      - npm install -g aws-cdk
      - npm install
      - cd ..
  pre_build:
    commands:
      # '__tests__'配下のユニットテストを実行
      - npm run test
      # package.json の devDependenciesに記載のあるデプロイに不要なパッケージの削除
      - npm prune --production
      # 別ディレクトリへコピー
      - mkdir function-code
      - cp -r src node_modules function-code
  build:
    commands:
      # Lambaのコードをzip化してS3にアップロード
      - cd function-code
      - zip -r function-code.zip .
      - aws s3 cp function-code.zip s3://$S3_BUCKET/$CODEBUILD_BUILD_ID/
      # CDKからCloudFormationのテンプレートを生成
      - cd ../cdk
      - cdk synth > ../template-export.yml
artifacts:
  files:
    - template-export.yml

ビルド設定のフェーズは、installpre_buildbuildpost_buildの4つのフェーズの内前半3つで管理されています。各フェーズごとにデプロイまでの前準備を行います。今回の場合は、installで依存関係のあるライブラリなどのインストール。pre_buildでユニットテスト・ソースを別ディレクトリにまとめる。buildでソースのアップロードとCloudFormationテンプレートの作成が行われています。最終的にはCodeBuildの出力先S3バケットへartifacts配下に書かれているtemplate-export.ymlのファイルが出力されます。

CodeBuildに関する設定の詳細は以下のリンクなどで確認してください。

このファイルがCodePipelineのDeployフェーズで読まれてCloudFormationが実行されます。実際にCodePipelineを見ると以下のようにファイルが指定されています。

上記の処理が裏で行われることで先ほど紹介したバックエンドAPIが構築されます。

感想

「形で考えるサーバーレス設計」をCDKで実装して勉強しようとしていたのですが公式で用意されたものがあったので紹介しました。他の設計についても公式で追加されるのが遅ければ構築して紹介してみたいと思います。これからサーバーレスを始める方にはシンプルでロールの設定などに困らずに試せるのでおすすめです。この記事が試す際の参考に慣れば幸いです。