Desarrollo backend

Laravel Queues en 2026: escalado horizontal de workers con Redis y Kubernetes

Ruslan Ismailov Publicado 14 min de lectura
L

Introducción: cuando el monolito deja de dar abasto

Las colas de tareas son de las primeras cosas que fallan cuando aumenta la carga. Con tráfico moderado, un solo proceso php artisan queue:work en el servidor es suficiente. Pero cuando el número de trabajos en cola crece más rápido de lo que el worker los procesa, aparecen los problemas clásicos: retrasos en la entrega de emails, exportaciones bloqueadas, notificaciones acumuladas y colas desbordadas.

En una arquitectura monolítica, el escalado horizontal de workers es un dolor de cabeza. Hay que lanzar nuevos procesos manualmente, controlar su estado y balancear la carga. En 2026, el stack Laravel + Redis + Kubernetes resuelve este problema con elegancia: declaras el estado deseado y la infraestructura se adapta sola a la carga.

Este artículo es una guía práctica para desarrolladores PHP de nivel medio/senior que quieren construir un sistema fiable y escalable de procesamiento de tareas en segundo plano basado en Laravel Queues, Redis Cluster y Kubernetes con HPA.

Arquitectura de Laravel Queues + Redis en 2026

Colas con nombre y prioridades

Laravel permite organizar múltiples colas con nombre y diferentes prioridades. El worker procesa las colas en el orden indicado al iniciarse:

php artisan queue:work redis --queue=critical,high,default,low --sleep=3 --tries=3 --max-time=3600

En el código del job, la prioridad se define mediante la propiedad $queue o al despachar:

<?php

// Despachar a una cola específica
SendInvoice::dispatch($order)->onQueue('critical');

// O mediante la propiedad de la clase
class ProcessPayment implements ShouldQueue
{
    public string $queue = 'critical';
    public int $tries = 5;
    public int $timeout = 120;
    public int $backoff = 30;
}

Lotes de tareas (Job Batching)

El Job Batching, introducido en Laravel 8, se ha convertido en 2026 en el estándar para el procesamiento paralelo de grandes volúmenes de datos. Un batch permite lanzar miles de tareas y recibir un callback al finalizar:

<?php

use Illuminate\\Bus\\Batch;
use Illuminate\\Support\\Facades\\Bus;

$batch = Bus::batch([
    new ImportChunk($chunk1),
    new ImportChunk($chunk2),
    new ImportChunk($chunk3),
])->then(function (Batch $batch) {
    // Todas las tareas completadas con éxito
    ImportCompleted::dispatch($batch->id);
})->catch(function (Batch $batch, Throwable $e) {
    // Primer fallo en el batch
    Log::error('Batch failed', ['batch_id' => $batch->id, 'error' => $e->getMessage()]);
})->finally(function (Batch $batch) {
    // Siempre se ejecuta
    Cache::forget('import_lock');
})->onQueue('high')
  ->dispatch();

Configuración de queue.php para Redis

<?php
// config/queue.php

return [
    'default' => env('QUEUE_CONNECTION', 'redis'),

    'connections' => [
        'redis' => [
            'driver'      => 'redis',
            'connection'  => 'queue', // conexión Redis separada
            'queue'       => env('REDIS_QUEUE', 'default'),
            'retry_after' => 90,
            'block_for'   => 5,   // BLPOP bloqueante en lugar de polling
            'after_commit' => true, // despachar solo tras confirmar la transacción
        ],
    ],
];

El parámetro after_commit es crítico en producción: garantiza que la tarea llegue a la cola solo después de que la transacción se haya confirmado correctamente en la base de datos.

Configuración de Redis Cluster como backend para las colas

En sistemas de alta carga, una instancia Redis única se convierte en un cuello de botella. Redis Cluster distribuye los datos entre varios nodos y proporciona tolerancia a fallos.

Configura una conexión Redis separada para las colas en config/database.php:

<?php
// config/database.php

