DevOps

PHP и Kubernetes: горизонтальное масштабирование воркеров очередей с учётом длины очереди Redis

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

Введение: почему HPA по CPU недостаточно для воркеров очередей

Горизонтальное масштабирование воркеров очередей — одна из классических задач в highload-архитектурах. На первый взгляд кажется, что стандартный Horizontal Pod Autoscaler (HPA) в Kubernetes справится с этим из коробки: растёт нагрузка — растёт потребление CPU — добавляются реплики. Но на практике такой подход плохо работает именно для воркеров очередей.

Проблема в том, что PHP-воркер очереди потребляет CPU не в момент накопления задач, а в момент их обработки. Если в очереди Redis накопилось 10 000 задач, а воркеров запущено мало, CPU каждого из них будет загружен на 100% — но реакция HPA всегда запаздывает: сначала нужно зафиксировать высокий CPU, потом усреднить метрику по окну наблюдения (обычно 1–3 минуты), потом принять решение о масштабировании. За это время очередь продолжает расти.

Второй сценарий ещё хуже: задачи лёгкие (например, отправка email через внешний API), и воркер проводит 90% времени в ожидании I/O. CPU остаётся низким, HPA не реагирует, очередь растёт часами. В обоих случаях метрика CPU — это косвенный и запаздывающий индикатор.

Правильный сигнал для масштабирования воркеров очередей — это длина самой очереди. Чем больше задач ждёт обработки, тем больше воркеров нужно запустить. Именно для этого существует KEDA (Kubernetes Event-driven Autoscaling) — инструмент, который умеет масштабировать Deployment напрямую по метрикам из внешних источников, включая Redis. В этой статье мы разберём production-ready настройку такого масштабирования для PHP-приложений.

Обзор архитектуры

Архитектура решения состоит из трёх ключевых компонентов:

  • PHP-воркер — процесс, который читает задачи из очереди Redis и выполняет их. Это может быть Laravel Queue Worker (php artisan queue:work) или нативный PHP-скрипт с циклом обработки.
  • Redis как брокер очередей — хранит задачи в виде списков (LIST). Laravel по умолчанию использует команду BLPOP для блокирующего чтения из ключа вида queues:default.
  • KEDA — оператор Kubernetes, который устанавливается в кластер и добавляет новый тип ресурса ScaledObject. KEDA опрашивает Redis (или другой источник), получает длину списка и на основании этого управляет количеством реплик Deployment.

Поток данных выглядит так: продюсер (веб-запрос, крон или другой сервис) кладёт задачи в Redis. KEDA периодически (по умолчанию каждые 30 секунд) запрашивает длину списка командой LLEN и вычисляет желаемое количество реплик: ceil(длина_очереди / targetValue). Kubernetes меняет количество Pod'ов Deployment, соблюдая ограничения minReplicaCount и maxReplicaCount.

Подготовка PHP-приложения: Dockerfile для воркера

Для production-среды воркер должен запускаться в отдельном Docker-образе, оптимизированном под долгоживущий процесс. Ниже приведён пример Dockerfile для Laravel-воркера:

FROM php:8.3-cli-alpine

RUN apk add --no-cache \
    linux-headers \
    $PHPIZE_DEPS \
  && pecl install redis \
  && docker-php-ext-enable redis \
  && pecl install pcntl \
  && docker-php-ext-install pcntl \
  && apk del $PHPIZE_DEPS

WORKDIR /app

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader

COPY . .

RUN php artisan config:cache \
  && php artisan route:cache \
  && php artisan event:cache

USER nobody

CMD ["php", "artisan", "queue:work", "redis", \
     "--queue=default", \
     "--sleep=3", \
     "--tries=3", \
     "--max-time=3600", \
     "--stop-when-empty"]

Несколько важных решений в этом Dockerfile:

  • Используется php:8.3-cli-alpine — минимальный образ без лишних веб-сервер компонентов.
  • Флаг --stop-when-empty заставляет воркер завершаться, когда очередь пуста. Это критично для корректного scale-down: KEDA уменьшает реплики, Pod получает SIGTERM, воркер завершает текущую задачу и выходит.
  • Флаг --max-time=3600 ограничивает время жизни воркера одним часом, что предотвращает утечки памяти в долгоживущих PHP-процессах.
  • Запуск от пользователя nobody следует принципу наименьших привилегий.

Конфигурация подключения к Redis задаётся через переменные окружения в config/queue.php:

'redis' => [
    'driver'     => 'redis',
    'connection' => 'default',
    'queue'      => env('REDIS_QUEUE', 'default'),
    'retry_after' => 90,
    'block_for'  => 5,
],

Установка KEDA в кластер Kubernetes

KEDA устанавливается через Helm — наиболее удобный способ для production:

helm repo add kedacore https://kedacore.github.io/charts
helm repo update

