深夜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でテストする必要がある。しかし日々の作業 — 機能の実装、ロジックのデバッグ、インテグレーションテストの実行 — においては、これが自分の知る中で最も実用的な選択肢だ。
