Módulo 4: LocalStack — AWS Local Development
4. Lambda Local con LocalStack
Descripción
En esta cápsula vas a desplegar y ejecutar funciones Lambda en LocalStack. Tomas el Lambda AI Endpoint que construiste en el Módulo 3, lo despliegas en LocalStack con mínimos cambios, lo invocas, y lees los logs — todo local, todo gratis. La promesa es real: el mismo código Lambda que correría en AWS funciona en tu laptop.
Contexto: En la cápsula anterior dominaste S3 local para almacenar assets AI. Ahora agregas la segunda pieza del pipeline: Lambda. Cuando termines, tendrás tanto almacenamiento (S3) como computación (Lambda) corriendo localmente en LocalStack. La cápsula 05 conecta ambos en un pipeline completo.
Lambda en LocalStack vs AWS
Qué cambia y qué no
Lo que NO cambia:
├── Tu código handler.py — idéntico
├── La firma: handler(event, context)
├── El formato del response (statusCode, headers, body)
├── Las APIs de invocación (invoke, list-functions)
└── Las variables de entorno
Lo que SÍ cambia:
├── El endpoint: localhost:4566 en vez de lambda.us-east-1.amazonaws.com
├── El deploy: awslocal en vez de aws
├── IAM: LocalStack Community no enforce permisos
├── Cold starts: más rápidos en LocalStack (no es infra real)
└── Logs: docker compose logs en vez de CloudWatch
La diferencia clave es el endpoint. Tu Lambda es el mismo código. Lo despliegas con el mismo comando. Solo apuntas a un lugar diferente.
Cómo ejecuta Lambda LocalStack
Cuando invocas una Lambda en LocalStack, esto es lo que pasa:
1. Tu invocación llega a localhost:4566
↓
2. LocalStack identifica que es una invocación Lambda
↓
3. Si LAMBDA_EXECUTOR=docker:
LocalStack crea un container Docker temporal
con tu código y lo ejecuta
↓
4. Si LAMBDA_EXECUTOR=local:
LocalStack ejecuta tu handler en su propio proceso
↓
5. El resultado se retorna a tu cliente
docker es más fiel a AWS (cada Lambda en su container aislado) pero más lento. local es más rápido pero menos fiel. Para desarrollo, ambos funcionan.
Preparar el Lambda del M3
Estructura del handler
Tu Lambda del Módulo 3 ya tiene todo lo necesario. Este es el handler simplificado que vamos a desplegar:
# 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": "Responde de forma concisa y útil."},
{"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
Empaquetar como zip
# Crear directorio de packaging
mkdir -p lambda/package
# Instalar dependencias en el directorio de package
pip install -r lambda/requirements.txt -t lambda/package/
# Copiar handler al package
cp lambda/handler.py lambda/package/
# Crear zip
cd lambda/package
zip -r ../handler.zip .
cd ../..
# Verificar tamaño
ls -lh lambda/handler.zip
# ~5-8MB esperado (openai SDK + dependencias)
Desplegar Lambda en LocalStack
Crear la función
# Asegúrate de que LocalStack está corriendo
curl http://localhost:4566/_localstack/health
# Crear la función Lambda
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}"
# Output esperado:
# {
# "FunctionName": "ai-endpoint",
# "Runtime": "python3.11",
# "Handler": "handler.handler",
# "MemorySize": 768,
# "Timeout": 60,
# ...
# }
Notas importantes:
- El
--rolees un ARN dummy — LocalStack Community no enforce IAM $OPENAI_API_KEYse expande de tu shell — asegúrate de tenerla configurada- El zip incluye tu handler + dependencias (openai SDK)
Verificar que existe
# Listar funciones
awslocal lambda list-functions
# Ver detalles de la función
awslocal lambda get-function --function-name ai-endpoint
# Ver solo configuración
awslocal lambda get-function-configuration --function-name ai-endpoint
Actualizar la función (cuando cambies el código)
# Reempaquetar
cd lambda/package
zip -r ../handler.zip .
cd ../..
# Actualizar código
awslocal lambda update-function-code \
--function-name ai-endpoint \
--zip-file fileb://lambda/handler.zip
# Actualizar variables de entorno
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}"
Invocar Lambda en LocalStack
Invocación directa (síncrona)
# Invocar con un payload
awslocal lambda invoke \
--function-name ai-endpoint \
--payload '{"body": "{\"prompt\": \"¿Qué es LocalStack en una frase?\", \"max_tokens\": 100}"}' \
--cli-binary-format raw-in-base64-out \
output.json
# Ver el resultado
cat output.json | python3 -m json.tool
# Output esperado:
# {
# "statusCode": 200,
# "headers": {...},
# "body": "{\"answer\": \"LocalStack es...\", \"model\": \"gpt-4o-mini\", ...}"
# }
Parsear el resultado completo
# El body viene como string JSON dentro del response
# Parsearlo:
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']}\")
"
Invocación desde 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": "Explica qué es serverless en 2 oraciones.",
"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 es un modelo de ejecución...
# Model: gpt-4o-mini
# Tokens: 89
# Duration: 2340ms
Invocación asíncrona
# Para procesamiento en background (no espera resultado)
awslocal lambda invoke \
--function-name ai-endpoint \
--invocation-type Event \
--payload '{"body": "{\"prompt\": \"Analiza este texto largo...\"}"}' \
--cli-binary-format raw-in-base64-out \
/dev/null
# Retorna 202 inmediatamente — la función corre en background
Leer Logs
Logs desde Docker
# Ver logs del container de LocalStack
docker compose logs localstack --tail 50
# Seguir logs en tiempo real
docker compose logs localstack -f
# Filtrar por Lambda
docker compose logs localstack | grep -i "lambda"
Logs desde awslocal (CloudWatch)
# LocalStack emula CloudWatch Logs básico
awslocal logs describe-log-groups
# Ver log streams de tu función
awslocal logs describe-log-streams \
--log-group-name /aws/lambda/ai-endpoint
# Ver eventos de log
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)"
Script de logs útil
#!/bin/bash
# scripts/lambda-logs.sh — Ver logs de Lambda en LocalStack
FUNCTION_NAME=${1:-ai-endpoint}
echo "=== Últimas invocaciones de $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 hay logs disponibles"
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
Comparar con AWS Deployment
Mismo código, diferente target
# Deploy a 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 a AWS real (si tuvieras cuenta)
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
# La ÚNICA diferencia: "awslocal" vs "aws" (y el role ARN real)
Tabla comparativa
LocalStack AWS Real
─────────────────────────────────────────────────────────
Comando deploy awslocal aws
Endpoint localhost:4566 lambda.region.amazonaws.com
Role ARN dummy ARN real con permisos
IAM enforcement No (Community) Sí
Cold starts Más rápidos Reales (1-15s)
Logs docker logs / awslocal CloudWatch
Coste $0 $$ por invocación
Fidelidad ~95% de la API 100%
Ideal para Desarrollo, testing Staging, producción
Deploy con Script Automatizado
Script completo de deploy
#!/bin/bash
# scripts/deploy-lambda.sh — Deploy Lambda a LocalStack
set -e
FUNCTION_NAME="ai-endpoint"
ZIP_FILE="lambda/handler.zip"
RUNTIME="python3.11"
HANDLER="handler.handler"
MEMORY=768
TIMEOUT=60
echo "=== Empaquetando Lambda ==="
cd lambda/package
zip -r ../handler.zip . -q
cd ../..
echo "Package creado: $(ls -lh $ZIP_FILE | awk '{print $5}')"
echo "=== Verificando LocalStack ==="
curl -s http://localhost:4566/_localstack/health | python3 -m json.tool
# Verificar si la función ya existe
EXISTING=$(awslocal lambda get-function --function-name $FUNCTION_NAME 2>/dev/null || true)
if [ -n "$EXISTING" ]; then
echo "=== Actualizando función existente ==="
awslocal lambda update-function-code \
--function-name $FUNCTION_NAME \
--zip-file fileb://$ZIP_FILE
else
echo "=== Creando nueva función ==="
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 "=== Verificando 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 exitoso"
else
echo "Deploy con problemas — revisar /tmp/lambda-test.json"
fi
Ejercicios
Ejercicio 1: Deploy y invoke de un Lambda simple
Crea una función Lambda que reciba un texto y retorne la cantidad de palabras y caracteres. Despliégala en LocalStack e invócala.
Ver solución
# 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],
}),
}
# Empaquetar (sin dependencias — solo 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 permite desarrollar contra AWS sin coste económico\"}"}' \
--cli-binary-format raw-in-base64-out \
output.json
cat output.json | python3 -m json.tool
# {"statusCode": 200, "body": "{\"words\": 8, \"characters\": 58, ...}"}
Ejercicio 2: Actualizar función y verificar cambio
Modifica la función word-counter para que también retorne la palabra más larga. Actualiza el código en LocalStack e invoca para verificar.
Ver solución
# lambda/word_counter.py (actualizado)
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),
}),
}
# Re-empaquetar
cd lambda
zip word_counter.zip word_counter.py
cd ..
# Actualizar (no create — update)
awslocal lambda update-function-code \
--function-name word-counter \
--zip-file fileb://lambda/word_counter.zip
# Invoke para verificar el cambio
awslocal lambda invoke \
--function-name word-counter \
--payload '{"body": "{\"text\": \"LocalStack permite desarrollar contra AWS sin coste económico\"}"}' \
--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: desarrollar (11 chars)
Ejercicio 3: Invocar Lambda con boto3 desde Python
Escribe un script Python que invoque la función ai-endpoint en LocalStack, envíe 3 prompts diferentes, y recolecte las respuestas con tiempos de duración.
Ver solución
# 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 = [
"¿Qué es S3 en una frase?",
"¿Qué es Lambda en una frase?",
"¿Qué es LocalStack en una frase?",
]
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"Promedio Lambda: {sum(r['lambda_ms'] for r in results) // len(results)}ms")
Ejercicio 4: Script de deploy completo con verificación
Crea un script bash que: empaquete el handler, despliegue a LocalStack (create o update según exista), invoque con un prompt de test, y reporte success/failure.
Ver solución
#!/bin/bash
# deploy-and-test.sh
set -e
FUNCTION="ai-endpoint"
HANDLER_DIR="lambda"
ZIP="$HANDLER_DIR/handler.zip"
echo "1. Empaquetando..."
cd "$HANDLER_DIR/package"
zip -r ../handler.zip . -q
cd ../..
echo " Size: $(ls -lh $ZIP | awk '{print $5}')"
echo "2. Verificando 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 no disponible en LocalStack"
exit 1
fi
echo " Lambda: available"
echo "3. Desplegando..."
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 " Función actualizada"
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 " Función creada"
fi
echo "4. Testing..."
awslocal lambda invoke \
--function-name $FUNCTION \
--payload '{"body": "{\"prompt\": \"Responde solo 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 completo"
Troubleshooting
"Lambda invoke retorna error 'Function not found'"
# Verifica que la función existe
awslocal lambda list-functions --query 'Functions[].FunctionName'
# Si no existe, despliégala:
awslocal lambda create-function ...
# Si el nombre no coincide, verifica casing
# Lambda names son case-sensitive
"Lambda invoke retorna timeout"
# El handler está tardando más que el timeout configurado
# Verifica el timeout:
awslocal lambda get-function-configuration \
--function-name ai-endpoint \
--query 'Timeout'
# Aumentar si es necesario:
awslocal lambda update-function-configuration \
--function-name ai-endpoint \
--timeout 120
"ModuleNotFoundError: No module named 'openai'"
# Las dependencias no están en el zip
# Verifica que empaquetaste correctamente:
unzip -l lambda/handler.zip | grep openai
# Debe mostrar archivos de openai/
# Si no están, re-empaqueta:
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 no está configurada en Lambda"
# Verifica las variables de entorno:
awslocal lambda get-function-configuration \
--function-name ai-endpoint \
--query 'Environment.Variables'
# Actualizar:
awslocal lambda update-function-configuration \
--function-name ai-endpoint \
--environment "Variables={OPENAI_API_KEY=sk-proj-tu-key-real}"
"Lambda con LAMBDA_EXECUTOR=docker no funciona"
# Verifica que Docker socket está montado en LocalStack
docker compose exec localstack ls -la /var/run/docker.sock
# Si no está, agrega al Compose:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Alternativa: usa LAMBDA_EXECUTOR=local
Resumen
- Lambda en LocalStack usa el mismo código y las mismas APIs que Lambda en AWS. Solo cambia el endpoint.
- El handler del M3 funciona en LocalStack sin cambios de código — solo cambia cómo lo despliegas.
awslocal lambda create-functiones idéntico aaws lambda create-function— mismos parámetros, mismo formato.- Invocación funciona con awslocal CLI o con boto3 (usando
endpoint_url). - Logs se leen desde Docker (
docker compose logs) o desde CloudWatch emulado (awslocal logs). - El ciclo de desarrollo es rápido: editar → empaquetar → update → invoke → verificar.
- La confianza clave: si funciona en LocalStack, funciona en AWS (con mínimos ajustes de IAM y endpoint).
Recursos Adicionales
- LocalStack Lambda Documentation — Guía oficial de Lambda en LocalStack
- AWS Lambda CLI Reference — Referencia completa del CLI
- boto3 Lambda Client — API de Lambda en boto3
- Lambda Deployment Package — Cómo empaquetar Lambda en Python
- LocalStack Lambda Executor — Modos de ejecución de Lambda
- AWS Lambda Invoke API — API de invocación