LocalStack: AWS S3、Lambda、DynamoDBをローカル環境でエミュレート — 費用ゼロで

Development tutorial - IT technology blog
Development tutorial - IT technology blog

深夜2時、CI/CDパイプラインがレッドに。LambdaをStagingにデプロイしたらResourceNotFoundExceptionが発生 — 必要なタイミングでDynamoDBテーブルが存在しないという状況だった。ログを追いながらデバッグしていると、気づいてしまった:チームのテストワークフロー全体が、本物のAWS devアカウントに直接アクセスしていたのだ。テストを走らせるたびに、レート制限や請求額の急騰が起きないよう祈るしかない状態だった。

それが、LocalStackを本気で見始めたきっかけだった。

AWSアプリ開発の3つのアプローチ — 最初の2つに問題がある理由

LocalStackの話に入る前に、なぜこれが広く使われているのかを理解するため、各選択肢を整理してみよう。

アプローチ1: 本物のAWS(devアカウント)を使う

新しいチームが最もよく選ぶ方法だ。開発用のAWSアカウントを別途作成し、開発者が直接接続する。一見問題なさそうだが、実態はそうでもない:

  • コスト:dev環境であっても、LambdaのInvocation、S3ストレージ、DynamoDBの読み書きは課金される。5人チームが毎日500件のインテグレーションテストを実行すれば、月末のdev環境だけの請求が$30〜50になることも珍しくない。
  • インターネット依存:ネットがなければテストできない — 空港で3時間足止めされて何もできなかった経験がある。
  • 競合:2人の開発者が同時にテストを並列実行するとDynamoDBのステートが競合し、原因不明のフレイキーなテストが発生する。
  • 後片付け:テスト後にS3バケットを削除し忘れると、不要なデータが時間とともに蓄積される。

アプローチ2: ライブラリでモック(moto、unittest.mock)

Pythonでmotoを使ってAWS呼び出しをモックするのはかなり一般的な選択肢だ。あるプロジェクトでmotoを使ったことがあり、純粋なユニットテストでは問題なく動作した。問題が露見し始めるのは、コードベースが大きくなってからだ。

motoはPythonレベルで動作しており、ネットワークレベルではない。Boto3がS3を呼び出す?motoで対応できる。しかし、インテグレーションテストで同じS3バケットを呼び出す必要があるNode.jsやGoのサービスが別途存在する場合、motoでは対応できない。どれだけ優れたモックでも、あらゆる言語から呼び出せる本物のHTTPエンドポイントの代わりにはなれない。

アプローチ3: LocalStack — ネットワークレベルでAWS全体をエミュレート

LocalStackはDockerコンテナとして動作し、ほぼすべてのAWSサービスをエミュレートするHTTPエンドポイントを公開する。PythonのBoto3、Node.js AWS SDK、AWS CLIなど、あらゆるSDKがエンドポイントをhttp://localhost:4566に向けるだけで、本物のAWSと同様に操作できる。

完全オフライン、課金なし、開発者間の競合なし、コンテナを再起動すればクリーンな状態に戻る。これがLocalStackが提供するものだ。

もちろん完璧ではない:Cognito、EKSなど一部の複雑なサービスや特定のLambdaランタイムはLocalStack Pro(有料版)が必要だ。無料版はS3、Lambda、DynamoDB、SQS、SNS、API Gatewayをカバーしており、実際のユースケースの80%には十分対応できる。AWSインフラ全体を本番と同じ構成で管理したい場合は、TerraformによるInfrastructure as Codeと組み合わせるのが効果的だ。

LocalStackを5分でセットアップ

前提条件

  • Dockerがインストール済みで起動していること
  • Python 3.8以上(awscli-localを使う場合)
  • AWS CLI(ターミナルから操作するため)

Docker ComposeでLocalStackを起動

プロジェクトにdocker-compose.ymlを作成する:

version: '3.8'
services:
  localstack:
    image: localstack/localstack:latest
    ports:
      - "4566:4566"
    environment:
      - SERVICES=s3,lambda,dynamodb,sqs
      - DEBUG=0
      - LAMBDA_EXECUTOR=docker
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "./localstack-data:/var/lib/localstack"
docker-compose up -d
# LocalStackが起動しているか確認
curl http://localhost:4566/_localstack/health

レスポンスのJSONに、running状態のサービス一覧が表示される。

awslocalのインストール — AWS CLIの便利なラッパー

pip install awscli-local
# awslocal = aws --endpoint-url=http://localhost:4566 --region us-east-1

実践:LocalStackでS3、DynamoDB、Lambdaを使う

S3 — ファイルのアップロードとダウンロード

# バケットを作成
awslocal s3 mb s3://my-test-bucket

# ファイルをアップロード
echo "Hello LocalStack" > test.txt
awslocal s3 cp test.txt s3://my-test-bucket/

# オブジェクト一覧を表示
awslocal s3 ls s3://my-test-bucket/

# ダウンロード
awslocal s3 cp s3://my-test-bucket/test.txt downloaded.txt

PythonでBoto3を使う場合:

import boto3

# エンドポイントをLocalStackに向ける
s3 = boto3.client(
    's3',
    endpoint_url='http://localhost:4566',
    region_name='us-east-1',
    aws_access_key_id='test',      # LocalStackはどんな値も受け付ける
    aws_secret_access_key='test'
)

# アップロード
s3.put_object(
    Bucket='my-test-bucket',
    Key='data/config.json',
    Body=b'{"env": "local"}'
)

# ダウンロード
response = s3.get_object(Bucket='my-test-bucket', Key='data/config.json')
print(response['Body'].read().decode('utf-8'))

DynamoDB — テーブルの作成とCRUD

