個人開発でAWSを使い始めたとき、最初にぶつかる壁がある。
「ローカルで動かせない。」
変更のたびにAWSにデプロイして確認することになる。1回のデプロイで気づくのは大抵1つのバグ。直してまたデプロイ。それを繰り返すうちに気づく。自分がデバッグしているのではなく、AWSにお金を払ってデバッグさせていることに。
しかも、AI(Claude CodeやCursorなど)に実装を任せる場合、AIは「実際に動かして確認する」ことができない。ローカルに本番相当の環境がなければ、AIが書いたコードを人間が毎回デプロイして結果を持ち帰るという二度手間になる。
この記事では、LocalStack + AWS SAM + Docker Compose の3つを組み合わせて、本番と同じLambda + S3 + PostgreSQL構成をローカルで完全に再現する環境を作ります。
この環境が整うと、次のことが実現します。
- Lambda → S3 / PostgreSQL への通信をローカルで完結させる
-
docker-compose up -dと./start-local.shの2コマンドで環境が立ち上がる - AWSアカウントへのデプロイなしで開発ループが回る
- AIが環境定義(IaC)を読んで「自走」できる
完成形のアーキテクチャ
3つのコンテナが共通のDockerネットワーク(sam-network)内で通信します。外から叩くのは localhost:3001(SAM API)のみです。Lambda内からLocalStackへのアクセスには host.docker.internal を使う点が重要で、これはStep 4で詳しく説明します。
なぜこの3つの組み合わせか
LocalStack
AWSサービスをローカルでエミュレートするOSSです。S3、SQS、DynamoDBなど主要サービスに対応しており、無料プランでS3は十分使えます。endpoint_url を切り替えるだけで既存のコードが動きます。
AWS SAM CLI
Lambda + API GatewayをIaCで定義・管理するツールです。sam local start-api で Lambda をローカルで起動できます。インフラ定義がコードに残るため、AI が構成を読んで差分提案できるようになります。
Docker Compose
LocalStack と PostgreSQL を宣言的に定義します。docker-compose.yml が一ファイルあれば、誰でも(AIでも)同じ環境を再現できます。
ハンズオン — Step by Step
以下では「ファイルをS3にアップロードし、署名付きURLを返す」シンプルなLambdaを題材にします。
前提環境
- Docker Desktop(Mac / Windows)
- AWS SAM CLI
- AWS CLI(LocalStack操作用)
- Python 3.12
ディレクトリ構成
myapp/
├── backend/
│ ├── uploadApi/
│ │ └── src/
│ │ ├── lambda_function.py
│ │ └── requirements.txt
│ ├── template.local.yml
│ └── start-local.sh
└── localstack_env/
├── docker-compose.yml
└── init/
└── ready.d/
└── 01_init_s3.sh
Step 1: Docker ネットワークを作る
Lambda コンテナ・LocalStack・PostgreSQL が同じネットワーク上で通信するための共有ネットワークです。一度作れば以降は不要です。
docker network create sam-network
Step 2: LocalStack + PostgreSQL を定義する
localstack_env/docker-compose.yml を作成します。
# localstack_env/docker-compose.yml
services:
localstack:
image: localstack/localstack:latest
container_name: localstack_main
networks:
- sam-network
ports:
- "4566:4566"
environment:
- SERVICES=s3 # 使うサービスだけ指定(起動が速くなる)
- DEBUG=1
- PERSISTENCE=1 # コンテナ停止後もS3データを保持する
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ap-northeast-1
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- ./localstack_data:/var/lib/localstack # データ永続化先
- ./init/ready.d:/etc/localstack/init/ready.d # 起動後に自動実行されるスクリプト
postgres-db:
image: postgres:15
container_name: postgres_main
networks:
- sam-network
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
sam-network:
external: true # docker network create sam-network で作成した外部ネットワーク
volumes:
postgres_data:
ポイント: sam-network: external: true がキーです。Lambdaコンテナ(SAMが起動)と LocalStack・PostgreSQL が同じネットワークに乗ることで、Lambda内から postgres-db:5432 や localstack_main:4566 でアクセスできます。
Step 3: S3バケットを自動初期化する init hook
LocalStack は ready.d/ に置いたスクリプトを起動完了後に自動実行します。毎回手動でバケットを作る手間がなくなります。
# localstack_env/init/ready.d/01_init_s3.sh
#!/bin/bash
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=ap-northeast-1
ENDPOINT_URL=http://localhost:4566
CORS_CONFIG='{
"CORSRules": [{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedOrigins": ["*"],
"MaxAgeSeconds": 3000
}]
}'
create_bucket_if_not_exists() {
local BUCKET_NAME="$1"
if aws --endpoint-url="$ENDPOINT_URL" s3api head-bucket --bucket "$BUCKET_NAME" 2>/dev/null; then
echo "✅ $BUCKET_NAME は既に存在します(スキップ)"
else
echo "🪣 $BUCKET_NAME を作成中..."
aws --endpoint-url="$ENDPOINT_URL" s3api create-bucket \
--bucket "$BUCKET_NAME" \
--region ap-northeast-1 \
--create-bucket-configuration LocationConstraint=ap-northeast-1
aws --endpoint-url="$ENDPOINT_URL" s3api put-bucket-cors \
--bucket "$BUCKET_NAME" \
--cors-configuration "$CORS_CONFIG"
echo "✅ $BUCKET_NAME の作成とCORS設定が完了しました"
fi
}
create_bucket_if_not_exists "myapp-uploads"
スクリプトに実行権限を付けておきます。
chmod +x localstack_env/init/ready.d/01_init_s3.sh
Step 4: SAMテンプレートを定義する
backend/template.local.yml を作成します。
# backend/template.local.yml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: myapp API(LOCAL環境)
Parameters:
S3Endpoint:
Type: String
Default: http://host.docker.internal:4566 # Lambdaコンテナ→LocalStack
S3EndpointForLocal:
Type: String
Default: http://localhost:4566 # ホスト側CLI用(署名付きURLなど)
Globals:
Function:
Runtime: python3.12
Timeout: 30
Environment:
Variables:
AWS_REGION: ap-northeast-1
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
S3_ENDPOINT: !Ref S3Endpoint
S3_ENDPOINT_FOR_LOCAL: !Ref S3EndpointForLocal
S3_BUCKET: myapp-uploads
DATABASE_HOST: postgres-db # sam-network 内のコンテナ名で指定
DATABASE_USER: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: mydb
Resources:
MyAppApi:
Type: AWS::Serverless::Api
Properties:
Name: MyAppApiLocal
StageName: local
Cors:
AllowOrigin: "'http://localhost:3000'"
AllowMethods: "'GET,POST,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization'"
UploadFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: local_uploadApi
CodeUri: ./uploadApi/src
Handler: lambda_function.lambda_handler
Events:
UploadApi:
Type: Api
Properties:
RestApiId: !Ref MyAppApi
Path: /upload/presigned-url
Method: POST
エンドポイントが2種類ある理由:
| 変数 | 値 | 用途 |
|---|---|---|
S3_ENDPOINT |
http://host.docker.internal:4566 |
Lambda → LocalStack(コンテナ間通信) |
S3_ENDPOINT_FOR_LOCAL |
http://localhost:4566 |
署名付きURLのホスト書き換え用 |
署名付きURLはLambda内で生成されますが、そのままではURLに host.docker.internal が含まれてしまいます。ブラウザからアクセスするには localhost に書き換える必要があります。この2変数で切り替えます。
Step 5: Lambdaの実装
backend/uploadApi/src/lambda_function.py を作成します。
# backend/uploadApi/src/lambda_function.py
import json
import os
import uuid
import boto3
from botocore.config import Config
S3_ENDPOINT = os.environ["S3_ENDPOINT"] # コンテナ間通信用
S3_ENDPOINT_LOCAL = os.environ["S3_ENDPOINT_FOR_LOCAL"] # 署名付きURL書き換え用
S3_BUCKET = os.environ["S3_BUCKET"]
CORS_HEADERS = {
"Access-Control-Allow-Origin": "http://localhost:3000",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Content-Type": "application/json",
}
def lambda_handler(event, context):
http_method = event.get("httpMethod")
resource = event.get("resource")
if http_method == "OPTIONS":
return {"statusCode": 200, "headers": CORS_HEADERS, "body": "{}"}
if http_method == "POST" and resource == "/upload/presigned-url":
return handle_presigned_url(event)
return {
"statusCode": 404,
"headers": CORS_HEADERS,
"body": json.dumps({"error": "Not Found"}),
}
def handle_presigned_url(event):
try:
body = json.loads(event.get("body") or "{}")
file_name = body.get("file_name", f"{uuid.uuid4()}.bin")
file_type = body.get("file_type", "application/octet-stream")
s3 = boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
region_name="ap-northeast-1",
aws_access_key_id="test",
aws_secret_access_key="test",
config=Config(signature_version="s3v4"),
)
object_key = f"uploads/{uuid.uuid4()}/{file_name}"
presigned_url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": S3_BUCKET,
"Key": object_key,
"ContentType": file_type,
},
ExpiresIn=300,
)
# Lambda内で生成したURLには host.docker.internal が含まれる
# ブラウザからアクセスできるよう localhost に書き換える
presigned_url = presigned_url.replace(S3_ENDPOINT, S3_ENDPOINT_LOCAL)
return {
"statusCode": 200,
"headers": CORS_HEADERS,
"body": json.dumps({
"upload_url": presigned_url,
"object_key": object_key,
}),
}
except Exception as e:
return {
"statusCode": 500,
"headers": CORS_HEADERS,
"body": json.dumps({"error": str(e)}),
}
backend/uploadApi/src/requirements.txt:
boto3==1.35.0
Step 6: ビルドと起動 — ここでMacユーザーが詰まる
sam build を実行すると、ビルド成果物は .aws-sam/build/ に出力されます。ところが Docker Desktop(Mac) はドット始まりの隠しディレクトリをコンテナにマウントできません。
そのまま sam local start-api を実行してもLambdaコンテナが成果物を見つけられず起動に失敗します。エラーメッセージが分かりにくいため、原因の特定に時間がかかりがちです。
解決策: .aws-sam/build/ を非隠しディレクトリ sam-build/ にコピーしてから起動するスクリプトを挟みます。
# backend/start-local.sh
#!/bin/bash
set -e
cd "$(dirname "$0")/.." # backend/ ディレクトリに移動
BUILD_FLAG=false
for arg in "$@"; do
if [ "$arg" = "--build" ]; then
BUILD_FLAG=true
fi
done
if [ "$BUILD_FLAG" = true ]; then
echo "▶ sam build --use-container ..."
sam build -t template.local.yml --use-container --no-cached
fi
echo "▶ .aws-sam/build → sam-build/ へコピー"
rm -rf sam-build
cp -r .aws-sam/build sam-build
echo "▶ sam local start-api 起動 (port 3001)"
sam local start-api \
--docker-network sam-network \
-p 3001 \
-t ./sam-build/template.yaml
実行権限を付けておきます。
chmod +x backend/start-local.sh
起動手順(スクリプト経由で起動):
# 1. LocalStack + PostgreSQL を起動
cd localstack_env
docker-compose up -d
# LocalStack が完全に起動するまで数秒待つ
# init hook (01_init_s3.sh) が自動でバケットを作ってくれる
# バケット確認
aws --endpoint-url=http://localhost:4566 s3 ls
# → 2024-01-01 00:00:00 myapp-uploads
# 2. SAM ビルド→起動(初回は --build が必要)
cd ../backend
./start-local.sh --build
# → sam local start-api が port 3001 で起動
2回目以降は sam build をスキップして高速起動できます。
./start-local.sh # ビルドスキップで再起動
Step 7: 動作確認
別ターミナルで確認します。
# 署名付きURL を取得
curl -X POST http://localhost:3001/upload/presigned-url \
-H "Content-Type: application/json" \
-d '{"file_name": "test.txt", "file_type": "text/plain"}'
# レスポンス例
# {
# "upload_url": "http://localhost:4566/myapp-uploads/uploads/xxx/test.txt?...",
# "object_key": "uploads/xxx/test.txt"
# }
# 取得したURLにファイルをアップロード
curl -X PUT "<upload_url>" \
-H "Content-Type: text/plain" \
-d "hello from local lambda"
# LocalStack上のオブジェクトを確認
aws --endpoint-url=http://localhost:4566 s3 ls s3://myapp-uploads/uploads/ --recursive
# → 2024-01-01 00:00:00 22 uploads/xxx/test.txt
LambdaからS3へのアップロードがローカルで完結しました。
まとめ
この記事で作った環境を整理します。
| コンポーネント | 役割 | アクセス先 |
|---|---|---|
| LocalStack | S3エミュレータ | localhost:4566 |
| PostgreSQL | RDB | localhost:5432 |
| SAM Lambda | API | localhost:3001 |
| sam-network | コンテナ間通信 | — |
| init hook | バケット自動初期化 | — |
| start-local.sh | Mac隠しディレクトリ回避 | — |
この環境が整ったことで、AWSのデプロイ費用を1円も使わずに、本番と同じ構成でLambdaの開発ループが回るようになりました。
もう一つ重要なのは、この構成がすべてコード(IaC)になっている点です。AIに「この環境でS3に画像を保存する機能を追加して」と頼むと、AIは template.local.yml と docker-compose.yml を読んで構成を理解した上で実装してくれます。手順書ではなくコードとして環境を定義することが、AI駆動開発の前提になります。
次回予告
ローカル環境で開発が回るようになった次のステップは「本番へのデプロイを自動化する」です。
次回は sam deploy と GitHub Actions を組み合わせて、mainブランチへのマージをトリガーにAWS本番環境へ自動デプロイされるパイプラインを構築します。ローカル(LocalStack)→ ステージング → 本番 の環境切り替えも含めて解説する予定です。
この記事の背景
この構成は、グループ旅行のしおり管理・費用精算・旅行レポート共有ができる個人開発サービス 旅BASE(tabibase) の実際のローカル開発環境をベースにしています。
「なぜこの構成にしたか」という設計思想や、AI(Claude Code)をチームの一員として機能させるワークフロー全体については、以下の記事で詳しく解説しています。本記事はその実装編の位置づけです。あわせて読んでいただけると、ローカル環境をIaCで作る理由がより腑に落ちると思います。