Laravel op Kubernetes: Horizon, queues en scheduler inrichten

Coding.nl
10 min leestijd
Inhoud van dit artikel
  1. Eén image, drie rollen
  2. Horizon als Deployment
  3. De scheduler: CronJob of Deployment
  4. Wat er gebeurt tijdens een deploy
  5. Monitoring die je echt nodig hebt
  6. Wanneer dit de moeite waard is

Draai Horizon als eigen Deployment met een ruime terminationGracePeriodSeconds, en de scheduler als CronJob met schedule:run of als Deployment met één replica en schedule:work. Gebruik voor web, Horizon en scheduler hetzelfde image, alleen met een ander command. Wat het verschil maakt tussen een rustig en een onrustig platform, zit in de details: signalen, timeouts, dubbele uitvoering en wat er gebeurt tijdens een rolling update.

Eén image, drie rollen

De basisregel: je bouwt één container-image per release. Dat image bevat je code, de Composer-dependencies en de gebouwde frontend-assets. Elke rol in het cluster start hetzelfde image met een ander command.

  • web: php-fpm met nginx ernaast, of Laravel Octane.
  • horizon: php artisan horizon.
  • scheduler: php artisan schedule:run of php artisan schedule:work.

Zo weet je zeker dat je queue-workers exact dezelfde code draaien als je webpods. Geen aparte worker-build die een commit achterloopt.

Een ingekorte Dockerfile:

dockerfile
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction

FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM php:8.4-fpm-alpine
RUN apk add --no-cache $PHPIZE_DEPS linux-headers \
    && docker-php-ext-install pcntl pdo_mysql opcache \
    && pecl install redis && docker-php-ext-enable redis
WORKDIR /var/www/html
COPY --from=vendor /app/vendor ./vendor
COPY . .
COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev \
    && chown -R www-data:www-data storage bootstrap/cache
USER www-data

Twee dingen staan hier bewust niet in. Geen php artisan migrate in een entrypoint, want dan proberen tien pods tegelijk te migreren. Migraties horen in een Job of een pre-sync hook, zie Argo CD sync waves en database-migraties. En geen config:cache tijdens de build, want dan bak je de environment-variabelen van je CI-runner in het image.

De pcntl-extensie is wel verplicht. Zonder pcntl werken job-timeouts niet en reageert een worker niet netjes op signalen. Laravel 13 vraagt minimaal PHP 8.3, dus kies je base image daarop.

Horizon als Deployment

Horizon is een master-proces dat supervisors start, die op hun beurt worker-processen beheren. In Kubernetes is dat master-proces PID 1 van de container. Stopt Horizon, dan stopt de container en start Kubernetes hem opnieuw. Je hebt dus geen Supervisor in de container nodig.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-horizon
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: shop
      app.kubernetes.io/component: horizon
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: shop
        app.kubernetes.io/component: horizon
    spec:
      terminationGracePeriodSeconds: 3600
      containers:
        - name: horizon
          image: registry.example.nl/shop:1.42.0
          command: ["php", "artisan", "horizon"]
          envFrom:
            - configMapRef:
                name: shop-env
            - secretRef:
                name: shop-secrets
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              memory: "1536Mi"

Let op de exec-vorm van command. Gebruik je een shell-wrapper zoals sh -c "php artisan horizon", dan krijgt de shell het SIGTERM-signaal en niet PHP. Horizon merkt dan niets van de shutdown en wordt na de grace period hard gekilld.

Graceful shutdown en timeouts

Bij een deploy of het leegmaken van een node stuurt Kubernetes SIGTERM. Horizon reageert daarop zoals op horizon:terminate: lopende jobs maken hun werk af, daarna stopt het proces. Kubernetes wacht maximaal terminationGracePeriodSeconds en stuurt daarna SIGKILL.

Drie waarden moeten daarom op elkaar aansluiten:

