フューチャー技術ブログ

Serverless連載1: SAMを使ったローカルテスト(Go編)

サーバレス連載 の第1弾です。

はじめに

こんにちは、TIG/DXチームの栗田です。AWSのLambdaに代表されるサーバレスアプリは実行時のみ稼働するため、サーバー稼働によるあらゆるコストから解放され、システム開発の工数を減らすことができます。例えば何らかのAPIを提供する場合でも、API GatewayとLambdaを組み合わせることで提供可能です。

一方で、サーバーレスアプリを開発するとき、ローカルでのテストが課題となります。例えばLambda + DynamoDBであれば、先の連載の手法を参考に、テストコードを書くことで解決します。一方で…

  • API GatewayからLambdaの着火もテストした上でDynamoDBまで書き込ませたい
  • なんならAPI Gaweway自体もLambdaで叩きたい

となると、少々ハードルがあがります。この問題を解決してくれるのが、AWS Serverless Application Model(AWS SAM)です。SAMはローカルにLambda実行用のdockerを立ち上げ、127.0.0.1:3000を叩くと指定した実行ファイルをdocker上で動作させることができるツールです。今回はSAMを使って API Gateway + Lambda (Go) + DynamoDBのローカルテストをする手法を紹介します。

なお、今回ターゲットとする構成は、Lambda→AWS Gateway→Lambda→DynamoDBのような構成です。例えばKinesisやS3から非同期な入力をトリガーにLambdaを着火し、特定のAPIを叩くようなケースです。便宜上、API Gatewayを叩くのをLambda A、API Gatewayから呼び出されるのをLambda Bとします。

これをSAMとDynamoDB localを使ってテストしますが、想定する最終的なテスト環境としては以下のような形になります。青で示したlambda_b.goから127.0.0.1:8000を叩くのが、前述した先の連載の手法です。lambda_a.goから127.0.0.1:3000を叩くのもGoから特定のAPIを叩く処理になるので、ここでは割愛します。つまり、今回記述するのは127.0.0.1:3000を叩いたらLambdaが着火し、DynamoDBに書き込むまでの部分になります。

環境とターゲット

今回は、下記の環境で動かしました。

項目 バージョン等 入手方法
OS macOS Mojave 10.15.5
awscli 1.16.209 $ pip install awscli。テストだけなら無くても動くかも。
aws-sam-cli 0.43.0 $ pip install aws-sam-cli
Docker 19.03.5 build 633a0ea
go 1.13.5

WebAPIのリターンとして、下記を期待することにします。(ただし処理はベタ書きです)

{"Company": "Future", "Year": "1989"}

構築

ディレクトリ構成

次のような構成とします。最初に宣言したとおり、今回APIを叩くLambdaについては触れません。

apigw
├─Makefile
├─lambdab
| ├─lambdab.go # APIから呼ばれるLambdaのコード
| └─lambdab_test.go # テストコード
├─template.yaml
└─testdata
└─db_table.json # DBテーブル作成用のコード

AWS SAMプロジェクトの用意

SAMのコマンドを使用して、Templatesを用意します。独自のテンプレートを用意してくることも可能ですが、今回は簡単かつシンプルにということでAWS Quick Start Templatesを使用します。
先の構成のディレクトリ内で、作業を進めます。

$ sam init --runtime go1.x --name apigw
Which template source would you like to use?
1 - AWS Quick Start Templates
2 - Custom Template Location
Choice: 1

Cloning app templates from https://github.com/awslabs/aws-sam-cli-app-templates.git

-----------------------
Generating application:
-----------------------
Name: apigw
Runtime: go1.x
Dependency Manager: mod
Application Template: hello-world
Output Directory: .

Next steps can be found in the README file at ./apigw/README.md
$ ls
apigw
$ ls apigw/
Makefile README.md hello-world template.yaml
$ ls apigw/hello-world/
go.mod main.go main_test.go

色々とできましたが、同梱されているREADME.mdに必要なことは記載してあります。そこから抜粋しますが、例えばファイル構造は以下のようになっています。

README.md(抜粋)
(前略)
.
├── Makefile # <-- Make to automate build
├── README.md # <-- This instructions file
├── hello-world # <-- Source code for a lambda function
│ ├── main.go # <-- Lambda function code
│ └── main_test.go # <-- Unit tests
└── template.yaml
(以下略)

Lambda(Go)の記述

最初に必要なパッケージをインストールします。

$ go get -u github.com/aws/aws-lambda-go/lambda
$ go get -u github.com/aws/aws-lambda-go/events

続いて、必要なパス構成にしていきます。
sam initで自動生成された実行ファイルはhello-world以下に入っています。
せっかくなので、これをオリジナルな名前にします。

$ cd apigw
$ mv hello-world lambdab

パス構造が変わったので、Makefiletemplate.yamlに変更を加えます。

Makefile
PHONY: deps clean build

deps:
go get -u ./...

clean:
rm -rf ./lambdab/lambdab # 変更

build:
GOOS=linux GOARCH=amd64 go build -o lambdab/lambdab ./lambdab/main.go # 変更

template.yamlへの変更において、

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
apigw

Sample SAM Template for apigw

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 5

Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: lambdab/ # 変更
Handler: lambdab # 変更
Runtime: go1.x
Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
Events:
CatchAll:
Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
Properties:
Path: /apigw # 変更
Method: GET
Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
Variables:
PARAM1: VALUE

template.yamlの内容について補足すると、これはCloudFormationの形式になっています。
このままsamのみでdeployすることも可能ですが、今回はテストのみなので、注記がいれてある場所のみ変更しました。
この設定で sam を立ち上げると、 http://127.0.0.1:3000/apigw にGETすると、./lambdab/lambdab が実行されることを示しています。

それではmain.go に変更を加えます。今回は、 events.APIGatewayProxyResponse のBodyと、それに応じて少しだけコードをいじっただけでほぼほぼ自動生成されたものになります。

main.go
package main

import (
"encoding/json"

"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)

type CompanyResponse struct {
Company string `json:"company"`
Year string `json:"year"`
}

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

response := CompanyResponse{
Company: "Future",
Year: "1989",
}

jsonBytes, _ := json.Marshal(response)

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

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

build

諸々設定ができたら、buildして準備は完了です。

$ make build
GOOS=linux GOARCH=amd64 go build -o lambdab/lambdab ./lambdab/main.go # 変更
$ ls lambdab/
go.mod go.sum lambdab main.go main_test.go

ローカルでAPIを動かしてテスト

コマンドを叩けばOKです。動き出したAPI(今回の場合 http://127.0.0.1:3000/apigw)にアクセスすると、コンテナイメージが呼び出され、実行結果が帰ってきます。

$ sam local start-api
Mounting HelloWorldFunction at http://127.0.0.1:3000/apigw [GET]
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
2020-03-09 22:05:33 * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
Invoking lambdab (go1.x)

Fetching lambci/lambda:go1.x Docker container image......
Mounting /Users/kurita/[パス情報なので省略]/apigw/lambdab as /var/task:ro,delegated inside runtime container
START RequestId: f2fd9e5a-f031-10c7-3340-25e3997c6dd1 Version: $LATEST
END RequestId: f2fd9e5a-f031-10c7-3340-25e3997c6dd1
REPORT RequestId: f2fd9e5a-f031-10c7-3340-25e3997c6dd1 Init Duration: 131.05 ms Duration: 4.07 ms Billed Duration: 100 ms Memory Size: 128 MBMax Memory Used: 21 MB
No Content-Type given. Defaulting to 'application/json'.
2020-03-09 22:05:38 127.0.0.1 - - [09/Mar/2020 22:05:38] "GET /apigw HTTP/1.1" 200 -
2020-03-09 22:05:39 127.0.0.1 - - [09/Mar/2020 22:05:39] "GET /favicon.ico HTTP/1.1" 403 -

ブラウザでAPI叩くと、ちゃんと返ってきているのがわかります。

AWS SAMを利用することで、ローカルでもAPI Gatewayを意識したLambda を動作させることができました。数が少ないなら一つずつ試しても良いですし、APIを適宜叩くツールを用意すれば、自動にテストを行うこともできます。

DynamoDB localとの連携

続いて、SAM上で動かすLambdaからDynamoDB localにアクセスします。
注意事項として、DynamoDB localはdocker上で動くので、SAMとDynamoDB localを同じdocker-networkにする必要があります。

DynamoDB localの準備

# AWS profile(初回のみ)
# localのDynamoDB
$ aws configure set aws_access_key_id dummy --profile local
$ aws configure set aws_secret_access_key dummy --profile local
$ aws configure set region ap-northeast-1 --profile local

# DynamoDBに必要なテーブル作成(初回のみ)
$ docker network create lambda-local-test
$ docker run -d --network lambda-local-test --name dynamoTest -p 8000:8000 amazon/dynamodb-local:1.12.0 -jar DynamoDBLocal.jar -sharedDb

# 停止コマンド
# docker stop dynamoTest

これでテスト用のdynamoDBができました。ここにテスト用のテーブルを切ります。テーブルはaws-cliを使います。

testdata/db_local.json
{
"TableName": "local_company_table",
"KeySchema": [
{
"AttributeName": "company",
"KeyType": "HASH"
},
{
"AttributeName": "year",
"KeyType": "RANGE"
}
],
"AttributeDefinitions": [
{
"AttributeName": "company",
"AttributeType": "S"
},
{
"AttributeName": "year",
"AttributeType": "S"
}
],
"ProvisionedThroughput": {
"ReadCapacityUnits": 2,
"WriteCapacityUnits": 2
}
}

テーブルを切ります。

$ aws dynamodb --profile local --endpoint-url http://localhost:8000 create-table --cli-input-json file://./testdata/db_local.json
(出力略)

Goのコード修正

先程はAPIアクセスに対してレスポンスを返すだけでしたが、今度は同じ内容をDynamoDBに書き込むように修正します。

main.go
package main

import (
"context"
"encoding/json"
"fmt"

"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"
)

var db *dynamodb.DynamoDB
var dbEndpoint = "http://dynamoTest:8000"
var region = "ap-northeast-1"
var testTable = "local_company_table"

type CompanyResponse struct {
Company string `json:"company"`
Year string `json:"year"`
}

func write(ctx context.Context, tableName string, v interface{}) error {
av, err := dynamodbattribute.MarshalMap(v)

if err != nil {
return fmt.Errorf("dynamodb attribute marshalling map: %w", err)
}
i := &dynamodb.PutItemInput{
Item: av,
TableName: aws.String(tableName),
}

if _, err = db.PutItemWithContext(ctx, i); err != nil {
return fmt.Errorf("dynamodb put item: %w", err)
}
return nil
}

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

sess := session.Must(session.NewSession(&aws.Config{
Endpoint: aws.String(dbEndpoint),
Region: aws.String(region),
}))
db = dynamodb.New(sess)

response := CompanyResponse{
Company: "Future",
Year: "1989",
}

jsonBytes, _ := json.Marshal(response)

if err := write(ctx, testTable, response); err != nil {
fmt.Print("%s", err)
return events.APIGatewayProxyResponse{
Body: string(jsonBytes),
StatusCode: 500,
}, nil
}

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

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

SAMによるテスト

SAMを実行するとき、docker-networkを指定します。これで、SAMで起動したdockerとDynamoDB localが同じネットワーク上に入ります。

$ make build
$ sam local start-api --docker-network lambda-local-test

もう一つターミナルを立ち上げてコマンドを打ちます。

# 先程作ったテーブルは未だ空です。これからここに書き込みます。
$ aws dynamodb scan --table-name local_company_table --profile local --endpoint-url http://localhost:8000
{
"Items": [],
"Count": 0,
"ScannedCount": 0,
"ConsumedCapacity": null
}
$ curl localhost:3000/apigw
{"Company":"Future","Year":"1989"}
# 作ったテーブルに必要な項目が入っている
$ aws dynamodb scan --table-name local_company_table --profile local --endpoint-url http://localhost:8000
{
"Items": [
{
"year": {
"S": "1989"
},
"company": {
"S": "Future"
}
}
],
"Count": 1,
"ScannedCount": 1,
"ConsumedCapacity": null
}

確かに、テーブルの中に値が格納されました。

まとめ

今回SAMを使ったテストができました。あとは必要に応じてAPIを叩くLambdaを作ったり、あるいはAPI Gatewayから呼び出されるLambdaを適宜増やしていくことで、目的とするシステムの開発が行なえます。

関連した記事にサーバレス連載Go Cloud 連載がありますので、オススメです。

参考