🎇

LocalStack と AWS SAM でサーバーレスアプリをテストする

2023/06/02に公開

LocalStack と AWS SAM を組み合わせてサーバーレスアプリをテストする方法を調べてみました。
LocalStack とはローカルで AWS サービスをエミュレートできるツールです。この記事ではローカルで作った S3 バケットと DynamoDB テーブルを使ってサーバーレスアプリの動作確認やテストを実行します。LocalStack には一部有料で提供している機能もありますが、この記事で使う機能に関しては無料で実行できます。

使用ツール

ハンズオン

以降はサンプルリポジトリの内容をもとに進めていきます。

LocalStack 起動

Docker Compose

まずは LocalStack を起動してみましょう。LocalStack を起動する方法はいくつかありますが、今回は Docker Compose を使うことにしました。
公式サイトの情報をベースに下記のように docker-compose.yml を定義しました。
localstack/localstack イメージをもとに LocalStack コンテナを設定しています。ポイントは以下の3つです。

  1. 4566 番ポートで LocalStack コンテナと通信する
  2. LocalStack コンテナの /etc/localstack/init/ready.d ディレクトリに初期化スクリプトを置く(後述)
  3. SAM で立ち上げた API Gateway と通信するために lstack ネットワークを作る(後述)
docker-compose.yml
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

docker-compose.yml
# ホストの 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 テーブルにデータをいくつか追加
init-aws.sh
#!/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 バケットを確認する例
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 に関する処理もありません。

./lambda/get-object-sample/main.go
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 を生成しています。

./client/s3.go
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 コンテナに向けることでローカルで作成したリソースに向けてリクエストを実行できます。値が空の場合はデフォルト設定が適用されます。

./client/config.go
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/
./lambda/get-object-sample/main_test.go
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 を生成して処理を実装します。その他の内容はほとんど同じなのでコード例は割愛します。

./client/dynamodb.go
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_ENDPOINThttp://localstack:4566 を設定しています。

template.yaml
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 と通信できるようにします。ホスト名の
localstackdocker-compose.yml に定義されている hostname の値です。

template.yaml
...
    Environment:
      Variables:
        ...
        AWS_ENDPOINT: http://localstack:4566
docker-compose.yml
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 での自動テストや開発環境構築に便利そうです。

参考

LocalStack
サンプルコード

株式会社ROBONの技術ブログ

Discussion