0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Lambda のローカル開発環境を0円で作る — LocalStack + SAM + Docker 完全構成

0
Posted at

個人開発で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:5432localstack_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.ymldocker-compose.yml を読んで構成を理解した上で実装してくれます。手順書ではなくコードとして環境を定義することが、AI駆動開発の前提になります。


次回予告

ローカル環境で開発が回るようになった次のステップは「本番へのデプロイを自動化する」です。

次回は sam deploy と GitHub Actions を組み合わせて、mainブランチへのマージをトリガーにAWS本番環境へ自動デプロイされるパイプラインを構築します。ローカル(LocalStack)→ ステージング → 本番 の環境切り替えも含めて解説する予定です。


この記事の背景

この構成は、グループ旅行のしおり管理・費用精算・旅行レポート共有ができる個人開発サービス 旅BASE(tabibase) の実際のローカル開発環境をベースにしています。

「なぜこの構成にしたか」という設計思想や、AI(Claude Code)をチームの一員として機能させるワークフロー全体については、以下の記事で詳しく解説しています。本記事はその実装編の位置づけです。あわせて読んでいただけると、ローカル環境をIaCで作る理由がより腑に落ちると思います。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?