Backend-разработка

Laravel Queues в 2026 году: горизонтальное масштабирование воркеров с Redis и Kubernetes

Ruslan Ismailov Опубликовано 14 мин чтения
L

Введение: когда монолит перестаёт справляться

Очереди задач — одна из первых вещей, которые ломаются при росте нагрузки. Пока трафик умеренный, один процесс php artisan queue:work на сервере справляется. Но когда число заданий в очереди начинает расти быстрее, чем воркер их обрабатывает, возникают классические проблемы: задержки в доставке email, зависшие экспорты, накопившиеся уведомления и переполненные очереди.

В монолитной архитектуре горизонтальное масштабирование воркеров — боль. Нужно вручную запускать новые процессы, следить за их состоянием, балансировать нагрузку. В 2026 году стек Laravel + Redis + Kubernetes закрывает эту проблему элегантно: вы декларируете желаемое состояние, а инфраструктура сама подстраивается под нагрузку.

Эта статья — практическое руководство для middle/senior PHP-разработчиков, которые хотят выстроить надёжную, масштабируемую систему обработки фоновых задач на базе Laravel Queues, Redis Cluster и Kubernetes с HPA.

Архитектура Laravel Queues + Redis в 2026 году

Именованные очереди и приоритеты

Laravel позволяет организовать несколько именованных очередей с разными приоритетами. Воркер обрабатывает очереди в порядке, указанном при запуске:

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

В коде задачи приоритет задаётся через свойство $queue или при диспетчеризации:

<?php

// Диспетчеризация в конкретную очередь
SendInvoice::dispatch($order)->onQueue('critical');

// Или через свойство класса
class ProcessPayment implements ShouldQueue
{
    public string $queue = 'critical';
    public int $tries = 5;
    public int $timeout = 120;
    public int $backoff = 30;
}

Батчи задач (Job Batching)

Job Batching, появившийся в Laravel 8, в 2026 году стал стандартом для параллельной обработки больших объёмов данных. Батч позволяет запустить тысячи задач и получить колбэк по завершению:

<?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) {
    // Все задачи выполнены успешно
    ImportCompleted::dispatch($batch->id);
})->catch(function (Batch $batch, Throwable $e) {
    // Первый сбой в батче
    Log::error('Batch failed', ['batch_id' => $batch->id, 'error' => $e->getMessage()]);
})->finally(function (Batch $batch) {
    // Всегда выполняется
    Cache::forget('import_lock');
})->onQueue('high')
  ->dispatch();

Конфигурация queue.php для Redis

<?php
// config/queue.php

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

    'connections' => [
        'redis' => [
            'driver'      => 'redis',
            'connection'  => 'queue', // отдельное Redis-соединение
            'queue'       => env('REDIS_QUEUE', 'default'),
            'retry_after' => 90,
            'block_for'   => 5,   // блокирующий BLPOP вместо поллинга
            'after_commit' => true, // диспетч только после коммита транзакции
        ],
    ],
];

Параметр after_commit критически важен в production: он гарантирует, что задача попадёт в очередь только после успешной фиксации транзакции в базе данных.

Настройка Redis Cluster как backend для очередей

Для высоконагруженных систем одиночный Redis-экземпляр становится узким местом. Redis Cluster распределяет данные по нескольким нодам и обеспечивает отказоустойчивость.

Настройте отдельное Redis-соединение для очередей в 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'),
        // Для 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',
        ],
    ],
],

Важный момент: при использовании Redis Cluster все ключи одной очереди должны попадать в один слот. Laravel автоматически использует хэш-теги ({queue_name}) для этого, но убедитесь, что версия predis или расширение phpredis поддерживает кластерный режим.

Контейнеризация воркеров: Dockerfile и Supervisor

Dockerfile для воркера

FROM php:8.3-cli-alpine

# Системные зависимости
RUN apk add --no-cache \
    supervisor \
    git \
    curl \
    libpng-dev \
    oniguruma-dev \
    libxml2-dev

# PHP-расширения
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath

# phpredis для лучшей производительности
RUN pecl install redis && docker-php-ext-enable redis

# Opcache для CLI (ускоряет запуск воркера)
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 . .

# Конфиг Supervisor
COPY docker/supervisor/worker.conf /etc/supervisor/conf.d/worker.conf

# Права
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"]

Конфигурация 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

Переменная окружения WORKER_NUMPROCS позволяет управлять числом процессов внутри контейнера. В связке с Kubernetes это даёт двухуровневое масштабирование: количество подов × количество процессов внутри пода.

Деплой воркеров как Kubernetes Deployment с 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 при обновлении
  template:
    metadata:
      labels:
        app: laravel-queue-worker
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9253"
    spec:
      terminationGracePeriodSeconds: 3600  # ждём завершения длинных задач
      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: дождаться завершения текущей задачи
                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:
    # Масштабирование по CPU
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    # Масштабирование по кастомной метрике (размер очереди из Prometheus)
    - type: External
      external:
        metric:
          name: laravel_queue_size
          selector:
            matchLabels:
              queue: default
        target:
          type: AverageValue
          averageValue: "100"  # 1 под на каждые 100 задач в очереди
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  # медленно уменьшаем, чтобы не флаппать
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120

Масштабирование по кастомной метрике (размер очереди Redis) — ключевое преимущество этого подхода. Когда очередь растёт, Kubernetes автоматически добавляет воркеры; когда она пустеет — уменьшает их количество, экономя ресурсы.

Мониторинг очередей: Laravel Horizon и Prometheus

Настройка Laravel Horizon

Laravel Horizon предоставляет красивый дашборд и детальную статистику по очередям. Установка:

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

Конфигурация config/horizon.php для Kubernetes-окружения:

<?php
// config/horizon.php

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

    'waits' => [
        'redis:critical' => 3,   // алерт если задача ждёт >3 сек
        'redis:default'  => 60,
        'redis:low'      => 300,
    ],

    'trim' => [
        'recent'        => 60,
        'pending'       => 60,
        'completed'     => 60,
        'recent_failed' => 10080, // 7 дней
        '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,
            ],
        ],
    ],
];

Prometheus-метрики для Kubernetes HPA

Для экспорта метрик Laravel-очередей в Prometheus используйте пакет spatie/laravel-prometheus или напишите собственный эндпоинт:

<?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";
        }

        // Failed jobs count
        $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']);
    }
}

Зарегистрируйте роут без middleware в routes/api.php:

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

Обработка failed jobs и dead letter queue

Конфигурация повторных попыток

В классе задачи настройте стратегию повторных попыток с экспоненциальной задержкой:

<?php

class ProcessWebhook implements ShouldQueue
{
    public int $tries = 5;
    public int $timeout = 60;
    public int $maxExceptions = 3; // сбой после 3 исключений, даже если tries не исчерпаны

    // Экспоненциальный backoff: 10, 60, 180, 420 секунд
    public function backoff(): array
    {
        return [10, 60, 180, 420];
    }

    public function handle(): void
    {
        // обработка вебхука
    }

    public function failed(Throwable $exception): void
    {
        // Уведомление в Slack/PagerDuty при окончательном сбое
        Log::error('Webhook processing failed', [
            'job_id'    => $this->job->getJobId(),
            'error'     => $exception->getMessage(),
            'payload'   => $this->webhook->toArray(),
        ]);

        // Помещаем в dead letter queue для ручного анализа
        DeadLetterQueue::push($this->webhook, $exception);
    }
}

Dead Letter Queue через отдельную очередь

Реализуйте паттерн Dead Letter Queue через именованную очередь dead-letter и отдельный воркер для мониторинга:

<?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
    {
        // Логика для повторной диспетчеризации из DLQ
        Artisan::call('queue:retry', ['id' => [$jobId]]);
    }
}

Artisan-команды для управления failed jobs

# Список упавших задач
php artisan queue:failed

# Повторная попытка конкретной задачи
php artisan queue:retry 5d5a4fb3-5e82-4af3-bedf-6df64b4ee59b

# Повторная попытка всех упавших задач
php artisan queue:retry all

# Очистка упавших задач старше 7 дней
php artisan queue:prune-failed --hours=168

# Просмотр размера очередей через Horizon
php artisan horizon:status

Советы по production-настройке и подводные камни

Graceful shutdown в Kubernetes

Один из главных подводных камней — принудительное убийство пода при масштабировании вниз. Настройте terminationGracePeriodSeconds равным максимальному таймауту задачи, и используйте preStop хук для отправки сигнала queue:restart. Воркеры завершат текущие задачи и корректно остановятся.

Memory leaks в долгоживущих воркерах

PHP не освобождает память так эффективно, как Go или Java. Используйте параметры --max-time и --memory чтобы воркер перезапускался до достижения критических значений. Supervisor автоматически запустит новый процесс.

Идемпотентность задач

Всегда проектируйте задачи идемпотентно: повторный запуск одной задачи не должен приводить к дублированию эффектов. Используйте Redis-блокировки для критических секций:

<?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();
    }
}

Разделение очередей по критичности

Не смешивайте платёжные задачи и рассылку новостей в одной очереди. Выделите отдельные Deployment-ы для критических очередей с гарантированными ресурсами и отдельные — для фоновых задач с лимитами.

Мониторинг lag-времени

Настройте алерты на время ожидания задачи в очереди (queue lag). Если задача в очереди critical ждёт больше 5 секунд — это уже инцидент. Laravel Horizon из коробки показывает этот параметр на дашборде.

Redis persistence

Убедитесь, что Redis настроен с AOF-персистентностью (appendonly yes) или используйте Redis Sentinel/Cluster. Потеря данных в очереди при падении Redis без персистентности — реальный production-риск.

Заключение

Связка Laravel Queues + Redis Cluster + Kubernetes HPA в 2026 году — это зрелый, production-готовый подход к горизонтальному масштабированию фоновых задач. Вы получаете автоматическое масштабирование воркеров на основе реального размера очереди, graceful shutdown без потери задач, детальный мониторинг через Laravel Horizon и Prometheus, и надёжную обработку сбоев через dead letter queue.

Ключевые принципы, которые стоит запомнить: задачи должны быть идемпотентными, воркеры — stateless и легко перезапускаемыми, а мониторинг метрик очереди должен быть основой для принятия решений об автоскейлинге. Начните с базовой конфигурации из этой статьи и адаптируйте её под специфику вашей нагрузки.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →