English version here.
En el capítulo anterior resolvimos dónde viven los datos: archivos en S3, caché y sesiones en DynamoDB, y una base de datos NoSQL que habla Eloquent. Pero quedó una pregunta flotando en el aire: si una función Lambda nace, responde y muere en milisegundos, ¿quién se queda despierto procesando los jobs en segundo plano?
En un servidor tradicional la respuesta es supervisord vigilando un proceso eterno. En un entorno efímero donde nada vive más de 15 minutos, esa receta simplemente no aplica.
En este capítulo vamos a reemplazar ese daemon por SQS + un worker en Lambda. Lo haremos en tres actos: primero la configuración (sorprendentemente corta), después el ciclo de vida de un job (donde está la magia), y finalmente el tema que casi nadie explica completo: qué pasa cuando un job falla, quién lo reintenta, y dónde termina.
1. El Mapa del Tesoro Serverless
Rápido, para ubicarnos:
Ya tenemos Lambda ejecutando nuestra App Laravel, CloudFront repartiendo tráfico entre S3 (assets) y Lambda (la app), y DynamoDB como caché, sesiones y nuestra base de datos principal. Hoy toca:
- Gestionar nuestra cola de eventos con SQS,
- y una segunda función Lambda para el worker de Laravel Queue.
¿Recuerdan la tabla de componentes del segundo capítulo? Hoy tachamos esta fila:
| Componente | Servidor Dedicado | AWS Serverless |
|---|---|---|
| Cola de Eventos | php artisan queue:work / quizás supervisor |
SQS + Lambda 🚀 |
2. Por Qué queue:work No Encaja en Serverless
a) Cómo funciona la cola en un servidor tradicional
Simplificando mucho, php artisan queue:work es un while (true): le pregunta a la cola si hay trabajo; si hay, lo procesa; si no, duerme unos segundos y vuelve a preguntar. Un proceso residente que ocupa memoria y CPU las 24 horas, aunque la cola esté vacía desde hace 3 días.
Y como es un proceso, necesita niñera:
- supervisor (o systemd) para resucitarlo cuando muere.
-
queue:restartdespués de cada deploy, porque el proceso tiene el código viejo cargado en memoria. -
--max-jobsy--max-timepara reciclarlo antes de que los memory leaks lo conviertan en un agujero negro. - ¿Y para escalar? Más procesos → más RAM → instancias más grandes → todo acompañado de una factura mayor.
queue:workes un empleado al que le pagan por presionar F5 en la bandeja de entrada cada tres segundos. Ocho horas. Aunque no llegue nada.
b) Por qué eso no existe en Lambda
Una invocación de Lambda vive, como máximo, 15 minutos 1. No hay proceso residente, no hay while (true), no hay nadie preguntando "¿hay trabajo?". Si intentan correr queue:work dentro de una Lambda... funciona 15 minutos, muere, y nadie lo resucita. Y si lo resucitan con un cron, están pagando una Lambda las 24 horas para hacer polling. Felicidades, reinventaron EC2, pero más caro.
c) La inversión del modelo: de pull a push
La solución no es adaptar el worker a Lambda, es darlo vuelta. En vez de que el worker busque trabajo (pull), la cola invoca al worker cuando hay trabajo (push):
- Laravel hace
dispatch()y registra el job en SQS. - SQS detecta que hay mensajes e invoca a una función Lambda con esos mensajes.
- Lambda procesa los jobs, termina y desaparece.
- Una cola vacía = cero invocaciones Lambda.
El worker deja de ser un proceso que espera y pasa a ser un evento que ocurre.
3. El Nuevo Stack de Colas: SQS como Buzón, Lambda como Worker
a) SQS en 30 segundos para devs Laravel
Amazon SQS (Simple Queue Service) es una cola de mensajes administrada 2. Lo mínimo que necesitan saber:
-
Mensaje: para nosotros, el payload JSON serializado del job. El mismo que verían en la columna
payloadde la tablajobssi usaran el driverdatabase. -
Visibility timeout: cuando alguien toma un mensaje, SQS lo oculta durante X segundos. Si en ese tiempo nadie lo borra, reaparece para que otro lo intente. Es el equivalente al
retry_afterde Laravel. - Dead Letter Queue (DLQ): una segunda cola donde SQS mueve los mensajes que fallaron demasiadas veces. La "caja de objetos perdidos".
- Standard vs FIFO: las colas estándar no garantizan orden (y son las que vamos a usar); las FIFO sí, a cambio de menos throughput.
Con el vocabulario listo, pasemos al código.
b) Conectando los cables con Serverless Lift
Partimos del serverless.yml que construimos en el segundo capítulo y agregamos dos variables de entorno y un construct de Lift 3 4:
service: laravel
provider:
name: aws
environment:
# ...
+ QUEUE_CONNECTION: sqs
+ SQS_QUEUE: ${construct:jobs.queueUrl}
functions:
web:
handler: Bref\LaravelBridge\Http\OctaneHandler
runtime: php-85
# ...
constructs:
website:
type: server-side-website
# ...
+ jobs:
+ type: queue
+ worker:
+ handler: Bref\LaravelBridge\Queue\QueueHandler
+ runtime: php-85
+ timeout: 60 # segundos, el techo del job más largo
plugins:
- ./vendor/bref/bref
- serverless-lift
QUEUE_CONNECTION: sqs activa el driver que Laravel ya trae de fábrica, y SQS_QUEUE recibe la URL completa de la cola que Lift acaba de crear (si prefieren pasar solo el nombre, van a necesitar también SQS_PREFIX 3).
Aunque en un servidor tradicional la cola se corre por CLI con
artisan, acá no usamosphp-85-console. Al igual que conOctaneHandler, usamos el runtime de eventosphp-85con la claseQueueHandlerde Bref.
¿Y qué hizo Lift por detrás con ese construct jobs? 4
- Creó la cola SQS.
- Creó una Dead Letter Queue con retención de 14 días (el máximo que permite SQS).
- Creó la función Lambda
workercon el handler de Bref. - Configuró el event source mapping: la pieza de AWS que conecta la cola SQS con la función Lambda, y la invoca cuando hay mensajes. Nuestro reemplazo de
queue:work. - Y le dio permiso
sqs:SendMessagea todas las funciones del stack 5. Por eso, a diferencia de las tablas DynamoDB del capítulo anterior, acá no escribimos ni una línea de IAM.
Las credenciales, al igual que con DynamoDB, las tiene asignadas el rol de ejecución de cada función. Rotan solas y nadie las ve.
c) Nueva infraestructura, el mismo código de siempre
Esta es la parte que más me gusta. Nada de lo que ya escribieron se entera del cambio:
// Un job
SendWelcomeEmail::dispatch($user->uuid);
// Un mail encolado
Mail::to($user)->queue(new WelcomeMail($user));
// Una notificación que implementa ShouldQueue
$user->notify(new VerifyEmail);
Todo eso va a SQS, y una Lambda lo procesa. Los listeners ShouldQueue, los jobs encadenados... casi todo sigue funcionando igual. Otro punto a favor para la abstracción de Laravel 💪.
Importante: El driver SQS de Laravel limita el
delay()/#[Backoff]hasta 900 segundos (15 minutos), que es el límite de SQS 6.Si necesitan varias colas (una para emails urgentes, otra con menos concurrencia para no reventar una API externa, una FIFO para procesar en orden), cada una es un construct
queueadicional, y desde Laravel 13.26 se enrutan conQueue::forward()7.
4. ¿Qué Pasa Realmente Cuando Despachan un Job?
Hasta acá todo parece magia: configuramos un bloque en serverless.yml, hicimos dispatch() y el job se ejecutó. Pero en producción la magia dura hasta el primer incidente. Veamos paso a paso quién hace qué, desde que el mensaje entra a SQS hasta que desaparece.
a) Laravel despacha, SQS recibe
Cuando llaman a SendWelcomeEmail::dispatch($user->uuid), Laravel serializa el job y el driver sqs ejecuta una llamada SendMessage a la API de SQS para dejarlo en la cola. Y listo; nuestra Lambda web ya cumplió su parte y puede terminar su ejecución.
El cuerpo del mensaje es el JSON de siempre (
displayName,job,data,uuid,attempts). Para Laravel, SQS es solo otro buzón donde depositar ese mismo sobre.
b) SQS avisa, Lambda despierta
El event source mapping que creó Lift hace el long polling por nosotros 8: AWS mantiene un proceso interno consultando la cola y, apenas detecta mensajes, invoca a nuestra función Lambda worker con un lote de hasta batchSize mensajes (Lift lo deja en 1 por defecto; ya veremos por qué y cuándo cambiarlo). El polling sigue existiendo; la diferencia es que, en vez del proceso queue:work, ahora AWS se encarga de hacerlo.
Importante: Ese polling de AWS cuenta como llamadas
ReceiveMessageen SQS, incluso con la cola vacía. Por suerte, la capa gratuita incluye 1 millón de requests mensuales 9: con la cola en reposo, ese chequeo constante genera ~21.000 requests por día (~630.000 al mes), por lo que no pagan nada.
c) Bref recibe el lote, Laravel ejecuta el job
Cuando Lambda despierta, le entrega el evento de SQS al QueueHandler de Bref 10. Si espiamos su método handleSqs() (simplificado) se ve exactamente qué pasa con cada mensaje:
public function handleSqs(SqsEvent $event, Context $context): void
{
$worker = $this->container->makeWith(Worker::class, [
'resetScope' => fn () => $this->resetLaravel(),
// ...
]);
foreach ($event->getRecords() as $sqsRecord) {
$timeout = $this->calculateJobTimeout($context->getRemainingTimeInMillis());
$worker->runSqsJob(
$job = $this->marshalJob($sqsRecord),
$this->connection,
$this->gatherWorkerOptions($timeout),
);
if (! $job->hasFailed() && ! $job->isDeleted()) {
$job->delete();
}
}
}
Tres detalles clave en esas pocas líneas:
-
Ese
Workeres el de Laravel. Literalmente extiende deIlluminate\Queue\Worker11. Bref no reinventó cómo correr un job: le pasa cada mensaje al mismo motor que usaqueue:work. Consecuencia directa:#[Tries],#[Backoff],$timeout,failed(), middlewares,ShouldBeUnique,retryUntil()y los eventosJobProcessing/JobProcessed/JobFailed... todo funciona igual que en un servidor tradicional. No hay una tabla de "features soportadas" que consultar. -
Entre job y job se limpia el estado. Ese callback
resetLaravel()vacía el contexto de logs, olvida las instancias scoped del contenedor y limpia las facades resueltas antes de cada ejecución. ¿Por qué? Por la misma razón que vimos con Octane en el segundo capítulo: la instancia de Lambda (y su proceso PHP) sobrevive entre un job y el siguiente. Sin esa limpieza, el segundo job heredaría la basura en memoria del primero. -
El borrado en SQS es explícito. Solo si el job terminó sin fallar, Bref llama a
$job->delete()para quitarlo de la cola. Si falló o ya fue eliminado (o liberado de vuelta a la cola, como veremos enseguida), no lo toca.
d) El timeout de Lambda vs. el $timeout del job
¿Vieron ese calculateJobTimeout($context->getRemainingTimeInMillis()) en el loop? Bref no usa un timeout fijo: consulta cuántos milisegundos le quedan de vida a la invocación de Lambda (menos 1 segundo de margen) y le asigna ese tiempo al job. Con ese valor, el Worker programa un pcntl_alarm() 11: si el job se pasa, suena la alarma, el proceso termina con exit(1) y la invocación falla. El job solo se marca como fallido (y va a failed_jobs) si definieron #[FailOnTimeout] o $failOnTimeout = true.
Dos implicaciones prácticas:
- El
timeoutdel worker enserverless.ymles, en la práctica, el techo del job más largo que pueden procesar. Configúrenlo pensando en su tarea más pesada. - Con un
batchSizemayor a 1, todos los jobs del lote comparten ese mismo reloj: si procesan tareas largas, conviene mantener un lote pequeño (o dejarlo en 1).
Ojo con
#[Timeout]/$timeout: Si definen un timeout propio en la clase del job, Laravel pisa el valor calculado por Bref. Si ese número supera el tiempo que le queda a Lambda, AWS matará la invocación en seco antes de que suene la alarma de PHP. Recomendación: usen un valor bastante menor altimeoutdel worker, sobre todo conbatchSizemayor a 1, donde los últimos jobs del lote arrancan con menos tiempo disponible.
5. ¿Quién Se Hace Cargo Cuando Algo Explota?
A todos nos gusta ver cuando el camino feliz se cumple al pie de la letra: el job entra, se ejecuta y se borra de SQS. Pero en el mundo real las APIs externas se caen, las validaciones fallan y los procesos se quedan sin memoria.
Seguramente se estarán preguntando: "Si Lift ya nos creó una Dead Letter Queue en SQS y Laravel ya tiene su propia tabla
failed_jobs, ¿acaso no están haciendo lo mismo?"
La respuesta es no. Cuando combinamos el Worker de Laravel con SQS y Lambda, aparecen dos mecanismos de reintento y fallo conviviendo en el mismo stack: uno del lado de nuestra aplicación y otro del lado de la infraestructura de AWS. Y aunque suenen parecidos, atacan problemas completamente distintos.
a) Los reintentos los maneja Laravel, no SQS
Empecemos por el lado de nuestra aplicación. Imaginen un job común y corriente configurado con reintentos y backoff:
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
#[Tries(3)]
#[Backoff(30)]
final class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(public readonly string $user_uuid) {}
public function handle(): void
{
// ... si esto lanza una excepción, Laravel reintenta en 30 segundos, hasta 3 veces
}
public function failed(Throwable $e): void
{
// ... agotados los 3 intentos, Laravel llama acá
}
}
Cuando handle() lanza una excepción, el Worker de Laravel hace lo de siempre: si quedan intentos, llama a $job->release(30). Y acá está el truco. El SqsJob nativo de Laravel simplemente modifica el visibility timeout del mensaje en SQS, pero Bref lo sobreescribe y hace otra cosa 12:
public function release($delay = 0)
{
$this->released = true;
$payload = $this->payload();
$payload['attempts'] = ($payload['attempts'] ?? 0) + 1;
// borra el mensaje original...
$this->sqs->deleteMessage([
'QueueUrl' => $this->queue,
'ReceiptHandle' => $this->job['ReceiptHandle'],
]);
// ...y envía una copia nueva, con el contador de intentos y el delay del backoff
$this->sqs->sendMessage([
'QueueUrl' => $this->queue,
'MessageBody' => json_encode($payload),
'DelaySeconds' => $this->secondsUntil($delay),
]);
}
No es un "reenviar". Es borrar el correo y redactar uno nuevo, idéntico, con
[Intento 2]en el asunto y programado para dentro de 30 segundos.
¿Por qué? Porque así el contador de intentos viaja dentro del payload (attempts() lo lee de ahí, no del ApproximateReceiveCount de SQS), y el backoff se traduce a un DelaySeconds real. Resultado: #[Tries(3)] y #[Backoff(30)] se comportan exactamente como en un servidor. Bref eligió deliberadamente la semántica de Laravel por sobre la de SQS 3.
Si no definen
#[Tries]/$tries, el handler de Bref usa 3 por defecto 13. Y si usan backoff exponencial (#[Backoff([10, 60, 300])]), también funciona: cada reenvío lleva el delay que corresponde a ese intento.
b) El efecto secundario: SQS nunca se entera del fallo
Cuando en handle() se lanza una excepción, el Worker de Laravel la atrapa en su propio try/catch: la reporta en los logs, decide si reintenta o da el job por perdido, y sigue de largo. Como esa excepción nunca llega hasta handleSqs(), para AWS Lambda la función terminó sin errores.
Esto significa que el mecanismo nativo de reintentos de SQS (visibility timeout → maxReceiveCount → DLQ) jamás se entera de que un job falló por una excepción de PHP. Ni en el primer intento, ni en el tercero.
Laravel es ese compañero que en la daily dice "todo bien" con producción en llamas. Y SQS le cree.
c) Primera capa: cuando el job agota sus intentos en Laravel
Cuando un job falla por tercera vez y agota sus intentos, Laravel se rinde: ejecuta el método failed() de la clase y registra el fallo usando el driver configurado en config/queue.php (queue.failed).
El problema es que, por defecto, Laravel intenta guardar ese registro en una tabla failed_jobs de una base de datos SQL... de la cual nos despedimos en el capítulo anterior. Sin base relacional, la salida rápida es desactivarlo completamente con QUEUE_FAILED_DRIVER: null 3: la excepción igual queda en los logs de CloudWatch, pero el job no se guarda en ningún lado. No lo recomiendo.
La buena noticia es que Laravel ya incluye soporte nativo para guardar failed_jobs en DynamoDB 14. Solo debemos ajustar config/queue.php:
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
// Al utilizar driver=dynamodb no es necesario especificar 'database'
// 'database' => env('DB_CONNECTION', 'sqlite'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'token' => env('AWS_SESSION_TOKEN'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_FAILED_JOBS_TABLE', 'failed_jobs'),
],
provider:
environment:
QUEUE_FAILED_DRIVER: dynamodb
DYNAMODB_FAILED_JOBS_TABLE: !Ref FailedJobsTable
Y declaramos la tabla dentro de resources en serverless.yml. Laravel usa application como partition key (el nombre de la app) y uuid como sort key:
FailedJobsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: failed_jobs
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: application, AttributeType: S }
- { AttributeName: uuid, AttributeType: S }
KeySchema:
- { AttributeName: application, KeyType: HASH }
- { AttributeName: uuid, KeyType: RANGE }
TimeToLiveSpecification:
AttributeName: expires_at
Enabled: true
Así como lo hicimos en el capítulo anterior con la tabla de caché y sesiones, utilizamos
TimeToLiveSpecification; Laravel guarda cada job fallido con fecha de vencimiento (expires_at, 7 días por defecto 14). Este driver ni siquiera implementaqueue:flush: DynamoDB se encarga de limpiar los registros caducados.
Por último, le damos permisos sobre la tabla al rol de ejecución:
provider:
iam:
role:
statements:
- Effect: Allow
Resource: !GetAtt FailedJobsTable.Arn
Action:
- dynamodb:PutItem
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:DeleteItem
Para listar o reintentar los jobs fallidos, como en Lambda no hay SSH, usamos el puente bref:cli que vimos en el segundo capítulo:
serverless bref:cli --args="queue:failed"
serverless bref:cli --args="queue:retry all"
"Entonces, ¿para qué está la DLQ que creó Lift?"
d) Segunda capa: cuando falla la infraestructura
Un mensaje solo llega a la Dead Letter Queue cuando falla la invocación completa de Lambda, sin darle oportunidad a Laravel de atrapar el error. Por ejemplo:
- Un job superó el tiempo que le quedaba a la invocación (el
pcntl_alarmse activó y la ejecución salió conexit(1)). - Se quedó sin memoria (OOM).
- Un error fatal de PHP fuera del alcance del
Worker. - Un fallo de arranque: una extensión faltante, un error de configuración, un
composer installque dejó algo afuera.
En cualquiera de estos escenarios, Lambda muere antes de que Bref pueda borrar el mensaje, así que entra en acción el mecanismo nativo de SQS:
-
Espera (visibility timeout): el mensaje queda oculto hasta que vence su tiempo de visibilidad. Lift lo configura automáticamente en 6 veces el
timeoutdel worker siguiendo la recomendación de AWS 15 (con los 60 segundos de nuestro ejemplo, reaparece a los 6 minutos). - Reintento: una vez visible de nuevo, SQS vuelve a entregárselo a Lambda.
-
Rumbo a la DLQ: si la invocación falla 3 veces seguidas (
maxRetriespor defecto en Lift), SQS retira el mensaje de la cola principal y lo guarda en la Dead Letter Queue hasta por 14 días, evitando que se pierda.
Para inspeccionar qué cayó en la DLQ o volver a encolarlo sin entrar a la consola de AWS, Lift nos da estos comandos 16:
serverless jobs:logs --tail # logs del worker, en vivo
serverless jobs:failed # qué hay en la DLQ
serverless jobs:failed:retry # devolver todo a la cola principal
serverless jobs:failed:purge # borrar todo y seguir con la vida
Claro que nadie quiere ejecutar serverless jobs:failed todos los días por las dudas. Si agregan la propiedad alarm al construct, Lift configura automáticamente una alarma en CloudWatch que les envía un email apenas cae un mensaje en la DLQ 4:
constructs:
jobs:
type: queue
# ...
alarm: alertas@mi-dominio.com # alerta por email cuando la DLQ recibe mensajes
Luego del primer
serverless deploy, AWS les enviará un correo de confirmación al email configurado enalarm(no olviden confirmarlo para activar las alertas).
e) Las dos capas, lado a lado
Primera capa: failed_jobs
|
Segunda capa: DLQ de SQS | |
|---|---|---|
| ¿Qué falló? | Nuestro código: excepción en handle()
|
La infraestructura: timeout, OOM, error fatal |
| ¿Quién reintenta? | Laravel (#[Tries], #[Backoff]) |
SQS (visibility timeout, maxRetries) |
| ¿Qué se reintenta? | Ese job, tras el backoff configurado |
El job en curso y el resto del lote (6× timeout) |
| ¿Dónde termina? | Tabla failed_jobs (DynamoDB, 7 días) |
Dead Letter Queue (SQS, 14 días) |
| ¿Cómo lo reintento? | bref:cli --args="queue:retry ..." |
serverless jobs:failed:retry |
| ¿Cómo me entero? |
failed(), CloudWatch, queue:failed
|
Alerta por email (alarm:) |
Fíjense en la fila ¿Qué se reintenta? de la segunda capa. Cuando Lambda muere a mitad de un lote (por timeout u OOM), los jobs que ya habían terminado fueron eliminados uno a uno con $job->delete(), así que no se repiten. SQS solo vuelve a entregar lo que quedó sin borrar: el job que estaba a mitad de ejecución y los que venían detrás en la fila.
El peligro está en ese job que quedó a medias: cuando SQS lo reintente, va a correr de nuevo desde el principio. Si ya había disparado un cobro o enviado un email un segundo antes de que muriera Lambda y no es idempotente, lo hará por segunda vez (y van a aprender la palabra "reembolso" antes de lo que quisieran).
Regla de oro: diseñen siempre jobs idempotentes.
6. Ajustando las Perillas del Worker para Producción
Las 8 líneas de la sección 3 alcanzan para arrancar, pero ahora que conocemos qué pasa debajo del capó podemos ajustar las perillas para producción. Así se ve una configuración pensada para el mundo real:
constructs:
jobs:
type: queue
maxRetries: 5 # maxReceiveCount de la cola: AWS recomienda >= 5
maxConcurrency: 20 # el freno "limpio": Lambda deja de leer, los mensajes esperan
worker:
handler: Bref\LaravelBridge\Queue\QueueHandler
runtime: php-85
layers:
- ${bref-extra:gd-php-85}
reservedConcurrency: 25 # tope duro y reserva; siempre >= maxConcurrency
timeout: 300
batchSize: 10
a) timeout: el techo del job más largo
Define tanto el tiempo máximo que puede durar una invocación como el visibility timeout de la cola en SQS (que Lift multiplica ×6). En mi caso lo llevé a 300 segundos porque el worker procesa imágenes pesadas, y redimensionar una foto de 20 MB lleva su tiempo. Si sus jobs solo envían emails o llaman a una API, con 30 o 60 segundos van sobrados.
b) batchSize: menos invocaciones, más responsabilidad
Lift lo deja en 1 por defecto para simplificar el manejo de errores 17. Subirlo a 10 significa que una ráfaga de 500 jobs se procesa en 50 invocaciones en lugar de 500, reduciendo cold-starts y costos. A cambio, recuerden las dos condiciones que vimos antes: todos los jobs del lote comparten el mismo reloj de Lambda, y nuestros jobs deben ser idempotentes por si una invocación muere a mitad del lote.
Existe también
maxBatchingWindow: cuántos segundos espera SQS para juntar un lote completo antes de invocar. Cero por defecto. Si no les molesta que un job espere unos segundos a que lleguen compañeros, pueden subirlo.
c) reservedConcurrency vs maxConcurrency
Si llegan 1.000 jobs de golpe, Lambda escala rápido hasta cientos de invocaciones simultáneas. Y eso suena hermoso hasta que esos 1.000 jobs pegan al mismo tiempo contra una API externa con rate limit, contra nuestro servidor SMTP, o contra la misma DynamoDB.
Hay dos formas de ponerle un techo:
-
reservedConcurrency(propiedad de la función): la función nunca ejecuta más de N instancias simultáneas. Pero además reserva esas N de la cuota total de concurrencia de la cuenta, aunque estén sin uso 18. -
maxConcurrency(propiedad del construct de Lift): limita cuántas invocaciones puede disparar el event source mapping de esta cola, sin reservar nada 19.
Lo ideal es combinarlos: maxConcurrency como freno limpio (los mensajes esperan en la cola sin gastar intentos) y reservedConcurrency apenas por encima, como red de seguridad por si un bug despacha jobs en loop.
Ojo: si usan solo
reservedConcurrency, todo lo que supere ese límite cae en throttling, y cada rechazo cuenta como un intento en SQS. ConmaxRetries: 3(el default de Lift), un pico de tráfico puede mandar jobs directo a la DLQ sin haberse ejecutado ni una vez. Por eso AWS recomienda subirmaxRetriesa por lo menos 5 20.
d) memorySize: más memoria también es más CPU
Como vimos en el segundo capítulo, en Lambda la potencia de CPU escala de forma proporcional a la memoria asignada. Un job pesado que tarda 10 segundos con 512 MB puede resolverse en 3 segundos con 1.769 MB; y como AWS cobra por GB-segundo, darle más memoria muchas veces termina saliendo más barato. Si su worker hace trabajo intensivo, pueden asignarle su propio memorySize y medir el punto dulce con aws-lambda-power-tuning 21.
e) Layers a medida: extensiones solo donde hacen falta
¿Notaron el layer ${bref-extra:gd-php-85} en la configuración? Mi worker necesita la extensión GD para procesar imágenes, pero la función HTTP web no. Al ser funciones Lambda separadas, cada una carga únicamente las extensiones que usa: las peticiones web siguen livianas, y el peso extra solo lo carga el worker en segundo plano.
f) ¿Hace falta un warmer en el worker?
En el segundo capítulo implementamos un warmer cada 5 minutos para evitar el cold-start en las peticiones web. ¿Necesita uno el worker? Para nada. Un cold-start de 200 ms en una ruta HTTP lo sufre un usuario mirando cómo carga la pantalla; en una cola en segundo plano, 200 ms extra no le quitan el sueño a nadie.
7. Lo que No Te Cuentan los Tutoriales (y Descubres en Producción)
Cosas que no están en la documentación (o que estaban en letra chica y aprendí por las malas):
a) SerializesModels y las claves compuestas de DynamoDB
¿Notaron que en SendWelcomeEmail (sección 5.a) pasé $user_uuid como string en lugar del modelo User? No fue casualidad. SerializesModels guarda solo getKey() (PK) e intenta restaurar el modelo con una consulta SQL tradicional que no incluye la SK ni soporta DynamoDB.
Para sus propios jobs, lo más simple es pasar el uuid al constructor y buscar el modelo dentro de handle(). Ahora bien, si usan notificaciones encoladas ($user->notify()), Laravel serializa el modelo User sí o sí: ahí no queda otra que sobreescribir getQueueableId() y newQueryForRestoration() en su modelo para codificar y reconstruir PK + SK.
b) Bus::batch() también necesita dónde guardar su progreso
Por defecto busca la tabla relacional job_batches, pero Laravel incluye soporte nativo para DynamoDB: solo deben crear la tabla job_batches (partition key application, sort key id), darle permisos IAM y configurar queue.batching.driver en dynamodb 22.
c) El entorno local no necesita tocar AWS
Como el código de nuestros jobs no cambia según el driver, en su máquina pueden seguir usando QUEUE_CONNECTION=database (o sync) con el queue:work de toda la vida, o bien emular SQS localmente con herramientas como MiniStack 23 o LocalStack 24. QUEUE_CONNECTION=sqs apuntando a AWS solo hace falta en serverless.yml.
d) El límite es de 1 MiB por mensaje
Es el tope de SQS 25. Los jobs deben transportar identificadores livianos, no archivos en base64 ni colecciones de 10.000 elementos. Si están cerca de ese límite, el diseño les está pidiendo a gritos que el job busque los datos cuando corre en vez de recibirlos en el payload.
e) La limpieza del SDK de AWS del segundo capítulo
Si usaron aws/aws-sdk-php con removeUnusedServices en composer.json para reducir el peso del deploy, asegúrense de agregar Sqs a la lista de servicios permitidos:
"extra": {
"aws/aws-sdk-php": [
"DynamoDb",
"S3",
"Sqs"
]
}
¿Por qué es tan fácil olvidarlo? Porque los tests corren con QUEUE_CONNECTION=sync o Queue::fake(), así que el CI pasa todo en verde sin instanciar jamás SqsClient... hasta que el primer dispatch() real en producción explota porque la clase fue borrada.
8. Costos: El Worker que Solo Cobra Cuando Trabaja
Actualicemos la cuenta, como en cada capítulo.
-
Antes (EC2): el worker vivía en una de las instancias
t3.smalldel primer capítulo (~$15/mes por instancia). Un procesoqueue:workresidente con RAM reservada ysupervisordencendido las 24 horas, aunque la cola pasara el 90% del día vacía. -
Ahora (SQS + Lambda):
-
SQS: el primer millón de requests mensuales es gratis; después cuesta \$0.40 por millón 26. Sumando los ~630.000 requests del polling en reposo y 3 requests por cada job (
SendMessage,ReceiveMessageyDeleteMessage), pueden procesar hasta ~120.000 jobs al mes por $0.00. - Lambda: la capa gratuita perpetua incluye 1 millón de invocaciones y 400.000 GB-segundos al mes. Sin jobs en la cola, el cómputo cuesta exactamente cero.
-
SQS: el primer millón de requests mensuales es gratis; después cuesta \$0.40 por millón 26. Sumando los ~630.000 requests del polling en reposo y 3 requests por cada job (
Pongámoslo en números para un mes con 100.000 jobs (de ~500 ms promedio con 512 MB de RAM):
| Concepto | Dentro del Free Tier | Tarifa Plena (sin Free Tier) |
|---|---|---|
| Polling SQS (~630k req/mes) | $0.00 | $0.25 |
| Mensajes SQS (300k req/mes) | $0.00 | $0.12 |
| Lambda Worker (100k jobs) | $0.00 | $0.44 |
| Total mensual | $0.00 | ~$0.81 |
¡Incluso sin la capa gratuita, procesar 100.000 jobs cuesta menos de un dólar al mes!
Y lo más importante: con DynamoDB en PAY_PER_REQUEST, Lambda facturando por milisegundo y SQS por request, casi ningún componente del sistema de colas cobra tarifa fija por existir (a excepción del polling de SQS, que en mi caso cae dentro del free tier).
9. ¿Qué Sigue?
Hoy jubilamos definitivamente a supervisord y al proceso queue:work que corría las 24 horas: los reemplazamos por una cola SQS y un worker en Lambda que solo despierta cuando hay trabajo. Vimos que Bref reutiliza el mismo Worker de Laravel (así que nuestros jobs no cambian), cómo hace su truco en release() para conservar los reintentos y el backoff, y cómo conviven failed_jobs en DynamoDB con la Dead Letter Queue de SQS.
Pero quedó un cabo suelto entre el capítulo anterior y este. En la sección 6 les conté que mi worker carga la extensión GD para redimensionar imágenes; ahora bien, si en el capítulo anterior vimos que los archivos viajan directo del navegador a S3 sin pasar por nuestra aplicación... ¿quién le avisa a Laravel que terminó de subirse un archivo para encolar ese job?
En el próximo capítulo vamos a cerrar ese círculo con S3 Event Notifications, y veremos por qué la solución no es conectar S3 directo a la cola que acabamos de crear.
Una pregunta para ustedes: ¿con qué corren sus colas de Laravel hoy en producción, o cuál fue la peor jugada que les hizo un worker a las 3 AM? Los leo en los comentarios: las mejores historias de producción salen de ahí, no de los post-mortems.
-
https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html#function-configuration-deployment-and-execution ↩
-
https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html ↩
-
https://github.com/getlift/lift/blob/master/docs/permissions.md ↩
-
https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html#SQS-SendMessage-request-DelaySeconds ↩
-
https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-scaling.html ↩
-
https://aws.amazon.com/sqs/pricing/#How_are_Amazon_SQS_requests_priced.3F ↩
-
https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/QueueHandler.php#L53-L78 ↩
-
https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/Worker.php#L10-L39 ↩
-
https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/SqsJob.php#L13-L38 ↩
-
https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/QueueHandler.php#L107-L117 ↩
-
https://laravel.com/framework/docs/13.x/queues#storing-failed-jobs-in-dynamodb ↩
-
https://github.com/getlift/lift/blob/master/docs/queue.md#retry-delay ↩
-
https://github.com/getlift/lift/blob/master/docs/queue.md#commands ↩
-
https://github.com/getlift/lift/blob/master/docs/queue.md#batch-size ↩
-
https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html ↩
-
https://github.com/getlift/lift/blob/master/docs/queue.md#max-concurrency ↩
-
https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html#events-sqs-queueconfig ↩
-
https://laravel.com/framework/docs/13.x/queues#storing-batches-in-dynamodb ↩
-
https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html ↩


Top comments (0)