# テーブルを作成
awslocal dynamodb create-table \
  --table-name Users \
  --attribute-definitions AttributeName=userId,AttributeType=S \
  --key-schema AttributeName=userId,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

# アイテムを追加
awslocal dynamodb put-item \
  --table-name Users \
  --item '{"userId": {"S": "u001"}, "name": {"S": "Nguyen Van A"}, "email": {"S": "[email protected]"}}'

# クエリ
awslocal dynamodb get-item \
  --table-name Users \
  --key '{"userId": {"S": "u001"}}'

Lambda — 関数のデプロイと呼び出し

handler.pyを作成する:

import json

def lambda_handler(event, context):
    name = event.get('name', 'World')
    return {
        'statusCode': 200,
        'body': json.dumps({'message': f'Hello, {name}!'})
    }
# パッケージング
zip function.zip handler.py

# LocalStackにデプロイ
awslocal lambda create-function \
  --function-name hello-function \
  --runtime python3.11 \
  --handler handler.lambda_handler \
  --role arn:aws:iam::000000000000:role/lambda-role \
  --zip-file fileb://function.zip

# 呼び出し
awslocal lambda invoke \
  --function-name hello-function \
  --payload '{"name": "LocalStack"}' \
  --cli-binary-format raw-in-base64-out \
  output.json

cat output.json
# {"statusCode": 200, "body": "{\"message\": \"Hello, LocalStack!\"}"}

pytestへの統合 — 見落とされがちなポイント

LocalStackを手動で起動するだけでは問題の半分しか解決できない。以下は、各テストの前にリソースを自動的に初期化するpytestフィクスチャのセットアップ方法だ — 追加の手動セットアップは一切不要。Pytestの基礎から実践的な使い方を押さえておくと、フィクスチャの設計がより明確になるだろう:

# conftest.py
import boto3
import pytest

ENDPOINT = 'http://localhost:4566'
REGION = 'us-east-1'
CREDS = {'aws_access_key_id': 'test', 'aws_secret_access_key': 'test'}

@pytest.fixture(scope='session')
def s3_client():
    return boto3.client('s3', endpoint_url=ENDPOINT, region_name=REGION, **CREDS)

@pytest.fixture(scope='session')
def dynamodb_client():
    return boto3.client('dynamodb', endpoint_url=ENDPOINT, region_name=REGION, **CREDS)

@pytest.fixture(autouse=True)
def setup_s3_bucket(s3_client):
    """各テストの前に新しいバケットを作成し、終了後に削除する"""
    bucket = 'test-bucket'
    s3_client.create_bucket(Bucket=bucket)
    yield bucket
    # クリーンアップ: 全オブジェクトを削除してからバケットを削除
    objects = s3_client.list_objects_v2(Bucket=bucket).get('Contents', [])
    for obj in objects:
        s3_client.delete_object(Bucket=bucket, Key=obj['Key'])
    s3_client.delete_bucket(Bucket=bucket)

# test_s3.py
def test_upload_and_retrieve(s3_client, setup_s3_bucket):
    bucket = setup_s3_bucket
    s3_client.put_object(Bucket=bucket, Key='test.txt', Body=b'content')
    response = s3_client.get_object(Bucket=bucket, Key='test.txt')
    assert response['Body'].read() == b'content'

バックグラウンドでLocalStackを起動した状態でテストを実行する:

pytest tests/ -v

本番投入前に知っておくべきこと

デフォルトではデータは永続化されない。コンテナを再起動するとすべて消える — S3バケット、DynamoDBテーブル、Lambda関数がすべてゼロにリセットされる。実行間でデータを保持したい場合は、上記のdocker-composeの例のようにボリュームをマウントすること。

Lambdaのコールドスタートは通常より遅い。LocalStackはLambdaの実行にDocker-in-Dockerを使用するため、最初のコールドスタートには10〜30秒かかることがある。2回目以降の呼び出しはずっと速くなる。テストのためだけに本物のAWSにデプロイすることと比べれば、このトレードオフは十分許容できる。

環境変数でエンドポイントを切り替える。http://localhost:4566をコードにハードコードするのは、デプロイ時に見つけにくいバグを生む最速の方法だ。Node.jsとPythonアプリケーションで環境変数を安全に管理する方法を参考に、代わりにこうする:

import os

endpoint = os.environ.get('AWS_ENDPOINT_URL', None)  # None = 本物のAWSを使用
s3 = boto3.client('s3', endpoint_url=endpoint)

ローカルで実行する場合はAWS_ENDPOINT_URL=http://localhost:4566 python app.pyと設定する。StagingやProdにデプロイする際はこの変数を設定しなければ、Boto3が自動的に本物のAWSに接続する — コードの変更は不要だ。

GitHub Actionsでも使える。ワークフローにlocalstack/setup-localstackアクションを追加するだけでよい。インテグレーションテストはrunner上で完全オフラインで動作し、どのAWSアカウントにも依存しない。GitHub Actions CI/CDの基礎ガイドでパイプライン全体の組み方を確認しておくと、LocalStackとの統合もスムーズに進む。

まとめ

あの深夜2時の出来事の後、チームのインテグレーションテスト全体をLocalStackに移行した。CI/CDパイプラインは目に見えて速くなった。AWS devアカウントの請求は約70%削減された。それ以上に重要なのは — 開発者が完全オフラインでテストできるようになったことだ。電車の中でも、Wi-Fiが不安定なカフェでも関係なく。

LocalStackがすべての問題を解決するわけではない。複雑なIAMポリシー、エッジケースでのサービス固有の挙動 — それらはリリース前に本物のAWSでテストする必要がある。しかし日々の作業 — 機能の実装、ロジックのデバッグ、インテグレーションテストの実行 — においては、これが自分の知る中で最も実用的な選択肢だ。

Share: