StackPractices
advanced Por Mathias Paulenko

Arquitectura Serverless

Decide cuándo usar serverless y cuándo no. Cubre patrones FaaS, diseño event-driven, cold starts, modelos de costo, vendor lock-in y estrategias de migración para aplicaciones serverless en producción.

Introducción

Escalado infinito, cero mantenimiento, paga solo por lo que uses — así se vende serverless. Brilla en algunos workloads y se desarma en otros. Si la eliges por las razones equivocadas, te llegan facturas sorpresa, latencia de cold start y sesiones de debugging que nadie disfruta. Dedícale unos minutos y saldrás sabiendo si serverless encaja con tu carga de trabajo, además de qué hace falta para mantenerla corriendo cuando llegue a producción.

Qué Significa Realmente Serverless

Serverless no significa “sin servidores.” Significa que no los gestionas tú. Provisioning, escalado, patching, disponibilidad — eso ahora es trabajo del proveedor. Tu trabajo se reduce a escribir funciones y conectar triggers.

Tradicional:    Tu gestionas VMs → Tu deployas app → Tu escalas → Tu patcheas
Contenedor:     Tu builds images → Orchestrator deploya → Tu configuras escalado
Serverless:     Tu escribes funciones → Provider maneja todo lo demas

Características Clave

  • Sin gestión de servidores: el proveedor parchea el OS y aprovisiona la capacidad
  • Auto-escalado: pasa de 0 a miles de ejecuciones concurrentes por su cuenta
  • Pay-per-use: se factura por tiempo de ejecución y memoria, nunca por tiempo idle
  • Event-driven: las funciones se disparan con HTTP requests, mensajes de cola, timers o eventos cloud
  • Stateless: cada invocación es independiente — nada sobrevive en memoria entre llamadas

Cuándo Ir Serverless

Buen Fit: Workloads Event-Driven

# AWS Lambda triggerada por S3 upload
import json
import boto3

def lambda_handler(event, context):
    for record in event["Records"]:
        bucket = record["s3"]["bucket"]["name"]
        key = record["s3"]["object"]["key"]
        
        # Procesar archivo subido
        process_file(bucket, key)
    
    return {"statusCode": 200, "body": json.dumps({"processed": len(event["Records"])})}

Serverless brilla para workloads que son:

  • Intermitentes: El tráfico sube y baja impredeciblemente
  • Event-driven: Triggerados por uploads, messages, o webhooks
  • Short-lived: Cada ejecución se completa en segundos, no horas
  • Stateless: Sin necesidad de conexiones long-running o estado en memoria

Buen Fit: HTTP APIs con Tráfico Variable

# API Gateway + Lambda para endpoints REST
def lambda_handler(event, context):
    http_method = event["httpMethod"]
    path = event["path"]
    
    if path == "/users" and http_method == "GET":
        users = db.users.list(limit=100)
        return {"statusCode": 200, "body": json.dumps(users)}
    
    if path == "/users" and http_method == "POST":
        body = json.loads(event["body"])
        user = db.users.create(body)
        return {"statusCode": 201, "body": json.dumps(user)}
    
    return {"statusCode": 404, "body": json.dumps({"error": "Not found"})}

Buen Fit: Tareas Programadas

# CloudWatch Events + Lambda para cron jobs
def lambda_handler(event, context):
    # Ejecutar limpieza nocturna
    delete_expired_sessions()
    send_daily_reports()
    update_aggregates()
    
    return {"status": "complete"}

Buen Fit: Stream Processing

# Lambda procesando DynamoDB streams
def lambda_handler(event, context):
    for record in event["Records"]:
        if record["eventName"] == "INSERT":
            new_item = record["dynamodb"]["NewImage"]
            # Enviar email de bienvenida para nuevos usuarios
            if new_item.get("type", {}).get("S") == "user":
                send_welcome_email(new_item["email"]["S"])
    
    return {"status": "processed"}

Cuándo NO Ir Serverless

Mal Fit: Tareas Long-Running

AWS Lambda tiene un timeout de 15 minutos. Todo lo que dure más — video encoding, procesamiento de datos pesado — pertenece a contenedores o VMs.

Mal Fit: Aplicaciones en Tiempo Real

WebSockets, streaming y long-polling necesitan conexiones que se mantienen abiertas. Las funciones son stateless por diseño y viven segundos como mucho. Servidores dedicados o un servicio WebSocket gestionado encajan mejor.

Mal Fit: High-Performance Computing

Lambda llega hasta 10GB de RAM, y la CPU que obtienes escala con la memoria que compras. ML training, simulaciones, cualquier cosa intensiva en CPU — ve por instancias GPU o un cluster HPC.