helm install keda kedacore/keda \
  --namespace keda \
  --create-namespace \
  --set prometheus.metricServer.enabled=true \
  --set prometheus.operator.enabled=true \
  --version 2.14.0

После установки проверьте, что поды KEDA запущены:

kubectl get pods -n keda
# NAME                                      READY   STATUS    RESTARTS   AGE
# keda-operator-7d8f9b6c4-xk2pq            1/1     Running   0          2m
# keda-operator-metrics-apiserver-xxx       1/1     Running   0          2m

Теперь создадим Deployment для PHP-воркеров. Обратите внимание: replicas здесь указывать не нужно — этим управляет KEDA:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: queue-worker
  namespace: app
spec:
  selector:
    matchLabels:
      app: queue-worker
  template:
    metadata:
      labels:
        app: queue-worker
    spec:
      terminationGracePeriodSeconds: 120
      containers:
        - name: worker
          image: your-registry/php-worker:latest
          env:
            - name: REDIS_HOST
              valueFrom:
                secretKeyRef:
                  name: redis-secret
                  key: host
            - name: REDIS_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: redis-secret
                  key: password
            - name: REDIS_QUEUE
              value: "default"
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "512Mi"
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 5"]

Параметр terminationGracePeriodSeconds: 120 даёт воркеру до 2 минут на завершение текущей задачи при получении сигнала завершения — это ключевой параметр для graceful shutdown.

Конфигурация ScaledObject для Redis

Теперь создаём ScaledObject — главный ресурс KEDA, который связывает Deployment с источником метрик:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: queue-worker-scaler
  namespace: app
spec:
  scaleTargetRef:
    name: queue-worker
  minReplicaCount: 0
  maxReplicaCount: 50
  pollingInterval: 15
  cooldownPeriod: 60
  triggers:
    - type: redis
      metadata:
        address: redis-service.app.svc.cluster.local:6379
        listName: queues:default
        listLength: "10"
        enableTLS: "false"
        databaseIndex: "0"
      authenticationRef:
        name: redis-trigger-auth
---
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: redis-trigger-auth
  namespace: app
spec:
  secretTargetRef:
    - parameter: password
      name: redis-secret
      key: password

Разберём ключевые параметры:

  • minReplicaCount: 0 — KEDA может масштабировать Deployment до нуля реплик, когда очередь пуста. Это экономит ресурсы кластера.
  • maxReplicaCount: 50 — верхний предел воркеров. Устанавливайте исходя из пропускной способности Redis и базы данных.
  • pollingInterval: 15 — KEDA проверяет длину очереди каждые 15 секунд.
  • cooldownPeriod: 60 — после достижения нуля задач KEDA ждёт 60 секунд перед scale-down до нуля, чтобы избежать флаппинга.
  • listLength: "10" — целевое количество задач на одного воркера. При 100 задачах в очереди будет запущено 10 воркеров, при 500 — 50 (до максимума).

Работа с несколькими очередями разного приоритета

В реальных приложениях часто используется несколько очередей с разным приоритетом: critical, default, low. Для каждой группы приоритетов лучше создать отдельный Deployment и ScaledObject с разными параметрами:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: critical-worker-scaler
  namespace: app
spec:
  scaleTargetRef:
    name: critical-queue-worker
  minReplicaCount: 2
  maxReplicaCount: 20
  pollingInterval: 10
  cooldownPeriod: 30
  triggers:
    - type: redis
      metadata:
        address: redis-service.app.svc.cluster.local:6379
        listName: queues:critical
        listLength: "5"
        enableTLS: "false"
      authenticationRef:
        name: redis-trigger-auth

Ключевые отличия для критической очереди:

  • minReplicaCount: 2 — всегда держим минимум 2 воркера, чтобы критические задачи обрабатывались без задержки на холодный старт.
  • listLength: "5" — более агрессивное масштабирование: один воркер на каждые 5 задач.
  • pollingInterval: 10 — проверяем очередь чаще.
  • cooldownPeriod: 30 — быстрее масштабируемся вниз после пика.

Для очереди низкого приоритета (low), наоборот, можно установить minReplicaCount: 0, listLength: "50" и cooldownPeriod: 300.

Мониторинг: метрики KEDA, Prometheus и Grafana

KEDA экспортирует метрики в формате Prometheus из коробки. После установки с флагом prometheus.metricServer.enabled=true метрики доступны на порту 9022 сервиса keda-operator-metrics-apiserver.

Создайте ServiceMonitor для Prometheus Operator:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: keda-metrics
  namespace: monitoring
spec:
  namespaceSelector:
    matchNames:
      - keda
  selector:
    matchLabels:
      app: keda-operator-metrics-apiserver
  endpoints:
    - port: metrics
      interval: 30s
      path: /metrics

