Inhoud van dit artikel
- Waarom CPU-autoscaling niet past bij queues
- Hoe KEDA werkt
- Stap 1: vind de echte naam van je Redis-lijst
- Stap 2: een worker-Deployment zonder vaste replicas
- Stap 3: het ScaledObject
- Meerdere queues
- Koude starts en scale-to-zero
- KEDA en Horizon: kies één baas
- De valkuil met Argo CD: replicas
- Lange jobs: ScaledJob
- Andere queue-drivers
- Meten of het werkt
Met KEDA schaal je Laravel queue-workers op het aantal wachtende jobs in plaats van op CPU. Je maakt een ScaledObject met een redis-trigger die de lengte van de Redis-lijst van je queue leest, bijvoorbeeld laravel-database-queues:default. KEDA zet daarmee je worker-Deployment op nul, één of tientallen pods, afhankelijk van hoeveel werk er ligt.
Waarom CPU-autoscaling niet past bij queues
De standaard Horizontal Pod Autoscaler kijkt naar CPU en geheugen. Voor webverkeer werkt dat redelijk. Voor queue-workers meet je daarmee het verkeerde.
- Een worker die op een API wacht, gebruikt nauwelijks CPU. Er kunnen 20.000 jobs klaarstaan terwijl je pods op 5% CPU draaien. De HPA schaalt niet.
- Een lege queue kost toch pods. De HPA gaat niet onder
minReplicas: 1. Voor een import-queue die één keer per nacht vol loopt, betaal je de rest van de dag voor niets. - De vraag die je wilt beantwoorden is: hoeveel werk ligt er? Dat staat in Redis, niet in de CPU-grafiek.
KEDA, Kubernetes Event-driven Autoscaling, lost precies dit op. Het is een CNCF graduated project en draait als operator in je cluster. Het leest een externe bron, zoals een Redis-lijst, een SQS-queue of een Prometheus-query, en vertaalt dat naar een schaalbeslissing.
Hoe KEDA werkt
KEDA bestaat uit een operator en een metrics-server. Je definieert een ScaledObject dat naar je Deployment wijst. Daarna gebeurt dit:
- KEDA maakt zelf een HPA aan voor die Deployment.
- Staat de queue op nul en is
minReplicaCount: 0, dan schaalt KEDA de Deployment naar nul pods. - Komt er werk binnen boven de activatiedrempel, dan zet KEDA de Deployment op één pod.
- Vanaf één pod neemt de HPA het over, gevoed met de queue-lengte via de KEDA metrics-server.
Het verschil tussen nul en één regelt KEDA zelf, het verschil tussen één en N regelt de HPA. Die tweedeling is handig om te weten als je gedrag gaat debuggen.
Dit artikel gaat ervan uit dat je app al op Kubernetes draait, met web en workers als losse Deployments. Hoe die basis eruitziet, staat op Laravel op Kubernetes.
Installatie gaat via de officiële Helm-chart:
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace
In een GitOps-opzet zet je die chart in een Argo CD Application, net als de rest van je platform-componenten.
Stap 1: vind de echte naam van je Redis-lijst
Hier gaat het in de praktijk het vaakst mis. KEDA's redis-scaler doet een LLEN op de lijst die je opgeeft. Die naam moet exact kloppen, inclusief de prefix die Laravel toevoegt.
Laravel's Redis-queue zet wachtende jobs in een lijst queues:<naam>. Daarvoor komt de Redis-prefix uit config/database.php. In een standaard Laravel 13-installatie is dat:
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
Met APP_NAME=Laravel wordt de sleutel dus laravel-database-queues:default. Heet je app Webshop API, dan wordt het webshop-api-database-queues:default. Oudere Laravel-versies gebruikten een underscore-variant zoals laravel_database_. Ga daarom niet uit van een aanname, maar kijk:
kubectl exec -it deploy/shop-web -- php artisan tinker --execute \
"echo config('database.redis.options.prefix');"
redis-cli -h valkey.internal --scan --pattern '*queues:*'
redis-cli -h valkey.internal LLEN laravel-database-queues:default
Je ziet bij --scan waarschijnlijk ook sleutels als queues:default:delayed, queues:default:reserved en queues:default:notify. Dat zijn sorted sets en hulplijsten. De redis-scaler telt alleen de lijst met wachtende jobs. Vertraagde jobs tellen pas mee zodra Laravel ze terugzet in de hoofdlijst.
Zet je REDIS_PREFIX expliciet in je environment. Dan verandert de sleutel niet als iemand de APP_NAME aanpast.
Stap 2: een worker-Deployment zonder vaste replicas
De workers zelf zijn een gewone Deployment met queue:work:
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-worker-default
spec:
selector:
matchLabels:
app.kubernetes.io/name: shop
app.kubernetes.io/component: worker-default
template:
metadata:
labels:
app.kubernetes.io/name: shop
app.kubernetes.io/component: worker-default
spec:
terminationGracePeriodSeconds: 330
containers:
- name: worker
image: registry.example.nl/shop:1.42.0
command:
- php
- artisan
- queue:work
- redis
- --queue=default
- --timeout=300
- --tries=3
- --max-time=3600
envFrom:
- configMapRef:
name: shop-env
- secretRef:
name: shop-secrets
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
memory: "512Mi"
Een paar keuzes uitgelegd:
- Geen
spec.replicas. Dat getal is van KEDA. Zet je het wel, dan vecht je GitOps-tool met de autoscaler. Daarover verderop meer. - Eén worker-proces per pod. KEDA schaalt pods. Eén proces per pod maakt de rekensom simpel: tien pods is tien workers.
--max-time=3600laat de worker na een uur netjes stoppen. Kubernetes start hem opnieuw. Dat voorkomt geheugenlekken in langlopende PHP-processen.terminationGracePeriodSecondsboven--timeout. Bij SIGTERM maaktqueue:workde lopende job af en stopt dan. Kubernetes moet lang genoeg wachten.
Let op block_for in config/queue.php. Met block_for: 0 blokkeert de worker oneindig op Redis en reageert hij volgens de documentatie pas na de volgende job op SIGTERM. Gebruik een kleine waarde of null.
Stap 3: het ScaledObject
Het Redis-wachtwoord komt uit een Secret, via een TriggerAuthentication:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: shop-redis-auth
spec:
secretTargetRef:
- parameter: password
name: shop-secrets
key: REDIS_PASSWORD
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: shop-worker-default
spec:
scaleTargetRef:
name: shop-worker-default
minReplicaCount: 0
maxReplicaCount: 20
pollingInterval: 15
cooldownPeriod: 300
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 2
periodSeconds: 60
triggers:
- type: redis
metadata:
address: valkey.internal:6379
listName: laravel-database-queues:default
listLength: "50"
activationListLength: "1"
databaseIndex: "0"
authenticationRef:
name: shop-redis-auth
Wat de belangrijkste velden doen:
listLength: "50"is de doelwaarde per pod. Bij 500 wachtende jobs wil de HPA naar 10 pods. Kies dit getal op basis van hoe snel één worker jobs verwerkt en hoe lang een job mag wachten.activationListLength: "1"bepaalt wanneer KEDA van nul naar één gaat. De default is 0: bij elke job in de lijst start er een pod.cooldownPeriod: 300is hoe lang KEDA wacht voordat het van één terug naar nul gaat.maxReplicaCount: 20is je veiligheidsklep. Twintig workers betekent twintig gelijktijdige databaseverbindingen en twintig keer zoveel calls naar externe API's.scaleDown-policy laat de HPA rustig afschalen, maximaal twee pods per minuut. Zo voorkom je dat pods heen en weer springen bij een queue die schommelt.
De rekenstap voor listLength: verwerkt één worker gemiddeld vijf jobs per seconde, dan is een backlog van 50 per pod na tien seconden weg. Duren je jobs twee seconden per stuk, dan duurt dezelfde backlog honderd seconden. Meet je eigen doorlooptijd in Horizon of je APM en reken vanaf daar.
Meerdere queues
Heb je default, mail en imports, maak dan per queue een Deployment en een ScaledObject. Elke queue krijgt zo een eigen minimum, maximum en drempel.
default:minReplicaCount: 1, want orderbevestigingen wil je niet laten wachten op een koude start.mail: kan naar nul, een paar seconden vertraging is acceptabel.imports: naar nul, laag maximum, en eventueel op aparte nodes met eennodeSelectorof taints.
Je kunt ook meerdere triggers op één ScaledObject zetten. De HPA neemt dan het hoogste gewenste aantal replicas. Voor een Deployment die meerdere queues afwerkt kan dat, maar losse Deployments per queue zijn makkelijker te begrijpen.
Koude starts en scale-to-zero
Naar nul schalen bespaart resources, maar heeft een prijs. Een nieuwe pod moet:
- ingepland worden op een node met ruimte;
- het image binnenhalen als het nog niet op die node staat;
- PHP en Laravel opstarten.
Bij een warme node is dat een paar seconden. Moet je cluster-autoscaler of Karpenter eerst een node toevoegen, dan reken je eerder in minuten. Voor queues waar een klant op wacht, zoals betalingsverwerking of orderbevestigingen, houd je dus minimaal één pod warm.
Pods schalen is de helft van het verhaal. Als twintig workers niet op je bestaande nodes passen, moeten er nodes bij. Hoe HPA, KEDA en node-autoscaling samenwerken staat op onze pagina over autoscaling met HPA, KEDA en Karpenter.
KEDA en Horizon: kies één baas
Horizon schaalt zelf het aantal worker-processen binnen een pod. KEDA schaalt het aantal pods. Combineer je die twee zonder nadenken, dan heb je twee autoscalers die op hetzelfde signaal reageren.
Drie werkbare varianten:
- Horizon met vaste pods. Geen KEDA. Horizon balanceert binnen de pods. Prima voor stabiele load. Zie Laravel op Kubernetes met Horizon en de scheduler.
- Kale
queue:workmet KEDA. Eén proces per pod, KEDA bepaalt het aantal pods. Meest voorspelbaar bij wisselende load. Je verliest het Horizon-dashboard voor die workers. - Horizon met
balance => 'simple'en vasteprocessesper pod, plus KEDA. Horizon schaalt dan niet zelf en KEDA schaalt pods. Je houdt het Horizon-dashboard. Elke pod start wel alle supervisors van de environment, dus houd die configuratie klein.
Horizon is volgens de Laravel-documentatie niet compatibel met Redis Cluster. Draai je toch een Redis Cluster, dan zit je aan queue:work, moeten je queuenamen een hash tag bevatten zoals {default}, en gebruik je KEDA's redis-cluster-scaler. De lijstnaam bevat dan ook de accolades.
De valkuil met Argo CD: replicas
Beheer je je manifests met Argo CD en staat er replicas: 2 in je Deployment, dan gebeurt dit: KEDA schaalt naar 8, Argo CD ziet drift en zet terug naar 2. Met self-heal aan wordt dat een eindeloos gevecht.
De oplossing:
- Laat
spec.replicasweg uit het manifest of de Helm-template als autoscaling aan staat. - Of laat Argo CD het veld negeren:
spec:
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas
Optie 1 is schoner. Optie 2 is handig als je een externe chart gebruikt die je niet wilt aanpassen. Meer over deze opzet staat op GitOps met Argo CD.
Lange jobs: ScaledJob
Bij afschalen kiest de HPA zelf welke pod verdwijnt. Die pod krijgt SIGTERM en moet binnen de grace period klaar zijn. Voor jobs van een paar minuten is dat geen probleem. Een import van een uur is lastiger.
KEDA heeft daarvoor ScaledJob. In plaats van een Deployment op te schalen, start KEDA per batch werk een Kubernetes Job die tot het einde doorloopt. Afschalen betekent dan simpelweg: geen nieuwe Jobs meer starten. Met queue:work --stop-when-empty maakt de worker de queue leeg en stopt dan.
Voor de meeste webshop- en platformqueues is een ScaledObject met korte jobs de betere keuze. Hak lange imports liever op in kleine jobs met batching. Dan profiteer je van retries per stuk en van normaal autoscalen. In Laravel queues voor orderverwerking staat hoe je dat soort werk opdeelt.
Andere queue-drivers
Gebruik je geen Redis, dan heeft KEDA ook scalers voor onder meer AWS SQS, RabbitMQ en MySQL- of PostgreSQL-queries. Draai je de database-queue-driver, dan kun je in theorie schalen op een COUNT(*) op de jobs-tabel. Dat werkt, maar je laat KEDA dan elke paar seconden een query op je productiedatabase doen. Bij serieuze volumes is Redis of SQS de betere driver.
Meten of het werkt
Zet deze signalen naast elkaar in Grafana:
- queue-lengte per queue (dezelfde waarde die KEDA leest);
- aantal worker-pods per queue;
- wachttijd van jobs, uit Horizon of uit eigen metrics;
- pending pods, als signaal dat je nodes vol zitten.
Zie je de queue groeien terwijl het aantal pods op maxReplicaCount staat, dan is je maximum te laag of is een downstream-systeem de bottleneck. Meer workers helpen niet als je ERP maar tien requests per seconde aankan. Hoe we die dashboards opbouwen: monitoring en logging op Kubernetes.
Veelgestelde vragen
Welke Redis-sleutel moet ik in listName zetten?+
De volledige sleutel inclusief Laravel-prefix, bijvoorbeeld laravel-database-queues:default bij een standaard Laravel 13-config. Controleer het met redis-cli --scan --pattern '<em>queues:</em>'. Zet REDIS_PREFIX expliciet, zodat de naam niet verandert als APP_NAME wijzigt.
Kan ik naar nul workers schalen?+
Ja, met minReplicaCount: 0. Reken wel op een koude start van enkele seconden tot minuten als er eerst een node bij moet. Houd voor klantkritische queues minimaal één pod warm.
Werkt KEDA samen met Laravel Horizon?+
Ja, maar laat niet twee autoscalers tegelijk sturen. Gebruik Horizon met balance => 'simple' en een vast aantal processen per pod, of stap voor geschaalde queues over op kale queue:work-workers.
Waarom schaalt KEDA niet terwijl er jobs klaarstaan?+
Meestal klopt de listName niet, staan de jobs nog in de :delayed-set, of kan KEDA niet bij Redis door netwerkbeleid of authenticatie. Kijk in de status van het ScaledObject en in de logs van de KEDA-operator.
Heb ik KEDA nodig als mijn queue stabiel is?+
Nee. Bij voorspelbare load is een vast aantal Horizon-pods eenvoudiger en goedkoper in beheer. KEDA loont bij pieken, nachtelijke imports en queues die lang leeg staan.