InstellingWaarRegel
timeoutsupervisor in config/horizon.phpGroter dan de langste job-timeout
retry_afterconfig/queue.phpEen paar seconden groter dan timeout
terminationGracePeriodSecondsPod specGroter dan je langste job

Is retry_after kleiner dan je timeout, dan pakt een andere worker dezelfde job op terwijl de eerste nog bezig is. Dan verwerk je een order of een export twee keer. De Horizon-documentatie waarschuwt hier expliciet voor.

terminationGracePeriodSeconds: 3600 lijkt veel. Het is een maximum, geen vaste wachttijd. Zijn alle jobs na tien seconden klaar, dan stopt de pod na tien seconden. Heb je jobs die echt een uur duren, overweeg dan of die niet beter in kleinere stukken kunnen. Lange jobs maken elke deploy en elke node-upgrade traag.

Redis, Valkey en geen cluster-modus

Horizon werkt alleen met de Redis-queue-driver en is volgens de documentatie niet compatibel met Redis Cluster. Je hebt dus één primaire Redis-instantie nodig, eventueel met een replica voor failover.

Of dat Redis zelf is of Valkey, de open fork onder de Linux Foundation, maakt voor Laravel in de praktijk weinig uit: beide spreken hetzelfde protocol. Kijk wel naar de licentie van wat je draait en naar wat je managed provider aanbiedt. Draai Redis bij voorkeur als managed dienst buiten het cluster, of met een operator die je actief onderhoudt. Een losse StatefulSet uit een willekeurige Helm-chart wordt snel een vergeten onderdeel.

Replicas maal maxProcesses

Een veelgemaakte rekenfout: elke Horizon-pod start zijn eigen set supervisors. Twee replicas met maxProcesses => 10 betekent tot twintig worker-processen. Die praten allemaal met je database en met externe API's.

Reken je resource requests daarop. De Horizon-default voor memory is 128 MB per worker: daarboven herstart de worker. Meet wat jouw workers echt gebruiken, en reken met maxProcesses maal dat getal, plus het master-proces. Acht workers op 128 MB is al ruim een gigabyte.

php
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 1,
            'maxProcesses' => 8,
            'timeout' => 300,
            'memory' => 128,
            'maxTime' => 3600,
        ],
        'supervisor-imports' => [
            'connection' => 'redis',
            'queue' => ['imports'],
            'balance' => 'simple',
            'processes' => 1,
            'timeout' => 1800,
        ],
    ],
],

Zware imports krijgen een eigen supervisor met een vast aantal processen. Zo kan één grote productfeed niet alle workers opeisen terwijl orderbevestigingen blijven liggen.

Wil je zwaar en licht werk ook op Kubernetes-niveau scheiden, met eigen nodes en eigen schaalgedrag, dan zijn losse queue:work-Deployments per queue vaak eenvoudiger dan Horizon. Horizon start in elke pod alle supervisors van de environment.

Horizon schalen, of toch niet

Horizon schaalt binnen een pod zelf, tussen minProcesses en maxProcesses. Kubernetes kan daarnaast het aantal pods schalen. Twee autoscalers die op elkaar reageren, geven onvoorspelbaar gedrag.

De eerlijke versie:

  • Stabiele, voorspelbare load: vast aantal Horizon-pods, Horizon balanceert binnen de pod. Simpel en goed te overzien.
  • Sterk wisselende load: overweeg kale queue:work-workers met KEDA, die schalen op de lengte van de Redis-lijst. Dat werken we uit in Laravel-queues automatisch schalen met KEDA.

Een PodDisruptionBudget voorkomt dat een node-upgrade al je Horizon-pods tegelijk weghaalt:

yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: shop-horizon
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: shop
      app.kubernetes.io/component: horizon

De scheduler: CronJob of Deployment

Laravel's scheduler verwacht dat elke minuut schedule:run draait. Op een server is dat één cron-regel. In Kubernetes heb je twee opties.

