aws-sam-cliでLambda,DynamoDBのサーバーレスアプリケーション開発に入門してみる

この記事は[ただの集団 Advent Calendar 2018 - Adventar](https://adventar.org/calendars/3447)の19日目です。昨日は todokr さんの[ScalaをGraalVMでネイティブイメージにコンパイルしてAWS Lambdaでサクサク動かす](http://todokr.hatenablog.com/entry/2018/12/18/001401)でした。

# 目的
本記事ではsam-cliを使用したnodejsのサーバーレスアプリケーションの開発の流れを環境構築からデプロイまで行います。
入門向けのため、例として、Lambda, API Gateway, DynamoDBを一通り使ったtodoアプリを作ってみます。

sam-cliはSAMの定義に従ったCloudFormationのコード生成や、ローカルでの動作確認用コマンドを有する便利なツールです。
サーバーレスアプリケーションの開発は愚か、DynamoDBを触ったことさえない素人の私でも簡単にアプリを構築することができましたので、ぜひ触ってみてはいかがでしょうか。

本記事の手順完了後の実装はこちらにあります。
https://github.com/umeneri/sam-todo

# 環境
- MacOS High Sierra 10.13.6
- Docker 18.06.1-ce
- Node v8.10.0 (ローカルはndenvでlambdaのバージョンと合わせています)
- NPM 5.6.0
- Python 2.7.15
- AWS CLI aws-cli 1.16.28
- Lambda Runtime: nodejs8.10

# 作るもの
構成：
ApiGateway -> lambda -> dynamodb

シンプルなサーバーレス構成です。

動作：
- ApiGatewayへのgetでタスク一覧を取得
- ApiGatewayへのputでタスクを新規作成

文面の都合上、今回は2つのメソッドだけ作成します。

# sam-cliの環境構築
※事前にaws-cliは使えるものとします。

sam-cliをpipでインストールします。

```sh
$ pip install aws-sam-cli
```


# 雛形アプリケーション作成
まず雛形を作ります。
sam initを行うと、簡単にサーバーレスアプリケーションの雛形を作成できます。また、デフォルトではnodejsの雛形が作成されます。

```sh
$ sam init --name sam-todo
$ cd sam-todo
```

雛形ができていることを確認します。 hello-worldディレクトリ内にlambdaで使用するアプリケーションコードがあります。

```sh
$ ls
README.md  hello-world/  template.yaml
```

hello-worldの名前を変え、タスク管理用のソースを作ります。

```sh
$ mv hello-world task
```

# ローカルでのlambdaアプリケーション作成

task/app.jsを下記コードに変更します。lambdaHandlerがlambdaで実行する関数です。

```js
exports.lambdaHandler = async (event, context) => {
  try {
    switch (event.httpMethod) {
      case "GET": {
        const tasks = [
            {
              id: 1,
              name: 'task1'
            },
            {
              id: 2,
              name: 'task2'
            }
          ];
        return {
          "statusCode": 200,
          "body": JSON.stringify(tasks)
        };
      }
      case "PUT": {
        return {
          "statusCode": 200,
          "body": JSON.stringify({})
        };
      }
      default:
        return {
          "statusCode": 501
        };
    }
  } catch (err) {
      console.log(err);
      return err;
  }
};
```

eventにはAPI Gatewayからのリクエストが入る想定なので、リクエストのhttpMethodがGET, PUT, それ以外で場合分けしてレスポンスを変えています。

template.ymlは下記の様に修正します。元のファイルと違い、DynamoDBへのアクセス権限をつけたり、ApiGatewayのメソッドを２種類用意しています。

```yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: sam-task
Globals:
    Function:
        Timeout: 3

Resources:
    # lambdaの関数
    TaskFunction:
        Type: AWS::Serverless::Function
        Properties:
            # 関数内で実行するコードのパス
            CodeUri: task/
            Handler: app.lambdaHandler
            Runtime: nodejs8.10
            Policies: AmazonDynamoDBFullAccess
            # Lambda内環境変数
            Environment:
                Variables:
                    PARAM1: VALUE
            Events:
                # API Gatewayでアクセスする際のパスとメソッド
                TaskList:
                    Type: Api
                    Properties:
                        Path: /task
                        Method: get
                PutTask:
                    Type: Api
                    Properties: 
                        Path: /task
                        Method: put
```


## lambdaの動作確認
では、以下で動作確認用のリクエストデータを作成します。samはこのようなデータも自動生成できて大変便利ですね。

```sh
$ sam local generate-event apigateway aws-proxy --method GET > task/data/get_event.json
```

動作確認します。`sam local invoke`でtemplate.ymlで定義した`TaskFunction`を実行します。
`-e`で先程生成したgetのリクエストを入れたときの動作を確認します。

最後の行にタスク一覧のjsonが確認できればOKです。

```shell
[git][* master]:~/dev/infra/sam-todo/ $ sam local invoke TaskFunction -e task/data/get_event.json
2018-12-16 11:19:37 Found credentials in shared credentials file: ~/.aws/credentials
2018-12-16 11:19:37 Invoking app.lambdaHandler (nodejs8.10)

Fetching lambci/lambda:nodejs8.10 Docker container image......
2018-12-16 11:19:41 Mounting /Users/koishi/dev/infra/sam-todo/task as /var/task:ro inside runtime container
START RequestId: 03df4092-e556-143e-8038-187fd187896b Version: $LATEST
END RequestId: 03df4092-e556-143e-8038-187fd187896b
REPORT RequestId: 03df4092-e556-143e-8038-187fd187896b  Duration: 7.60 ms  Billed Duration: 100 ms  Memory Size: 128 MB  Max Memory Used: 30 MB

{"statusCode":200,"body":"[{\"id\":1,\"name\":\"task1\"},{\"id\":2,\"name\":\"task2\"}]"}
```

PUTイベントも同じように動作確認します。`{"statusCode":200,"body":"{}"}`が出力されればOKです。

```sh
$ sam local generate-event apigateway aws-proxy --method PUT > task/data/put_event.json
$ sam local invoke TaskFunction -e task/data/put_event.json
```

# DynamoDBを使用する
それではDynamoDBも加え、タスクを永続的に保存できるようにします。

## DynamoDBの準備
まず、ローカルでDynamoDBを使うためにDynamoDB Localをインストールします。
今後の運用を考えdocker-compose.ymlに記述します。

```yml
version: '3'

services:
  dynamodb:
    image: amazon/dynamodb-local
    container_name: dynamodb
    ports:
      - 8000:8000
```

DynamoDBを起動しておきます。

```sh
$  docker-compose up -d
```

DynamoDBへのアクセスを確認します。

```sh
$ curl http://localhost:8000
```

以下が出力されます。少なくとも動いてはいることが確認できました。

```
{"__type":"com.amazonaws.dynamodb.v20120810#MissingAuthenticationToken","message":"Request must contain either a valid (registered) AWS access key ID or X.509 certificate."}%
```


localのLambdaがDynamoDBにアクセスするためには、DynamoDBのエンドポイントを知る必要があります。
今回はエンドポイントを環境ごとに分けたいので、環境変数にエンドポイントを設定することにします。

まずtemplate.ymlのEnvironmentでDynamoDBのエンドポイントを指定します。空でもまずは大丈夫です。

```
Environment:
    Variables:
        DYNAMODB_ENDPOINT: ""
```

続いて、task/env.jsonを作成します。こちらは環境ごとに環境変数を切り替えるためのファイルです。今回はローカルのDynamoDBのエンドポイントのみ環境変数として指定します。

```json
{
    "TaskFunction": {
        "DYNAMODB_ENDPOINT": "http://192.168.100.185:8000"
    }
}
```
注意です。エンドポイントをlocalhostにすると、後述します`sam local start-api`でDynamoDBへアクセス出来ませんでした。そこでローカルのIP`192.168.100.185`をifconfig等で取得し入力しています。

## テーブル定義作成
以下のスキーマを用意し、DynamoDBにタスク管理用テーブルを作成します。

```json
{
    "AttributeDefinitions": [
        {
            "AttributeName": "Id",
            "AttributeType": "N"
        }
    ],
    "TableName": "Task",
    "KeySchema": [
        {
            "AttributeName": "Id",
            "KeyType": "HASH"
        }
    ]
}
```
各タスクはIdを持ち、Idに紐づくHashにタスク名等を保存する形式です。

では、下記でテーブルを作成します。

```sh
$ aws dynamodb create-table --cli-input-json file://task/schema/task.json --endpoint-url http://127.0.0.1:8000
```

長かったですが、ようやくDynamoDBの準備ができました。

## アプリ修正

テーブルができたところで、アプリからテーブルにアクセスできるよう修正します。
以下の様にDynamoDBを扱うためのクラスとして、task/dynamodb-client.jsを作成します。

今後行数が増え複雑化することを考えると、lambdaのコードとファイルを分離させておいたほうが全体の見通しがよく再利用性も上がります。

```js
const AWS = require('aws-sdk')

class DynamoDBClient {
  constructor(tableName) {
    const endpoint = process.env.DYNAMODB_ENDPOINT;
    const config = endpoint !== "" ? { endpoint } : { region: 'ap-northeast-1' };

    this.documentClient = new AWS.DynamoDB.DocumentClient(config);
    this.tableName = tableName;
  }

  scan() {
    return this.documentClient.scan({ TableName: this.tableName }).promise();
  }

  put(itemParams) {
    const dbParams = {
      TableName: this.tableName,
      Item: itemParams,
    }

    return this.documentClient.put(dbParams).promise();
  }
}

exports.DynamoDBClient = DynamoDBClient;
```
DyamoDBClientのメソッドはscanとputの２つです。それぞれタスク一覧、新規作成に対応します。

次にapp.jsをDynamoDBClientを使用するように修正します。

```js
const DynamoDB = require('./dynamodb-client')
const dbClient = new DynamoDB.DynamoDBClient('Task');

exports.lambdaHandler = async (event, context) => {
  try {
    switch (event.httpMethod) {
      case "GET": {
        const dbOutput = await dbClient.scan();

        return {
          "statusCode": 200,
          "body": JSON.stringify(dbOutput)
        };
      }
      case "PUT": {
        const body = JSON.parse(event.body);
        const dbOutput = await dbClient.put(body);

        return {
          "statusCode": 200,
          "body": JSON.stringify(dbOutput)
        };
      }
      default:
        return {
          "statusCode": 501
        };
    }
  } catch (err) {
    console.log(err);
    return err;
  }
};

```

put_event.jsonのbodyを下記のように修正します。json形式で新しいタスクのIdとNameが入ってくる想定ですね。

```
  "body": "{ \"Id\": 1, \"Name\": \"task1\"}",
```

それでは、DynamoDBも含めてローカルで動作確認してみましょう。lambdaの実行は、先程と同じく`sam local invoke`です。
はじめにputで作成したタスクをgetで出力する手順で、正しくタスクができていることを確認します。

タスクの作成：

```sh
$ sam local invoke TaskFunction -e task/data/put_event.json --env-vars task/env.json
```

タスクの一覧表示：

```sh
$ sam local invoke TaskFunction -e task/data/get_event.json --env-vars task/env.json
```

## API Gatewayからの動作確認

では、API Gatewayも含めて動作確認をします。`sam local start-api`を行うと、`http://127.0.0.1:3000`にApi Gatewayのサーバーが立ち上がるので、今度はcurlでリクエストできるようになります。

```sh
$ sam local start-api --env-vars task/env.json
```

別ターミナルを開き、`http://127.0.0.1:3000`をcurlで叩いてみましょう。

taskの作成：

```sh
$ curl -XPUT http://127.0.0.1:3000/task -d '{"Id": 2, "Name": "task2" }'
```

taskの一覧表示：

```sh
curl http://127.0.0.1:3000/task
```

PUTでタスクを作成し、一覧表示でタスクができていることが確認できればOKです！
これでローカルの開発は完了です。

# 本番デプロイ
本番デプロイし、最後の動作確認をしましょう。

## DynamoDBをtemplateに追加
まず本番でDynamoDBを生成するためにtemplate内にDynamoDBの定義を追加します。

Resouces:の配下に以下を追加します。

```
    TaskDynamoDBTable:
        Type: AWS::DynamoDB::Table
        Properties:
            BillingMode: PAY_PER_REQUEST
            AttributeDefinitions:
                - AttributeName: Id
                  AttributeType: N
            TableName: Task
            KeySchema:
                - AttributeName: Id
                  KeyType: HASH

```

DynamoDBのBillingModeは従量課金制にしています。開発中で使用頻度が小さいのであれば従来のProvisiondの場合より料金が安くすみます。
TypeはAWS::Serverless::SimpleTableとするのがSAMの流儀なのでしょうが、こちらだと従量課金設定がtemplate上でできませんでした。

## デプロイする
さて、準備が整ったので、いよいよデプロイです。

まずlambdaのソースをzip化したものを置くために、S3バケットを作成します。

```sh
$ aws s3 mb s3://sam-todo-backet
```

`sam package`でzipをS3のバケットにアップロードします。また、`--output-template-file`で指定した`packaged.yml`に整形されたCloudFormationのテンプレートファイルが作られます。

```sh
$ sam package --template-file template.yaml --s3-bucket sam-todo-backet --output-template-file packaged.yaml
```

次に、`sam deploy`でデプロイを行い、AWS上にリソースを作成できればOKです。sam-todo-stackがCloud Formationのstack名です。

```sh
$ sam deploy --template-file packaged.yaml --stack-name sam-todo-stack --capabilities CAPABILITY_IAM
```

## 本番の動作確認する

API Gatewayができているので、そのURIにアクセスして動作確認します。
URIとリソースができているかの確認もしたいので、AWSのコンソールのLambdaの関数`TaskFunction`に行きます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/30677/fc2031e8-a935-5a36-6b0b-2e322bee55da.png)
画像のように、LambdaとAPI Gatewayが紐付いていますね。

APIGateWayのパネルをクリックするとURIが表示されます。

![Lambda_Management_Console.png](https://qiita-image-store.s3.amazonaws.com/0/30677/4c0ce99b-cf7f-4a10-2aab-1310227a040c.png)


URIに対して下記のようにリクエストしてみます。

タスクの作成(PUT)：

```sh
curl -XPUT <API Gateway URL> -d '{"Id": 1, "Name": "task1" }'
```

タスク一覧(GET):

```sh
 curl <API Gateway URL>
```

GETして以下のようにタスクが作成できていればOKです！ お疲れ様でした。

```
{"Items":[{"Id":1,"Name":"task1"}],"Count":1,"ScannedCount":1}%
```