Ключевые метрики для дашборда Grafana:

  • keda_scaler_metrics_value — текущее значение метрики (длина очереди Redis).
  • keda_scaled_object_paused — приостановлен ли ScaledObject.
  • keda_scaler_active — активен ли скейлер (есть ли задачи в очереди).
  • kube_deployment_spec_replicas — желаемое количество реплик.
  • kube_deployment_status_replicas_available — реально запущенные реплики.

Пример PromQL-запроса для отображения задержки масштабирования:

keda_scaler_metrics_value{scaledObject="queue-worker-scaler"}
  / on() group_left()
kube_deployment_status_replicas_available{deployment="queue-worker"}

Этот запрос показывает среднее количество задач на активного воркера — ключевой SLO-индикатор.

Тестирование автомасштабирования

Перед выводом в production необходимо провести нагрузочное тестирование. Самый простой способ — залить задачи в Redis напрямую:

kubectl run redis-load-test --image=redis:7-alpine --rm -it --restart=Never -- \
  sh -c 'for i in $(seq 1 500); do \
    redis-cli -h redis-service.app.svc.cluster.local \
    RPUSH queues:default "{\"uuid\":\"test-$i\",\"displayName\":\"TestJob\",\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"data\":{\"command\":\"test\"}}"; \
  done'

Наблюдайте за масштабированием в реальном времени:

watch -n 5 kubectl get pods -n app -l app=queue-worker

Типичное поведение при правильной настройке: после заливки 500 задач с listLength: 10 KEDA за 15–30 секунд должен поднять 50 воркеров (если maxReplicaCount позволяет). По мере обработки задач количество реплик будет снижаться ступенчато, и через cooldownPeriod после исчерпания очереди вернётся к нулю.

Проверьте события ScaledObject для диагностики:

kubectl describe scaledobject queue-worker-scaler -n app

Подводные камни: graceful shutdown и потеря задач

Самый критичный момент при масштабировании вниз — graceful shutdown воркеров. Когда Kubernetes удаляет Pod, он отправляет SIGTERM. Если воркер обрабатывает задачу в этот момент, задача должна завершиться корректно или вернуться в очередь.

Laravel Queue Worker корректно обрабатывает SIGTERM начиная с версии 8.x: после получения сигнала воркер завершает текущую задачу и выходит. Для этого необходимо, чтобы:

  • Расширение pcntl было установлено в PHP (включено в Dockerfile выше).
  • terminationGracePeriodSeconds в Pod spec был больше максимального времени выполнения одной задачи.
  • В preStop hook добавлен sleep 5 — это даёт kube-proxy время убрать Pod из балансировщика до того, как придёт SIGTERM.

Вторая проблема — флаппинг (постоянное масштабирование вверх-вниз при равномерном потоке задач). Если задачи приходят со скоростью, при которой очередь то пустеет, то снова наполняется, KEDA будет постоянно создавать и удалять Pod'ы. Решения:

  • Увеличьте cooldownPeriod до 120–300 секунд для некритичных очередей.
  • Установите minReplicaCount: 1, чтобы не масштабироваться до нуля.
  • Используйте параметр advanced.horizontalPodAutoscalerConfig.behavior в ScaledObject для тонкой настройки скорости масштабирования вниз.

Третья проблема — задачи в состоянии reserved/processing. Laravel помещает задачу во временный ключ Redis (queues:default:reserved) на время обработки. Если воркер убивается без graceful shutdown, задача остаётся в reserved и вернётся в очередь только после истечения retry_after (по умолчанию 90 секунд). Убедитесь, что в CI/CD-пайплайне при rolling update используется стратегия RollingUpdate с корректными maxUnavailable: 0.

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 5
    maxUnavailable: 0

Заключение

Горизонтальное автомасштабирование PHP-воркеров очередей на основе длины очереди Redis с помощью KEDA — это зрелое, production-ready решение, которое в 2026 году стало стандартом для highload PHP-приложений на Kubernetes. Связка PHP + Redis + Docker + KEDA даёт точную реакцию на реальную нагрузку вместо косвенных метрик CPU.

Ключевые выводы из статьи:

  • HPA по CPU не подходит для воркеров очередей — используйте KEDA с Redis-триггером.
  • minReplicaCount: 0 экономит ресурсы, но требует тщательной настройки graceful shutdown.
  • Разделяйте очереди разного приоритета на отдельные Deployment с разными ScaledObject.
  • Мониторинг через Prometheus и Grafana обязателен для production: без метрик вы не увидите узкие места.
  • Флаг --max-time и terminationGracePeriodSeconds — обязательные параметры для стабильной работы.

Правильно настроенное event-driven масштабирование позволяет обрабатывать всплески нагрузки в десятки раз эффективнее, чем статически сконфигурированный пул воркеров, при этом минимизируя затраты в периоды низкой активности.

Технологии

Теги

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

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