Module 4: LocalStack — AWS Local Development

4. Lambda Locally with LocalStack

Overview

In this capsule you'll deploy and run Lambda functions on LocalStack. You take the Lambda AI Endpoint you built in Module 3, deploy it on LocalStack with minimal changes, invoke it, and read the logs — all local, all free. The promise is real: the same Lambda code that would run on AWS works on your laptop.

Context: In the previous capsule you mastered local S3 to store AI assets. Now you add the second piece of the pipeline: Lambda. When you finish, you'll have both storage (S3) and compute (Lambda) running locally on LocalStack. Capsule 05 connects both into a complete pipeline.


Lambda in LocalStack vs AWS

What changes and what doesn't

What does NOT change:
├── Your handler.py code — identical
├── The signature: handler(event, context)
├── The response format (statusCode, headers, body)
├── The invocation APIs (invoke, list-functions)
└── The environment variables

What DOES change:
├── The endpoint: localhost:4566 instead of lambda.us-east-1.amazonaws.com
├── The deploy: awslocal instead of aws
├── IAM: LocalStack Community doesn't enforce permissions
├── Cold starts: faster in LocalStack (it's not real infra)
└── Logs: docker compose logs instead of CloudWatch

The key difference is the endpoint. Your Lambda is the same code. You deploy it with the same command. You just point to a different place.

How LocalStack runs Lambda

When you invoke a Lambda on LocalStack, this is what happens:

1. Your invocation arrives at localhost:4566
    ↓
2. LocalStack identifies it as a Lambda invocation
    ↓
3. If LAMBDA_EXECUTOR=docker:
   LocalStack creates a temporary Docker container
   with your code and runs it
    ↓
4. If LAMBDA_EXECUTOR=local:
   LocalStack runs your handler in its own process
    ↓
5. The result is returned to your client

docker is more faithful to AWS (each Lambda in its own isolated container) but slower. local is faster but less faithful. For development, both work.


Prepare the M3 Lambda

The handler structure

Your Module 3 Lambda already has everything you need. This is the simplified handler we're going to deploy:

# lambda/handler.py
import json
import os
import time
from openai import OpenAI

client = OpenAI(
    api_key=os.environ.get("OPENAI_API_KEY", ""),
    timeout=50,
    max_retries=1,
)

MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
DEFAULT_MAX_TOKENS = int(os.environ.get("DEFAULT_MAX_TOKENS", "500"))

IS_COLD_START = True


def handler(event, context):
    global IS_COLD_START
    was_cold = IS_COLD_START
    IS_COLD_START = False
    start_time = time.time()

    try:
        body = json.loads(event.get("body", "{}"))
    except (json.JSONDecodeError, TypeError):
        return _response(400, {"error": "Invalid JSON body"})

    prompt = body.get("prompt", "").strip()
    if not prompt:
        return _response(400, {"error": "prompt is required"})

    max_tokens = body.get("max_tokens", DEFAULT_MAX_TOKENS)

    try:
        response = client.chat.completions.create(
            model=MODEL_NAME,
            messages=[
                {"role": "system", "content": "Respond concisely and helpfully."},
                {"role": "user", "content": prompt},
            ],
            max_tokens=max_tokens,
        )
    except Exception as e:
        return _response(502, {"error": f"LLM call failed: {str(e)}"})

    total_ms = round((time.time() - start_time) * 1000)

    return _response(200, {
        "answer": response.choices[0].message.content,
        "model": response.model,
        "tokens_used": response.usage.total_tokens,
        "duration_ms": total_ms,
        "cold_start": was_cold,
    })


def _response(status_code, body):
    return {
        "statusCode": status_code,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
        },
        "body": json.dumps(body),
    }

Requirements

# lambda/requirements.txt
openai>=1.0.0

Package as a zip

# Create the packaging directory
mkdir -p lambda/package

# Install dependencies into the package directory
pip install -r lambda/requirements.txt -t lambda/package/

# Copy the handler to the package
cp lambda/handler.py lambda/package/

# Create the zip
cd lambda/package
zip -r ../handler.zip .
cd ../..

# Check the size
ls -lh lambda/handler.zip
# ~5-8MB expected (openai SDK + dependencies)

Deploy Lambda on LocalStack

Create the function

# Make sure LocalStack is running
curl http://localhost:4566/_localstack/health

# Create the Lambda function
awslocal lambda create-function \
  --function-name ai-endpoint \
  --runtime python3.11 \
  --handler handler.handler \
  --zip-file fileb://lambda/handler.zip \
  --role arn:aws:iam::000000000000:role/lambda-role \
  --timeout 60 \
  --memory-size 768 \
  --environment "Variables={OPENAI_API_KEY=$OPENAI_API_KEY,MODEL_NAME=gpt-4o-mini,DEFAULT_MAX_TOKENS=500}"

# Expected output:
# {
#     "FunctionName": "ai-endpoint",
#     "Runtime": "python3.11",
#     "Handler": "handler.handler",
#     "MemorySize": 768,
#     "Timeout": 60,
#     ...
# }

Important notes:

  • The --role is a dummy ARN — LocalStack Community doesn't enforce IAM
  • $OPENAI_API_KEY expands from your shell — make sure you have it configured
  • The zip includes your handler + dependencies (openai SDK)

Verify it exists

# List functions
awslocal lambda list-functions

# See the function's details
awslocal lambda get-function --function-name ai-endpoint

# See only the configuration
awslocal lambda get-function-configuration --function-name ai-endpoint

Update the function (when you change the code)

# Repackage
cd lambda/package
zip -r ../handler.zip .
cd ../..

# Update the code
awslocal lambda update-function-code \
  --function-name ai-endpoint \
  --zip-file fileb://lambda/handler.zip

# Update the environment variables
awslocal lambda update-function-configuration \
  --function-name ai-endpoint \
  --environment "Variables={OPENAI_API_KEY=$OPENAI_API_KEY,MODEL_NAME=gpt-4o-mini,DEFAULT_MAX_TOKENS=300}"

Invoke Lambda on LocalStack

Direct invocation (synchronous)

# Invoke with a payload
awslocal lambda invoke \
  --function-name ai-endpoint \
  --payload '{"body": "{\"prompt\": \"What is LocalStack in one sentence?\", \"max_tokens\": 100}"}' \
  --cli-binary-format raw-in-base64-out \
  output.json

# See the result
cat output.json | python3 -m json.tool

# Expected output:
# {
#     "statusCode": 200,
#     "headers": {...},
#     "body": "{\"answer\": \"LocalStack is...\", \"model\": \"gpt-4o-mini\", ...}"
# }

Parse the full result

# The body comes as a JSON string inside the response
# Parse it:
cat output.json | python3 -c "
import json, sys
resp = json.load(sys.stdin)
body = json.loads(resp['body'])
print(f\"Answer: {body['answer']}\")
print(f\"Model: {body['model']}\")
print(f\"Tokens: {body['tokens_used']}\")
print(f\"Duration: {body['duration_ms']}ms\")
print(f\"Cold start: {body['cold_start']}\")
"

Invocation from Python (boto3)

# invoke_lambda.py
import boto3
import json

lambda_client = boto3.client(
    "lambda",
    endpoint_url="http://localhost:4566",
    aws_access_key_id="test",
    aws_secret_access_key="test",
    region_name="us-east-1",
)

payload = {
    "body": json.dumps({
        "prompt": "Explain what serverless is in 2 sentences.",
        "max_tokens": 150,
    })
}

response = lambda_client.invoke(
    FunctionName="ai-endpoint",
    InvocationType="RequestResponse",
    Payload=json.dumps(payload),
)

result = json.loads(response["Payload"].read())
body = json.loads(result["body"])

print(f"Answer: {body['answer']}")
print(f"Model: {body['model']}")
print(f"Tokens: {body['tokens_used']}")
print(f"Duration: {body['duration_ms']}ms")
python invoke_lambda.py
# Answer: Serverless is an execution model...
# Model: gpt-4o-mini
# Tokens: 89
# Duration: 2340ms

Asynchronous invocation

# For background processing (doesn't wait for the result)
awslocal lambda invoke \
  --function-name ai-endpoint \
  --invocation-type Event \
  --payload '{"body": "{\"prompt\": \"Analyze this long text...\"}"}' \
  --cli-binary-format raw-in-base64-out \
  /dev/null

# Returns 202 immediately — the function runs in the background

Read Logs

Logs from Docker

# See the LocalStack container's logs
docker compose logs localstack --tail 50

# Follow the logs in real time
docker compose logs localstack -f

# Filter by Lambda
docker compose logs localstack | grep -i "lambda"

Logs from awslocal (CloudWatch)

# LocalStack emulates basic CloudWatch Logs
awslocal logs describe-log-groups

# See your function's log streams
awslocal logs describe-log-streams \
  --log-group-name /aws/lambda/ai-endpoint

# See the log events
awslocal logs get-log-events \
  --log-group-name /aws/lambda/ai-endpoint \
  --log-stream-name "$(awslocal logs describe-log-streams \
    --log-group-name /aws/lambda/ai-endpoint \
    --query 'logStreams[0].logStreamName' \
    --output text)"

Handy logs script

#!/bin/bash
# scripts/lambda-logs.sh — See Lambda logs in LocalStack

FUNCTION_NAME=${1:-ai-endpoint}

echo "=== Latest invocations of $FUNCTION_NAME ==="

LOG_GROUP="/aws/lambda/$FUNCTION_NAME"

STREAMS=$(awslocal logs describe-log-streams \
  --log-group-name "$LOG_GROUP" \
  --order-by LastEventTime \
  --descending \
  --query 'logStreams[0:3].logStreamName' \
  --output text 2>/dev/null)

if [ -z "$STREAMS" ]; then
  echo "No logs available"
  exit 0
fi

for STREAM in $STREAMS; do
  echo "--- Stream: $STREAM ---"
  awslocal logs get-log-events \
    --log-group-name "$LOG_GROUP" \
    --log-stream-name "$STREAM" \
    --query 'events[].message' \
    --output text
done

Compare with AWS Deployment

Same code, different target

# Deploy to LocalStack
awslocal lambda create-function \
  --function-name ai-endpoint \
  --runtime python3.11 \
  --handler handler.handler \
  --zip-file fileb://lambda/handler.zip \
  --role arn:aws:iam::000000000000:role/lambda-role \
  --timeout 60 \
  --memory-size 768

# Deploy to real AWS (if you had an account)
aws lambda create-function \
  --function-name ai-endpoint \
  --runtime python3.11 \
  --handler handler.handler \
  --zip-file fileb://lambda/handler.zip \
  --role arn:aws:iam::123456789:role/lambda-execution-role \
  --timeout 60 \
  --memory-size 768

# The ONLY difference: "awslocal" vs "aws" (and the real role ARN)

Comparison table

                        LocalStack              Real AWS
─────────────────────────────────────────────────────────
Deploy command          awslocal                aws
Endpoint                localhost:4566          lambda.region.amazonaws.com
Role                    Dummy ARN               Real ARN with permissions
IAM enforcement         No (Community)          Yes
Cold starts             Faster                  Real (1-15s)
Logs                    docker logs / awslocal  CloudWatch
Cost                    $0                      $$ per invocation
Fidelity                ~95% of the API         100%
Ideal for               Development, testing    Staging, production

Deploy with an Automated Script

Complete deploy script

#!/bin/bash
# scripts/deploy-lambda.sh — Deploy Lambda to LocalStack

set -e

FUNCTION_NAME="ai-endpoint"
ZIP_FILE="lambda/handler.zip"
RUNTIME="python3.11"
HANDLER="handler.handler"
MEMORY=768
TIMEOUT=60

echo "=== Packaging Lambda ==="
cd lambda/package
zip -r ../handler.zip . -q
cd ../..
echo "Package created: $(ls -lh $ZIP_FILE | awk '{print $5}')"

echo "=== Verifying LocalStack ==="
curl -s http://localhost:4566/_localstack/health | python3 -m json.tool

# Check whether the function already exists
EXISTING=$(awslocal lambda get-function --function-name $FUNCTION_NAME 2>/dev/null || true)

if [ -n "$EXISTING" ]; then
  echo "=== Updating existing function ==="
  awslocal lambda update-function-code \
    --function-name $FUNCTION_NAME \
    --zip-file fileb://$ZIP_FILE
else
  echo "=== Creating new function ==="
  awslocal lambda create-function \
    --function-name $FUNCTION_NAME \
    --runtime $RUNTIME \
    --handler $HANDLER \
    --zip-file fileb://$ZIP_FILE \
    --role arn:aws:iam::000000000000:role/lambda-role \
    --timeout $TIMEOUT \
    --memory-size $MEMORY \
    --environment "Variables={OPENAI_API_KEY=${OPENAI_API_KEY},MODEL_NAME=gpt-4o-mini}"
fi

echo "=== Verifying deploy ==="
awslocal lambda get-function-configuration \
  --function-name $FUNCTION_NAME \
  --query '{Name: FunctionName, Runtime: Runtime, Memory: MemorySize, Timeout: Timeout}' \
  --output table

echo "=== Test invoke ==="
awslocal lambda invoke \
  --function-name $FUNCTION_NAME \
  --payload '{"body": "{\"prompt\": \"ping\", \"max_tokens\": 10}"}' \
  --cli-binary-format raw-in-base64-out \
  /tmp/lambda-test.json

STATUS=$(cat /tmp/lambda-test.json | python3 -c "import json,sys; print(json.load(sys.stdin)['statusCode'])")
echo "Status: $STATUS"

if [ "$STATUS" = "200" ]; then
  echo "Deploy successful"
else
  echo "Deploy had problems — check /tmp/lambda-test.json"
fi

Exercises

Exercise 1: Deploy and invoke a simple Lambda

Create a Lambda function that receives a text and returns the number of words and characters. Deploy it on LocalStack and invoke it.

See solution
# lambda/word_counter.py
import json

def handler(event, context):
    try:
        body = json.loads(event.get("body", "{}"))
    except (json.JSONDecodeError, TypeError):
        return {"statusCode": 400, "body": json.dumps({"error": "Invalid JSON"})}

    text = body.get("text", "")
    if not text:
        return {"statusCode": 400, "body": json.dumps({"error": "text is required"})}

    return {
        "statusCode": 200,
        "body": json.dumps({
            "words": len(text.split()),
            "characters": len(text),
            "text_preview": text[:50],
        }),
    }
# Package (no dependencies — only stdlib)
cd lambda
zip word_counter.zip word_counter.py
cd ..

# Deploy
awslocal lambda create-function \
  --function-name word-counter \
  --runtime python3.11 \
  --handler word_counter.handler \
  --zip-file fileb://lambda/word_counter.zip \
  --role arn:aws:iam::000000000000:role/lambda-role \
  --timeout 10 \
  --memory-size 128

# Invoke
awslocal lambda invoke \
  --function-name word-counter \
  --payload '{"body": "{\"text\": \"LocalStack lets you develop against AWS without infrastructure costs\"}"}' \
  --cli-binary-format raw-in-base64-out \
  output.json

cat output.json | python3 -m json.tool
# {"statusCode": 200, "body": "{\"words\": 9, \"characters\": 68, ...}"}

Exercise 2: Update a function and verify the change

Modify the word-counter function so it also returns the longest word. Update the code in LocalStack and invoke to verify.

See solution
# lambda/word_counter.py (updated)
import json

def handler(event, context):
    try:
        body = json.loads(event.get("body", "{}"))
    except (json.JSONDecodeError, TypeError):
        return {"statusCode": 400, "body": json.dumps({"error": "Invalid JSON"})}

    text = body.get("text", "")
    if not text:
        return {"statusCode": 400, "body": json.dumps({"error": "text is required"})}

    words = text.split()
    longest = max(words, key=len) if words else ""

    return {
        "statusCode": 200,
        "body": json.dumps({
            "words": len(words),
            "characters": len(text),
            "longest_word": longest,
            "longest_word_length": len(longest),
        }),
    }
# Repackage
cd lambda
zip word_counter.zip word_counter.py
cd ..

# Update (not create — update)
awslocal lambda update-function-code \
  --function-name word-counter \
  --zip-file fileb://lambda/word_counter.zip

# Invoke to verify the change
awslocal lambda invoke \
  --function-name word-counter \
  --payload '{"body": "{\"text\": \"LocalStack lets you develop against AWS without infrastructure costs\"}"}' \
  --cli-binary-format raw-in-base64-out \
  output.json

cat output.json | python3 -c "
import json, sys
resp = json.load(sys.stdin)
body = json.loads(resp['body'])
print(f\"Longest word: {body['longest_word']} ({body['longest_word_length']} chars)\")
"
# Longest word: infrastructure (14 chars)

Exercise 3: Invoke Lambda with boto3 from Python

Write a Python script that invokes the ai-endpoint function on LocalStack, sends 3 different prompts, and collects the responses with their durations.

See solution
# test_lambda_batch.py
import boto3
import json
import time

lambda_client = boto3.client(
    "lambda",
    endpoint_url="http://localhost:4566",
    aws_access_key_id="test",
    aws_secret_access_key="test",
    region_name="us-east-1",
)

prompts = [
    "What is S3 in one sentence?",
    "What is Lambda in one sentence?",
    "What is LocalStack in one sentence?",
]

results = []
for prompt in prompts:
    payload = {"body": json.dumps({"prompt": prompt, "max_tokens": 80})}

    start = time.time()
    response = lambda_client.invoke(
        FunctionName="ai-endpoint",
        InvocationType="RequestResponse",
        Payload=json.dumps(payload),
    )
    invoke_time = round((time.time() - start) * 1000)

    result = json.loads(response["Payload"].read())
    body = json.loads(result["body"])

    results.append({
        "prompt": prompt,
        "answer": body["answer"][:80],
        "tokens": body["tokens_used"],
        "lambda_ms": body["duration_ms"],
        "total_ms": invoke_time,
    })

print(f"{'Prompt':<35} {'Tokens':>6} {'Lambda':>8} {'Total':>8}")
print("-" * 65)
for r in results:
    print(f"{r['prompt']:<35} {r['tokens']:>6} {r['lambda_ms']:>6}ms {r['total_ms']:>6}ms")

print(f"\nTotal tokens: {sum(r['tokens'] for r in results)}")
print(f"Average Lambda: {sum(r['lambda_ms'] for r in results) // len(results)}ms")

Exercise 4: Complete deploy script with verification

Create a bash script that: packages the handler, deploys to LocalStack (create or update depending on whether it exists), invokes with a test prompt, and reports success/failure.

See solution
#!/bin/bash
# deploy-and-test.sh
set -e

FUNCTION="ai-endpoint"
HANDLER_DIR="lambda"
ZIP="$HANDLER_DIR/handler.zip"

echo "1. Packaging..."
cd "$HANDLER_DIR/package"
zip -r ../handler.zip . -q
cd ../..
echo "   Size: $(ls -lh $ZIP | awk '{print $5}')"

echo "2. Verifying LocalStack..."
HEALTH=$(curl -s http://localhost:4566/_localstack/health)
LAMBDA_STATUS=$(echo $HEALTH | python3 -c "import json,sys; print(json.load(sys.stdin)['services'].get('lambda','unavailable'))")
if [ "$LAMBDA_STATUS" != "available" ]; then
  echo "   ERROR: Lambda not available in LocalStack"
  exit 1
fi
echo "   Lambda: available"

echo "3. Deploying..."
EXISTS=$(awslocal lambda get-function --function-name $FUNCTION 2>/dev/null && echo "yes" || echo "no")

if [ "$EXISTS" = "yes" ]; then
  awslocal lambda update-function-code \
    --function-name $FUNCTION \
    --zip-file fileb://$ZIP > /dev/null
  echo "   Function updated"
else
  awslocal lambda create-function \
    --function-name $FUNCTION \
    --runtime python3.11 \
    --handler handler.handler \
    --zip-file fileb://$ZIP \
    --role arn:aws:iam::000000000000:role/lambda-role \
    --timeout 60 \
    --memory-size 768 \
    --environment "Variables={OPENAI_API_KEY=${OPENAI_API_KEY},MODEL_NAME=gpt-4o-mini}" > /dev/null
  echo "   Function created"
fi

echo "4. Testing..."
awslocal lambda invoke \
  --function-name $FUNCTION \
  --payload '{"body": "{\"prompt\": \"Respond only OK\", \"max_tokens\": 5}"}' \
  --cli-binary-format raw-in-base64-out \
  /tmp/test-result.json > /dev/null

STATUS=$(cat /tmp/test-result.json | python3 -c "import json,sys; print(json.load(sys.stdin).get('statusCode','error'))")

if [ "$STATUS" = "200" ]; then
  echo "   PASS — Status 200"
  BODY=$(cat /tmp/test-result.json | python3 -c "import json,sys; r=json.load(sys.stdin); print(json.loads(r['body'])['answer'][:50])")
  echo "   Response: $BODY"
else
  echo "   FAIL — Status $STATUS"
  cat /tmp/test-result.json | python3 -m json.tool
  exit 1
fi

echo "Deploy complete"

Troubleshooting

"Lambda invoke returns error 'Function not found'"

# Verify the function exists
awslocal lambda list-functions --query 'Functions[].FunctionName'

# If it doesn't exist, deploy it:
awslocal lambda create-function ...

# If the name doesn't match, check the casing
# Lambda names are case-sensitive

"Lambda invoke returns timeout"

# The handler is taking longer than the configured timeout
# Check the timeout:
awslocal lambda get-function-configuration \
  --function-name ai-endpoint \
  --query 'Timeout'

# Increase it if necessary:
awslocal lambda update-function-configuration \
  --function-name ai-endpoint \
  --timeout 120

"ModuleNotFoundError: No module named 'openai'"

# The dependencies aren't in the zip
# Verify you packaged correctly:
unzip -l lambda/handler.zip | grep openai
# It should show files from openai/

# If they're not there, repackage:
pip install -r lambda/requirements.txt -t lambda/package/
cp lambda/handler.py lambda/package/
cd lambda/package && zip -r ../handler.zip . && cd ../..

"OPENAI_API_KEY is not configured in Lambda"

# Verify the environment variables:
awslocal lambda get-function-configuration \
  --function-name ai-endpoint \
  --query 'Environment.Variables'

# Update:
awslocal lambda update-function-configuration \
  --function-name ai-endpoint \
  --environment "Variables={OPENAI_API_KEY=sk-proj-your-real-key}"

"Lambda with LAMBDA_EXECUTOR=docker doesn't work"

# Verify the Docker socket is mounted in LocalStack
docker compose exec localstack ls -la /var/run/docker.sock

# If it's not, add it to the Compose:
volumes:
  - /var/run/docker.sock:/var/run/docker.sock

# Alternative: use LAMBDA_EXECUTOR=local

Summary

  • Lambda on LocalStack uses the same code and the same APIs as Lambda on AWS. Only the endpoint changes.
  • The M3 handler works on LocalStack with no code changes — only how you deploy it changes.
  • awslocal lambda create-function is identical to aws lambda create-function — same parameters, same format.
  • Invocation works with the awslocal CLI or with boto3 (using endpoint_url).
  • Logs are read from Docker (docker compose logs) or from emulated CloudWatch (awslocal logs).
  • The development cycle is fast: edit → package → update → invoke → verify.
  • The key confidence: if it works on LocalStack, it works on AWS (with minimal IAM and endpoint adjustments).

Additional Resources

  1. LocalStack Lambda Documentation — Official Lambda guide on LocalStack
  2. AWS Lambda CLI Reference — Complete CLI reference
  3. boto3 Lambda Client — Lambda API in boto3
  4. Lambda Deployment Package — How to package Lambda in Python
  5. LocalStack Lambda Executor — Lambda execution modes
  6. AWS Lambda Invoke API — Invocation API