LocalStack と AWS SAM でサーバーレスアプリをテストする
LocalStack と AWS SAM を組み合わせてサーバーレスアプリをテストする方法を調べてみました。
LocalStack とはローカルで AWS サービスをエミュレートできるツールです。この記事ではローカルで作った S3 バケットと DynamoDB テーブルを使ってサーバーレスアプリの動作確認やテストを実行します。LocalStack には一部有料で提供している機能もありますが、この記事で使う機能に関しては無料で実行できます。
使用ツール
ハンズオン
以降はサンプルリポジトリの内容をもとに進めていきます。
LocalStack 起動
Docker Compose
まずは LocalStack を起動してみましょう。LocalStack を起動する方法はいくつかありますが、今回は Docker Compose を使うことにしました。
公式サイトの情報をベースに下記のように docker-compose.yml
を定義しました。
localstack/localstack
イメージをもとに LocalStack コンテナを設定しています。ポイントは以下の3つです。
- 4566 番ポートで LocalStack コンテナと通信する
- LocalStack コンテナの
/etc/localstack/init/ready.d
ディレクトリに初期化スクリプトを置く(後述) - SAM で立ち上げた API Gateway と通信するために
lstack
ネットワークを作る(後述)
version: "3.8"
services:
localstack:
container_name: localstack_main
hostname: localstack
image: localstack/localstack
ports:
- 4566:4566 # LocalStack と 4566 番ポートで通信する
environment:
- DOCKER_HOST=unix:///var/run/docker.sock
volumes:
- "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
- "./setup:/etc/localstack/init/ready.d" # 初期化スクリプトを置く場所
networks:
- lstack # SAM と通信するために必要
# SAM と通信するために必要
networks:
lstack:
name: lstack
docker-compose.yml
と同じディレクトリで docker-compose up
コマンドを実行して LocalStack コンテナを立ち上げてみましょう。
$ docker-compose up
実行すると localstack_main |...
のようなログが表示されているはずです。
$ docker-compose up
Starting localstack_main ... done
Attaching to localstack_main
localstack_main | ...
...
ちゃんと起動できたか確認するため LocalStack コンテナと通信してみます。AWS CLI で S3 バケット一覧を表示するコマンドに --endpoint-url http://localhost:4566
を追加して実行します。http://localhost:4566
は先ほど起動した LocalStack コンテナのエンドポイントです。
# LocalStack コンテナの S3 一覧表示
$ aws --endpoint-url http://localhost:4566 s3 ls
XXXX-XX-XX XX:XX:XX sample-bucket
初期化スクリプト
前のステップで S3 バケットの一覧を表示したとき sample-bucket
というバケットがありました。この sample-bucket
は ./setup/init-aws.sh
スクリプトによって作られたバケットです。
LocalStack コンテナの特定のディレクトリにスクリプトを置くと、コンテナが立ち上がったとき自動的にスクリプトが実行されます。コンテナ起動後にスクリプトを実行したかったので etc/localstack/init/ready.d
ディレクトリに初期化スクリプトを配置しました。スクリプトの配置は docker-compose.yml
の volumes で実行しています。
Initialization Hooks
# ホストの setup ディレクトリをコンテナの /etc/localstack/init/ready.d ディレクトリにマウント
volumes:
...
- "./setup:/etc/localstack/init/ready.d" # 初期化スクリプトを置く場所
setup ディレクトリの初期化スクリプト init-aws.sh
では以下の処理を実行しています。
- S3
- sample-bucket バケット作成
- sample-bucket に sample.txt ファイル追加
- DynamoDB
- sample テーブルを作成
- sample テーブルにデータをいくつか追加
#!/bin/bash
#
# LocalStack 起動時に AWS リソースを作成する
#
export AWS_DEFAULT_REGION=ap-northeast-1
# aws コマンドの向き先を Localstack に向ける
aws="aws --endpoint-url http://localhost:4566"
# このスクリプトのあるディレクトリに移動
cd "$(dirname "$0")"
# DynamoDB テーブル作成
${aws} dynamodb create-table --cli-input-json file://sample_table.json
# テストデータ追加
${aws} dynamodb put-item --table-name sample --item '{"id":{"S":"id1"}}'
${aws} dynamodb put-item --table-name sample --item '{"id":{"S":"id2"}}'
${aws} dynamodb put-item --table-name sample --item '{"id":{"S":"id3"}}'
# S3 バケット作成
${aws} s3api create-bucket --bucket sample-bucket --create-bucket-configuration LocationConstraint=ap-northeast-1
# sample.txt 追加
${aws} s3api put-object --bucket sample-bucket --key sample.txt --body ./sample.txt
ブラウザでリソース確認
ブラウザの Web アプリから LocalStack コンテナに作ったリソースを確認することもできます。
https://app.localstack.cloud/ にアクセスしてサインインした後、System Status タブや Resources タブから各 AWS リソースの状態を確認できます。
S3 バケットを確認する例
Go で AWS Lambda 実装
Go 言語を使って AWS Lambda を実装します。下記は Lambda に関連するディレクトリやファイルです。
.
├── client
│ ├── config.go # aws.Config 関連の処理
│ ├── dynamodb.go # dynamodb.CLient 生成
│ └── s3.go # s3.CLient 生成
├── go.mod
├── go.sum
└── lambda # Lambda に関するコード
├── get-object-sample
│ ├── main.go # S3 からファイルを取得する Lambda
│ └── main_test.go
└── scan-sample
├── main.go # DynamoDB をスキャンする Lambda
└── main_test.go
S3 からファイルを取得する Lambda
まずは ./lambda/get-object-sample/main.go
の内容を確認します。
S3 の sample-bucket
から sample.txt
ファイルを取得して内容をレスポンスで返す Lambda です。main.go の処理は特筆するところはなく LocalStack に関する処理もありません。
package main
import (
"bytes"
"context"
"net/http"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"ssstoyama/local-serverless/client"
)
func main() {
lambda.Start(handler)
}
func handler(ctx context.Context, request *events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) {
c, err := client.NewS3(ctx)
if err != nil {
return nil, err
}
output, err := c.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String("sample-bucket"),
Key: aws.String("sample.txt"),
})
if err != nil {
return nil, err
}
defer output.Body.Close()
var buf bytes.Buffer
_, err = buf.ReadFrom(output.Body)
if err != nil {
return nil, err
}
return &events.APIGatewayProxyResponse{StatusCode: http.StatusOK, Body: buf.String()}, nil
}
つぎに NewS3 関数の内容を確認します。
newConfig 関数で生成した aws.Config 型の値をもとに s3.Client を生成しています。
package client
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// NewS3 S3 Client 生成
func NewS3(ctx context.Context) (*s3.Client, error) {
cfg, err := newConfig(ctx)
if err != nil {
return nil, err
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
}), nil
}
さいごに newConfig 関数の内容を確認します。
newConfig 関数では環境変数の値によって設定を切り替えています。
ここで重要なのは AWS_ENDPOINT
変数です。AWS_ENDPOINT
の値を LocalStack コンテナに向けることでローカルで作成したリソースに向けてリクエストを実行できます。値が空の場合はデフォルト設定が適用されます。
package client
import (
"context"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
)
// 環境変数から値を取得して aws.Config 構造体を生成する
//
// 環境変数:
// - AWS_REGION
// - AWS_ENDPOINT
func newConfig(ctx context.Context) (aws.Config, error) {
awsRegion := ""
if region := os.Getenv("AWS_REGION"); region != "" {
awsRegion = region
}
awsEndpoint := ""
if endpoint := os.Getenv("AWS_ENDPOINT"); endpoint != "" {
awsEndpoint = endpoint
}
customResolver := aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) {
if awsEndpoint != "" {
return aws.Endpoint{
PartitionID: "aws",
URL: awsEndpoint,
SigningRegion: awsRegion,
}, nil
}
// デフォルト設定にフォールバックする
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
return config.LoadDefaultConfig(ctx,
config.WithRegion(awsRegion),
config.WithEndpointResolver(customResolver),
)
}
実装した Lambda を LocalStack コンテナでテストしてみます。
get-object-sample/main_test.go
では AWS_ENDPOINT
を LocalStack コンテナのエンドポイント http://localhost:4566 に設定してテストを実行します。
$ go test ./lambda/get-object-sample/
package main
import (
"context"
"net/http"
"os"
"testing"
"github.com/aws/aws-lambda-go/events"
)
func TestMain(m *testing.M) {
os.Setenv("AWS_REGION", "ap-northeast-1")
// エンドポイントを LocalStack コンテナに向ける
os.Setenv("AWS_ENDPOINT", "http://localhost:4566")
os.Exit(m.Run())
}
func TestHandler(t *testing.T) {
const expected = "Hello Sample!\n"
ctx := context.Background()
res, err := handler(ctx, &events.APIGatewayProxyRequest{})
if err != nil {
t.Fatal(err)
}
if http.StatusOK != res.StatusCode {
t.Fatal(res.StatusCode)
}
if expected != res.Body {
t.Fatal(res.Body)
}
}
DynamoDB をスキャンする Lambda
DynamoDB をスキャンする Lambda では aws.Config の値をもとに dynamodb.Client を生成して処理を実装します。その他の内容はほとんど同じなのでコード例は割愛します。
package client
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
)
// NewDynamoDB DynamoDB Client 生成
func NewDynamoDB(ctx context.Context) (*dynamodb.Client, error) {
cfg, err := newConfig(ctx)
if err != nil {
return nil, err
}
return dynamodb.NewFromConfig(cfg), nil
}
サーバーレスアプリ起動
API Gateway ローカル起動
Lambda の実装ができたら API Gateway をローカルで起動して Lambda の実行ができるか試してみます。
下記の yaml ファイルは SAM テンプレートの定義です。環境変数 AWS_ENDPOINT
に http://localstack:4566
を設定しています。
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Runtime: go1.x
Timeout: 15
Environment:
Variables:
AWS_REGION: ap-northeast-1
AWS_ENDPOINT: http://localstack:4566
Resources:
ApiGateway:
Type: AWS::Serverless::Api
Properties:
StageName: local
ScanSample:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambda/scan-sample/
Handler: main
Events:
Api:
Type: Api
Properties:
Path: /data
Method: GET
RestApiId: !Ref ApiGateway
GetObjectSample:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambda/get-object-sample/
Handler: main
Events:
Api:
Type: Api
Properties:
Path: /file
Method: GET
RestApiId: !Ref ApiGateway
Outputs:
RestApiId:
Value: !Sub ${ApiGateway}
テンプレートを定義したらビルドして API Gateway をローカルで実行します。
ここでのポイントは sam local start-api
で API Gateway をローカル実行するとき、--docker-network lstack
オプションを追加していることです。
sam local start-api
を実行すると API Gateway 用のコンテナが起動します。LocalStack コンテナと API Gateway コンテナはそれぞれ独立して起動しているためお互い通信することができません。そこでコンテナ用のネットワークを作ってそれぞれのコンテナ間で通信できるように lstack
ネットワークで橋渡しします。lstack
ネットワークは docker-compose.yml
で作りました。SAM でコンテナを起動するときにネットワークに lstack
を指定することで LocalStack コンテナと通信できるわけです。
sam build
sam local start-api --docker-network lstack
また、SAM テンプレートの AWS_ENDPOINT
の値には http://localhost:4566
ではなく http://localstack:4566
という値を設定しました。これは、API Gateway コンテナで localhost を指定すると API Gateway コンテナ内の localhost と通信しようとします。しかし、LocalStack は別のコンテナで実行しているため localhost へのリクエストは失敗します。そのため http://localstack:4566
を指定して LocalStack と通信できるようにします。ホスト名の
localstack
は docker-compose.yml
に定義されている hostname
の値です。
...
Environment:
Variables:
...
AWS_ENDPOINT: http://localstack:4566
services:
localstack:
container_name: localstack_main
hostname: localstack
...
リクエストを送ってみる
起動した API Gateway にリクエストを送って動作確認します。
# GetObjectSample Lambda
$ curl http://localhost:3000/file
Hello Sample!
# ScanSample Lambda
$ curl http://localhost:3000/data
[{"id":"id1"},{"id":"id2"},{"id":"id3"}]
まとめ
LocalStack を使うと AWS CLI コマンドで手軽にテスト用・確認用リソースを作れるところに魅力を感じました。IAM Role や SQS、Step Functions のリソースも作成できるので、使いこなせば CI/CD での自動テストや開発環境構築に便利そうです。
Discussion