Mal Fit: Tráfico Constante y Predecible

Con tráfico estable 24/7, serverless sale más caro que instancias reservadas. Contenedores con autoscaling entregan la misma elasticidad por menos.

Mal Fit: Workflows Stateful Complejos

Las funciones serverless son stateless. Los shopping carts o los formularios multi-paso necesitan estado en algún lado — y ese “algún lado” es una base de datos o Redis, más la latencia y complejidad que trae consigo.

Patrones de Diseño Serverless

Patrones de Trigger

La mayoría de las funciones serverless encajan en una de cinco formas de trigger. Elegir la correcta determina cómo escala la función y cómo se manifiestan los fallos.

PatrónCaso de usoEjemplo
Función por endpoint HTTPAPIs RESTAPI Gateway → Lambda
Función event-drivenProcesamiento asíncronoSubida a S3 → generador de thumbnails
Función programadaJobs cronCloudWatch Events → reporte nocturno
Función disparada por colaTrabajos desacopladosSQS → procesador de órdenes
Función disparada por streamDatos en tiempo realDynamoDB Streams → actualizador de caché

Composición de Funciones

Divide operaciones complejas en funciones más pequeñas que se encadenan.

# Step Functions state machine para procesamiento de ordenes
{
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:validate-order",
      "Next": "ChargePayment"
    },
    "ChargePayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:charge-payment",
      "Next": "CheckResult"
    },
    "CheckResult": {
      "Type": "Choice",
      "Choices": [
        {"Variable": "$.paymentStatus", "StringEquals": "success", "Next": "ShipOrder"},
        {"Variable": "$.paymentStatus", "StringEquals": "failed", "Next": "NotifyFailure"}
      ]
    },
    "ShipOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:ship-order",
      "End": true
    },
    "NotifyFailure": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:notify-failure",
      "End": true
    }
  }
}

Fan-Out / Fan-In

Una función triggera múltiples funciones paralelas, luego agrega resultados.

import boto3
import json
from concurrent.futures import ThreadPoolExecutor

lambda_client = boto3.client("lambda")

def fan_out_handler(event, context):
    items = event["items"]
    
    # Invocar processor para cada item en paralelo
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = []
        for item in items:
            futures.append(
                executor.submit(
                    lambda_client.invoke,
                    FunctionName="process-item",
                    Payload=json.dumps({"item": item})
                )
            )
        
        results = [json.loads(f.result()["Payload"].read()) for f in futures]
    
    # Agregar resultados
    return {"processed": len(results), "results": results}

Event Sourcing

Trata los eventos almacenados como la fuente de verdad, y deja que las funciones los proyecten en read models.

# Funcion 1: Guardar event
def save_event_handler(event, context):
    event_store.append({
        "aggregate_id": event["aggregateId"],
        "event_type": event["type"],
        "data": event["data"],
        "timestamp": event["timestamp"]
    })
    return {"status": "saved"}

# Funcion 2: Proyectar a read model (triggerada por event store)
def project_handler(event, context):
    for record in event["Records"]:
        event_data = json.loads(record["body"])
        
        if event_data["event_type"] == "UserCreated":
            db.users.upsert({
                "id": event_data["aggregate_id"],
                "name": event_data["data"]["name"],
                "email": event_data["data"]["email"]
            })

Sagas y Transacciones de Compensación

Las funciones no pueden mantener transacciones ACID distribuidas entre servicios. Cuando una operación de negocio abarca varias funciones, usa el patrón saga: cada función ejecuta su paso y publica un evento, y si un paso falla, una función de compensación deshace el trabajo anterior. Step Functions expresa la compensación con bloques Catch que enrutan los fallos a un estado de rollback.

# Saga de procesamiento de órdenes — un fallo de pago dispara la compensación
Comment: Saga de procesamiento de órdenes
StartAt: ValidateOrder
States:
  ValidateOrder:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:validate-order
    Next: ProcessPayment
  ProcessPayment:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:process-payment
    Catch:
      - ErrorEquals: ["PaymentFailed"]
        ResultPath: "$.error"
        Next: RefundOrder
    Next: NotifySuccess
  RefundOrder:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:refund-order
    End: true
  NotifySuccess:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:notify-success
    End: true

Para consistencia dentro de un solo servicio, el patrón outbox es más simple: la función escribe en la base de datos y registra el evento en la misma transacción, y luego un proceso separado lee la tabla outbox y publica los eventos.

Cold Starts