'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),

    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
        'prefix'  => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
    ],

    'queue' => [
        'url'      => env('REDIS_URL'),
        'host'     => env('REDIS_HOST', '127.0.0.1'),
        'username' => env('REDIS_USERNAME'),
        'password' => env('REDIS_PASSWORD'),
        'port'     => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_QUEUE_DB', '1'),
        // Para Redis Cluster
        'cluster'  => [
            ['host' => 'redis-node-1', 'port' => 6379, 'password' => env('REDIS_PASSWORD')],
            ['host' => 'redis-node-2', 'port' => 6379, 'password' => env('REDIS_PASSWORD')],
            ['host' => 'redis-node-3', 'port' => 6379, 'password' => env('REDIS_PASSWORD')],
        ],
        'options'  => [
            'cluster' => 'redis',
        ],
    ],
],

Punto importante: al usar Redis Cluster, todas las claves de una misma cola deben caer en el mismo slot. Laravel usa automáticamente hash tags ({queue_name}) para esto, pero asegúrate de que la versión de predis o la extensión phpredis soporta el modo cluster.

Contenerización de workers: Dockerfile y Supervisor

Dockerfile para el worker

FROM php:8.3-cli-alpine

# Dependencias del sistema
RUN apk add --no-cache \
    supervisor \
    git \
    curl \
    libpng-dev \
    oniguruma-dev \
    libxml2-dev

# Extensiones PHP
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath

# phpredis para mayor rendimiento
RUN pecl install redis && docker-php-ext-enable redis

# Opcache para CLI (acelera el arranque del worker)
RUN docker-php-ext-install opcache

# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www

COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction

COPY . .

# Configuración de Supervisor
COPY docker/supervisor/worker.conf /etc/supervisor/conf.d/worker.conf

# Permisos
RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache

USER www-data

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

Configuración de Supervisor

; docker/supervisor/worker.conf
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/artisan queue:work redis \
    --queue=critical,high,default,low \
    --sleep=3 \
    --tries=3 \
    --max-time=3600 \
    --memory=256 \
    --timeout=90
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=%(ENV_WORKER_NUMPROCS)s
redirect_stderr=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stopwaitsecs=3600

La variable de entorno WORKER_NUMPROCS permite controlar el número de procesos dentro del contenedor. Combinado con Kubernetes, esto ofrece un escalado en dos niveles: número de pods × número de procesos dentro del pod.

Despliegue de workers como Kubernetes Deployment con HPA

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: laravel-queue-worker
  namespace: production
  labels:
    app: laravel-queue-worker
spec:
  replicas: 3
  selector:
    matchLabels:
      app: laravel-queue-worker
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 0  # zero-downtime durante actualizaciones
  template:
    metadata:
      labels:
        app: laravel-queue-worker
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9253"
    spec:
      terminationGracePeriodSeconds: 3600  # esperar a que finalicen tareas largas
      containers:
        - name: worker
          image: your-registry/laravel-app:latest
          imagePullPolicy: Always
          env:
            - name: APP_ENV
              value: production
            - name: QUEUE_CONNECTION
              value: redis
            - name: WORKER_NUMPROCS
              value: "2"
          envFrom:
            - secretRef:
                name: laravel-secrets
          resources:
            requests:
              memory: "256Mi"
              cpu: "200m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          lifecycle:
            preStop:
              exec:
                # Graceful shutdown: esperar a que la tarea actual finalice
                command: ["/bin/sh", "-c", "php artisan queue:restart && sleep 30"]
      restartPolicy: Always

Horizontal Pod Autoscaler (HPA)

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: laravel-queue-worker-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: laravel-queue-worker
  minReplicas: 2
  maxReplicas: 20
  metrics:
    # Escalado por CPU
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    # Escalado por métrica personalizada (tamaño de cola desde Prometheus)
    - type: External
      external:
        metric:
          name: laravel_queue_size
          selector:
            matchLabels:
              queue: default
        target:
          type: AverageValue
          averageValue: "100"  # 1 pod por cada 100 tareas en cola
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  # reducimos despacio para evitar flapping
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120

