💫

【Go】LocalStack活用メモ:ローカル開発環境からテストまで

2024/12/16に公開

この記事は何

ローカル環境でAWSサービスをエミュレートできるLocalStack、便利ですよね。
本当のAWS環境を使わないようにすることで、セキュリティの担保(詳細は後述)やうっかりとした事故の防止を実現しつつ、アプリケーションのコード内で無用なモックを用意する必要もなくなります。

LocalStackの活用方法の様々な記事がありますが、Go言語での開発において、1:ローカル開発環境用途、2:テスト時の使い捨て用途の両方をまとめて整理した記事は見かけませんでした。本記事では両者を整理して、スッとLocalStackを活用できることを目的とします。

具体的に取り扱う内容は以下の通りです。

  • ローカル開発環境:Go言語で書いたアプリケーションが動くアプリコンテナと、LocalStackコンテナ間で通信する環境を構築します。
  • テスト:testcontainers-goを使ってLocalStackコンテナを立ち上げ・立ち下げます。

この記事で扱わないこと

  • Go言語の基本は説明しません。
  • LocalStackの取り扱いを網羅的には説明しません。

ローカル開発環境編

本章の題材として、次のようなアプリケーションと開発環境を想定します。

  • aws-sdk-go-v2を使ってS3バケットからのReadを行うアプリケーション。
  • ローカル開発でDockerを活用する。
  • 本番環境ではアプリケーションはEC2やLambdaで稼働させるものとし、アクセス権はEC2/LambdaにアタッチするIAM Roleで管理する。したがってローカル開発時のみAWSへのアクセスには何かしらの認証が必要。

最後の要件を満たすには、IAM UserやSecurity Token Service(STS)の利用が考えられますが、セキュリティを担保するためIAM Userは使いたくはないですし、STSにしてもできれば簡素な運用をしたいところです。
この悩みはローカル開発環境から本当のAWSにアクセスしようとすることに起因するため、AWSをローカルでエミュレートすることでバサっと解決を図ります。ここでLocalStackの出番となります

本章では、まずLocalStackを使わない場合のGoのコードとDocker等の設定を示し、その後にLocalStackを導入するための変更を加えます。

LocalStackを使わない場合

まず、以下のような簡単なアプリケーションコードを用意しました。S3に保存したJSONファイルを読み込み、標準出力に表示するだけの機能を持ちます。

main.go
package main

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

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type s3Config struct {
	region string
	bucket string
	key    string
}

func newS3Config() *s3Config {
	return &s3Config{
		region: os.Getenv("AWS_REGION"),
		bucket: os.Getenv("AWS_S3_BUCKET"),
		key:    os.Getenv("AWS_S3_KEY"),
	}
}

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

func main() {
	ctx := context.Background()
	s3Config := newS3Config()

	// S3を利用するためのクライアントの呼び出し
	config, _ := config.LoadDefaultConfig(ctx, config.WithRegion(s3Config.region))
	client := s3.NewFromConfig(config, func(o *s3.Options) {
		o.UsePathStyle = true
	})

	object, _ := client.GetObject(ctx, &s3.GetObjectInput{
		Bucket: aws.String(s3Config.bucket),
		Key:    aws.String(s3Config.key),
	})
	defer object.Body.Close()

	var users []User
	json.NewDecoder(object.Body).Decode(&users)

	fmt.Printf("%+v", users)
}

このアプリケーションを実行するためのDockerfilecompose.ymlを用意しました。Dockerfileはアプリケーションをビルドして実行するだけなので掲載を割愛します。

compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: go-localstack-sample-app
    environment:
      - AWS_REGION=${AWS_REGION}
      - AWS_S3_BUCKET=${AWS_S3_BUCKET}
      - AWS_S3_KEY=${AWS_S3_KEY}
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}

compose.ymlでは、S3にアクセス可能な権限を持ったIAM Userのアクセスキーとシークレットキーを環境変数に設定しています。

さて、これでアプリケーション自体は動きますが、IAM Userを使いたくない等の動機から、本物のAWS環境を剥がしていきます。

LocalStackを取り入れる

Docker ComposeでLocalStackを使っていきます。詳細な説明は公式リファレンスをご覧ください。

