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
--roleis a dummy ARN — LocalStack Community doesn't enforce IAM $OPENAI_API_KEYexpands 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-functionis identical toaws 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
- LocalStack Lambda Documentation — Official Lambda guide on LocalStack
- AWS Lambda CLI Reference — Complete CLI reference
- boto3 Lambda Client — Lambda API in boto3
- Lambda Deployment Package — How to package Lambda in Python
- LocalStack Lambda Executor — Lambda execution modes
- AWS Lambda Invoke API — Invocation API