Magento
Magento 2 cron vastgelopen: 14 uur orders stilletjes weg
Een bevroren cron-tabel, een telefoon in het magazijn die stil bleef en 87 mails die nooit vertrokken. De Magento 2 storing waar elke shop-eigenaar bang voor moet zijn.

Het telefoontje kwam om 09:14 op zaterdag. De magazijnmanager bij een middelgroot Nederlands woonmerk was de ochtendorders aan het inpakken toen ze iets vreemds opmerkte. De picklijst was leeg. Niet 'we hebben alles verstuurd' leeg, maar 'het systeem heeft ons sinds gisteravond niets meer doorgegeven' leeg. Ze opende de Magento admin, sorteerde orders op datum, en zag drieënveertig nieuwe regels staan uit de avond ervoor en de nacht. Geen enkele was naar het WMS gepusht. Geen enkele klant had een bevestigingsmail gekregen.
Tegen de tijd dat wij inlogden, stond de teller op zevenentachtig orders. De webshop draaide gewoon. Klanten betaalden. De checkout werkte. De voorraad ging omlaag. Wat was gestopt, veertien uur eerder rond 19:30 op vrijdagavond, was de cron.
Hoe een vastgelopen cron er echt uitziet
Als je alleen vanaf de merchant-kant met Magento 2 hebt gewerkt, voelt 'cron' als een vaag backend-iets. Dat is het niet. De transactionele mails die de order van een klant bevestigen. De push naar Mollie of Adyen van de status van de geboekte betaling. Het signaal naar je verzendplugin dat een order betaald is en klaar voor verwerking. De product index die zorgt dat zoekresultaten matchen met de catalogus. Bijna alles wat Magento doet tussen het moment dat een klant op 'Betalen' klikt en het moment dat het magazijn een picklijst krijgt, loopt via de cron.
De status van die jobs leeft in één MySQL-tabel met de naam cron_schedule. Elke job die zou moeten draaien krijgt een rij, met een status-veld dat door pending, running en dan success, missed of error heen loopt. Een gezonde tabel beweegt. Oude rijen worden opgeruimd, nieuwe rijen komen in de wachtrij, en de finished_at kolom volgt kort op scheduled_at.
Wat we die zaterdagochtend zagen was anders. Een query als deze vertelde het verhaal in drie regels:
SELECT job_code, status, COUNT(*) AS n,
MIN(scheduled_at) AS oldest,
MAX(scheduled_at) AS newest
FROM cron_schedule
GROUP BY job_code, status
ORDER BY oldest;
Twee jobs stonden vast in running sinds 19:31 vrijdag. Daarachter lagen meer dan negenhonderd rijen opgestapeld als pending, de oudste gepland voor 19:32 vrijdag, de nieuwste voor 09:14 zaterdag. Er was veertien uur lang niets bewogen. De pijnlijkste regel was er één die we al vermoedden. sales_email_order_sender had sinds vrijdagavond nul keer gedraaid. Zevenentachtig orderbevestigingen waren in de wachtrij gezet, gesigneerd en in de kast gelegd.
De oorzaak was saai
Dat is hij meestal. De shop draaide Magento 2.4.4 op één VPS van 8 GB. PHP-FPM had rond 19:30 een van zijn workers laten crashen tijdens een zware indexer-rebuild, toen de geheugendruk piekte boven op een lopende productimport. Het cron-proces dat het file-lock op var/locks/cron_group_default.lock vasthield, ging mee met de worker, en het lock-bestand zelf werd nooit vrijgegeven. Volgende ticks van de systeem-cron daemon zagen het lock, gingen ervan uit dat er al een instance liep en stopten zwijgend. De cron_schedule rijen die uit running hadden moeten gaan, bleven daar staan. De pending queue groeide, uur na uur, tegen een scheduler die stilletjes was opgehouden te luisteren.
De host zat al veertig minuten in swap voordat de worker crashte. vmstat 5 liet zien dat de si en so kolommen langdurig op 4 tot 6 MB per seconde stonden, wat op deze VPS betekende dat de kernel meer geheugen aan het pagen was dan de disk kon opnemen. PHP-FPM's pm.max_children stond op 25, met een per-worker limiet van 256 MB geheugen. De rekensom, 25 maal 256, komt uit op 6,4 GB. Dat liet geen ruimte over voor MySQL, Redis en de kernel page cache zodra de productimport er een groot file handle bovenop opende. De OOM killer pakte de PHP-FPM pool als eerste, en de cron-worker die het lock vasthield ging mee. Magento was slachtoffer van de host, niet de auteur van zijn eigen falen.
Van buitenaf zag de shop er prima uit. De checkout reageerde. De frontend serveerde gecachte pagina's vanuit Varnish. Alleen de asynchrone helft van Magento, de helft die de klant nooit ziet, was stilgevallen.
Als je Magento 2 monitoring alleen naar HTTP statuscodes kijkt, kun je deze storing niet zien. De site geeft netjes 200 OK terug terwijl orders niet verstuurd worden. Je moet de database-tabel in de gaten houden, niet de webserver.
De fix van die ochtend
Het herstel zelf duurde twaalf minuten zodra we begrepen hoe het probleem in elkaar zat. Adobe's eigen documentatie over de Magento cron-architectuur beschrijft het lock-file mechanisme en het pad voor handmatig herstel. In de praktijk deden we drie dingen, in deze volgorde.
Eerst killden we het dode lock en eventuele rondhangende PHP-processen:
ps aux | grep "cron:run" | grep -v grep
sudo kill -9 <pid>
sudo rm -f /var/www/magento/var/locks/cron_group_default.lock
Daarna resetten we de running rijen zodat de scheduler ze weer zou oppakken in plaats van ze te behandelen alsof ze al liepen:
UPDATE cron_schedule
SET status = 'missed',
messages = CONCAT(IFNULL(messages, ''), ' [reset by ops 2026-06-04]')
WHERE status = 'running'
AND scheduled_at < NOW() - INTERVAL 30 MINUTE;
Tot slot triggerden we de mail-sender met de hand, zodat de zevenentachtig klanten die op een bevestiging wachtten niet nog een cron-cyclus hoefden af te wachten:
php bin/magento cron:run --group=default
php bin/magento queue:consumers:start sales.email.order
Binnen vijf minuten stroomden de bevestigingsmails uit. Binnen een uur hadden we de backlog weggewerkt, de klanten benaderd wier orders door hun beloofde verzendvenster waren gedreven, en een kleine compensatie aangeboden aan de vier die al hadden geklaagd. Niemand haakte af. De shop-eigenaar verloor er geen enkele klant aan. Wat ze wel verloor, en wat niemand terug kan kopen, was veertien uur vertrouwen in haar eigen monitoring.
De cron-groepen splitsen
Het herstel bracht de shop weer online. De configuratiewijziging de ochtend erna maakte dezelfde storing moeilijker te herhalen. Standaard komt Magento 2 met drie cron-groepen: default, index en consumers. Op een installatie met één VPS kunnen alle drie binnen hetzelfde parent-proces onder hetzelfde lock draaien. Dat is precies de topologie die ons beet. Een zware reindex liet de transactionele jobs verhongeren die bevestigingsmails verstuurden en orders naar het WMS pushten, omdat ze wachtten op hetzelfde lock dat de reindex vasthield.
De fix heeft twee delen. Eerst geef je de indexer-groep een eigen crontab-regel, gedraaid door een eigen PHP-proces, met een eigen lock-bestand:
# /etc/cron.d/magento-default
* * * * * www-data /usr/bin/php /var/www/magento/bin/magento cron:run --group=default \
2>&1 | tee -a /var/log/magento/cron-default.log
# /etc/cron.d/magento-index
* * * * * www-data /usr/bin/php /var/www/magento/bin/magento cron:run --group=index \
2>&1 | tee -a /var/log/magento/cron-index.log
Daarna verkort je de schedule history voor de index-groep, zodat haar rijen de cron_schedule tabel niet overspoelen. De default success lifetime is zestig minuten, wat redelijk is voor een transactionele job die één keer per uur draait, maar veel ruis oplevert voor een indexer die elke minuut draait. Wij gingen naar tien, met een langere failure lifetime zodat error-rijen zichtbaar blijven voor de ochtendstandup:
<!-- app/etc/config.php (partial) -->
<config>
<cron>
<jobs>
<indexer_reindex_all_invalid>
<schedule>
<history_cleanup_every>10</history_cleanup_every>
<history_success_lifetime>10</history_success_lifetime>
<history_failure_lifetime>60</history_failure_lifetime>
</schedule>
</indexer_reindex_all_invalid>
</jobs>
</cron>
</config>
Na het splitsen van de groepen en het tunen van de lifetimes zakte de cron_schedule tabel van zo'n twaalfduizend rijen in steady state naar onder de vierhonderd. De twee crontabs houden onafhankelijke lock-bestanden vast. Een vastgelopen index-groep kan de default groep niet meer bevriezen. De transactionele helft van de shop heeft nu een eigen smalle rijstrook, en de indexer heeft de zijne.
Drie alerts die we die middag inbouwden
Het interessante werk begon nadat het stof was neergedaald. Magento bestaat lang genoeg dat 'kijk naar de cron' geen onbekend terrein is, maar het meeste publieke advies blijft algemeen. Wij wilden drie specifieke alerts, elk afgesteld op een andere faalmodus van hetzelfde onderliggende probleem. Elke alert moest binnen vijftien minuten afgaan, en elke alert moest goedkoop genoeg zijn dat een klein intern team ze groen kon houden zonder een eigen SRE.
Alert 1: cron_schedule heartbeat
Deze ligt voor de hand. Elke vijf minuten queryt een klein script de schedule-tabel en kijkt hoe lang geleden de laatste job naar success ging. Als die gap groter is dan vijftien minuten, post het script naar een Slack webhook. De query is korter dan de beschrijving van de alert:
SELECT TIMESTAMPDIFF(MINUTE, MAX(finished_at), NOW()) AS quiet_minutes
FROM cron_schedule
WHERE status = 'success';
De drempel van vijftien minuten is niet willekeurig. De default cron-groep van Magento tikt elke minuut, en de traagste 'normale' job die we op deze shop maten draaide rond de vier minuten. Vijftien minuten geeft ons een veiligheidsmarge zonder af te gaan op een trage reindex. Het script draait als zijn eigen systeem-cron, los van de crontab van Magento zelf, zodat een bevroren Magento-cron de watchdog niet ook kan bevriezen.
Alert 2: orders zonder bevestigingsmail
De heartbeat vangt de technische storing. Deze tweede alert vangt het gevolg dat de klant daadwerkelijk voelt. Elke tien minuten stelt het script een simpele vraag aan de sales_order tabel: zijn er betaalde orders uit het laatste uur die nog geen email_sent = 1 hebben?
SELECT entity_id, increment_id, created_at
FROM sales_order
WHERE state IN ('processing', 'complete')
AND email_sent IS NULL
AND created_at < NOW() - INTERVAL 30 MINUTE
AND created_at > NOW() - INTERVAL 6 HOUR;
Als die query meer dan drie rijen retourneert, behandelen we het als een incident. Het grace-venster van dertig minuten houdt rekening met legitiem trage mail-runs tijdens een flash sale. Het plafond van zes uur zorgt dat de alert niet voor eeuwig blijft schreeuwen over een historisch foutje. Dit is de alert die ons vrijdagnacht wakker had gemaakt, uren voordat de magazijnmanager het zaterdagochtend opmerkte.
Alert 3: leeftijd van het lock-bestand
De derde alert is de goedkope vangnetlaag. De var/locks/ directory mag nooit een lock-bestand bevatten dat ouder is dan de langste legitieme job. Wij kozen dertig minuten als grens. Een shellscript van één regel, vanuit de systeem-crontab, doet het werk:
find /var/www/magento/var/locks -name '*.lock' -mmin +30 \
-exec echo "STALE_LOCK: {}" \;
Elke niet-lege output triggert een Slack-post. Wij stuurden de output door een kleine Python-helper die de hostname en de leeftijd van het lock-bestand in minuten toevoegt, zodat de on-call engineer de hele context op één regel ziet in plaats van een kale bestandsnaam.
Waarom drie, niet één
De verleiding bij het bouwen van incident-monitoring is om alles centraal in één dashboard met één regel te gooien. Wij leerden op de harde manier dat één alert één faalmodus betekent. Twee alerts betekenen dat je de storing nog vangt als de alert zelf faalt. Drie alerts betekenen dat je op zaterdag kunt slapen.
Elk van de drie hierboven houdt een andere laag in de gaten. De heartbeat kijkt naar de scheduler. De mailcheck kijkt naar de uitkomst die de klant ziet. De lock-file check kijkt naar het besturingssysteem. Elk afzonderlijk had de storing van vrijdag binnen vijftien minuten gevangen. Samen maken ze de faalmodus die we die ochtend doorleefden in de praktijk onmogelijk om ongezien terug te keren.
Er is een tweede reden voor het drietal die we pas drie maanden later echt opmerkten, in een los incident op een andere shop. Eén alert vertelt je dat er iets stuk is. Drie alerts die in volgorde afgaan vertellen je welke laag het eerst brak. Bij een bevroren scheduler gaat de lock-file watcher binnen dertig minuten af. De heartbeat volgt vijftien minuten later. De email-sent check komt nog een paar minuten daarna. De volgorde van de meldingen lezen vertelt de on-call engineer om eerst in de lock-directory te kijken voordat hij in MySQL gaat SSH'en. Die diagnostische snelkoppeling is de kleine extra moeite van drie checks in plaats van één meer dan waard.
De rest van het advies is de saaie soort die altijd aan het einde van dit soort verhalen opduikt. Hou queue_message_status klein door zijn lifetime te tunen. Let op de geheugendruk op de PHP-FPM pool, want een hangende worker is veel vaker de bovenliggende oorzaak dan een bug in Magento. Adobe's Experience League gids over het beheren van cron jobs bevat de canonieke commando's voor het splitsen van groepen en het apart draaien van consumers. En test de alerts door de cron bewust te killen in een staging omgeving, want het slechtste moment om te ontdekken dat je Slack webhook zes maanden geleden is verlopen, is het moment waarop een echt incident begint.
De audit van vijf minuten
Toen we deze monitoringlaag voor de Magento 2 stack van de woon-klant aansloten, kwamen we steeds terug op één punt: geen van de drie alerts vroeg om nieuwe infrastructuur. De queries hierboven draaien op de bestaande MySQL instance. De watchdog-scripts draaien als gewone systeem-cron jobs. Slack is gratis. Als jij een Magento 2 shop draait en in de komende vijf minuten wilt weten of je hetzelfde verborgen risico in huis hebt, open dan een SQL-client op je productie-database, plak de heartbeat-query bovenaan deze post in, en kijk hoe lang geleden de laatste finished_at is. Als het antwoord meer dan vijftien minuten is, heb je je eerste alert al om te schrijven. We hebben varianten van deze procesautomatisering gebouwd voor een half dozijn Magento en WooCommerce shops, en het patroon is altijd goedkoper, kleiner en saaier dan de storing die het in gang zette.
Kern
Als je Magento 2 monitoring alleen HTTP statuscodes bekijkt, zie je een vastgelopen cron niet. De site blijft op 200 OK staan terwijl orders niet verstuurd worden.
FAQ
Hoe vaak loopt de Magento 2 cron echt vast?
Vaker dan vendors toegeven. Op een shop met zware indexer-werkzaamheden op één VPS kun je minstens één vastgelopen-cron incident per jaar verwachten. De fix is monitoring, geen Magento-upgrade.
Lost een upgrade naar Magento 2.4.7 dit op?
Het verlaagt de frequentie. Het gesplitste message queue- en consumer-procesmodel in nieuwere releases maakt sommige faalmodi herstelbaar. Het haalt de noodzaak van de drie alerts die hier beschreven staan niet weg.
Kan ik vertrouwen op het ingebouwde cron-monitoring paneel van Magento?
De ingebouwde Systeem > Tools > Cron Group weergave laat zien wat er is gebeurd. Hij vertelt je niet wat is opgehouden met gebeuren, en dat is juist de faalmodus die je orders kost. Je hebt een externe watchdog nodig.
Wat is de kleinste versie hiervan die ik vandaag kan draaien?
Eén cron-entry op je VPS die elke vijf minuten cron_schedule queryt en naar een Slack webhook post als de laatste finished_at ouder is dan vijftien minuten. Twaalf regels bash plus een SQL-query.