Arquitectura Serverless: Cuándo Tiene Sentido (Y Cuándo Realmente No)
Resumen
Serverless es fenomenal para cargas de trabajo event-driven, con tráfico variable, sin estado y de corta duración — y absolutamente terrible para procesos de larga ejecución, lógica con estado e inferencia de ML. El punto óptimo es un enfoque híbrido: serverless en los bordes (handlers de API, webhooks, cron jobs, procesamiento de archivos) y contenedores en el núcleo (lógica de negocio, pipelines de datos, cualquier cosa que dure más de 30 segundos). Haz las cuentas antes de comprometerte — una factura de $47k en Lambda es más fácil de acumular de lo que crees.
Déjame contarte sobre la función Lambda más cara que he visto en mi vida. Un equipo al que estaba consultando decidió desplegar un modelo de machine learning — un clasificador de imágenes en PyTorch, de aproximadamente 1.2 GB con dependencias — como una función AWS Lambda. Su razonamiento? "Somos un equipo serverless. Todo va en Lambda."
El modelo tardaba 8-12 segundos por inferencia. Cada invocación asignaba 3 GB de memoria. Estaban procesando imágenes de productos de un catálogo de e-commerce — unas 50,000 imágenes por día, con picos durante las cargas de nuevo inventario.
La factura de Lambda del primer mes: $47,000.
Para dar contexto, la misma carga de trabajo ejecutándose en dos instancias g4dn.xlarge con GPU habría costado unos $1,100/mes. Estaban pagando 42 veces más por peor rendimiento y mayor latencia. Los cold starts solos añadían 15-20 segundos para la primera solicitud después de períodos de inactividad, porque Lambda tenía que cargar una imagen de contenedor de 1.2 GB cada vez.
Los ayudé a migrar la inferencia a ECS Fargate con soporte de GPU en aproximadamente una semana. Su factura bajó a $1,400/mes (un poco más que EC2 directo porque Fargate tiene overhead, pero la simplicidad operacional lo valía). El CTO me dijo que fue el engagement de consultoría con mayor ROI que habían tenido, lo cual dice más sobre la decisión original que sobre mis habilidades.
Esta historia no es sobre Lambda siendo malo. Lambda es una pieza increíble de tecnología. Esta historia es sobre usar la herramienta correcta para el trabajo — y serverless es una herramienta muy específica para trabajos muy específicos.
Qué Significa Realmente Serverless
Aclaremos algo de confusión, porque "serverless" es uno de los términos más sobrecargados de nuestra industria.
┌─────────────────────────────────────────────────────────────────┐
│ El Espectro Serverless │
├─────────────────────────────────────────────────────────────────┤
│ │
│ "Serverless" en la práctica significa: │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ FaaS │ │ Colas │ │ Bases de │ │ Auth │ │
│ │ │ │ Managed │ │ Datos │ │ Managed │ │
│ │ Lambda │ │ SQS/SNS │ │ Managed │ │ Cognito │ │
│ │ Cloud │ │ EventBr. │ │ DynamoDB │ │ Auth0 │ │
│ │ Functions│ │ │ │ Aurora S.│ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Lo que SÍ es: Lo que NO es: │
│ │
│ ✓ Sin servidores que gestionar ✗ No existen servidores │
│ ✓ Pago por ejecución ✗ Siempre más barato │
│ ✓ Auto-escala a cero ✗ Infinitamente escalable │
│ ✓ Orientado a eventos ✗ Bueno para todo │
│ ✓ Infraestructura gestionada ✗ Cero overhead operacional │
│ │
└─────────────────────────────────────────────────────────────────┘
La idea clave: serverless no significa que no haya servidores. Significa que los servidores son problema de otro. Estás intercambiando control por conveniencia, y ese trade-off tiene implicaciones reales para costo, rendimiento y flexibilidad.
El Cambio de Mentalidad Serverless
Serverless no es solo un modelo de despliegue — es un patrón de arquitectura. No simplemente "metes tu app en Lambda." Reestructuras tu aplicación alrededor de eventos, funciones sin estado y servicios gestionados. Si estás intentando hacer que un servidor web tradicional funcione en Lambda, la vas a pasar mal.
El Punto Óptimo: Cuándo Serverless Gana
Después de trabajar con serverless en producción en más de una docena de proyectos, he identificado las cargas de trabajo donde genuinamente brilla. El hilo común es: event-driven, tráfico variable, sin estado y de corta duración.
1. Handlers de Webhooks
Esta es la aplicación asesina de serverless. Los webhooks son inherentemente event-driven, impredecibles en volumen, y necesitan responder rápido. Podrías recibir cero webhooks durante una hora, y luego 10,000 en un minuto cuando alguien hace una operación masiva.
// webhook-handler/index.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { verifyWebhookSignature } from "./utils/crypto";
import { processStripeEvent } from "./processors/stripe";
import { processGithubEvent } from "./processors/github";
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const source = event.pathParameters?.source;
const body = JSON.parse(event.body || "{}");
const signature = event.headers["x-webhook-signature"] || "";
// Verificar autenticidad del webhook
if (!verifyWebhookSignature(body, signature, source)) {
return { statusCode: 401, body: JSON.stringify({ error: "Firma inválida" }) };
}
try {
switch (source) {
case "stripe":
await processStripeEvent(body);
break;
case "github":
await processGithubEvent(body);
break;
default:
return { statusCode: 400, body: JSON.stringify({ error: `Fuente desconocida: ${source}` }) };
}
return { statusCode: 200, body: JSON.stringify({ received: true }) };
} catch (error) {
console.error("Procesamiento de webhook falló:", error);
// Retornar 200 de todas formas para prevenir reintentos — manejaremos via DLQ
return { statusCode: 200, body: JSON.stringify({ received: true, queued: true }) };
}
};Siempre Retorna 200 para Webhooks
Retorna 200 incluso si el procesamiento falla, luego enruta los fallos a una dead letter queue. La mayoría de proveedores de webhooks reintentarán en respuestas no-2xx, lo cual puede crear problemas de thundering herd cuando tu servicio downstream ya está luchando. Acepta el evento, confirma recepción y maneja los fallos de forma asíncrona.
2. Triggers de Procesamiento de Archivos
Los triggers de eventos de S3 son otra área donde serverless es genuinamente la mejor opción. Cuando un archivo llega a un bucket, un Lambda se dispara. Sin polling, sin servidores ociosos esperando archivos.
// image-processor/index.ts
import { S3Event } from "aws-lambda";
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import sharp from "sharp";
const s3 = new S3Client({});
interface ThumbnailSize {
suffix: string;
width: number;
height: number;
}
const SIZES: ThumbnailSize[] = [
{ suffix: "thumb", width: 150, height: 150 },
{ suffix: "medium", width: 600, height: 600 },
{ suffix: "large", width: 1200, height: 1200 },
];
export const handler = async (event: S3Event): Promise<void> => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
// Saltar si ya es una imagen procesada
if (key.includes("/processed/")) continue;
console.log(`Procesando: ${bucket}/${key}`);
const { Body } = await s3.send(
new GetObjectCommand({ Bucket: bucket, Key: key })
);
const imageBuffer = Buffer.from(await Body!.transformToByteArray());
// Generar todos los tamaños de thumbnails en paralelo
await Promise.all(
SIZES.map(async (size) => {
const resized = await sharp(imageBuffer)
.resize(size.width, size.height, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer();
const outputKey = key.replace(
/^uploads\//,
`processed/${size.suffix}/`
).replace(/\.[^.]+$/, ".webp");
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: outputKey,
Body: resized,
ContentType: "image/webp",
})
);
})
);
console.log(`Generados ${SIZES.length} thumbnails para ${key}`);
}
};3. Tareas Programadas (Cron Jobs)
Correr un servidor 24/7 solo para ejecutar una tarea cada 6 horas? Eso es literalmente para lo que se diseñó EventBridge + Lambda.
// daily-report/index.ts
import { ScheduledEvent } from "aws-lambda";
import { getActiveUsers, getRevenueMetrics, getSystemHealth } from "./metrics";
import { sendSlackReport } from "./notifications";
export const handler = async (event: ScheduledEvent): Promise<void> => {
console.log("Generando reporte diario:", event.time);
const [users, revenue, health] = await Promise.all([
getActiveUsers({ period: "24h" }),
getRevenueMetrics({ period: "24h" }),
getSystemHealth(),
]);
const report = {
date: event.time,
activeUsers: users.count,
newSignups: users.newSignups,
mrr: revenue.mrr,
churnRate: revenue.churn,
errorRate: health.errorRate,
p99Latency: health.p99,
alerts: health.activeAlerts,
};
await sendSlackReport(report);
console.log("Reporte diario enviado exitosamente");
};Cold Starts: La Realidad en 2026
Los cold starts han sido la queja #1 sobre serverless desde que Lambda se lanzó. Veamos dónde están las cosas realmente en 2026 con benchmarks reales.
┌─────────────────────────────────────────────────────────────────┐
│ Benchmarks de Cold Start (2026) │
│ 128-512 MB Memoria | us-east-1 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Runtime P50 P95 P99 │
│ ───────────────────────────────────────────────────────────── │
│ Node.js 22 130ms 280ms 410ms │
│ Python 3.12 140ms 310ms 450ms │
│ Go (AL2023) 35ms 80ms 120ms │
│ Rust (AL2023) 30ms 75ms 110ms │
│ Java 21 (SnapStart) 250ms 520ms 800ms │
│ .NET 8 (Native AOT) 210ms 380ms 550ms │
│ │
│ Con Provisioned 0ms* 0ms* 0ms* │
│ Concurrency (* más ~$15/mes por unidad provisionada) │
│ │
│ Imagen de 800ms 2,100ms 3,500ms │
│ Contenedor (1 GB) │
│ │
└─────────────────────────────────────────────────────────────────┘
Algo de contexto para estos números:
Node.js y Python se han vuelto notablemente rápidos. Un cold start de 130ms en un Lambda de API típico es efectivamente invisible para los usuarios finales. Si tu respuesta de API tarda 200-500ms de todas formas, unos 130ms extra en la primera solicitud después de inactividad están bien. La mayoría de usuarios no lo notarán.
Go y Rust son increíblemente rápidos. Si los cold starts son una preocupación y puedes escribir Go o Rust, tu problema de cold starts esencialmente no existe. Un cold start de 35ms es más rápido que la mayoría de round trips de red.
Java con SnapStart fue un cambio radical. Antes de SnapStart, los cold starts de Java eran de 3-8 segundos — genuinamente inutilizable para cargas de trabajo API. SnapStart toma un snapshot de la JVM inicializada y la restaura, cortando los cold starts en 80-90%.
Imágenes de contenedor siguen siendo lentas si son grandes. Si estás usando Lambda con una imagen de contenedor de 1 GB (lo cual podrías necesitar para ML o dependencias pesadas), los cold starts siguen siendo dolorosos. Ahí es donde comenzó la historia de los $47k.
Trampas de Cold Start
La asignación de memoria afecta la duración del cold start. Un Lambda con 128 MB obtiene menos CPU y arranca más lento. Subir a 512 MB o 1 GB puede realmente reducir los cold starts (y el tiempo total de ejecución) lo suficiente para ser más barato en general. Siempre haz benchmarks — la configuración de memoria más barata raramente es el costo total más barato.
Minimizando Cold Starts en la Práctica
// BUENO: Carga lazy de dependencias pesadas
// Solo importa lo que necesitas, cuando lo necesitas
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
let dynamoClient: DynamoDBClient | undefined;
function getDynamoClient(): DynamoDBClient {
if (!dynamoClient) {
// Este import solo ocurre en la primera invocación
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
dynamoClient = new DynamoDBClient({});
}
return dynamoClient;
}
// MALO: Importar todo al nivel superior
// import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
// import { S3Client } from "@aws-sdk/client-s3";
// import { SESClient } from "@aws-sdk/client-ses";
// import { SNSClient } from "@aws-sdk/client-sns";
// Incluso si este handler solo usa DynamoDB, TODOS se cargan
export const handler = async (event: any) => {
const client = getDynamoClient();
// ... lógica del handler
};Patrones Serverless que Funcionan
Después de años construyendo con serverless, estos son los patrones que uso repetidamente. Están probados en batalla y escalan.
Patrón 1: API Gateway + Lambda + DynamoDB
El pan de cada día. Este stack maneja una cantidad sorprendente de casos de uso.
┌─────────────────────────────────────────────────────────────────┐
│ El Stack de API Serverless │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Cliente │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ API Gateway │ ← Auth, throttling, validación de requests │
│ │ (HTTP API) │ │
│ └──────┬───────┘ │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────┐ │
│ │GET / │ │POST /│ ← Lambda individual por ruta │
│ │users │ │users │ (o handler compartido con routing) │
│ └──┬───┘ └──┬───┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ │
│ │ DynamoDB │ ← Latencia de milisegundos │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
// api/users/get.ts
import { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.USERS_TABLE!;
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const userId = event.pathParameters?.id;
if (!userId) {
return { statusCode: 400, body: JSON.stringify({ error: "Falta el ID de usuario" }) };
}
const result = await ddb.send(
new GetCommand({ TableName: TABLE, Key: { pk: `USER#${userId}`, sk: "PROFILE" } })
);
if (!result.Item) {
return { statusCode: 404, body: JSON.stringify({ error: "Usuario no encontrado" }) };
}
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result.Item),
};
};Patrón 2: SQS + Lambda (Procesamiento Asíncrono)
Cuando necesitas desacoplar la solicitud del procesamiento. Este es el patrón "acepta y procesa después".
// queue-processor/index.ts
import { SQSEvent, SQSBatchResponse } from "aws-lambda";
interface OrderEvent {
orderId: string;
userId: string;
items: Array<{ productId: string; quantity: number }>;
total: number;
}
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const batchItemFailures: SQSBatchResponse["batchItemFailures"] = [];
for (const record of event.Records) {
try {
const order: OrderEvent = JSON.parse(record.body);
await processOrder(order);
console.log(`Orden procesada ${order.orderId}`);
} catch (error) {
console.error(`Error procesando registro ${record.messageId}:`, error);
// Reportar fallo individual — SQS solo reintentará este mensaje
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
// Respuesta de batch parcial — solo los items fallidos vuelven a la cola
return { batchItemFailures };
};
async function processOrder(order: OrderEvent): Promise<void> {
// Validar inventario, cobrar pago, enviar confirmación...
// Cada paso podría ser su propio Lambda en un workflow de Step Functions
}Las Respuestas de Batch Parciales Son Esenciales
Siempre usa ReportBatchItemFailures con SQS + Lambda. Sin esto, si un mensaje en un batch de 10 falla, TODOS los 10 se reintentan. Con esto, solo el mensaje fallido se reintenta. He visto equipos quemar su presupuesto de SQS porque no sabían que esta funcionalidad existía.
Patrón 3: Step Functions para Orquestación
Cuando tu workflow tiene múltiples pasos, lógica de ramificación, o necesidades de manejo de errores, Step Functions es el camino. No intentes orquestar workflows complejos dentro de un solo Lambda.
┌─────────────────────────────────────────────────────────────────┐
│ Step Function de Procesamiento de Pedidos │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ Validar Pedido │ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ ¿Válido? │ │
│ └─┬───────┬─┘ │
│ Sí │ │No │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────────┐ │
│ │ Reservar│ │Notificar │ │
│ │Inventar.│ │ (Inválido) │ │
│ └────┬────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │Procesar Pago │───── Fallo ──▶ ┌──────────────┐ │
│ └──────┬───────┘ │Liberar Stock │ │
│ │ Éxito │ + Notificar │ │
│ ▼ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Enviar │ │
│ │ Confirmación│ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Anti-Patrones Serverless: Cuándo Realmente No Funciona
Ahora la parte que podría salvarte de una factura de $47k. Estas son cargas de trabajo que parecen que podrían ser serverless pero absolutamente no deberían serlo.
Anti-Patrón 1: Procesos de Larga Ejecución
Lambda tiene un timeout de 15 minutos. Si tu proceso podría tardar más que eso, Lambda es la elección incorrecta. Incluso si normalmente termina en 5 minutos, alcanzar el timeout en el 2% de las invocaciones significa que el 2% de tus operaciones fallan silenciosamente.
┌─────────────────────────────────────────────────────────────────┐
│ Realidad del Timeout de Lambda │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Distribución del Tiempo de Ejecución: │
│ │
│ ████████████████████████████████ Típico │
│ █████████████████████████████████████████ P95 │
│ ███████████████████████████████████████████████ P99 │
│ ████████████████████████████████████████████████████████████ XX│
│ 0 min 3 min 6 min 9 min 12 min 15 min TIMEOUT │
│ │
│ ¿Esa cola? Ahí es donde tus datos se corrompen porque │
│ el Lambda murió a mitad de operación sin shutdown graceful. │
│ │
└─────────────────────────────────────────────────────────────────┘
Mejor alternativa: Tareas ECS Fargate o jobs de EKS. Sin límites de timeout, shutdown graceful, y puedes correr durante horas si es necesario.
Anti-Patrón 2: Inferencia de ML
Esta fue la historia de los $47k. Los modelos de ML típicamente son:
- Grandes (cientos de MBs a varios GBs) — cold starts lentos
- Intensivos en CPU/GPU — Lambda te da cómputo limitado
- Sensibles a latencia — los cold starts arruinan la experiencia
- Tráfico constante — estás pagando por invocación para carga predecible
Mejor alternativa: Endpoints de SageMaker, ECS con instancias GPU, o incluso una simple instancia EC2 con auto-scaling group.
Anti-Patrón 3: Cargas de Trabajo con Estado
Las funciones Lambda son sin estado por diseño. Si tu aplicación necesita mantener estado entre solicitudes — conexiones WebSocket, cachés en memoria, datos de sesión — Lambda está peleando contra ti.
// NO HAGAS ESTO — el estado se pierde entre invocaciones
let connectionPool: DatabasePool;
let cache: Map<string, CachedItem> = new Map();
let requestCount = 0;
export const handler = async (event: any) => {
requestCount++; // Esto se reinicia en cold start
// cache puede o no existir dependiendo de si
// esta es una invocación en caliente o en frío
// connectionPool podría estar obsoleto, podría no existir
// Estás construyendo sobre arena movediza
};La Trampa del Contenedor Caliente
Sí, Lambda reutiliza contenedores y tu estado global persiste entre invocaciones en caliente. No, no deberías depender de esto. AWS no da garantías sobre la reutilización de contenedores. Si tu aplicación se rompe cuando el caché está vacío o el connection pool desapareció, tu aplicación tiene un bug — solo que se manifiesta aleatoriamente, lo cual es peor.
Anti-Patrón 4: APIs de Alto Throughput en Estado Estable
Si tu API maneja 1,000+ solicitudes por segundo de forma consistente, 24/7, serverless es casi con certeza más caro que contenedores. El precio por invocación de Lambda no tiene sentido cuando tienes carga predecible y constante.
Análisis de Costos: Matemáticas Honestas
Esta es la sección que la mayoría de artículos de serverless se saltan, y es la más importante. Hagamos matemáticas reales con números reales.
Escenario: API REST Manejando Solicitudes de Usuarios
Supuestos: Tiempo de ejecución promedio de 200ms, 256 MB de memoria, runtime Node.js.
┌─────────────────────────────────────────────────────────────────┐
│ Comparación de Costos Mensuales (us-east-1, 2026) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Solicitudes/Día Lambda Fargate (0.5 vCPU) EC2 │
│ ────────────────────────────────────────────────────────────── │
│ 10,000/día $0.86 $27.00 $15.00 │
│ 100,000/día $8.64 $27.00 $15.00 │
│ 500,000/día $43.20 $27.00 $15.00 │
│ 1,000,000/día $86.40 $54.00* $30.00* │
│ 5,000,000/día $432.00 $108.00* $60.00* │
│ │
│ * Escalado para manejar la carga (2-4 instancias) │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ El Punto de Cruce: ~400,000 solicitudes/día │ │
│ │ Por debajo, Lambda gana. Por encima, │ │
│ │ contenedores ganan. Siempre. │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ Pricing de Lambda: │
│ $0.20 por 1M solicitudes + $0.0000166667 por GB-segundo │
│ 200ms × 256MB = 0.05 GB-s por solicitud │
│ Costo por solicitud: $0.0000010334 │
│ │
│ Pricing de Fargate: │
│ 0.5 vCPU + 1 GB = ~$0.037/hora = ~$27/mes │
│ Puede manejar ~200 solicitudes concurrentes │
│ │
└─────────────────────────────────────────────────────────────────┘
Pero hay algo que estos números no capturan: Lambda no cuesta nada cuando está inactivo. Si tu tráfico es variable — intenso durante horas de oficina, muerto en la noche y los fines de semana — el costo efectivo de Lambda podría ser 60-70% del cálculo de estado estable.
// Calculadora de costos — ejecútala con tus propios números
interface CostParams {
dailyRequests: number;
avgExecutionMs: number;
memoryMb: number;
burstFactor: number; // 1.0 = constante, 3.0 = muy variable
}
function calculateMonthlyCost(params: CostParams): {
lambda: number;
fargate: number;
recommendation: string;
} {
const { dailyRequests, avgExecutionMs, memoryMb, burstFactor } = params;
// Costos de Lambda
const monthlyRequests = dailyRequests * 30;
const gbSeconds = (avgExecutionMs / 1000) * (memoryMb / 1024) * monthlyRequests;
const lambdaCost =
(monthlyRequests / 1_000_000) * 0.20 + // Costo por solicitud
gbSeconds * 0.0000166667; // Costo de cómputo
// Costos de Fargate (0.5 vCPU, 1 GB, maneja ~200 req/s)
const peakRps = (dailyRequests * burstFactor) / 86400;
const instances = Math.max(1, Math.ceil(peakRps / 200));
const fargateCost = instances * 0.037 * 24 * 30; // tarifa horaria × horas
const recommendation =
lambdaCost < fargateCost * 0.8
? "Lambda (significativamente más barato)"
: lambdaCost < fargateCost
? "Lambda (ligeramente más barato, pero considera Fargate por simplicidad)"
: "Fargate (más barato a esta escala)";
return { lambda: Math.round(lambdaCost * 100) / 100, fargate: Math.round(fargateCost * 100) / 100, recommendation };
}
// Ejemplo: tu API SaaS típica
console.log(calculateMonthlyCost({
dailyRequests: 100_000,
avgExecutionMs: 200,
memoryMb: 256,
burstFactor: 2.5,
}));
// { lambda: 8.64, fargate: 27.00, recommendation: "Lambda (significativamente más barato)" }No Olvides los Costos Ocultos
El pricing de Lambda no incluye API Gateway ($1/millón de solicitudes para HTTP API, $3.50/millón para REST API), CloudWatch Logs (frecuentemente $5-20/mes para Lambdas activos), X-Ray tracing, o el tiempo que tu equipo gasta debuggeando sistemas distribuidos. Los contenedores tienen historias de observabilidad y debugging más simples. Incluye todo en el cálculo.
El Enfoque Híbrido: Serverless en los Bordes, Contenedores en el Núcleo
Después de construir y operar sistemas serverless por años, el patrón al que sigo volviendo es el enfoque híbrido. No es tan limpio ni ideológico como "full serverless" o "todo contenedores," pero funciona.
┌─────────────────────────────────────────────────────────────────┐
│ La Arquitectura Híbrida │
├─────────────────────────────────────────────────────────────────┤
│ │
│ BORDE SERVERLESS NÚCLEO EN CONTENEDORES │
│ (Event-driven, variable) (Estado estable, complejo) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ API Gateway │ │ Cluster ECS/EKS │ │
│ │ + Lambda │──requests──▶│ │ │
│ │ (auth ligero, │ │ Lógica de │ │
│ │ routing, │ │ negocio core, │ │
│ │ validación) │ │ queries complejos│ │
│ └──────────────────┘ │ procesamiento │ │
│ │ de datos │ │
│ ┌──────────────────┐ │ │ │
│ │ Triggers S3 │──eventos──▶│ │ │
│ │ (procesamiento │ │ │ │
│ │ de archivos) │ └──────────────────┘ │
│ └──────────────────┘ │ │
│ │ │
│ ┌──────────────────┐ ┌───────▼──────────┐ │
│ │ EventBridge │ │ RDS / ElastiCache│ │
│ │ + Lambda │ │ (Estado │ │
│ │ (cron jobs, │ │ persistente) │ │
│ │ notificaciones) │ └──────────────────┘ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ SQS + Lambda │ │
│ │ (tareas async, │ │
│ │ envío de emails,│ │
│ │ webhooks) │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
La filosofía es simple:
- Serverless para los bordes: Routing de API, webhooks, procesamiento de archivos, cron jobs, tareas asíncronas. Son de tráfico variable, event-driven y sin estado — exactamente donde Lambda destaca.
- Contenedores para el núcleo: Lógica de negocio, procesamiento de datos, cualquier cosa que mantenga estado o dure más de unos segundos. Costo predecible, control total, debugging fácil.
Esto no es un compromiso — es usar cada tecnología donde es más fuerte.
Implementando el Híbrido: API Gateway como Router
// infra/api-stack.ts (AWS CDK)
import * as cdk from "aws-cdk-lib";
import * as apigateway from "aws-cdk-lib/aws-apigatewayv2";
import * as lambda from "aws-cdk-lib/aws-lambda";
export class ApiStack extends cdk.Stack {
constructor(scope: cdk.App, id: string) {
super(scope, id);
const httpApi = new apigateway.HttpApi(this, "HttpApi", {
corsPreflight: {
allowOrigins: ["https://myapp.com"],
allowMethods: [apigateway.CorsHttpMethod.ANY],
},
});
// Rutas ligeras → Lambda
// Verificación de auth, validación de input, luego reenviar al servicio core
const authHandler = new lambda.Function(this, "AuthHandler", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "index.handler",
code: lambda.Code.fromAsset("functions/auth"),
memorySize: 256,
timeout: cdk.Duration.seconds(10),
});
// Rutas pesadas → ALB → ECS
// Lógica de negocio compleja se queda en contenedores
const albIntegration = new apigateway.HttpAlbIntegration(
"CoreServiceIntegration",
this.coreServiceAlb // Referencia a tu ALB de ECS
);
httpApi.addRoutes({
path: "/api/auth/{proxy+}",
methods: [apigateway.HttpMethod.ANY],
integration: new apigateway.HttpLambdaIntegration("AuthIntegration", authHandler),
});
httpApi.addRoutes({
path: "/api/v1/{proxy+}",
methods: [apigateway.HttpMethod.ANY],
integration: albIntegration,
});
}
}Vendor Lock-In y Estrategias de Salida
Hablemos del elefante en la habitación. Serverless, más que casi cualquier otra decisión arquitectónica, te ata a un proveedor de cloud específico. Tus funciones Lambda usan AWS SDK, tus patrones de acceso a DynamoDB son específicos de DynamoDB, tus workflows de Step Functions son específicos de AWS.
Es esto un problema? Depende de qué tan probable es que cambies de cloud. Para la mayoría de empresas, la respuesta es "aproximadamente nunca." Pero sigue siendo inteligente minimizar el acoplamiento innecesario.
El Enfoque de Arquitectura Hexagonal
Mantén tu lógica de negocio agnóstica al cloud envolviendo servicios cloud detrás de interfaces:
// ports/storage.ts — la interfaz (agnóstica al cloud)
export interface StoragePort {
get(key: string): Promise<Buffer | null>;
put(key: string, data: Buffer, contentType: string): Promise<void>;
delete(key: string): Promise<void>;
listByPrefix(prefix: string): Promise<string[]>;
}
// adapters/s3-storage.ts — implementación AWS
import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
export class S3Storage implements StoragePort {
private client: S3Client;
private bucket: string;
constructor(bucket: string) {
this.client = new S3Client({});
this.bucket = bucket;
}
async get(key: string): Promise<Buffer | null> {
try {
const response = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: key })
);
return Buffer.from(await response.Body!.transformToByteArray());
} catch (err: any) {
if (err.name === "NoSuchKey") return null;
throw err;
}
}
async put(key: string, data: Buffer, contentType: string): Promise<void> {
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: contentType })
);
}
async delete(key: string): Promise<void> {
await this.client.send(
new DeleteObjectCommand({ Bucket: this.bucket, Key: key })
);
}
async listByPrefix(prefix: string): Promise<string[]> {
const response = await this.client.send(
new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix })
);
return (response.Contents || []).map((obj) => obj.Key!);
}
}
// adapters/gcs-storage.ts — implementación GCP (si alguna vez la necesitas)
// Misma interfaz, diferente SDK de cloud// handlers/process-upload.ts — usa el port, no el adapter
import { StoragePort } from "../ports/storage";
export function createUploadProcessor(storage: StoragePort) {
return async (fileKey: string): Promise<void> => {
const file = await storage.get(fileKey);
if (!file) throw new Error(`Archivo no encontrado: ${fileKey}`);
// Procesar archivo... (lógica de negocio pura, sin imports de AWS)
const processed = await transformFile(file);
await storage.put(`processed/${fileKey}`, processed, "application/octet-stream");
};
}Gestión Pragmática del Lock-In
No abstraigas todo desde el día uno — eso es generalización prematura. Envuelve los servicios que más probablemente vas a cambiar (almacenamiento, colas, bases de datos). Acepta acoplamiento directo para servicios profundamente integrados (Step Functions, EventBridge). El objetivo no es cero lock-in — es un costo de cambio manejable.
La Infraestructura como Código Es Tu Plan de Salida
Incluso si tu código tiene imports específicos del cloud, tener toda tu infraestructura definida en CDK o Terraform significa que tienes un plano completo de tu sistema. Si alguna vez necesitas migrar, sabes exactamente qué necesita reconstruirse.
┌─────────────────────────────────────────────────────────────────┐
│ Evaluación de Riesgo de Lock-In │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Servicio Nivel Lock-In Esfuerzo de Migración │
│ ───────────────────────────────────────────────────────────── │
│ Lambda (cómputo) Bajo Meter en contenedor │
│ API Gateway Bajo Cualquier reverse proxy │
│ DynamoDB ALTO Rediseñar modelo de datos │
│ Step Functions ALTO Reescribir orquestación │
│ SQS/SNS Medio Cambio a RabbitMQ/Kafka │
│ S3 Bajo Cualquier object storage │
│ CloudWatch Medio Cambio a Datadog/Grafana │
│ Cognito ALTO Auth0/auth personalizado │
│ │
│ Regla general: el lock-in de la capa de datos duele más. │
│ El lock-in de la capa de cómputo suele ser manejable. │
│ │
└─────────────────────────────────────────────────────────────────┘
Mi Framework de Decisión Serverless
Después de todo lo que hemos cubierto, aquí está el framework de decisión que realmente uso cuando llega una nueva carga de trabajo:
┌─────────────────────────────────────────────────────────────────┐
│ ¿Debería Esto Ser Serverless? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. ¿Cuánto tiempo ejecuta? │
│ > 15 min ──────────────────▶ Contenedores/VMs │
│ < 15 min ──────────────────▶ Continuar │
│ │
│ 2. ¿Cuál es el patrón de tráfico? │
│ Variable/impredecible ─────▶ Señal fuerte de serverless │
│ Constante 24/7 ────────────▶ Probablemente contenedores │
│ │
│ 3. ¿Es sin estado? │
│ Sí ────────────────────────▶ Continuar │
│ No ────────────────────────▶ Contenedores │
│ │
│ 4. ¿Tamaño del paquete? │
│ < 250 MB ──────────────────▶ Lambda (zip) │
│ 250 MB - 1 GB ─────────────▶ Lambda (imagen contenedor) │
│ > 1 GB ────────────────────▶ Contenedores │
│ │
│ 5. ¿Costo a la escala esperada? │
│ < punto de cruce ──────────▶ Serverless │
│ > punto de cruce ──────────▶ Contenedores │
│ │
│ 6. ¿Requisitos de latencia? │
│ > 200ms aceptable ─────────▶ Serverless está bien │
│ < 100ms requerido ─────────▶ Provisionado o contenedores │
│ │
└─────────────────────────────────────────────────────────────────┘
Conclusión
Serverless no es una religión. Es una herramienta. Una muy buena herramienta, para los problemas correctos.
El patrón al que sigo volviendo después de años de experiencia en producción:
- Empieza con las características de la carga de trabajo, no con la tecnología. Variable? Sin estado? De corta duración? Event-driven? Serverless es probablemente tu mejor apuesta.
- Haz las cuentas. El tier gratuito de Lambda es generoso y el pricing por invocación es increíble para cargas de trabajo de bajo tráfico. Pero la curva cruza con los contenedores más rápido de lo que la mayoría piensa.
- Ve híbrido. Usa serverless en los bordes donde sus fortalezas brillan — routing de API, webhooks, cron jobs, procesamiento de archivos. Usa contenedores para la lógica de negocio core donde necesitas control, estado y costos predecibles.
- Diseña para portabilidad donde sea barato hacerlo. Arquitectura hexagonal, infraestructura como código, e interfaces limpias entre tu lógica de negocio y los servicios cloud.
- Monitorea costos obsesivamente. Configura alertas de facturación. Revisa los costos de Lambda semanalmente durante el primer mes de cualquier nuevo despliegue. Esa factura de $47k no fue un evento único — fue un mes de nadie mirando los números.
La mejor arquitectura no es la más serverless ni la más nativa de contenedores. Es la que cada componente usa la tecnología que mejor se adapta a las características de su carga de trabajo, perfil de costos y requisitos operacionales. Esa no es una conclusión muy emocionante, pero es honesta — y en esta industria, la ingeniería honesta le gana al hype cada vez.
Frequently Asked Questions
No te pierdas nada
Artículos sobre IA, ingeniería y las lecciones que aprendo construyendo cosas. Sin spam, lo prometo.
Osvaldo Restrepo
Senior Full Stack AI & Software Engineer. Building production AI systems that solve real problems.