PHP и Kubernetes: горизонтальное масштабирование воркеров очередей с учётом длины очереди Redis
Введение: почему 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 был больше максимального времени выполнения одной задачи.- В
preStophook добавлен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. Подробнее обо мне →