Inhoud van dit artikel
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:runofphp 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:
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.
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:
| Instelling | Waar | Regel |
|---|---|---|
timeout | supervisor in config/horizon.php | Groter dan de langste job-timeout |
retry_after | config/queue.php | Een paar seconden groter dan timeout |
terminationGracePeriodSeconds | Pod spec | Groter 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.
// 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:
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
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
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.
// 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:
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:rungebruikt tot het einde van de minuut nog de oude code. Met de Deployment-variant ben je daar bijRecreatevanaf.
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
waitsin 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.




