Entendiendo qué se está midiendo realmente
Antes de optimizar nada, es esencial entender la anatomía de un cold start. Un cold start de Lambda tiene tres fases bien distintas:
- Init de infraestructura, AWS provisiona una microVM (Firecracker), descarga el paquete de deploy de S3 y arma el entorno de ejecución. Sobre esta fase no hay ningún control.
- Init de runtime, El runtime del lenguaje arranca. Para Node.js, V8 se inicia y parsea el código. Para Java, la JVM carga.
- Init de aplicación, El módulo handler se carga, corren los inicializadores estáticos, se establecen conexiones.
La mayoría de la gente mezcla las tres y después se pregunta por qué su "optimización" no movió la aguja. Solo es posible afectar las fases 2 y 3 directamente.
Así se extraen mediciones limpias de CloudWatch Logs Insights:
filter @type = "REPORT"
| parse @log /\/aws\/lambda\/(?<functionName>[^\/]+)/
| stats
avg(@initDuration) as avg_init,
pct(@initDuration, 50) as p50_init,
pct(@initDuration, 99) as p99_init,
count(*) as cold_starts
by functionName
| sort avg_init desc
Esto devuelve el Init Duration que cubre fases 2 y 3 pero no la fase 1. La fase 1 son típicamente 100-400ms y no hay nada que se pueda hacer al respecto excepto usar provisioned concurrency.
Provisioned concurrency: la garantía cara
Provisioned concurrency es la solución de fuerza bruta. Se le indica a AWS "mantener N entornos calientes todo el tiempo" y así lo hacen. Los cold starts desaparecen para esas N ejecuciones concurrentes.
# serverless.yml
functions:
checkout:
handler: src/checkout.handler
runtime: nodejs20.x
memorySize: 1024
provisionedConcurrency: 50
events:
- http:
path: /checkout
method: post
La matemática es directa pero significativa. A 1024MB de memoria en us-east-1, provisioned concurrency cuesta aproximadamente $0.0000041667 por GB-segundo. Para 50 instancias provisionadas corriendo 24/7:
50 instancias * 1 GB * 86400 seg/día * 30 días * $0.0000041667/GB-seg = ~$540/mes
Son $540 por mes solo para mantener funciones calientes, antes de cualquier costo de invocación. Para una función de checkout que maneja alrededor de 200 requests concurrentes en pico, provisionar 200 instancias costaría $2.160/mes. Se necesita un enfoque más eficiente.
Application Auto Scaling para provisioned concurrency
La técnica comúnmente pasada por alto es combinar provisioned concurrency con Application Auto Scaling. Se establece una base y se deja que escale:
aws application-autoscaling register-scalable-target \
--service-namespace lambda \
--resource-id function:checkout-prod:checkout-prod-alias \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--min-capacity 10 \
--max-capacity 200
aws application-autoscaling put-scaling-policy \
--service-namespace lambda \
--resource-id function:checkout-prod:checkout-prod-alias \
--scalable-dimension lambda:function:ProvisionedConcurrency \
--policy-name checkout-scaling \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 0.7,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization"
},
"ScaleInCooldown": 300,
"ScaleOutCooldown": 60
}'
Target 70% de utilización. Escalar para arriba rápido (60s de cooldown), para abajo lento (300s). Este enfoque puede reducir el costo a más o menos $180/mes cubriendo el 95% de los picos de tráfico sin cold starts. El 5% restante, ráfagas súbitas e impredecibles, sigue sufriendo cold starts. Ahí es donde las optimizaciones de runtime importan.
SnapStart: la respuesta de Java (finalmente)
Para Java en Lambda, SnapStart es transformacional. Toma un snapshot de la microVM Firecracker después de que la fase de inicialización termina, y después restaura desde ese snapshot en vez de bootear la JVM de cero.
Al migrar una función Spring Boot 3.2 del runtime estándar Java 21 a SnapStart, los resultados son significativos:
| Métrica | Sin SnapStart | Con SnapStart |
|---|---|---|
| p50 cold start | 4.200ms | 620ms |
| p99 cold start | 8.100ms | 1.100ms |
| Memoria en init | 512MB | 512MB |
Eso es una reducción del 85% en p50. Pero hay caveats que los blog posts de AWS convenientemente se saltean.
El problema de unicidad
SnapStart restaura desde un snapshot congelado. Eso significa que todo lo que se inicializó antes del snapshot, generadores de números random, UUIDs, tokens de conexión, será idéntico entre todas las instancias restauradas. AWS provee hooks de runtime para manejar esto:
import software.amazon.lambda.snapstart.SnapshotRestore;
public class CheckoutHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private SecureRandom secureRandom;
private HikariDataSource dataSource;
public CheckoutHandler() {
// Esto corre ANTES del snapshot
dataSource = initializeDataSource();
// Registrar hook de restore
SnapshotRestore.registerRestoreHook(() -> {
// Esto corre DESPUÉS del restore, en cada cold start
secureRandom = new SecureRandom();
secureRandom.nextBytes(new byte[32]); // Forzar re-seeding
// Re-establecer conexiones a la base
if (dataSource != null) {
dataSource.getHikariPoolMXBean().softEvictConnections();
}
});
}
// ...
}
Compatibilidad con CRaC
Por debajo, SnapStart usa la API CRaC (Coordinated Restore at Checkpoint) de OpenJDK. Al escribir hooks de restore contra la API CRaC directamente, funcionan tanto en SnapStart como en deploys CRaC standalone:
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
public class ConnectionManager implements Resource {
public ConnectionManager() {
Core.getGlobalContext().register(this);
}
@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// Cerrar conexiones antes del snapshot
connectionPool.close();
}
@Override
public void afterRestore(Context<? extends Resource> context) {
// Re-establecer después del restore
connectionPool = createPool();
}
}
LLRT: cuando se abandona el JIT por completo
LLRT (Low Latency Runtime) es el runtime experimental de JavaScript de Amazon construido sobre QuickJS en vez de V8. La idea clave: V8 gasta tiempo significativo de cold start en warmup de compilación JIT. QuickJS es un intérprete puro, sin JIT, sin tiers de compilación, sin warmup. El trade-off es throughput bruto por velocidad de startup.
Al benchmarkear una función API moderadamente compleja (parsing de JSON, llamadas a DynamoDB, transformación de respuesta) entre runtimes, se observa lo siguiente:
Node.js 20 estándar:
Cold start: 340ms
Invocación warm: 12ms
LLRT v1.2:
Cold start: 48ms
Invocación warm: 28ms
El cold start baja un 86%, pero las invocaciones warm son 2.3x más lentas. Para funciones que se inicializan seguido pero corren poco (API gateways, webhooks, procesadores de eventos), LLRT gana claramente. Para funciones compute-heavy que se mantienen warm, se pierde rendimiento.
Deployar LLRT requiere un custom runtime layer:
FROM public.ecr.aws/awsguru/llrt:v1.2-arm64 as llrt
FROM public.ecr.aws/lambda/provided:al2023-arm64
COPY --from=llrt /usr/local/bin/llrt /var/runtime/bootstrap
COPY dist/ /var/task/
CMD ["index.handler"]
Un gotcha crítico: LLRT no soporta toda la API de Node.js. Sin módulo fs (usar el workaround de /tmp), crypto limitado (sin createCipheriv), sin módulos nativos. Es importante verificar la matriz de compatibilidad antes de comprometerse.
V8 snapshots: el approach DIY
V8 tiene un mecanismo de snapshot incorporado que serializa el estado del heap después de la inicialización. Cloudflare Workers usa esto extensivamente, es la razón por la que los cold starts de Workers son básicamente cero. La misma técnica puede aplicarse con custom runtimes.
La idea: ejecutar el código de inicialización, serializar el heap de V8 a un blob binario, y en el cold start deserializar el blob en vez de re-ejecutar la inicialización. El flujo conceptual usando v8.startupSnapshot:
// snapshot-builder.mjs, correr en build time
const v8 = require('node:v8');
v8.startupSnapshot.setDeserializeMainFunction((data) => {
// Esto corre en el restore
const { handler } = require('./dist/handler');
module.exports = { handler };
});
// Pre-inicializar objetos caros
const config = require('./config');
const routes = require('./routes');
const validators = require('./validators');
// Estos objetos ahora están en el snapshot
v8.startupSnapshot.addSerializeCallback(() => {
console.log('Serializando snapshot del heap V8...');
});
Construir el snapshot:
node --snapshot-blob=snapshot.blob --build-snapshot snapshot-builder.mjs
Y arrancarlo:
node --snapshot-blob=snapshot.blob index.mjs
En pruebas, esto redujo la fase de inicialización de Node.js de 180ms a 35ms para una función con carga pesada de módulos. Combinado con LLRT o el runtime estándar de Node.js, es una técnica poderosa. La contra es la complejidad del build y el hecho de que no todo se serializa limpiamente (Promises, handles abiertos, etc.).
La metodología correcta: cómo benchmarkear bien
Cada artículo sobre cold starts muestra un solo número. Ese número no significa nada sin contexto. A continuación, una metodología robusta de benchmarking:
#!/usr/bin/env python3
"""Harness de benchmark de cold starts"""
import boto3
import time
import json
import statistics
from concurrent.futures import ThreadPoolExecutor
lambda_client = boto3.client('lambda', region_name='us-east-1')
def force_cold_start(function_name: str) -> dict:
"""Actualizar una env var para forzar nuevos entornos de ejecución"""
lambda_client.update_function_configuration(
FunctionName=function_name,
Environment={'Variables': {'BENCH_TS': str(time.time_ns())}}
)
waiter = lambda_client.get_waiter('function_updated_v2')
waiter.wait(FunctionName=function_name)
time.sleep(2)
start = time.perf_counter_ns()
response = lambda_client.invoke(
FunctionName=function_name,
Payload=json.dumps({"benchmark": True}),
LogType='Tail'
)
end = time.perf_counter_ns()
import base64
log_result = base64.b64decode(response['LogResult']).decode()
init_duration = None
for line in log_result.split('\n'):
if 'Init Duration' in line:
init_duration = float(line.split('Init Duration: ')[1].split(' ms')[0])
return {
'e2e_ms': (end - start) / 1_000_000,
'init_duration_ms': init_duration,
}
def benchmark(function_name: str, iterations: int = 30):
results = []
for i in range(iterations):
result = force_cold_start(function_name)
results.append(result)
print(f" [{i+1}/{iterations}] init={result['init_duration_ms']:.1f}ms e2e={result['e2e_ms']:.1f}ms")
init_durations = [r['init_duration_ms'] for r in results if r['init_duration_ms']]
print(f"\n--- {function_name} ({len(init_durations)} muestras) ---")
print(f" p50: {statistics.median(init_durations):.1f}ms")
print(f" p90: {sorted(init_durations)[int(len(init_durations)*0.9)]:.1f}ms")
print(f" p99: {sorted(init_durations)[int(len(init_durations)*0.99)]:.1f}ms")
print(f" mean: {statistics.mean(init_durations):.1f}ms")
print(f" std: {statistics.stdev(init_durations):.1f}ms")
if __name__ == '__main__':
benchmark('checkout-prod', iterations=30)
Puntos clave: mínimo 30 iteraciones, forzar cold starts actualizando variables de entorno, medir Init Duration de la línea REPORT (no la latencia end-to-end que incluye red), y siempre reportar percentiles, los promedios son engañose es para distribuciones bimodales.
La matriz de decisión
Después de un benchmarking exhaustivo, emerge el siguiente framework de decisión:
Usar provisioned concurrency cuando: se necesitan SLAs de latencia garantizada, el costo se justifica por el impacto de negocio, y el tráfico es medianamente predecible.
Usar SnapStart cuando: el runtime es Java/Kotlin, las restricciones de unicidad son manejables, y la inicialización es dominada por la JVM.
Usar LLRT cuando: el runtime es Node.js, las funciones son de corta vida y IO-bound, y no se necesita compatibilidad total con la API de Node.js.
Usar V8 snapshots cuando: hay inicialización pesada de módulos, se acepta invertir en complejidad del build pipeline, y se necesita control granular.
Combinar enfoques: Provisioned concurrency + SnapStart. LLRT + dependencias mínimas. V8 snapshots + tree shaking. Las técnicas son complementarias, no mutuamente excluyentes.
Como ejemplo práctico, una función de checkout puede beneficiarse de provisioned concurrency (auto-escalado, 10-200) más tree-shaking agresivo de dependencias que reduce el bundle de 12MB a 1.8MB. Los cold starts bajan de 1.247ms a 180ms, y provisioned concurrency atrapa la carga predecible. Los picos impredecibles sufren un cold start de 180ms en vez de 1.247ms, una mejora significativa para workloads sensibles a latencia.