Laravel-queues automatisch schalen met KEDA

Coding.nl
9 min leestijd
Inhoud van dit artikel
  1. Waarom CPU-autoscaling niet past bij queues
  2. Hoe KEDA werkt
  3. Stap 1: vind de echte naam van je Redis-lijst
  4. Stap 2: een worker-Deployment zonder vaste replicas
  5. Stap 3: het ScaledObject
  6. Meerdere queues
  7. Koude starts en scale-to-zero
  8. KEDA en Horizon: kies één baas
  9. De valkuil met Argo CD: replicas
  10. Lange jobs: ScaledJob
  11. Andere queue-drivers
  12. 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:

  1. KEDA maakt zelf een HPA aan voor die Deployment.
  2. Staat de queue op nul en is minReplicaCount: 0, dan schaalt KEDA de Deployment naar nul pods.
  3. Komt er werk binnen boven de activatiedrempel, dan zet KEDA de Deployment op één pod.
  4. 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:

bash
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:

php
'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:

bash
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:

yaml
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=3600 laat de worker na een uur netjes stoppen. Kubernetes start hem opnieuw. Dat voorkomt geheugenlekken in langlopende PHP-processen.
  • terminationGracePeriodSeconds boven --timeout. Bij SIGTERM maakt queue:work de 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:

yaml
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: 300 is hoe lang KEDA wacht voordat het van één terug naar nul gaat.
  • maxReplicaCount: 20 is 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 een nodeSelector of 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:

  1. ingepland worden op een node met ruimte;
  2. het image binnenhalen als het nog niet op die node staat;
  3. 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:work met 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 vaste processes per 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:

  1. Laat spec.replicas weg uit het manifest of de Helm-template als autoscaling aan staat.
  2. Of laat Argo CD het veld negeren:
yaml
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.

Ruthger Idema

Geschreven door Ruthger Idema

Eigenaar van Coding.nl. 15+ jaar e-commerce met Magento, Shopify en Laravel, samen met het vaste team in Emmen.

Wil je dit voor jouw shop laten uitzoeken? Plan een half uur

Een half uur via video met de mensen die het werk ook echt doen. Kies hieronder een moment dat jou past.

  • Direct met het vaste team, geen tussenlagen
  • Concrete feedback op je huidige situatie of plannen
  • Eerlijk advies, ook als het antwoord "niet doen" is
  • Indicatie van kosten en tijdlijn
Liever bellen? 0591-239 299
Beschikbaar deze week

Plan een gratis Tech Check

Vrijblijvend jouw uitdagingen bespreken

Of bel direct: 0591-239 299