LocalStack 2.0でAWSクラウドリソースをローカル環境でシミュレート

先日、LocalStack 2.0がリリースされたので、Spring Bootアプリケーションで利用してみます。 LocalStack 1.0と2.0の違いをChatGPTに聞いてみました。

LocalStack 1.0とLocalStack 2.0は、AWSクラウドリソースをローカル環境でシミュレートするために使用されるオープンソースプロジェクトです。これにより、開発者はオフラインでAWSサービスをテストできます。LocalStack 2.0は、1.0に比べていくつかの重要な改善点があります。

パフォーマンスと安定性:LocalStack 2.0は、より高速で安定したパフォーマンスが期待されるように設計されています。1.0に存在するいくつかのバグや問題が修正されています。
サポートされるサービス:LocalStack 2.0では、新しいAWSサービスと機能のサポートが追加されています。これにより、開発者は最新のAWS機能をローカルで利用できるようになります。
プロジェクト構造とアーキテクチャ:LocalStack 2.0では、プロジェクトの構造とアーキテクチャが再設計され、より維持管理が容易になり、拡張性が向上しています。
プロセス分離:LocalStack 1.0では、すべてのサービスが1つのプロセス内で実行されていましたが、LocalStack 2.0では、各サービスが独自のプロセス内で実行されるようになりました。これにより、サービス間の依存関係や問題が軽減され、安定性が向上しています。
コンフィギュレーションとデプロイメント:LocalStack 2.0では、設定とデプロイメントのプロセスが改善され、より使いやすくなっています。
コミュニティサポート:LocalStack 2.0は、1.0と比べてアクティブな開発とサポートが続いており、開発者が新しい機能やバグ修正を利用できるようになっています。

これらの改善点により、LocalStack 2.0は1.0よりも高速で安定し、より多くのAWSサービスと機能をサポートし、開発者がAWSアプリケーションをローカル環境でテストする際に優れた選択肢となります。

LocalStackの料金体系は、無料と有料があります。有料版でしか利用できないAWSクラウドリソースもあります。詳しくは、AWS Service Feature Coverageに書いてあります。

LocalStackを利用したSpring Bootアプリケーション

本記事の環境
- Java 17
- Spring Boot 3.0.6
- AWS SDK for Java 2.20.58
- LocalStack 2.0.2
- Testcontainers 1.18.0

Docker上でLocalStackを起動する

Docker上でLocalStackを起動するために、次のdocker-compose.ymlを用意しました。

version: "3.9"

services:
  localstack:
    image: localstack/localstack:2.0.2
    ports:
      - "127.0.0.1:4566:4566"
    environment:
      # LocalStackのログを出力するかどうかを指定
      - DEBUG=1
      # Docker Composeで起動されるコンテナがDockerデーモンと通信するために使用するDockerホストのアドレスを指定
      - DOCKER_HOST=unix:///var/run/docker.sock

起動します。

$ docker-compose up -d
[+] Running 1/1
 ✔ Container localstack2-java-localstack-1  Started 

AWS環境のセットアップ

AWS CLIのコマンドで、LocalStack内の全サービスにアクセスすることができます。また、LocalStackに特化したLocalStack AWS CLIというものもあります。

CLIのインストール

AWS CLIかLocalStack AWS CLIのどちらかをインストールしてください。

# AWS CLI
$ pip install awscli

# LocalStack AWS CLI
$ pip install awscli-local

クレデンシャルの設定

クレデンシャルとデフォルトリージョンの環境変数を設定してください。

$ export AWS_ACCESS_KEY_ID="test"
$ export AWS_SECRET_ACCESS_KEY="test"
$ export AWS_DEFAULT_REGION="ap-northeast-1"

このクレデンシャルはダミーなので、direnvを利用して、このリポジトリディレクトリのみ環境変数が適用されるようにしました。

export AWS_ACCESS_KEY_ID="test"
export AWS_SECRET_ACCESS_KEY="test"
export AWS_DEFAULT_REGION="ap-northeast-1"

動作確認

AWS CLIかLocalStack AWS CLIのコマンドを叩いて動作確認をします。

$ aws --endpoint-url=http://localhost:4566 kinesis list-streams

    "StreamNames": []
}
$ awslocal kinesis list-streams

    "StreamNames": []
}

これでLocalStackを利用する環境は整いました。

Spring Boot アプリケーションの実装

サンプルとして、Amazon SQSを利用してキューにメッセージを格納するコマンドラインアプリケーションを実装します。 細かい実装は、GitHubをご覧ください。

SqsClientをBean登録します。今回、LocalStackを4566ポートで起動しているのでエンドポイントを書き換える必要があります。

@Configuration(proxyBeanMethods = false)
public class SqsConfig {