Un cold start ocurre cuando nadie ha llamado a la función recientemente: el proveedor tiene que levantar un contenedor nuevo y cargar el runtime antes de que tu código pueda ejecutarse — lo que cuesta entre 1 y 10 segundos de entrada.

Factores de Cold Start

FactorImpactoMitigacion
RuntimeJava > .NET > Python > Node.js > GoElegir runtimes de inicio rápido (Go, Node.js)
Tamaño de paqueteMás grande = cold start más lentoMinimizar dependencias
Configuracion VPCVPC añade 1-2s cold startUsar VPC-less si es posible
Asignación de memoriaMás memoria = inicio más rápidoAsignar 512MB+ para funciones CPU-heavy
Provisioned concurrencyElimina cold startPagar por instancias always-warm

Mitigando Cold Starts

# Lazy initialization: cargar dependencias pesadas fuera del handler
import boto3
import json

# Estos cargan solo en cold start
s3 = boto3.client("s3")
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("users")

def lambda_handler(event, context):
    # Handler es rapido — dependencias ya cargadas
    user_id = event["pathParameters"]["userId"]
    response = table.get_item(Key={"id": user_id})
    
    return {
        "statusCode": 200,
        "body": json.dumps(response.get("Item", {}))
    }

Provisioned Concurrency

Los endpoints sensibles a latencia pueden pagar provisioned concurrency, que mantiene instancias warm.

# AWS CLI: asignar provisioned concurrency
aws lambda put-provisioned-concurrency-config \
  --function-name my-api \
  --qualifier live \
  --provisioned-concurrent-executions 10

Otras opciones que conviene conocer: los pings keep-alive programados eliminan el cold start en funciones invocadas raramente a un costo casi nulo, y compilar a código nativo (Go, Rust, o AOT de .NET) recorta el tiempo de inicialización frente a runtimes interpretados. Para un recorrido práctico, consulta Optimizar Cold Starts en AWS Lambda.

Modelo de Costo

Cómo Funciona el Pricing Serverless

Costo = (Numero de requests) × (Duracion de ejecucion en ms) × (Memoria asignada) × (Precio por GB-ms)

Pricing de AWS Lambda (ejemplo):

  • $0.20 por 1 millón de requests
  • $0.0000166667 por GB-segundo
  • Free tier: 1 millón de requests + 400,000 GB-segundos por mes

Ejemplo de Comparación de Costo

# Escenario: 10 millones de requests/mes, 100ms cada uno, 512MB memoria

# Serverless (Lambda)
requests_cost = 10_000_000 / 1_000_000 * 0.20  # $2.00
compute_cost = 10_000_000 * 0.1 * 0.5 / 1024 * 0.0000166667 * 1000  # ~$8.13
total_serverless = requests_cost + compute_cost  # ~$10.13/mes

# Contenedor (ECS Fargate: 0.5 vCPU, 1GB RAM, always running)
container_cost = 730 * (0.025 * 0.5 + 0.003 * 1)  # ~$10.22/mes

# Break-even: ~10M requests/mes a 100ms cada uno
# Menos de 10M: serverless es mas barato
# Más de 10M: contenedor es más barato

Tips de Optimización de Costo

  • Right-size memory: Más memoria puede reducir el tiempo de ejecución, bajando el costo total
  • Reducir tiempo de ejecución: Optimizar código para terminar más rápido
  • Usar caching: Cachear computaciones costosas en Redis o DynamoDB DAX
  • Evitar over-provisioning: Empezar con 128MB y aumentar solo si es necesario
  • Monitorear costos: Setear billing alerts para detectar spikes inesperados

Vendor Lock-In

Las plataformas serverless difieren más de lo que parece. Mover de Lambda a Cloud Functions o Azure Functions implica reescribir handlers, triggers y configuración de infraestructura.

Factores de Lock-In

FactorAWS LambdaGoogle Cloud FunctionsAzure Functions
Handler signaturelambda_handler(event, context)handler(request)main(req: func.HttpRequest)
TriggersAPI Gateway, S3, SQS, DynamoDBHTTP, Pub/Sub, Cloud StorageHTTP, Blob, Queue, Event Grid
Infra as CodeCloudFormation, SAM, CDKCloud Deployment ManagerARM, Bicep
Gestión de estadoStep FunctionsWorkflowsDurable Functions

Reduciendo Lock-In

# Abstraer el handler de la logica de negocio
def process_order(order_data: dict) -> dict:
    # Logica de negocio pura — sin codigo cloud-specific
    validate(order_data)
    charge_payment(order_data)
    ship_order(order_data)
    return {"status": "complete"}