El escalado por métrica personalizada (tamaño de la cola Redis) es la ventaja clave de este enfoque. Cuando la cola crece, Kubernetes añade workers automáticamente; cuando se vacía, los reduce, ahorrando recursos.

Monitoreo de colas: Laravel Horizon y Prometheus

Configuración de Laravel Horizon

Laravel Horizon proporciona un dashboard elegante y estadísticas detalladas sobre las colas. Instalación:

composer require laravel/horizon
php artisan horizon:install
php artisan migrate

Configuración de config/horizon.php para entorno Kubernetes:

<?php
// config/horizon.php

return [
    'use'     => 'default',
    'prefix'  => env('HORIZON_PREFIX', 'horizon:'),
    'middleware' => ['web', 'auth'],

    'waits' => [
        'redis:critical' => 3,   // alerta si la tarea espera más de 3 seg
        'redis:default'  => 60,
        'redis:low'      => 300,
    ],

    'trim' => [
        'recent'        => 60,
        'pending'       => 60,
        'completed'     => 60,
        'recent_failed' => 10080, // 7 días
        'failed'        => 10080,
        'monitored'     => 10080,
    ],

    'silenced' => [],

    'metrics' => [
        'trim_snapshots' => [
            'job'   => 24,
            'queue' => 24,
        ],
    ],

    'fast_termination' => false,
    'memory_limit'     => 256,

    'environments' => [
        'production' => [
            'supervisor-critical' => [
                'connection' => 'redis',
                'queue'      => ['critical'],
                'balance'    => 'auto',
                'autoScalingStrategy' => 'time',
                'minProcesses' => 2,
                'maxProcesses' => 10,
                'tries'      => 3,
                'timeout'    => 90,
            ],
            'supervisor-default' => [
                'connection' => 'redis',
                'queue'      => ['high', 'default', 'low'],
                'balance'    => 'auto',
                'autoScalingStrategy' => 'size',
                'minProcesses' => 1,
                'maxProcesses' => 20,
                'balanceMaxShift'  => 3,
                'balanceCooldown' => 3,
                'tries'      => 3,
                'timeout'    => 120,
            ],
        ],
    ],
];

Métricas de Prometheus para Kubernetes HPA

Para exportar métricas de las colas de Laravel a Prometheus, usa el paquete spatie/laravel-prometheus o crea tu propio endpoint:

<?php
// app/Http/Controllers/MetricsController.php

namespace App\\Http\\Controllers;

use Illuminate\\Support\\Facades\\Redis;

class MetricsController extends Controller
{
    public function index(): string
    {
        $queues = ['critical', 'high', 'default', 'low'];
        $output = '';

        foreach ($queues as $queue) {
            $size = Redis::connection('queue')->llen('queues:' . $queue);
            $output .= "laravel_queue_size{queue=\"$queue\"} $size\n";
        }

        // Conteo de failed jobs
        $failedCount = Redis::connection('queue')->zcard('failed_jobs');
        $output .= "laravel_queue_failed_jobs_total $failedCount\n";

        return response($output, 200, ['Content-Type' => 'text/plain; version=0.0.4']);
    }
}

Registra la ruta sin middleware en routes/api.php:

Route::get('/metrics', [MetricsController::class, 'index'])
    ->middleware('throttle:60,1');

Gestión de failed jobs y dead letter queue

Configuración de reintentos

En la clase del job, configura la estrategia de reintentos con backoff exponencial:

<?php

class ProcessWebhook implements ShouldQueue
{
    public int $tries = 5;
    public int $timeout = 60;
    public int $maxExceptions = 3; // falla tras 3 excepciones, aunque no se agoten los tries

    // Backoff exponencial: 10, 60, 180, 420 segundos
    public function backoff(): array
    {
        return [10, 60, 180, 420];
    }

    public function handle(): void
    {
        // procesamiento del webhook
    }

