サーバレス連載 の第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に書き込むまでの部分になります。
環境とターゲット 今回は、下記の環境で動かしました。
Web APIのリターンとして、下記を期待することにします(ただし処理はベタ書きです)
{ "Company" : "Future" , "Year" : "1989" }
構築 ディレクトリ構成 次のような構成とします。最初に宣言したとおり、今回APIを叩くLambdaについては触れません。
apigw ├─Makefile ├─lambdab | ├─lambdab.go | └─lambdab_test.go ├─template.yaml └─testdata └─db_table.json
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 ├── README.md ├── hello-world │ ├── main.go │ └── main_test.go └── 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 apigwmv hello-world lambdab
パス構造が変わったので、Makefile
とtemplate.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 Globals: Function: Timeout: 5 Resources: HelloWorldFunction: Type: AWS::Serverless::Function Properties: CodeUri: lambdab/ Handler: lambdab Runtime: go1.x Tracing: Active Events: CatchAll: Type: Api Properties: Path: /apigw Method: GET Environment: 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 mainimport ( "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 を動作させることができました。数が少ないなら1つずつ試しても良いですし、APIを適宜叩くツールを用意すれば、自動にテストを行うこともできます。
DynamoDB localとの連携 続いて、SAM上で動かすLambdaからDynamoDB localにアクセスします。 注意事項として、DynamoDB localはdocker上で動くので、SAMとDynamoDB localを同じdocker-networkにする必要があります。
DynamoDB localの準備 $ 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 $ 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
これでテスト用の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 mainimport ( "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.DynamoDBvar 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
もう1つターミナルを立ち上げてコマンドを打ちます。
$ 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 連載 がありますので、オススメです。
参考