# AWS Lambda handler
def lambda_handler(event, context):
    order_data = json.loads(event["body"])
    result = process_order(order_data)
    return {"statusCode": 200, "body": json.dumps(result)}

# Google Cloud Functions handler
def handler(request):
    order_data = request.get_json()
    result = process_order(order_data)
    return json.dumps(result)

Estrategia de Migración

Patrón Strangler Fig

Reemplaza gradualmente un monolito con funciones serverless. Enruta el tráfico hacia serverless primero para las features nuevas, y luego reemplaza progresivamente la funcionalidad existente — consulta el patrón strangler fig para la mecánica completa de migración.

# API Gateway: rutar nuevos endpoints a Lambda, viejos a monolito
{
  "routes": [
    {"path": "/api/v2/users", "target": "lambda:users-v2"},
    {"path": "/api/v1/*", "target": "http://monolith.example.com"}
  ]
}

Pasos de Migración

  1. Identificar candidatos: elige endpoints intermitentes, event-driven o stateless
  2. Empezar pequeño: mueve un endpoint a serverless y obsérvalo
  3. Testear exhaustivamente: verifica performance, costo y confiabilidad antes de seguir
  4. Rutar tráfico: deja que API Gateway divida los requests entre lo viejo y lo nuevo
  5. Monitorear: compara métricas en ambos lados
  6. Expandir: migra más endpoints a medida que crece la confianza
  7. Descommissionar: apaga la infraestructura vieja cuando la migración aterrice

Ejemplo Resuelto: Pipeline de Procesamiento de Imágenes

La teoría solo llega hasta cierto punto. Aquí va un pipeline event-driven completo — subes una imagen a S3 y obtienes tres versiones redimensionadas más un registro de metadata — con la infraestructura, el monitoreo y los números de costo detrás.

flowchart diagram: Subida del usuario

Paso 1 — Subida. Alguien deja una imagen en uploads/originals/ y el evento S3:ObjectCreated dispara la función de resize.

Paso 2 — Resize. La función lee la imagen, genera las versiones thumbnail (100×100), medium (500×500) y large (1200×1200) en uploads/resized/, y luego deja un mensaje en image-processed-queue con las claves generadas. Necesita 1024 MB de memoria y un timeout de 30 segundos — las imágenes grandes tardan 10–15 segundos en procesarse.

Paso 3 — Metadata. Una segunda Lambda consume la cola, extrae dimensiones, formato, tamaño y hash, y los escribe en una tabla ImageMetadata (PK imageId, SK versión). El mensaje se elimina al procesar correctamente; tras tres recepciones fallidas termina en image-processed-dlq.

La infraestructura en Terraform:

resource "aws_lambda_function" "resize" {
  function_name = "image-resize"
  handler       = "handler.lambda_handler"
  runtime       = "python3.11"
  memory_size   = 1024
  timeout       = 30

  environment {
    variables = {
      OUTPUT_BUCKET = "uploads-resized"
      QUEUE_URL     = aws_sqs_queue.image_processed.id
    }
  }
}

resource "aws_sqs_queue" "image_processed" {
  name               = "image-processed-queue"
  visibility_timeout = 60 # debe superar el timeout de la Lambda

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.dlq.arn
    maxReceiveCount     = 3
  })
}

Ojo con el visibility_timeout de 60 segundos — tiene que superar el timeout de la Lambda, o el mensaje puede reaparecer en la cola mientras la función todavía lo está procesando y terminar manejado dos veces.

Monitoreo. Alarmas de CloudWatch sobre tasa de errores superior al 1% y duración p95 por encima de 20 segundos, una notificación de SNS a Slack cuando algo cae en la DLQ, y tracing con X-Ray para desglosar la latencia por paso.

Fallos parciales de batch. Cuando la Lambda de metadata lee un batch de mensajes SQS y uno envenena el lote, devolver un error genérico hace que SQS reintente todos los mensajes — incluidos los que se procesaron bien. Devuelve batchItemFailures solo con los IDs de los mensajes fallidos, para que el resto se confirme y se elimine.

Costo a 10k imágenes por día: alrededor de $19/mes — unos $15 por Lambda (1.5M de invocaciones), $2 por almacenamiento S3, $0.40 por SQS y $1.25 por DynamoDB on-demand.

Observabilidad

Serverless es donde el debugging viene a sufrir: no hay SSH a una función ni debugger que attachar.

Logging Estructurado

import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    request_id = context.aws_request_id
    
    logger.info(json.dumps({
        "request_id": request_id,
        "event": "function_start",
        "user_id": event.get("user_id"),
        "action": event.get("action")
    }))
    
    try:
        result = process_request(event)
        logger.info(json.dumps({
            "request_id": request_id,
            "event": "function_success",
            "duration_ms": 150
        }))
        return result
    except Exception as e:
        logger.error(json.dumps({
            "request_id": request_id,
            "event": "function_error",
            "error": str(e)
        }))
        raise

