Laravel is één van de frameworks waarmee veel klanten van Cyberfusion werken. We hosten Laravel-applicaties voor klanten, bouwen zelf software met Laravel én helpen developers bij technische problemen die verder gaan dan de hostingomgeving alleen.

Soms levert dat een puzzel op waar je als developer behoorlijk gek van kunt worden. Zoals bij een klant die ontdekte dat een belangrijke Laravel-queue al dagenlang geen jobs meer verwerkte. De bijbehorende worker draaide gewoon, er waren geen duidelijke foutmeldingen en dezelfde worker werkte prima wanneer je hem handmatig startte.

We gingen op onderzoek uit.

Een Laravel-worker die draait, maar niets doet

De applicatie van onze klant gebruikt Laravel-queues om taken op de achtergrond uit te voeren. Denk aan het verwerken van gegevens, periodieke controles en andere werkzaamheden die niet tijdens een HTTP-request plaats hoeven te vinden.

Daarvoor draaien meerdere queue-workers, elk verantwoordelijk voor een eigen queue. De klant merkte dat het aantal wachtende jobs steeds verder opliep. Eén van de belangrijkste queues verwerkte blijkbaar al dagenlang niets meer.

Het vreemde: de daemon draaide gewoon.

Nog vreemder: wanneer de klant op dezelfde server hetzelfde Laravel-commando handmatig uitvoerde, werden de wachtende jobs onmiddellijk verwerkt. Zodra dat handmatig gestarte proces stopte, begon de queue opnieuw op te lopen.

Waarom deed de worker onder de daemon niets?

Wat zegt Laravel zelf?

Een logische eerste stap is kijken wat Laravel en de procesmanager eigenlijk doen.

Een Laravel-queue-worker is een langlopend PHP-proces. Met bijvoorbeeld php artisan queue:work start je een proces dat voortdurend nieuwe jobs uit een queue ophaalt en uitvoert.

Dat proces blijft doorgaans bestaan totdat het expliciet wordt gestopt, zichzelf beëindigt of door een fout omvalt. Daarom gebruik je in productie meestal een procesmanager, zoals Supervisor of systemd, om de worker te starten en waar nodig opnieuw op te starten.

Op Core hoeven klanten hier niets voor te doen: wanneer je een Laravel-project aanmaakt, wordt de queue-worker achter de schermen automatisch geconfigureerd en gestart.

Maar een draaiend PHP-proces betekent niet automatisch dat Laravel nog jobs verwerkt.

Een proces kan vasthangen terwijl het ergens op wacht. Bijvoorbeeld op een databasequery of een externe API. Voor de procesmanager is er dan ogenschijnlijk niets aan de hand. De worker is immers niet gestopt.

De logs maakten het alleen maar vreemder

We begonnen met het bekijken van de logbestanden en de daadwerkelijk draaiende processen.

De eerste queue bleek nog regelmatig jobs te verwerken. In de logs zagen we bijvoorbeeld:

11:21:11 ScheduledJob  20s DONE
11:21:11 ScheduledJob  RUNNING
11:21:28 ScheduledJob  17s DONE
11:21:28 ScheduledJob  RUNNING
11:21:45 ScheduledJob  17s DONE
11:21:45 ScheduledJob  RUNNING

Daar leek weinig mis mee.

Bij de tweede queue-worker was het anders. Volgens de logs had de worker sinds 10 juli om 18:25 uur geen enkele job meer afgerond.

Dat strookte alleen niet met wat de klant had waargenomen. Volgens de klant waren er tot 12 juli nog jobs verwerkt.

Was de herstart de oorzaak? Was een worker door een tijdelijke storing in Redis of MariaDB vastgelopen? Of waren er twee processen die elkaar in de weg zaten?

Er waren genoeg mogelijke verklaringen, maar geen daarvan paste bij alle symptomen.

We besloten de vastgelopen daemon voorlopig niet te herstarten. Daarmee zouden de jobs waarschijnlijk weer verwerkt worden, maar zouden we ook de kans verliezen om de oorzaak te achterhalen.

Een Laravel-job die al zes dagen bezig was

Door het PHP-proces achter de daemon verder te onderzoeken, vonden we uiteindelijk iets opmerkelijks.

De worker was helemaal niet gestopt met het verwerken van jobs. Hij was nog steeds bezig met dezelfde job die hij op 10 juli had opgepakt.

Het ging om een job die een SSL-certificaat controleerde. Daarvoor deed de applicatie een API-call, naar een IP-adres van Cloudflare. Uit navraag bij de klant bleek dat dat vermoedelijk een request naar de API van Let’s Encrypt, die achter Cloudflare staat. De job wachtte dus al bijna een week op een API-call die maar niet werd afgerond. Omdat deze Laravel-worker jobs achter elkaar verwerkt, kwam hij nooit meer toe aan de volgende job in de queue.

En inderdaad: Let’s Encrypt had rond die tijd een storing. Vermoedelijk was de request daardoor blijven hangen en zorgde het ontbreken van een effectieve timeout ervoor dat de job niet werd afgerond.

