DEV Community

Cover image for Qué Reemplazó a queue:work en Mi Aplicación Laravel (Y Por Qué Dejé de Rezarle a Supervisor a las 3 AM)
Hermann D. Schimpf
Hermann D. Schimpf

Posted on Originally published at hds-solutions.net

Qué Reemplazó a queue:work en Mi Aplicación Laravel (Y Por Qué Dejé de Rezarle a Supervisor a las 3 AM)

English version here.


Banner Image


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:

AWS Serverless architecture

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 SSH $ 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:restart después de cada deploy, porque el proceso tiene el código viejo cargado en memoria.
  • --max-jobs y --max-time para 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:work es 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):

  1. Laravel hace dispatch() y registra el job en SQS.
  2. SQS detecta que hay mensajes e invoca a una función Lambda con esos mensajes.
  3. Lambda procesa los jobs, termina y desaparece.
  4. 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 payload de la tabla jobs si usaran el driver database.
  • 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_after de 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
Enter fullscreen mode Exit fullscreen mode

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 usamos php-85-console. Al igual que con OctaneHandler, usamos el runtime de eventos php-85 con la clase QueueHandler de 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 worker con 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:SendMessage a 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);
Enter fullscreen mode Exit fullscreen mode

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 queue adicional, y desde Laravel 13.26 se enrutan con Queue::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 ReceiveMessage en 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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Tres detalles clave en esas pocas líneas:

  1. Ese Worker es el de Laravel. Literalmente extiende de Illuminate\Queue\Worker 11. Bref no reinventó cómo correr un job: le pasa cada mensaje al mismo motor que usa queue:work. Consecuencia directa: #[Tries], #[Backoff], $timeout, failed(), middlewares, ShouldBeUnique, retryUntil() y los eventos JobProcessing/JobProcessed/JobFailed... todo funciona igual que en un servidor tradicional. No hay una tabla de "features soportadas" que consultar.
  2. 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.
  3. 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 timeout del worker en serverless.yml es, 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 batchSize mayor 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 al timeout del worker, sobre todo con batchSize mayor 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á
    }
}
Enter fullscreen mode Exit fullscreen mode

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),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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'),
],
Enter fullscreen mode Exit fullscreen mode
provider:
  environment:
    QUEUE_FAILED_DRIVER: dynamodb
    DYNAMODB_FAILED_JOBS_TABLE: !Ref FailedJobsTable
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 implementa queue: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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

"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_alarm se activó y la ejecución salió con exit(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 install que 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:

  1. Espera (visibility timeout): el mensaje queda oculto hasta que vence su tiempo de visibilidad. Lift lo configura automáticamente en 6 veces el timeout del worker siguiendo la recomendación de AWS 15 (con los 60 segundos de nuestro ejemplo, reaparece a los 6 minutos).
  2. Reintento: una vez visible de nuevo, SQS vuelve a entregárselo a Lambda.
  3. Rumbo a la DLQ: si la invocación falla 3 veces seguidas (maxRetries por 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Luego del primer serverless deploy, AWS les enviará un correo de confirmación al email configurado en alarm (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
Enter fullscreen mode Exit fullscreen mode

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. Con maxRetries: 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 subir maxRetries a 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"
    ]
}
Enter fullscreen mode Exit fullscreen mode

¿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.small del primer capítulo (~$15/mes por instancia). Un proceso queue:work residente con RAM reservada y supervisord encendido 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, ReceiveMessage y DeleteMessage), 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.

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.


  1. https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html#function-configuration-deployment-and-execution ↩

  2. https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html ↩

  3. https://bref.sh/docs/laravel/queues ↩

  4. https://github.com/getlift/lift/blob/master/docs/queue.md ↩

  5. https://github.com/getlift/lift/blob/master/docs/permissions.md ↩

  6. https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html#SQS-SendMessage-request-DelaySeconds ↩

  7. https://bref.sh/docs/laravel/queues#multiple-queues ↩

  8. https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-scaling.html ↩

  9. https://aws.amazon.com/sqs/pricing/#How_are_Amazon_SQS_requests_priced.3F ↩

  10. https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/QueueHandler.php#L53-L78 ↩

  11. https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/Worker.php#L10-L39 ↩

  12. https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/SqsJob.php#L13-L38 ↩

  13. https://github.com/brefphp/laravel-bridge/blob/a62196d5cb3efe95861fc6b78f7e5606bd2ad09d/src/Queue/QueueHandler.php#L107-L117 ↩

  14. https://laravel.com/framework/docs/13.x/queues#storing-failed-jobs-in-dynamodb ↩

  15. https://github.com/getlift/lift/blob/master/docs/queue.md#retry-delay ↩

  16. https://github.com/getlift/lift/blob/master/docs/queue.md#commands ↩

  17. https://github.com/getlift/lift/blob/master/docs/queue.md#batch-size ↩

  18. https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html ↩

  19. https://github.com/getlift/lift/blob/master/docs/queue.md#max-concurrency ↩

  20. https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html#events-sqs-queueconfig ↩

  21. https://github.com/alexcasalboni/aws-lambda-power-tuning ↩

  22. https://laravel.com/framework/docs/13.x/queues#storing-batches-in-dynamodb ↩

  23. https://github.com/ministackorg/ministack ↩

  24. https://github.com/localstack/localstack ↩

  25. https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html ↩

  26. https://aws.amazon.com/sqs/pricing/ ↩

Top comments (0)