    public function failed(Throwable $exception): void
    {
        // Notificación a Slack/PagerDuty en caso de fallo definitivo
        Log::error('Webhook processing failed', [
            'job_id'    => $this->job->getJobId(),
            'error'     => $exception->getMessage(),
            'payload'   => $this->webhook->toArray(),
        ]);

        // Enviamos a dead letter queue para análisis manual
        DeadLetterQueue::push($this->webhook, $exception);
    }
}

Dead Letter Queue mediante una cola separada

Implementa el patrón Dead Letter Queue mediante una cola con nombre dead-letter y un worker separado para monitoreo:

<?php

class DeadLetterQueue
{
    public static function push(mixed $payload, Throwable $exception): void
    {
        Redis::connection('queue')->rpush('queues:dead-letter', json_encode([
            'payload'    => serialize($payload),
            'exception'  => $exception->getMessage(),
            'trace'      => $exception->getTraceAsString(),
            'failed_at'  => now()->toISOString(),
        ]));
    }

    public static function requeue(string $jobId): void
    {
        // Lógica para redespachar desde la DLQ
        Artisan::call('queue:retry', ['id' => [$jobId]]);
    }
}

Comandos Artisan para gestionar failed jobs

# Listar tareas fallidas
php artisan queue:failed

# Reintentar una tarea específica
php artisan queue:retry 5d5a4fb3-5e82-4af3-bedf-6df64b4ee59b

# Reintentar todas las tareas fallidas
php artisan queue:retry all

# Limpiar tareas fallidas con más de 7 días
php artisan queue:prune-failed --hours=168

# Ver el estado de las colas con Horizon
php artisan horizon:status

Consejos de configuración en producción y errores comunes

Graceful shutdown en Kubernetes

Uno de los principales problemas es la terminación forzada del pod durante el escalado hacia abajo. Configura terminationGracePeriodSeconds igual al timeout máximo de la tarea y usa el hook preStop para enviar la señal queue:restart. Los workers terminarán las tareas actuales y se detendrán correctamente.

Fugas de memoria en workers de larga duración

PHP no libera memoria tan eficientemente como Go o Java. Usa los parámetros --max-time y --memory para que el worker se reinicie antes de alcanzar valores críticos. Supervisor lanzará automáticamente un nuevo proceso.

Idempotencia de las tareas

Diseña siempre las tareas de forma idempotente: ejecutar una tarea varias veces no debe producir efectos duplicados. Usa bloqueos de Redis para las secciones críticas:

<?php

public function handle(): void
{
    $lock = Cache::lock('process-order-' . $this->orderId, 120);

    if (! $lock->get()) {
        Log::warning('Job already running, skipping', ['order_id' => $this->orderId]);
        return;
    }

    try {
        $this->processOrder();
    } finally {
        $lock->release();
    }
}

Separación de colas por criticidad

No mezcles tareas de pago y envíos de newsletters en la misma cola. Destina Deployments separados para colas críticas con recursos garantizados y otros distintos para tareas en segundo plano con límites definidos.

Monitoreo del lag de cola

Configura alertas sobre el tiempo de espera de una tarea en cola (queue lag). Si una tarea en la cola critical espera más de 5 segundos, ya es un incidente. Laravel Horizon muestra este parámetro directamente en el dashboard.

Persistencia de Redis

Asegúrate de que Redis esté configurado con persistencia AOF (appendonly yes) o usa Redis Sentinel/Cluster. La pérdida de datos en la cola si Redis cae sin persistencia es un riesgo real en producción.

Conclusión

La combinación Laravel Queues + Redis Cluster + Kubernetes HPA en 2026 es un enfoque maduro y listo para producción para el escalado horizontal de tareas en segundo plano. Obtienes escalado automático de workers basado en el tamaño real de la cola, graceful shutdown sin pérdida de tareas, monitoreo detallado con Laravel Horizon y Prometheus, y gestión fiable de fallos mediante dead letter queue.

Los principios clave a recordar: las tareas deben ser idempotentes, los workers deben ser stateless y fáciles de reiniciar, y el monitoreo de las métricas de cola debe ser la base para las decisiones de autoescalado. Comienza con la configuración básica de este artículo y adáptala a las necesidades específicas de tu carga de trabajo.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →