エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

AWS SAM + DynamoDB Local + Go で始めるサーバレスアプリケーション開発

この記事は エムスリー Advent Calendar 2018 6日目の記事です。

こんにちは、エムスリー エンジニアリンググループの大和です。 普段は Spring Boot (Java + Kotlin) を使用したサーバサイド開発をしていますが、この度 AWS Lambda を使用したアプリケーション開発を行うことになったため、開発環境を整えるところまでを記事にしたいと思います。

今回は、例として次のシンプルな構成のアプリケーションを作成していきます。

f:id:daiwa_home:20181206120347p:plain
アプリケーション構成

AWS SAM とは

AWS SAM (Serverless Application Model) とは、AWS 上でサーバーレスアプリケーションを構築するために使用できるフレームワークです*1。 YAML もしくは JSON 形式 (正確には AWS CloudFormation のテンプレート) で構成を記述できます。 また、コマンドラインで扱うためのツールとして AWS SAM CLI が提供され、こちらを使用することでローカルで実行できます*2

似たようなフレームワークに Serverless Framework がありますが、今回は AWS のみを使用することに加え、(Node.js 以外での) ローカルでのテストが行いやすいため AWS SAM を採用しています。

セットアップ

前提として、Python3 と Docker が必要です。 その上で AWS CLI をインストールします。 AWS CLI および AWS SAM CLI は Python で記述されているので、venv や pipenv で管理するとよいでしょう。

$ pip install awscli

$ aws configure --profile sam-cli-test
AWS Access Key ID [None]: dummy
AWS Secret Access Key [None]: dummy
Default region name [None]: us-east-1
Default output format [None]: json

$ pip install aws-sam-cli

ローカルで使用する場合であっても、リージョン等の設定が必要なため aws configure を実行しています。 プロファイルの切り替えは、オプション --profile もしくは環境変数 AWS_PROFILE で指定できます。

$ export AWS_PROFILE=sam-cli-test

雛形作成

次に雛形を作成していきます。作成は sam init で行えます。 今回は Go で関数を作成していきます。

$ sam init --runtime go --name sam-cli-test

$ cd sam-cli-test

$ tree .
.
├── Makefile
├── README.md
├── hello-world
│   ├── main.go
│   └── main_test.go
└── template.yaml

1 directory, 5 files

hello-world/main.go が Lambda 関数の実装、 template.yaml が AWS SAM によるテンプレートです。 template.yaml 中の関数定義を見ると以下のようになっています。

( template.yaml の一部抜粋)

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: hello-world
      Runtime: go1.x
      Tracing: Active
      Events:
        CatchAll:
          Type: Api
          Properties:
            Path: /hello
            Method: GET
      Environment:
        Variables:
          PARAM1: VALUE

Type: AWS::Serverless::Function で Lambda 関数であることを示し、 CatchAll: 以下で API の定義を行っています。

また、今回は Go のライブラリのバージョン管理に dep を使用します*3
そのため、今回のプロジェクト sam-cli-test$GOPATH 以下に作成する必要があります。 (例: ~/go/src/github.com/<username>/sam-cli-test)

$ dep init 
  Using ^1.7.0 as constraint for direct dep github.com/aws/aws-lambda-go
  Locking in v1.7.0 (fb8f88d) for direct dep github.com/aws/aws-lambda-go

ローカルでのテスト

単体テスト

作成された雛形に、Go でかかれた単体テストがあるのでそれを実行します。

$ go test ./hello-world
ok      github.com/<username>/sam-cli-test/hello-world 0.016s

Lambda 関数の実行

sam local start-api によりローカルで Lambda 関数を実行できます。 Lambda 関数の実行にはバイナリが必要なので、 make でビルドします。

$ make build
GOOS=linux GOARCH=amd64 go build -o hello-world/hello-world ./hello-world

$ sam local start-api 
2018-12-04 14:39:59 Found credentials in shared credentials file: ~/.aws/credentials
2018-12-04 14:39:59 Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]
2018-12-04 14:39:59 You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2018-12-04 14:39:59  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)

この状態で http://localhost:3000 に GET でアクセスすると結果が返ってきます。 ( xxx.xxx.xxx.xxx の部分には IP アドレスが入ります)

$ curl http://localhost:3000/hello
Hello, xxx.xxx.xxx.xxx

DynamoDB Local を使用した開発

関数の作成

ここまで sam init で作成した雛形を基に動作を確認してきましたが、次は DynamoDB のアクセスを行う関数を作成していきます。 まず、 GET /users/{id} に対応する Lambda 関数を作成します。 ローカルで実行している DynamoDB を指定するために DYNAMODB_ENDPOINT 、およびデプロイ時のテーブル名を指定するために DYNAMODB_TABLE_NAME の環境変数を使用しています。

( user/main.go )

package main

import (
    "encoding/json"
    "os"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/dynamodb"
    "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    // Environment variables
    endpoint := os.Getenv("DYNAMODB_ENDPOINT")
    tableName := os.Getenv("DYNAMODB_TABLE_NAME")

    // Request
    id, _ := request.PathParameters["id"]

    // DynamoDB
    sess := session.Must(session.NewSession())
    config := aws.NewConfig().WithRegion("us-east-1")
    if len(endpoint) > 0 {
        config = config.WithEndpoint(endpoint)
    }

    db := dynamodb.New(sess, config)
    response, err := db.GetItem(&dynamodb.GetItemInput{
        TableName: aws.String(tableName),
        Key: map[string]*dynamodb.AttributeValue{
            "Id": {
                N: aws.String(string(id)),
            },
        },
        AttributesToGet: []*string{
            aws.String("Id"),
            aws.String("Name"),
        },
        ConsistentRead:         aws.Bool(true),
        ReturnConsumedCapacity: aws.String("NONE"),
    })
    if err != nil {
        return events.APIGatewayProxyResponse{}, err
    }

    user := User{}
    err = dynamodbattribute.Unmarshal(&dynamodb.AttributeValue{M: response.Item}, &user)
    if err != nil {
        return events.APIGatewayProxyResponse{}, err
    }

    // Json
    bytes, err := json.Marshal(user)
    if err != nil {
        return events.APIGatewayProxyResponse{}, err
    }

    return events.APIGatewayProxyResponse{
        Body:       string(bytes),
        StatusCode: 200,
    }, nil
}

func main() {
    lambda.Start(handler)
}

次に、作成した関数と DynamoDB の定義 (+ 環境変数) を template.yaml に追加します*4

( template.yaml の一部抜粋)

Resources:
  HelloWorldFunction:
    # 省略
  UserGetFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: user/
      Handler: user
      Runtime: go1.x
      Tracing: Active
      Policies: AmazonDynamoDBFullAccess
      Events:
        GetUser:
          Type: Api
          Properties:
            Path: /users/{id}
            Method: GET
      Environment:
        Variables:
          DYNAMODB_ENDPOINT: ""
          DYNAMODB_TABLE_NAME: !Ref UserDynamoDBTable
  UserDynamoDBTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: Id
        Type: Number
      ProvisionedThroughput:
        ReadCapacityUnits: 2
        WriteCapacityUnits: 2

dep ensure で依存関係を更新した後に、作成した関数をビルドします。 このとき、Makefile にビルドのルールを追記しておきます。

$ dep ensure
$ make build
GOOS=linux GOARCH=amd64 go build -o hello-world/hello-world ./hello-world
GOOS=linux GOARCH=amd64 go build -o user/user ./user

DynamoDB Local

DynamoDB Local は Docker イメージで提供されています*5。 今回は、実行用に次の docker-compose.yaml を作成しました。

( docker-compose.yaml )

version: '3'

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

次のコマンドで実行します。

$ docker-compose up -d

テストデータ作成

テスト用のテーブルを DynamoDB に作成します。 まずは template.yaml に記述した内容を基に JSON 形式でテーブルの情報を記述します。 ここでのテーブル名は User としています。

( test/user_table.json )

{
    "AttributeDefinitions": [
        {
            "AttributeName": "Id",
            "AttributeType": "N"
        }
    ],
    "TableName": "User",
    "KeySchema": [
        {
            "AttributeName": "Id",
            "KeyType": "HASH"
        }
    ],
    "ProvisionedThroughput": {
        "ReadCapacityUnits": 2,
        "WriteCapacityUnits": 2
    }
}

また、テスト用のデータを生成する PutRequest を同様に JSON 形式で記述します。

( test/user_table_data.json )

{
    "User": [
        {
            "PutRequest": {
                "Item": {
                    "Id": { "N": "1" },
                    "Name": { "S": "Foo" }
                }
            }
        },
        {
            "PutRequest": {
                "Item": {
                    "Id": { "N": "2" },
                    "Name": { "S": "Bar" }
                }
            }
        },
        {
            "PutRequest": {
                "Item": {
                    "Id": { "N": "3" },
                    "Name": { "S": "Baz" }
                }
            }
        }
    ]
}

AWS CLI のコマンドを実行してデータを反映します。 このとき、 --endpoint-url に Docker 上で実行している DynamoDB のポートを指定します。

$ aws dynamodb create-table --cli-input-json file://test/user_table.json --endpoint-url http://192.0.2.1:8000
{
    "TableDescription": {
        "AttributeDefinitions": [
            {
                "AttributeName": "Id",
                "AttributeType": "N"
            }
        ],
        "TableName": "User",
        "KeySchema": [
            {
                "AttributeName": "Id",
                "KeyType": "HASH"
            }
        ],
        "TableStatus": "ACTIVE",
        "CreationDateTime": 1543988218.179,
        "ProvisionedThroughput": {
            "LastIncreaseDateTime": 0.0,
            "LastDecreaseDateTime": 0.0,
            "NumberOfDecreasesToday": 0,
            "ReadCapacityUnits": 2,
            "WriteCapacityUnits": 2
        },
        "TableSizeBytes": 0,
        "ItemCount": 0,
        "TableArn": "arn:aws:dynamodb:ddblocal:000000000000:table/User"
    }
}

$ aws dynamodb batch-write-item --request-items file://test/user_table_data.json --endpoint-url http://192.0.2.1:8000
{
    "UnprocessedItems": {}
}

"TableArn": "arn:aws:dynamodb:ddblocal:000000000000:table/User" から DynamoDB にデータが反映されたことが確認できます。

テスト

ローカルでのテスト用の環境変数を JSON 形式で定義します。

( test/env.json )

{
    "UserGetFunction": {
        "DYNAMODB_ENDPOINT": "http://192.0.2.1:8000",
        "DYNAMODB_TABLE_NAME": "User"
    }
}

--env-vars で環境変数を指定して実行してみます。

$ sam local start-api --env-vars test/env.json

$ curl http://localhost:3000/users/1
{"id":1,"name":"Foo"}

正しくデータを取得できました。

デプロイ

ローカルでの動作確認が行えたため、次は実際に AWS 上にデプロイしてみます。 まず、 aws validate を使用してテンプレートを確認します。 ここでは AWS にアクセスするためのプロファイル dev を指定しています。

$ sam validate --profile dev
2018-12-04 14:47:39 Found credentials in shared credentials file: ~/.aws/credentials
/Users/<username>/go/src/github.com/<username>/sam-cli-test/template.yaml is a valid SAM Template

テンプレートに問題がなければ s3 にアップロードし、デプロイします。

$ sam package --template-file template.yaml --s3-bucket <bucketname> --output-template-file packaged.yaml --profile dev
Uploading to xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  4337276 / 4337276.0  (100.00%)
Successfully packaged artifacts and wrote output template to file packaged.yaml.
Execute the following command to deploy the packaged template
aws cloudformation deploy --template-file /Users/<username>/go/src/github.com/<username>/sam-cli-test/packaged.yaml --stack-name <YOUR STACK NAME>

$ aws cloudformation deploy --template-file packaged.yaml --stack-name sam-cli-test  --capabilities CAPABILITY_IAM --profile dev

Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - sam-cli-test

AWS コンソールで DynamoDB にデータのセットを行い、デプロイされた API のエンドポイントを確認した後にAPI を呼び出してみます。

$ curl https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod/users/1
{"id":1,"name":"Foo"}

無事に実行できました!

まとめ & 感想

AWS Lambda によるサーバーレスアプリケーションの開発環境を AWS SAM + DynamoDB Local で整えました。 DynamoDB Local 用の JSON ファイルを記述する必要はありますが、ローカルで DynamoDB を使用した関数の開発が行えるのはメリットだと感じています。 また、 AWS 上でのテスト後も CloudFormation で一括削除できるので便利です。

We are hiring!

エムスリーでは、本番環境で AWS や Firebase 等を活用しています。 興味を持たれた方は、是非下のリンクよりお問い合わせください。

jobs.m3.com

参考