compose.yml
services:
  localstack:
    image: localstack/localstack:3.7.2
    container_name: localstacks3
    ports:
      - "4566:4566"
    environment:
      - SERVICES=s3
      - DEBUG=1
    networks:
      - app-network
    volumes:
      - "./dev/init-aws.sh:/etc/localstack/init/ready.d/init-aws.sh" # 初期化スクリプト
      - "./dev/users.json:/docker-entrypoint-initaws.d/users.json" # 初期データ
      - "./volume:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
      interval: 10s
      retries: 5

  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: go-localstack-sample-app
    environment:
      - AWS_REGION=ap-northeast-1
      - AWS_S3_BUCKET=test-bucket
      - AWS_S3_KEY=users.json
      - AWS_ACCESS_KEY_ID=dummy
      - AWS_SECRET_ACCESS_KEY=dummy
      - AWS_ENDPOINT_URL=http://localstacks3:4566
    networks:
      - app-network
    depends_on:
      localstack:
        condition: service_healthy

networks:
  app-network:
    driver: bridge

ポイントは...

  • アプリコンテナとLocalStackコンテナ間で通信を行うため、networkの設定を加えています。
  • アプリから実際のAWSではなくLocalStackにアクセスするように、AWS_ENDPOINT_URL環境変数を設定しています。エンドポイントはhttp://{LocalStackのコンテナ}:{LocalStackのポート}です。
  • LocalStackを起動した時にS3のバケットを作り、バケット内にファイルを置いておきたいことがあると思います。初期化ファイルinit-aws.shを所定のディレクトリにマウントすることで実現可能です。

この時のローカル環境のディレクトリ構成およびinit-aws.shの内容は次のとおりです。

.
├── Dockerfile
├── compose.yml
├── dev
│   ├── init-aws.sh
│   └── users.json
├── go.mod
├── go.sum
└── main.go
init-aws.sh
#!/bin/bash
export AWS_ACCESS_KEY_ID=dummy AWS_SECRET_ACCESS_KEY=dummy

awslocal s3 mb s3://test-bucket
awslocal s3 cp /docker-entrypoint-initaws.d/users.json s3://test-bucket/users.json
  • 今回作成しているアプリはS3バケット内のファイルを読み込めないとエラーになります。そのためLocalStackのコンテナが立ち上がった後、ファイルのアップロード完了まではアプリコンテナは起動を待たなければなりません。LocalStackの/_localstack/healthエンドポイントでヘルスチェックが行えるため、アプリコンテナはdepends_onでLocalStackコンテナのヘルスチェック完了を待つようにします。

ここまで変更してみると、compose.ymlの環境変数にはダミーの値しか登場しなくなることがわかります。安心感がありますね😊。そしてアプリケーションのコードにはなんの変更も加える必要はありません。大変便利ですね!

ローカル開発環境におけるLocalStackの活用例は以上です!

テスト編

ローカル開発環境の立ち上げは、Docker Composeの設定類が諸々あり、少し長くなってしまいましたね。ご安心ください、テスト編はtestcontainers-goを使ってサクッと進めていきます。ありがとう、神...。

testcontainers-goは、Go言語のテスト時にコンテナの立ち上げ・立ち下げを行ってくれるモジュールですね。様々なところで使われているのを見かけます。任意のDockerイメージを指定してテストで利用できるため非常に便利です。
頻繁に使われるDockerイメージはtestcontainers-goで利用しやすいようにモジュールが用意されています。LocalStack用のモジュールもあるため、積極的に使っておきたいと思います。

さて、現在のコードではテストを実施し辛いためいったんリファクタリングしておきましょう。*s3.ClientというAWS SDKのクライアントをDIするようにし、テストではtestcontainers-goで立ち上げたコンテナを向けたクライアントを使えるようにします。

main.go
type Repository struct {
	client *s3.Client
}

func NewRepository(client *s3.Client) *Repository {
	return &Repository{client}
}

func (r *Repository) GetUsers(ctx context.Context, bucket, key string) []User {
	object, _ := r.client.GetObject(ctx, &s3.GetObjectInput{
		Bucket: aws.String(bucket),
		Key:    aws.String(key),
	})
	defer object.Body.Close()

	var users []User
	json.NewDecoder(object.Body).Decode(&users)
	return users
}