Optie 1: CronJob met schedule:run

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: shop-scheduler
spec:
  schedule: "* * * * *"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 30
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 0
      activeDeadlineSeconds: 300
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: scheduler
              image: registry.example.nl/shop:1.42.0
              command: ["php", "artisan", "schedule:run"]
              envFrom:
                - configMapRef:
                    name: shop-env
                - secretRef:
                    name: shop-secrets

Voordelen: elke minuut een schone pod, altijd de actuele image-versie, en een mislukte run is zichtbaar als mislukte Job.

Nadelen: elke minuut een pod starten kost tijd en wat CPU. Op een druk cluster kan het opstarten seconden duren. concurrencyPolicy: Forbid voorkomt dat runs elkaar inhalen. Zonder die regel kun je bij een trage run twee schedulers tegelijk hebben.

Gebruik je sub-minuut-taken zoals everyTenSeconds(), dan blijft schedule:run de hele minuut draaien. Dat werkt in een CronJob, maar overlap met de volgende run wordt dan wel een reëel risico. Forbid laat in dat geval een minuut vallen.

Optie 2: Deployment met schedule:work

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-scheduler
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: shop
      app.kubernetes.io/component: scheduler
  template:
    metadata:
      labels:
        app.kubernetes.io/name: shop
        app.kubernetes.io/component: scheduler
    spec:
      terminationGracePeriodSeconds: 120
      containers:
        - name: scheduler
          image: registry.example.nl/shop:1.42.0
          command: ["php", "artisan", "schedule:work"]
          envFrom:
            - configMapRef:
                name: shop-env
            - secretRef:
                name: shop-secrets

Eén langlopend proces dat elke minuut de scheduler aanroept. Geen pod-startup per minuut. De Laravel-documentatie noemt schedule:work vooral voor lokaal gebruik, maar in een container met één replica doet het precies wat je wilt.

strategy: Recreate is hier belangrijk. Met de standaard RollingUpdate draait de nieuwe pod even naast de oude, en dan heb je twee schedulers. Recreate stopt eerst de oude en start dan de nieuwe. Je mist hooguit een minuut.

Dubbele uitvoering voorkomen met onOneServer

Welke optie je ook kiest: zet onOneServer() op elke taak die niet twee keer mag draaien. Of zet het globaal met Schedule::alwaysOnOneServer() in je AppServiceProvider.

php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('orders:export-erp')
    ->everyFiveMinutes()
    ->withoutOverlapping(10)
    ->onOneServer();

Schedule::command('horizon:snapshot')->everyFiveMinutes();

onOneServer vraagt een centrale cache-driver: redis, database, memcached of dynamodb. De file-driver werkt niet, want elke pod heeft zijn eigen bestandssysteem. Let op: scheduled closures zonder name() vallen buiten alwaysOnOneServer.

Vergeet horizon:snapshot niet. Zonder die taak blijft het metrics-scherm van Horizon leeg.

Laat de scheduler dispatchen, niet rekenen

Een scheduler-pod hoort licht te zijn. Zet zwaar werk in de queue:

php
Schedule::job(new SyncStockFromWms)->everyTenMinutes()->onOneServer();

Dan draait het echte werk in Horizon, met retries, timeouts en monitoring. De scheduler blijft een klok. Voor ideeën welke taken een webshop dagelijks nodig heeft: Laravel scheduler voor e-commerce.

Wat er gebeurt tijdens een deploy

Bij een rolling update draaien oude en nieuwe pods even naast elkaar. Voor queues betekent dat:

  • Nieuwe code dispatcht jobs die oude workers oppakken. Voeg je een constructor-parameter toe aan een job, dan kan een oude worker die job niet goed unserializen. Maak job-wijzigingen backwards compatible, of geef de nieuwe job een nieuwe class.
  • Migraties draaien voordat de nieuwe pods starten. Oude Horizon-workers draaien dan nog even op het nieuwe schema. Kolommen verwijderen doe je dus pas een release later.
  • Sub-minuut-taken in de scheduler. Een lopende schedule:run gebruikt tot het einde van de minuut nog de oude code. Met de Deployment-variant ben je daar bij Recreate vanaf.