Distributed Tracing

Herramientas de tracing como X-Ray u OpenTelemetry siguen un request mientras salta entre funciones, y capturan automáticamente las llamadas al SDK de AWS en el camino.

from aws_xray_sdk.core import patch_all
import boto3

patch_all()

def lambda_handler(event, context):
    # X-Ray automaticamente tracea esta funcion
    # y cualquier AWS SDK call que haga
    dynamodb = boto3.resource("dynamodb")
    table = dynamodb.Table("users")
    response = table.get_item(Key={"id": event["user_id"]})
    return response.get("Item")

Seguridad

IAM Least Privilege

Dale a cada función solo los permisos que realmente necesita.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:123:table/users"
    }
  ]
}

Variables de Entorno para Secrets

No hardcodees secrets — extráelos de Secrets Manager o Parameter Store mediante variables de entorno.

import os
import boto3

def get_secret():
    secret_name = os.environ["DB_SECRET_NAME"]
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

Errores Comunes

  • Lambda monolítica: meter toda una aplicación en una sola función. Divídela en funciones de propósito único para que los fallos y el escalado queden aislados.
  • Espera síncrona: llamar servicios downstream lentos de forma síncrona dentro de una función. Pagas por tiempo de espera inactivo — usa colas o step functions.
  • Ignorar los límites de timeout: Lambda tiene un tope de 15 minutos y Azure Functions de 10. Los trabajos más largos pertenecen a ECS, Batch o Fargate.
  • Tratar las funciones como servidores: escribir estado en memoria o disco local. Lo que escribas ahí desaparece cuando el contenedor se recicla — persiste externamente.
  • Sin estrategia de reintentos: las invocaciones asíncronas que fallan en silencio pierden trabajo. Adjunta siempre una cola de mensajes muertos y una alarma.
  • Memoria sobreaprovisionada: en Lambda la memoria también controla la CPU asignada, así que subirla a ciegas puede ayudar — pero mídelo, porque también sube el precio por milisegundo.

Checklist de Producción

  • Funciones son stateless e idempotent
  • Mitigacion de cold start (provisioned concurrency o lazy init)
  • Asignacion de memoria right-sized
  • Timeout seteado apropiadamente (no el max por defecto)
  • Roles IAM siguen least privilege
  • Secrets en Secrets Manager o Parameter Store
  • Logging estructurado con request IDs
  • Distributed tracing habilitado
  • Dead letter queue para invocaciones async fallidas
  • Estrategia de retry configurada
  • Monitoreo de costo y billing alerts
  • Deployment via Infrastructure as Code (SAM, CDK, Serverless Framework)

See Also

Preguntas frecuentes

¿Es serverless más barato que contenedores?

Depende de la forma de tu tráfico. El tráfico intermitente o con picos favorece a serverless porque nunca pagas por tiempo idle; el tráfico alto y estable favorece a los contenedores, donde la capacidad reservada sale más barata. El break-even suele caer alrededor del 60–70% de utilización sostenida.

¿Cómo manejo tareas long-running en serverless?

Divídelas en piezas más pequeñas con Step Functions o un patrón basado en cola, de modo que cada función quede bajo el límite de timeout. Para trabajos realmente largos medidos en horas, AWS Batch o Fargate es la herramienta correcta.

¿Puedo usar serverless para websockets?

No directamente. La WebSocket API de API Gateway puede mantener la conexión abierta mientras Lambda procesa cada mensaje; opciones gestionadas como AppSync subscriptions o Pusher también sirven.

¿Cómo testeo funciones serverless localmente?

Para un runtime de Lambda local, sam local invoke de la SAM CLI o serverless invoke local emulan el entorno de ejecución. Para integration tests, LocalStack emula los servicios AWS con los que habla tu función.

¿Qué es provisioned concurrency?

Provisioned concurrency precalienta un número fijo de instancias para que respondan al instante. Pagas por esa capacidad se use o no — vale la pena en endpoints sensibles a latencia donde un cold start es inaceptable.

¿Cómo manejo conexiones de base de datos en serverless?

Cada invocación puede abrir una conexión nueva a la base de datos, lo que agota el pool rápido. Pon un proxy delante — Amazon RDS Proxy o pgBouncer. O evita el pooling del todo: DynamoDB no tiene pool de conexiones que agotar, y Aurora Serverless escala las conexiones por su cuenta.