Dat verklaarde ineens alles:

  • De daemon bleef op running staan, omdat het PHP-proces nog bestond.
  • Er verschenen geen nieuwe afgeronde jobs in de logs, omdat de worker vastzat in één job.
  • De queue bleef groeien, omdat er geen jobs meer werden opgepakt.
  • Hetzelfde Laravel-commando werkte handmatig wel, omdat daarmee een volledig nieuw worker-proces werd gestart.

Een enkele vastgelopen HTTP-verbinding had zo een complete Laravel-queue tot stilstand gebracht.

Maar waarom werkte alles dan nog tot 12 juli?

Er bleef één raadsel over.

De vastgelopen worker was sinds 10 juli bezig met dezelfde job. Toch wist de klant zeker dat er tot 12 juli nog jobs werden verwerkt.

Ook daarvoor vonden we, na veel hoofdpijn, een verklaring.

De applicatie was eerder overgezet van Supervisor naar 'daemons' die Core automatisch aanmaakt. Tijdens die overgang waren beide procesmanagers tijdelijk naast elkaar actief.

Dat is op zichzelf geen probleem: meerdere Laravel-workers kunnen dezelfde queue verwerken, zolang de queue-backend en configuratie dat ondersteunen.

Tot 12 juli draaide er daarom nog een tweede worker, beheerd door Supervisor. Terwijl de worker onder de nieuwe daemon -toevallig- was vastgelopen, bleef de worker onder Supervisor gewoon jobs verwerken.

Wat kun je als Laravel-developer rekening mee houden?

Deze casus laat zien dat een draaiende Laravel-worker niet automatisch een functionerende Laravel-worker is. Met een paar voorzorgsmaatregelen kun je voorkomen dat één vastgelopen job een queue compleet blokkeert.

1. Voorzie al je HTTP-requests van timeouts.

Iedere externe HTTP-request kan vastlopen, ook richting betrouwbare diensten. Stel daarom altijd zowel een connection-timeout als een request-timeout in. Met de HTTP-client van Laravel’s is dat eenvoudig:

Http::connectTimeout(5)
    ->timeout(30)
    ->get('https://example.com/api');

2. Verwar --max-time niet met --timeout.

Laravel heeft twee opties die geregeld met elkaar worden verward:

  • --max-time=3600: laat een worker maximaal een uur jobs verwerken. In combinatie met Supervisor of systemd kan de worker vervolgens opnieuw worden gestart. Dit helpt om opgebouwd geheugengebruik, bijvoorbeeld door memoryleaks, periodiek op te ruimen.
  • --timeout=60: begrenst hoe lang één job mag draaien. Wordt deze tijd overschreden, dan beëindigt Laravel de worker.

Belangrijk: --max-time onderbreekt geen vastgelopen job. De worker controleert pas na afloop van een job of hij zijn maximale levensduur heeft bereikt. Een job die zes dagen blijft hangen, wordt dus niet na een uur afgebroken door --max-time=3600.

Gebruik --timeout om de uitvoeringstijd van jobs te begrenzen en houd deze waarde lager dan retry_after, zodat dezelfde job niet opnieuw beschikbaar komt terwijl de oorspronkelijke worker nog bezig is. Voor het afdwingen van Laravel-jobtimeouts is de PHP-extensie PCNTL nodig.

En ook met --timeout blijft het belangrijk om HTTP-requests afzonderlijk te begrenzen. Bepaalde blokkerende I/O-operaties respecteren de timeout van de worker namelijk niet.

3. Monitor of jobs daadwerkelijk worden verwerkt.

Een procesmanager kan controleren of een worker draait, maar weet niet noodzakelijk of die nog vooruitgang boekt. Daarom is het verstandig om ook de wachttijd van jobs, de omvang van queues en eventuele vastgelopen jobs te monitoren.

Laravel Horizon biedt hiervoor uitgebreide mogelijkheden bij Redis-queues. Ook buiten Horizon kun je zulke controles inrichten, bijvoorbeeld met Better Stack.

Managed hosting gaat verder dan je applicatiecode

Bij Cyberfusion werken we dagelijks met Laravel, zowel in onze eigen software als in de applicaties die klanten op ons platform hosten. Daardoor kunnen onze engineers ook meedenken wanneer een probleem zich ergens tussen Laravel, PHP, Linux en de onderliggende infrastructuur bevindt.

In deze casus had een simpele herstart de queue weer aan het werk gekregen. Maar dan hadden we niet geweten waarom de worker was vastgelopen, en had dezelfde situatie zich later opnieuw kunnen voordoen.

We zijn daarom - samen met de developer van de applicatie - verder gaan zoeken. Totdat we konden aanwijzen welke Laravel-job problematisch bleek, op welke HTTP-request die wachtte, en waarom de queue pas twee dagen later volledig stilviel.

Dat is échte managed hosting: engineers die de techniek achter je applicatie begrijpen, verder kijken dan de status van een server, en helpen om problemen daadwerkelijk te verklaren.

Zodat developers zich kunnen richten op waar hun klanten voor betalen: applicaties bouwen en verbeteren.