Met GitOps via Argo CD regel je de volgorde declaratief: eerst de migratie-Job, dan web, Horizon en scheduler. Hoe je dat opzet staat op onze pagina over GitOps met Argo CD.

Monitoring die je echt nodig hebt

Het Horizon-dashboard is handig, maar je kijkt er niet de hele dag naar. Zorg dat deze signalen in je alerting terechtkomen:

  • Wachttijd per queue. Horizon kan zelf melden bij lange wachttijden via waits in de config. Stuur die naar Slack of mail.
  • Failed jobs. Een stijging na een deploy is bijna altijd een code- of serialisatieprobleem.
  • Pod-restarts en OOMKilled. Een Horizon-pod die steeds OOMKilled wordt, heeft een te lage memory limit voor het aantal workers.
  • Mislukte scheduler-Jobs bij de CronJob-variant.
  • Laatste succesvolle run van kritieke taken. Een taak die stil niet draait, zie je anders pas als een klant belt.

Logs van workers schrijf je naar stdout, zodat Loki of je logging-stack ze oppikt. Zet LOG_CHANNEL=stderr of een JSON-formatter. Hoe we Prometheus, Grafana en Loki voor webshops inrichten, lees je bij monitoring en logging op Kubernetes.

Het Horizon-dashboard zelf zet je achter de viewHorizon-gate. Controleer na elke deploy dat /horizon zonder login een 403 geeft.

Wanneer dit de moeite waard is

Kubernetes is geen doel. Een Laravel-app met een handvol queues draait prima op één goede server met Supervisor, of op een platform als Laravel Forge of Cloud. De opzet in dit artikel loont pas als je:

  • meerdere Laravel-apps of -services op één platform draait;
  • queue-werk hebt dat sterk wisselt, zoals imports, feeds of piekmomenten rond acties;
  • al een Kubernetes-cluster hebt voor andere onderdelen;
  • web en achtergrondwerk los van elkaar wilt schalen.

Meer over die afweging en de rest van de opzet lees je op Laravel op Kubernetes. Over Horizon zelf, los van Kubernetes: Laravel Horizon.

Veelgestelde vragen

Moet Horizon in een aparte pod draaien of naast php-fpm?+

In een aparte pod. Zo schaal je web en achtergrondwerk los, en trekt een zware import je webverkeer niet onderuit. Een Horizon-container naast php-fpm in dezelfde pod betekent dat je bij elke extra webpod ook extra workers krijgt.

Is schedule:work in productie veilig?+

Ja, als je één replica draait met strategy: Recreate en kritieke taken onOneServer() geeft. De documentatie noemt het voor lokaal gebruik, maar het commando doet in een container precies wat het moet doen: elke minuut de scheduler aanroepen.

Hoe hoog zet ik terminationGracePeriodSeconds?+

Hoger dan je langste job. Kubernetes wacht nooit langer dan nodig: zijn de lopende jobs klaar, dan stopt de pod direct. Zet je de waarde te laag, dan worden jobs halverwege gekilld en later opnieuw uitgevoerd.

Kan Horizon met Redis Cluster werken?+

Nee, volgens de Laravel-documentatie is Horizon niet compatibel met Redis Cluster. Gebruik één primaire Redis- of Valkey-instantie, eventueel met replica en failover. Wil je toch een cluster, dan zit je aan kale queue:work-workers met hash tags in de queuenaam.

Waar draai ik migraties als ik Horizon en de scheduler op Kubernetes heb?+

In een losse Kubernetes Job die vóór de nieuwe pods draait, bijvoorbeeld als PreSync hook in Argo CD. Nooit in het entrypoint van je containers. Schrijf migraties zo dat de vorige release er nog mee kan werken.

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