El problema
Un lunes por la mañana alguien lanza una importación masiva. Su función empieza a escalar —para eso está Lambda— y en pocos minutos hay novecientas invocaciones en vuelo.
Y entonces tu checkout empieza a devolver errores.
No has desplegado nada. El checkout no ha cambiado. Lo que ha pasado es que la concurrencia de Lambda es un bote de la cuenta y la región, no de la función: mil ejecuciones simultáneas por defecto, repartidas por orden de llegada entre todo lo que tengas desplegado.
Es el modo de fallo más desagradable de Lambda porque cruza fronteras entre equipos. Una función de un proyecto que nadie mira puede tirar la API de producción de otro, y el equipo del checkout va a pasar la mañana buscando el problema en su código, donde no está.
Y hay una segunda cara del mismo problema. Lambda escala más rápido que casi cualquier cosa a la que llame: mil entornos cada diez segundos, por función. Si detrás hay una base de datos relacional con cien conexiones, o el API de un tercero con su propio límite, Lambda no está absorbiendo el pico: se lo está pasando entero al eslabón que no aguanta.
La solución
Repartir el bote explícitamente. La concurrencia reservada aparta un trozo para una función concreta, y hace dos cosas a la vez que conviene entender bien porque suelen contarse solo la mitad:
Reserved concurrency establece a la vez el máximo y el mínimo de instancias concurrentes asignadas a tu función. Cuando una función tiene concurrencia reservada, ninguna otra función puede usar esa concurrencia.
Es decir: es suelo y techo en la misma configuración.
- Como suelo, garantiza que el checkout siempre tendrá sus 200, pase lo que pase con el resto de la cuenta.
- Como techo, impide que el importador pase de 300 y ahogue a la base de datos o al bote común.
Casi siempre quieres las dos cosas, pero por motivos distintos: reservas para proteger lo crítico, y limitas para proteger lo que está detrás.
Además no cuesta nada: a diferencia de la concurrencia aprovisionada, configurar concurrencia reservada no añade ningún cargo.
Cuándo usarlo
- Tienes funciones críticas compartiendo cuenta con trabajos por lotes, importadores o entornos de prueba.
- Una función habla con algo que no escala como Lambda: una base de datos relacional, un ERP, un API de terceros con cuota.
- Quieres apagar una función sin borrarla: reservada a 0 la detiene en seco.
- Consumes una cola con un backlog enorme y necesitas marcar el ritmo de drenado.
Cuándo NO usarlo
Cómo implementarlo
- Mide con
ConcurrentExecutionspor función, durante un periodo que incluya un pico real. - Estima con la fórmula:
concurrencia = peticiones por segundo × duración media en segundos. - Reserva para lo crítico un valor por encima del pico observado.
- Limita lo peligroso al ritmo que aguante su dependencia, no al que aguante Lambda.
- Deja holgura sin reservar para todo lo demás — y recuerda que el sistema te obliga a dejar al menos 100.
- Vigila
Throttles. Un tope sin alarma es un cuello de botella invisible.
El código
# Reservar 200 para el checkout: es su suelo y su techo a la vez.
aws lambda put-function-concurrency \
--function-name checkout --reserved-concurrent-executions 200
# Apagar una funcion en seco, sin borrarla ni tocar sus disparadores.
aws lambda put-function-concurrency \
--function-name importador --reserved-concurrent-executions 0
# Quitar el limite y devolver la funcion al bote comun.
aws lambda delete-function-concurrency --function-name importador
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as cw from 'aws-cdk-lib/aws-cloudwatch';
// Critica: se reserva por encima del pico medido, para garantizar cupo.
const checkout = new lambda.Function(this, 'Checkout', {
runtime: lambda.Runtime.NODEJS_22_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('dist/checkout'),
reservedConcurrentExecutions: 200,
});
// Peligrosa: se limita al ritmo que aguanta la base de datos de detras,
// no al que aguanta Lambda.
const importador = new lambda.Function(this, 'Importador', {
runtime: lambda.Runtime.NODEJS_22_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('dist/importador'),
reservedConcurrentExecutions: 30,
});
// Un tope sin alarma es un cuello de botella invisible: lo que antes era
// un error ruidoso pasa a ser una cola que crece en silencio.
new cw.Alarm(this, 'AlarmaThrottles', {
metric: importador.metricThrottles({ period: cdk.Duration.minutes(5) }),
threshold: 1,
evaluationPeriods: 2,
comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: cw.TreatMissingData.NOT_BREACHING,
});
Te va a morder
Coste
Configurar concurrencia reservada no cuesta nada. Es una de las pocas palancas de AWS que mejora la fiabilidad sin aparecer en la factura.
Lo que sí mueve dinero es su efecto indirecto, y en las dos direcciones:
| Efecto | Consecuencia en la factura |
|---|---|
| Techo en una función desbocada | menos invocaciones — ahorra |
| Menos presión sobre la base de datos | menos throttling y menos reintentos — ahorra |
| Backlog acumulado en colas | más sondeos y más tiempo de retención — suma un poco |
| Cupo reservado y sin usar | ningún cargo, pero capacidad desperdiciada |
Ese último punto es el coste real y no se ve: reservar 300 para una función que
usa 40 no te cobra nada, pero deja 260 unidades inutilizables para el resto de
la cuenta. Es un coste de oportunidad, y solo aparece el día que otra función
empieza a recibir Throttles sin motivo aparente.
Fuentes
Semántica de suelo y techo, ausencia de cargo, regla de reservar como mucho el cupo sin reservar menos 100, comportamiento del cupo reservado y sin usar, apagado con reservada a 0, límites propios de Amazon MQ y fórmula de estimación de concurrencia: Configuring reserved concurrency for a function. Límite por defecto de 1 000 ejecuciones concurrentes, velocidad de escalado y cuotas reducidas en cuentas nuevas: Lambda quotas. Retención por defecto y máxima de una cola SQS: Amazon SQS message quotas.