この*Repository.GetUsersメソッドのテストを書いてみます。先に必要なモジュールを追加しましょう。

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/localstack

テストを書いていきます。

main_test.go
package main

import (
	"context"
	"os"
	"testing"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/s3/types"
	"github.com/docker/go-connections/nat"
	"github.com/google/go-cmp/cmp"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/localstack"
)

const (
	region = "ap-northeast-1"
)


func TestRepository_GetUsers(t *testing.T) {
	// 説明のわかりやすさのためテーブルドリブンテストにはしません
	t.Setenv("AWS_ACCESS_KEY_ID", "dummy")     // 何かしらの値が必要
	t.Setenv("AWS_SECRET_ACCESS_KEY", "dummy") // 何かしらの値が必要
	ctx := context.Background()

	c, _ := localstack.Run(ctx, "localstack/localstack:3.7.2")
	defer func(c *localstack.LocalStackContainer) {
		testcontainers.TerminateContainer(c)
	}(c)

	provider, _ := testcontainers.NewDockerProvider()
	defer provider.Close()

	// 立ち上がったLocalStackコンテナのエンドポイントを割り出す
	host, _ := provider.DaemonHost(ctx)
	port, _ := c.MappedPort(ctx, nat.Port("4566/tcp"))
	awsEndpoint := "http://" + host + ":" + port.Port()

	awsCfg, _ := config.LoadDefaultConfig(ctx, config.WithRegion(region))
	client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
		o.UsePathStyle = true
		o.BaseEndpoint = aws.String(awsEndpoint)
	})
	initBucket(t, ctx, client, "test-bucket", "./dev/users.json", "users.json")

	// テスト用のクライアントをDIする
	repository := NewRepository(client)
	users := repository.GetUsers(ctx, "test-bucket", "users.json")

	want := []User{{ID: 1}}
	if diff := cmp.Diff(want, users); diff != "" {
		t.Error(diff)
	}
}

// バケットを作成し初期ファイルを配置する
func initBucket(
	t *testing.T,
	ctx context.Context,
	client *s3.Client,
	bucket, localPath, s3Key string,
) error {
	t.Helper()

	client.CreateBucket(ctx, &s3.CreateBucketInput{
		Bucket: aws.String(bucket),
		CreateBucketConfiguration: &types.CreateBucketConfiguration{
			LocationConstraint: types.BucketLocationConstraint(region),
		},
	})

	f, _ := os.Open(localPath)
	defer f.Close()

	client.PutObject(ctx, &s3.PutObjectInput{
		Bucket: aws.String(bucket),
		Key:    aws.String(s3Key),
		Body:   f,
	})
	return nil
}

testcontainers-goを使ったコンテナ立ち上げの基本は公式リファレンスに倣っています。ポイントは...

  • hostportを取得してエンドポイントURLを割り出します。
  • 環境変数にAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYが必要です。(値は問わず)

複数のテストでコンテナを使い回そうと思うとTestMainで立ち上げ処理を行うことも考えられますが、環境変数の設定にはt.Setenvを使いたいところもあるので、何がベストプラクティスなのかなぁ?というのは私は理解できていません。ご存じの方はぜひご教示ください。

なお、本記事では深くは触れませんが、以前同一モジュール下の複数パッケージでtestcontainers-goを使うテストを書いたところ、フレーキーなテストになってしまったことがありました。このあたりを参考にryukの設定を整えたところ解消しましたので、もし同じような課題にぶつかった方がいれば参照してみてください。

これで気軽にAWS環境(仮想)を使ったテストが出来ますね、やりました!🥂

おわりに

本記事ではローカル開発環境とテストの両方でLocalStackを活用し、Go言語のアプリケーション開発を進める方法を整理しました。とても便利なツールなのでバンバン活用していきたいと思います。
今回は使いませんでしたが、LocalStackのリソースをTerraformで構築することもできるので、今後トライしてみようと思います。

参考

ローカル環境開発編

テスト編

GitHubで編集を提案

Discussion