Docker ComposeでLocalStackを立ち上げてLambda関数のテストをやってみた

2023.04.11

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

事業本部 Delivery 部のアベシです。
今回はLocalStackを用いたローカル環境でのテストについて書きました。
導入方法から簡単なlambda関数のテストを実行するところまでやってみましたので手順やコードをブログに残したいと思います。

はじめにLocalStackとは

LocalStackのDockerイメージを使用してローカル環境にAWSの擬似的なサービスを立ち上げることができます。 テストの際に実際のAWSサービスにリソースをデプロイすること無く、AWSの挙動を確かめる事ができます。 AWSにデプロイしてE2Eテストを行う場合に比べて以下の恩恵を受けられると考えます。

  • 実際のAWSにデプロイするテストと比べて速やかにテストできる
  • AWSを使用しないので利用費の発生が無い。
  • コンテナを立ち下げる事ですぐに環境をリセットできる

前提

以下のソフトのインストールが完了していることを前提とします。

  • Docker
  • Docker Compose
    注意:Docker Composeのバージョンは1.9.0以上が必要です。

実行環境

実行時に使用したソフトウェア等のバージョンは以下の通りです。

項目名 バージョン
mac os Ventura 13.2
Docker 20.10.20
Docker Compose 2.14.0
jest 29.5.0
AWS SDK for JavaScript v3

LocalStackをローカル環境で実行する方法

LocalStackを実行する方法は以下の4通りあります

  • LocalStack CLI
  • Docker
  • Docker Compose
  • Kubernetes

How to install LocalStack

今回は一番簡単そうな方法のDocker ComposeでLocalStackを実行する方法を紹介致します。

Docker Composeを使ってLocalStackを実行

LocalStackを起動するにはdocker-compose.ymlに起動設定を記載してコンテナを立ち上げます。

プロジェクトの作成とdocker-compose.ymlの作成

まずはプロジェクトの作成と、LocalStackをDocker上で起動するための設定を記載するファイルdocker-compose.ymlを作成していきます。

$ mkdir localstack-test
$ cd localstack-test
$ touch docker-compose.yml

docker-compose.ymlの書き方については公式に公開されているリファレンスが以下のリポジトリにありますので参考にしました。

docker-compose.ymlの内容

先程のリファレンスを参考にして記述したymlファイルが以下です。

docker-compose.yml

version: "3.8"

services:
  localstack:
    image: localstack/localstack:2.0.1
    ports:
      - "127.0.0.1:4566:4566"
    environment:
      - DEBUG=1
      - DOCKER_HOST=unix:///var/run/docker.sock

1 書式のバージョンの設定

version: "3.8"

今使える最新の3.8を使います。 Docker Enginのバージョンが19.03.0以上であることが使用条件となっています。

2 使用するLocalStackのイメージの指定

image: localstack/localstack:2.0.1

今回は現時点で一番新しいlocalstack/localstack:2.0.1を指定しています。

イメージは以下のリンク先のDockerHubから確認できます。

3 ポートの指定

ports:
  - "127.0.0.1:4566:4566"

リファレンスに記載の通りに指定します。

4 環境変数の指定

environment:
  - DEBUG=1
  - DOCKER_HOST=unix:///var/run/docker.sock
  • DEBUGはLocalStackのログを出力するかどうかを指定する環境変数です。 1を指定するとログをリアルタイムに出力してくれます。
  • DOCKER_HOSTはDocker Composeで起動されるコンテナがDockerデーモンと通信するために使用するDockerホストのアドレスを指定する環境変数です。

テスト対象のLambda関数

以下のLambda関数に対してテストを行います。
S3にGetObjectを実行して、バケットに保存されているJSONファイルを取得して返却する処理です。

sample-localstack.ts

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

interface Event {
  key: string;
}
interface Response {
  prefecture_code: string;
  prefecture: string;
}

export const handler = async (event: Event): Promise<Response> => {
  const awsConfig = {
    endpoint: process.env.S3_ENDPOINT_LOCALSTACK || '',
    region: 'ap-northeast-1',
  };
  const s3Client = new S3Client(awsConfig);
  try {
    const response = await s3Client.send(
      new GetObjectCommand({
        Bucket: process.env.BUCKET_NAME,
        Key: event.key,
      }),
    );
    const stringResponse = await response.Body?.transformToString();
    const objectResponse = JSON.parse(stringResponse as string);
    return objectResponse;
  } catch (error) {
    console.error(error);
    throw error;
  }
};

コードについて

使用しているSDKはAWS SDK for JavaScript v3です。
v3のGetObjectの戻り値はReadableStreamとなっていて、transformToString()という便利なメソッドを使用して文字列に変換しています。
S3Clientをnewする時に引数に渡すendpointは、テスト時はdotenvの環境変数を使用します。
渡しているURLは以下です。

https://s3.localhost.localstack.cloud:4566

テストコード

テストコードは以下のような内容となっています。
テストフレームワークにはjestを使用しています。

sample-localstack.test.ts

import {
  S3Client,
  CreateBucketCommand,
  PutObjectCommand,
  DeleteBucketCommand,
  DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import * as fs from 'fs';
import * as path from 'path';
import { handler } from '../../../src/lambda/handlers/sample-localstack.test.ts';

const awsConfig = {
  endpoint: 'https://s3.localhost.localstack.cloud:4566',
  region: 'ap-northeast-1',
};

const s3Client = new S3Client(awsConfig);
describe('正常系', () => {
  const event = {
    key: 'sample/value1',
  };

  beforeAll(async () => {
    await s3Client.send(
      new CreateBucketCommand({ Bucket: 'sample-get-object-from-s3' }),
    );
    await s3Client.send(
      new PutObjectCommand({
        Body: fs.readFileSync(path.join(__dirname, 'test-data.json')),
        Bucket: 'sample-get-object-from-s3',
        Key: 'sample/value1',
      }),
    );
  });
  afterAll(async () => {
    await s3Client.send(
      new DeleteObjectCommand({
        Bucket: 'sample-get-object-from-s3',
        Key: 'sample/value1',
      }),
    );
    await s3Client.send(
      new DeleteBucketCommand({ Bucket: 'sample-get-object-from-s3' }),
    );
  });

  test('テストデータのオブジェクトを返却する事を確認', async () => {
    const response = await handler(event);
    expect(response).toHaveProperty('prefecture_code');
    expect(response.prefecture_code).toBe('001');
    expect(response).toHaveProperty('prefecture');
    expect(response.prefecture).toBe('北海道');
  });
});

コードについて

S3バケットとオブジェクトのセットアップ、クリーンアップ

beforeAllとafterAllで、テスト用バケットとオブジェクトのセットアップとクリーンアップを行っています。

  • beforeAllでS3にバケットを作成し、テストデータを保存しています。
  • afterAllでバケットとオブジェクトを削除しています。

LocalStack上のS3のエンドポイント

クラスnew S3Client()のインスタンス化の際に渡すendpointは、LocalStackのS3用のエンドポイントを指定しています。 以下のURLの場合、LocalStackのS3に対して仮想ホスト形式のURLでアクセスするようになります。

'https://s3.localhost.localstack.cloud:4566';

これに対して、以下のURLを指定した場合はLocalStackのS3にパス形式でアクセスするようになります。

'http://localhost:4566';

しかしAWS SDK for JavaScript v3は仮想ホスト形式で接続する挙動をとるので、このままではS3に接続できません。
こちらには回避方法があり、forcePathStyle: trueを設定する事でパス形式を強制してS3に接続できます。

テストデータ

test-data.json

{
  "prefecture_code": "001",
  "prefecture": "北海道"
}

テスト実行

テストの実行前にLocalStackのイメージを使ってコンテナを立ち上げます。
Docker Composeを使用すると、テスト前の準備はこのコマンドでコンテナを立ち上げるだけなので非常に楽に思えます。 以下のコマンドを実行します。

$ docker-compose up

テストを実行します。

npx dotenv -e ./test/environment/.env jest test/unit/handlers/sample-localstack.test.ts

テスト結果

テストパスしました。

 PASS  test/unit/handlers/sample-localstack.test.ts
  正常系
    ✓ テストデータのオブジェクトを返却する事を確認 (73 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.8 s, estimated 5 s
Ran all test suites matching /test\/unit\/handlers\/sample-localstack.test.ts/i.

コンテナの後始末

テストが終わったら、以下のコマンドでコンテナを停止、削除します。

$ docker-compose down

コンテナが停止しても前回の設定が残ってしまい、次回起動時にうまくコンテナの設定が反映されない時があるので以下のコマンドでコンテナを削除します。

$ docker rm $(docker ps -q -a)

以下のコマンドでコンテナが無いことを確かめます。

$ docker ps -q -a

以上。