Lab práctico · Semana 8: Desacoplamiento, serverless y contenedores

API serverless con API Gateway (HTTP API), Lambda y DynamoDB

⏱ 75-90 minDificultad: mediaTask statements: 2.13.2

Qué vas a construir

Una pequeña API de notas totalmente serverless:

  • Amazon DynamoDB (tabla en modo bajo demanda) para guardar las notas.
  • AWS Lambda (Python, arm64) con un rol de ejecución de mínimo privilegio.
  • Amazon API Gateway HTTP API como puerta de entrada, con throttling en la etapa.

Después vas a provocar throttling (HTTP 429), a limitar la función con concurrencia reservada y a medir un cold start.

flowchart LR
    C["Cliente (curl)"] -->|"HTTPS"| API["API Gateway HTTP API (etapa $default)"]
    API -->|"invocación síncrona"| L["Lambda lab15-notas (arm64)"]
    L -->|"PutItem / Scan"| D[("DynamoDB lab15-notas")]
    L --> CW["CloudWatch Logs"]

Antes de empezar

  • Usuario de IAM Identity Center o IAM con MFA; no uses root.
  • Región eu-south-2 (España) en CloudShell.
  • Coste (30/09/2026): Lambda incluye 1 millón de peticiones y 400.000 GB-s al mes gratis; DynamoDB bajo demanda y API Gateway cobran por petición (unos cientos de peticiones = céntimos o nada). Nada de lo que creas cobra por hora estando parado, pero bórralo igualmente.
export AWS_REGION=eu-south-2
export CUENTA=$(aws sts get-caller-identity --query Account --output text)
mkdir -p ~/lab15 && cd ~/lab15

Paso 1: la tabla de DynamoDB

aws dynamodb create-table --table-name lab15-notas \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --tags Key=proyecto,Value=lab15

aws dynamodb wait table-exists --table-name lab15-notas
TABLA_ARN=$(aws dynamodb describe-table --table-name lab15-notas --query Table.TableArn --output text)
echo "$TABLA_ARN"

Paso 2: el rol de ejecución de la función (mínimo privilegio)

La política de confianza dice quién puede asumir el rol (el servicio Lambda); la política de permisos, qué puede hacer (escribir logs y usar solo esta tabla).

cat > confianza.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "lambda.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

cat > permisos.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:Scan", "dynamodb:GetItem"],
      "Resource": "$TABLA_ARN"
    }
  ]
}
EOF

ROL_ARN=$(aws iam create-role --role-name lab15-lambda-rol \
  --assume-role-policy-document file://confianza.json \
  --query Role.Arn --output text)

aws iam attach-role-policy --role-name lab15-lambda-rol \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

aws iam put-role-policy --role-name lab15-lambda-rol \
  --policy-name lab15-dynamodb --policy-document file://permisos.json

echo "$ROL_ARN"
sleep 10   # los roles nuevos tardan unos segundos en propagarse

Paso 3: la función Lambda

El código atiende POST (crear nota) y GET (listar notas). Los clientes de AWS se crean fuera del handler para reutilizarlos entre invocaciones calientes.

cat > lambda_function.py <<'EOF'
import base64
import json
import os
import uuid

import boto3

tabla = boto3.resource("dynamodb").Table(os.environ["TABLA"])


def respuesta(codigo, cuerpo):
    return {
        "statusCode": codigo,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(cuerpo, ensure_ascii=False),
    }


def lambda_handler(event, context):
    metodo = event["requestContext"]["http"]["method"]

    if metodo == "POST":
        cuerpo = event.get("body") or "{}"
        if event.get("isBase64Encoded"):
            cuerpo = base64.b64decode(cuerpo).decode("utf-8")
        datos = json.loads(cuerpo)
        nota = {"id": str(uuid.uuid4()), "texto": str(datos.get("texto", ""))}
        tabla.put_item(Item=nota)
        return respuesta(201, nota)

    if metodo == "GET":
        notas = tabla.scan(Limit=25).get("Items", [])
        return respuesta(200, notas)

    return respuesta(405, {"error": "Método no permitido"})