    @Bean
    public SqsClient sqsClient(SqsProperties properties) {
        var builder = SqsClient.builder()
                .region(Region.AP_NORTHEAST_1)
                .endpointOverride(properties.endpointUrl());
        if (properties.endpointUrl() != null) {
            // LocalStackのエンドポイントに書き換える
            builder.endpointOverride(properties.endpointUrl());
        }
        return builder.build();
    }
}

SQSのキューにメッセージを格納する処理を実装。

@Component
public class SqsQueueMessageSender implements ApplicationRunner {

    static final String DEFAULT_MESSAGE = "test-message";

    private final SqsClient sqsClient;
    private final SqsProperties sqsProperties;

    public SqsQueueMessageSender(SqsClient sqsClient, SqsProperties sqsProperties) {
        this.sqsClient = sqsClient;
        this.sqsProperties = sqsProperties;
    }

    @Override
    public void run(ApplicationArguments args) {
        var queueUrl = sqsClient.getQueueUrl(builder -> builder.queueName(sqsProperties.queueName()))
                .queueUrl();
        sqsClient.sendMessage(builder -> builder.queueUrl(queueUrl).messageBody(DEFAULT_MESSAGE));
    }
}

SQSキューの作成

AWS CLIかLocalStack AWS CLIのコマンドを叩いてキューを作成します。

$ aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name sample-queue
$ awslocal sqs create-queue --queue-name sample-queue
{
    "QueueUrl": "http://localhost:4566/000000000000/sample-queue"
}

アプリケーションの実行

Spring Bootアプリケーションを起動して、キューにメッセージを格納します。

./gradlew clean bootRun --args='--spring.profiles.active=local'                    

> Task :bootRun

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.0.6)

2023-05-05T13:40:25.871+09:00  INFO 21202 --- [           main] c.b1a9idps.localstack2java.Application   : Starting Application using Java 17.0.6 with PID 21202 (/Users/ryosuke/workspace/localstack2-java/build/classes/java/main started by ryosuke in /Users/ryosuke/workspace/localstack2-java)
2023-05-05T13:40:25.873+09:00  INFO 21202 --- [           main] c.b1a9idps.localstack2java.Application   : The following 1 profile is active: "local"
2023-05-05T13:40:26.617+09:00  INFO 21202 --- [           main] c.b1a9idps.localstack2java.Application   : Started Application in 1.145 seconds (process running for 1.402)

BUILD SUCCESSFUL in 2s
5 actionable tasks: 5 executed

AWS CLIかLocalStack AWS CLIのコマンドを格納したメッセージを受信できることを確認します。

$ aws --endpoint-url=http://localhost:4566 sqs receive-message \
  --queue-url=http://queue.localhost.localstack.cloud:4566/000000000000/sample-queue
$ awslocal sqs receive-message \
  --queue-url=http://queue.localhost.localstack.cloud:4566/000000000000/sample-queue

無事にLocalStackを利用してAWSクラウドリソースをローカル環境でシミュレートすることができました。

Testcontainersを利用したテストコード

LocalStackをサポートしているTestcontainersを利用してテストコードを書きます。 SqsClientのクレデンシャル等をTestcontainers用のものを利用することで、テストコードを書くことができます。

@ExtendWith(SpringExtension.class)
@ActiveProfiles("unittest")
@ContextConfiguration(classes = {TestConfig.class}, initializers = ConfigDataApplicationContextInitializer.class)
@Testcontainers
class SqsQueueMessageSenderTest extends AbstractContainerBaseTest {

    @Container
    final LocalStackContainer localStack = new LocalStackContainer(LOCALSTACK_IMAGE_NAME)
            .withServices(LocalStackContainer.Service.SQS);

    SqsQueueMessageSender sqsQueueMessageSender;
    SqsClient sqsClient;
    @Autowired
    SqsProperties sqsProperties;

    @BeforeEach
    void beforeEach() {
        sqsClient = SqsClient.builder()
                .endpointOverride(localStack.getEndpointOverride(LocalStackContainer.Service.SQS))
                .credentialsProvider(
                        StaticCredentialsProvider.create(
                                AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())
                        )
                )
                .region(Region.of(localStack.getRegion()))
                .build();
        sqsClient.createQueue(builder -> builder.queueName(sqsProperties.queueName()));

        sqsQueueMessageSender = new SqsQueueMessageSender(sqsClient, sqsProperties);
    }

    @Test
    void sendMessage() {
        sqsQueueMessageSender.run(null);

        var queueUrl = sqsClient.getQueueUrl(builder -> builder.queueName(sqsProperties.queueName()))
                .queueUrl();

        var actual = sqsClient.receiveMessage(builder -> builder
                        .queueUrl(queueUrl)
                        .maxNumberOfMessages(10));
        Assertions.assertThat(actual.messages())
                .hasSize(1)
                .extracting(Message::body)
                .containsExactly(DEFAULT_MESSAGE);
    }

}

Links