PHP y Kubernetes: escalado horizontal de workers de colas con base en la longitud de la cola Redis
Introducción: por qué el HPA basado en CPU no es suficiente para workers de colas
El escalado horizontal de workers de colas es una de las tareas clásicas en arquitecturas de alta concurrencia. A primera vista, parece que el Horizontal Pod Autoscaler (HPA) estándar de Kubernetes puede resolverlo de forma nativa: aumenta la carga, sube el consumo de CPU y se añaden réplicas. Sin embargo, en la práctica este enfoque funciona mal precisamente para los workers de colas.
El problema es que un worker de cola PHP no consume CPU en el momento en que se acumulan las tareas, sino en el momento de procesarlas. Si en la cola de Redis se han acumulado 10 000 tareas y hay pocos workers activos, la CPU de cada uno estará al 100%, pero la reacción del HPA siempre llega tarde: primero hay que detectar una CPU elevada, luego promediar la métrica en la ventana de observación (normalmente 1–3 minutos) y después tomar la decisión de escalar. Durante ese tiempo la cola sigue creciendo.
El segundo escenario es aún peor: las tareas son ligeras (por ejemplo, enviar emails a través de una API externa) y el worker pasa el 90% del tiempo esperando I/O. La CPU se mantiene baja, el HPA no reacciona y la cola crece durante horas. En ambos casos, la métrica de CPU es un indicador indirecto y tardío.
La señal correcta para escalar workers de colas es la longitud de la propia cola. Cuantas más tareas esperan procesarse, más workers hay que lanzar. Para eso existe KEDA (Kubernetes Event-driven Autoscaling): una herramienta que escala Deployments directamente a partir de métricas de fuentes externas, incluyendo Redis. En este artículo analizaremos una configuración production-ready de este tipo de escalado para aplicaciones PHP.
Visión general de la arquitectura
La arquitectura de la solución se compone de tres elementos clave:
- Worker PHP: proceso que lee tareas de la cola Redis y las ejecuta. Puede ser un Laravel Queue Worker (
php artisan queue:work) o un script PHP nativo con un bucle de procesamiento. - Redis como broker de colas: almacena las tareas en forma de listas (LIST). Laravel usa por defecto el comando
BLPOPpara leer de forma bloqueante desde claves del tipoqueues:default. - KEDA: operador de Kubernetes que se instala en el clúster y añade un nuevo tipo de recurso,
ScaledObject. KEDA consulta Redis (u otra fuente), obtiene la longitud de la lista y, en función de ello, gestiona el número de réplicas del Deployment.
El flujo de datos es el siguiente: un productor (solicitud web, cron u otro servicio) inserta tareas en Redis. KEDA consulta periódicamente (por defecto cada 30 segundos) la longitud de la lista con el comando LLEN y calcula el número deseado de réplicas: ceil(longitud_cola / targetValue). Kubernetes ajusta el número de Pods del Deployment respetando los límites minReplicaCount y maxReplicaCount.
Preparación de la aplicación PHP: Dockerfile para el worker
En entornos de producción, el worker debe ejecutarse en una imagen Docker independiente, optimizada para procesos de larga duración. A continuación se muestra un ejemplo de Dockerfile para un worker 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"]
Algunas decisiones importantes en este Dockerfile:
- Se utiliza
php:8.3-cli-alpine: una imagen mínima sin componentes de servidor web innecesarios. - El flag
--stop-when-emptyhace que el worker se detenga cuando la cola está vacía. Esto es fundamental para el scale-down correcto: KEDA reduce las réplicas, el Pod recibeSIGTERM, el worker termina la tarea actual y sale. - El flag
--max-time=3600limita la vida del worker a una hora, lo que previene fugas de memoria en procesos PHP de larga duración. - Ejecutar con el usuario
nobodysigue el principio de mínimo privilegio.
La configuración de la conexión a Redis se define mediante variables de entorno en config/queue.php:
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => 5,
],
Instalación de KEDA en el clúster Kubernetes
KEDA se instala mediante Helm, que es el método más cómodo para producción:
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
Tras la instalación, verifique que los pods de KEDA están en ejecución:
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
Ahora crearemos el Deployment para los workers PHP. Nótese que no es necesario especificar replicas aquí, ya que KEDA se encarga de gestionarlo:
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"]
El parámetro terminationGracePeriodSeconds: 120 le da al worker hasta 2 minutos para finalizar la tarea actual al recibir la señal de terminación. Es el parámetro clave para un graceful shutdown correcto.
Configuración del ScaledObject para Redis
Ahora creamos el ScaledObject, el recurso principal de KEDA que conecta el Deployment con la fuente de métricas:
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
Analicemos los parámetros clave:
minReplicaCount: 0: KEDA puede escalar el Deployment a cero réplicas cuando la cola está vacía, ahorrando recursos del clúster.maxReplicaCount: 50: límite máximo de workers. Ajústelo según la capacidad de Redis y la base de datos.pollingInterval: 15: KEDA comprueba la longitud de la cola cada 15 segundos.cooldownPeriod: 60: una vez que la cola llega a cero, KEDA espera 60 segundos antes de escalar a cero para evitar el flapping.listLength: "10": número objetivo de tareas por worker. Con 100 tareas en la cola se lanzarán 10 workers; con 500, 50 (hasta el máximo).
Trabajo con múltiples colas de diferente prioridad
En aplicaciones reales suelen usarse varias colas con distintas prioridades: critical, default y low. Para cada grupo de prioridades es mejor crear un Deployment y un ScaledObject independientes con parámetros diferenciados:
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
Diferencias clave para la cola crítica:
minReplicaCount: 2: siempre se mantienen al menos 2 workers para que las tareas críticas se procesen sin esperar el arranque en frío.listLength: "5": escalado más agresivo: un worker por cada 5 tareas.pollingInterval: 10: la cola se comprueba con mayor frecuencia.cooldownPeriod: 30: el scale-down tras un pico es más rápido.
Para la cola de baja prioridad (low), por el contrario, se puede establecer minReplicaCount: 0, listLength: "50" y cooldownPeriod: 300.
Monitoreo: métricas de KEDA, Prometheus y Grafana
KEDA exporta métricas en formato Prometheus de forma nativa. Tras la instalación con el flag prometheus.metricServer.enabled=true, las métricas están disponibles en el puerto 9022 del servicio keda-operator-metrics-apiserver.
Cree un ServiceMonitor para el 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
Métricas clave para el dashboard de Grafana:
keda_scaler_metrics_value: valor actual de la métrica (longitud de la cola Redis).keda_scaled_object_paused: indica si el ScaledObject está pausado.keda_scaler_active: indica si el scaler está activo (hay tareas en la cola).kube_deployment_spec_replicas: número deseado de réplicas.kube_deployment_status_replicas_available: réplicas realmente activas.
Ejemplo de consulta PromQL para mostrar la latencia de escalado:
keda_scaler_metrics_value{scaledObject="queue-worker-scaler"}
/ on() group_left()
kube_deployment_status_replicas_available{deployment="queue-worker"}
Esta consulta muestra el número promedio de tareas por worker activo, un indicador SLO fundamental.
Pruebas del autoescalado
Antes de pasar a producción es imprescindible realizar pruebas de carga. La forma más sencilla es insertar tareas directamente en 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'
Observe el escalado en tiempo real:
watch -n 5 kubectl get pods -n app -l app=queue-worker
Comportamiento típico con una configuración correcta: tras insertar 500 tareas con listLength: 10, KEDA debería levantar 50 workers en 15–30 segundos (si maxReplicaCount lo permite). A medida que se procesan las tareas, el número de réplicas irá disminuyendo de forma escalonada y, transcurrido el cooldownPeriod desde que la cola se vacía, volverá a cero.
Compruebe los eventos del ScaledObject para diagnosticar problemas:
kubectl describe scaledobject queue-worker-scaler -n app
Problemas comunes: graceful shutdown y pérdida de tareas
El punto más crítico al escalar hacia abajo es el graceful shutdown de los workers. Cuando Kubernetes elimina un Pod, envía SIGTERM. Si el worker está procesando una tarea en ese momento, esta debe completarse correctamente o volver a la cola.
Laravel Queue Worker gestiona correctamente SIGTERM desde la versión 8.x: tras recibir la señal, el worker termina la tarea actual y sale. Para ello es necesario que:
- La extensión
pcntlesté instalada en PHP (incluida en el Dockerfile anterior). terminationGracePeriodSecondsen la spec del Pod sea mayor que el tiempo máximo de ejecución de una tarea.- El hook
preStopincluya unsleep 5, lo que da tiempo a kube-proxy para retirar el Pod del balanceador antes de que llegue el SIGTERM.
El segundo problema es el flapping (escalado continuo arriba y abajo con un flujo uniforme de tareas). Si las tareas llegan a una velocidad que hace que la cola se vacíe y se vuelva a llenar constantemente, KEDA estará creando y eliminando Pods sin parar. Soluciones:
- Aumente
cooldownPerioda 120–300 segundos para colas no críticas. - Establezca
minReplicaCount: 1para no escalar hasta cero. - Use el parámetro
advanced.horizontalPodAutoscalerConfig.behavioren el ScaledObject para ajustar con precisión la velocidad del scale-down.
El tercer problema son las tareas en estado reserved/processing. Laravel mueve la tarea a una clave temporal de Redis (queues:default:reserved) mientras se procesa. Si el worker se elimina sin graceful shutdown, la tarea queda en reserved y no volverá a la cola hasta que expire el retry_after (90 segundos por defecto). Asegúrese de que en el pipeline de CI/CD durante un rolling update se utilice la estrategia RollingUpdate con maxUnavailable: 0.
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 5
maxUnavailable: 0
Conclusión
El autoescalado horizontal de workers de colas PHP basado en la longitud de la cola Redis con KEDA es una solución madura y production-ready que en 2026 se ha convertido en el estándar para aplicaciones PHP de alta concurrencia en Kubernetes. La combinación PHP + Redis + Docker + KEDA ofrece una respuesta precisa a la carga real en lugar de depender de métricas indirectas como la CPU.
Conclusiones clave del artículo:
- El HPA basado en CPU no es adecuado para workers de colas: use KEDA con el trigger de Redis.
minReplicaCount: 0ahorra recursos, pero requiere una configuración cuidadosa del graceful shutdown.- Separe las colas de distinta prioridad en Deployments independientes con ScaledObjects diferenciados.
- El monitoreo con Prometheus y Grafana es imprescindible en producción: sin métricas no podrá identificar cuellos de botella.
- Los flags
--max-timeyterminationGracePeriodSecondsson parámetros obligatorios para un funcionamiento estable.
Un escalado event-driven bien configurado permite gestionar picos de carga de manera hasta decenas de veces más eficiente que un pool de workers configurado de forma estática, minimizando al mismo tiempo los costes durante los periodos de baja actividad.
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í →