EOF

zip -q funcion.zip lambda_function.py

FUNC_ARN=$(aws lambda create-function --function-name lab15-notas \
  --runtime python3.13 --architectures arm64 \
  --handler lambda_function.lambda_handler \
  --role "$ROL_ARN" --zip-file fileb://funcion.zip \
  --memory-size 256 --timeout 10 \
  --environment "Variables={TABLA=lab15-notas}" \
  --query FunctionArn --output text)

aws lambda wait function-active-v2 --function-name lab15-notas
echo "$FUNC_ARN"

Pruébala sin API Gateway, con un evento simulado:

cat > evento-get.json <<'EOF'
{ "requestContext": { "http": { "method": "GET" } } }
EOF
aws lambda invoke --function-name lab15-notas \
  --payload fileb://evento-get.json salida.json && cat salida.json; echo

Paso 4: la HTTP API (creación rápida)

La creación rápida (quick create) de una HTTP API crea la API, una integración con la función, la ruta $default y la etapa $default con despliegue automático:

read API_ID API_URL < <(aws apigatewayv2 create-api --name lab15-api \
  --protocol-type HTTP --target "$FUNC_ARN" \
  --query '[ApiId,ApiEndpoint]' --output text)
echo "API: $API_ID  URL: $API_URL"

API Gateway necesita permiso para invocar la función. Es una política de recurso en Lambda, limitada a esta API:

aws lambda add-permission --function-name lab15-notas \
  --statement-id apigw-lab15 --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:$AWS_REGION:$CUENTA:$API_ID/*"

Paso 5: usar la API

curl -s -X POST "$API_URL/notas" -H "Content-Type: application/json" \
  -d '{"texto":"Repasar SQS FIFO"}'; echo
curl -s -X POST "$API_URL/notas" -H "Content-Type: application/json" \
  -d '{"texto":"Lambda: 15 minutos como máximo"}'; echo
curl -s "$API_URL/notas" | jq .

Mira los logs de la función (el grupo /aws/lambda/lab15-notas lo crea Lambda al primer uso):

aws logs tail /aws/lambda/lab15-notas --since 10m

Busca las líneas REPORT: Duration, Billed Duration, Max Memory Used y, en la primera invocación, Init Duration: eso es el cold start. Las invocaciones siguientes (calientes) no lo tienen.

Paso 6: throttling en API Gateway

Limita la etapa a 1 petición por segundo con ráfaga de 2 y lanza 20 peticiones seguidas:

aws apigatewayv2 update-stage --api-id "$API_ID" --stage-name '$default' \
  --default-route-settings '{"ThrottlingBurstLimit":2,"ThrottlingRateLimit":1}'

sleep 5
for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code} " "$API_URL/notas"
done; echo

Verás algunos 200 y muchos 429 (Too Many Requests): API Gateway rechaza el exceso antes de invocar Lambda, protegiendo el backend. En producción, el cliente debe reintentar con backoff exponencial.

Quita el límite para seguir:

aws apigatewayv2 update-stage --api-id "$API_ID" --stage-name '$default' \
  --default-route-settings '{"ThrottlingBurstLimit":100,"ThrottlingRateLimit":50}'

Paso 7: concurrencia reservada

La concurrencia reservada garantiza y a la vez limita cuántas ejecuciones simultáneas puede tener la función. Ponla a 0 para ver el efecto extremo (la función deja de ejecutarse):

aws lambda put-function-concurrency --function-name lab15-notas \
  --reserved-concurrent-executions 0
sleep 5
curl -s -w "\nHTTP %{http_code}\n" "$API_URL/notas"

Ahora el error lo genera Lambda (throttling de la función) y API Gateway lo devuelve al cliente como un error del servidor. Vuelve a dejarla sin límite:

aws lambda delete-function-concurrency --function-name lab15-notas

En un caso real, un valor como 20 protegería una base de datos relacional con pocas conexiones.

Paso 8 (opcional): más memoria, menos duración

Cambia la memoria a 1.024 MB, invoca varias veces y compara Duration y Billed Duration en los logs con las de 256 MB. Como la CPU es proporcional a la memoria, las funciones con cálculo intensivo pueden salir más baratas con más memoria.

aws lambda update-function-configuration --function-name lab15-notas --memory-size 1024
aws lambda wait function-updated-v2 --function-name lab15-notas
for i in 1 2 3; do curl -s -o /dev/null "$API_URL/notas"; done
aws logs tail /aws/lambda/lab15-notas --since 2m | grep REPORT

Comprueba que funciona

  • curl -s "$API_URL/notas" devuelve las notas en JSON.
  • aws dynamodb scan --table-name lab15-notas --select COUNT coincide con las notas creadas.
  • Viste respuestas 429 con el throttling de la etapa.
  • En la consola de API Gateway, la HTTP API muestra la ruta $default integrada con la función; en la consola de Lambda, el disparador de API Gateway.

Limpieza

aws apigatewayv2 delete-api --api-id "$API_ID"
aws lambda delete-function --function-name lab15-notas
aws logs delete-log-group --log-group-name /aws/lambda/lab15-notas

aws iam delete-role-policy --role-name lab15-lambda-rol --policy-name lab15-dynamodb
aws iam detach-role-policy --role-name lab15-lambda-rol \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name lab15-lambda-rol

aws dynamodb delete-table --table-name lab15-notas
cd ~ && rm -rf ~/lab15

Preguntas para pensar como arquitecto

  1. Un cliente B2B necesita una clave de API con un límite de 10.000 peticiones al día, y otro cliente, 100.000. ¿Te sirve la HTTP API del lab?
Respuesta

No. Las API keys y los usage plans (cuotas y límites por cliente) solo existen en las REST APIs. Habría que usar una REST API con dos usage plans, cada uno asociado a la clave de su cliente. Recuerda que las API keys identifican y limitan, pero no autentican: combínalas con IAM, Cognito o un Lambda authorizer.

  1. La función tarda a veces 40 segundos porque llama a un sistema externo lento, y los clientes reciben 504. ¿Qué cambiarías?
Respuesta

El timeout de integración de una HTTP API es de 30 s como máximo (29 s en REST, ampliable solo en Regional/Private y a costa de throttling). Mejor desacoplar: la API recibe la petición, la deja en una cola SQS (o inicia una ejecución de Step Functions) y responde 202 Accepted con un identificador; un consumidor procesa en segundo plano y el cliente consulta el estado o recibe una notificación (WebSocket, SNS, correo).

  1. La API empieza a recibir 5.000 peticiones por segundo y la tabla de DynamoDB aguanta, pero Lambda devuelve errores de throttling. ¿Qué miras?
Respuesta

La concurrencia necesaria ≈ peticiones/s × duración media. Con 5.000 rps y 300 ms hacen falta unas 1.500 ejecuciones simultáneas, por encima de las 1.000 por defecto de la cuenta (y las cuentas nuevas pueden tener menos). Solicita un aumento de cuota en Service Quotas, reduce la duración (más memoria, código más eficiente) y revisa que ninguna otra función tenga reservada demasiada concurrencia. En eu-south-2, recuerda además que la cuota por defecto de API Gateway es de 2.500 rps.

  1. Los usuarios se quejan de que la primera petición tras un rato sin uso tarda más de un segundo. ¿Opciones?
Respuesta

Es un cold start. Opciones: provisioned concurrency en un alias (entornos ya inicializados; tiene coste), SnapStart si el runtime es Java, Python 3.12+ o .NET 8+ (en versiones publicadas), reducir el paquete y las dependencias, inicializar clientes fuera del handler y dar más memoria a la función para acelerar la inicialización.

  1. ¿Por qué la política del rol permite solo PutItem, Scan y GetItem sobre un ARN concreto y no dynamodb:* sobre *?
Respuesta

Por el principio de mínimo privilegio: si alguien explotara una vulnerabilidad del código, solo podría leer y escribir esa tabla, no borrarla ni tocar otras tablas de la cuenta. Además, las credenciales del rol son temporales y Lambda las rota automáticamente: no hay claves de acceso en el código.


Volver al módulo