— Artikel — № 092

092 —Magento

Magento 2 cache warmer: een bash-playbook dat reboots overleeft

Het is vrijdag 21:14, je hebt net cache:flush gedraaid, en de volgende bezoeker wacht vier seconden. Dit is de bash-warmer die een NL-bureau nu op elke webshop draait.

Bovenaanzicht van Magento cache-warmer playbook op ruitjespapier met manilamap, crontab-strook, messing plaatje, lakzegel.
Hero · gestileerd stilleven№ 092

Het is vrijdag 21:14. Het deployscript heeft net bin/magento cache:flush gedraaid, en er draait geen cache warmer achteraan. De eerstvolgende bezoeker op een productpagina krijgt een koude full page cache en wacht 4,2 seconden op TTFB. De tweede bezoeker wacht 3,9. Tegen de tijd dat de cache warm genoeg is om bruikbaar te zijn, hebben drie mensen in het Slack-kanaal al gevraagd of de site eruit ligt.

Een Nederlands bureau waar we mee samenwerken had bijna een jaar lang exact deze loop iedere vrijdagmiddag. De oplossing die uiteindelijk bleef hangen, was geen Magento 2 cache warmer-extensie. Het was een bash-script van twee uur, een gecureerd tekstbestand met URL's en een sleep die de origin met rust laat. Het hele ding past op één terminalscherm en komt netjes terug na een reboot.

Deze post is het hele script. Kopieer het, stel de throttle af en zet het achter een systemd unit zodat het niet doodgaat zodra iemand in het datacenter de kernel patcht.

De URL-lijst die niemand cureert

Een cache warmer is zo goed als de URL's die je hem voert. De meeste Magento 2 shops leveren een sitemap.xml die juist de URL's overslaat die ertoe doen: gefilterde categoriepagina's, gesorteerde listings, populaire productvarianten met voorraad-attributen in de URL, de winkelmand- en checkoutweergaven die je eigenlijk pre-rendered wilt hebben. Alleen de sitemap warmen levert koude caches op precies de pagina's waar de homepage naar linkt.

Bouw de URL-lijst in twee passes op. Pak eerst de sitemap als basis:

curl -s https://example.com/sitemap.xml \
  | grep -oE '<loc>[^<]+</loc>' \
  | sed -E 's|</?loc>||g' > /var/lib/magento-warmer/urls.txt

Voeg vervolgens de top 200 product-URL's toe op basis van recente verkopen. Draai dit rechtstreeks tegen de Magento-database. Dat is sneller dan crawlen en levert je de URL's op die omzet draaien:

SELECT CONCAT('https://example.com/', ur.request_path) AS url
FROM url_rewrite ur
JOIN sales_order_item soi ON soi.product_id = ur.entity_id
WHERE ur.entity_type = 'product'
  AND ur.store_id = 1
  AND ur.redirect_type = 0
  AND soi.created_at > NOW() - INTERVAL 30 DAY
GROUP BY ur.request_path
ORDER BY SUM(soi.qty_ordered) DESC
LIMIT 200;

Hang het resultaat achter dezelfde urls.txt en dedupliceer met sort -u. Voor een catalogus van 5000 SKU's komt het bestand uit op zo'n 1,2 MB en parset het in minder dan een seconde. Draai de SQL een keer per week vanuit cron, dan blijft de warmer volgen wat je klanten daadwerkelijk kopen, in plaats van hoe je categorieboom er in 2024 toevallig uitzag.

Gefilterde categoriepagina's

Als je faceted navigation hebt (prijsslider, merkcheckbox, kleurswatch), verandert de cache key per filtercombinatie. Je kunt ze niet allemaal warmen, en je moet het ook niet proberen. Kies de drie filters per categorie waarvan je analytics laten zien dat er ook echt op geklikt wordt, bouw die URL's met de hand op en zet ze in de lijst. Twaalf filter-URL's per categorie is ruim voldoende. Meer en je verbrandt warmer-tijd aan pagina's waar geen klant naar kijkt.

Een bash-loop die de origin met rust laat

Dit is de warmer. Een dertigtal regels POSIX-vriendelijke bash, gebruikt xargs voor concurrency en schrijft een tab-gescheiden log die je kunt greppen:

#!/usr/bin/env bash
set -euo pipefail

URL_FILE="${URL_FILE:-/var/lib/magento-warmer/urls.txt}"
LOG_FILE="${LOG_FILE:-/var/log/magento-warmer.log}"
CONCURRENCY="${CONCURRENCY:-4}"
SLEEP_MS="${SLEEP_MS:-250}"
UA="MagentoWarmer/1.0 (+ops@example.com)"

warm_one() {
  local url="$1"
  local http
  http=$(curl -s -o /dev/null \
    -w '%{http_code} %{time_starttransfer} %{size_download}' \
    -A "$UA" -H 'X-Cache-Warm: 1' \
    --max-time 30 "$url" || echo '000 0 0')
  printf '%s\t%s\t%s\n' "$(date -Iseconds)" "$url" "$http" \
    >> "$LOG_FILE"
}

export -f warm_one
export LOG_FILE UA

SLEEP_SEC=$(awk "BEGIN{print ${SLEEP_MS}/1000}")
xargs -a "$URL_FILE" -n 1 -P "$CONCURRENCY" -I{} \
  bash -c "warm_one '{}'; sleep $SLEEP_SEC"

Twee waarden zijn belangrijk: CONCURRENCY en SLEEP_MS. De defaults (vier parallelle requests, 250 ms tussenpoos) werken prima voor een single-node Magento op een bescheiden VPS. Zit je achter Varnish op een viercore-origin, dan kun je meestal omhoog naar acht en 100 ms. Hou de origin tijdens de eerste run in de gaten met htop: als de PHP-FPM workers structureel boven de 70% blijven, schaal terug. De warmer hoort onzichtbaar te zijn voor echt verkeer, niet een zelfveroorzaakte denial of service. De xargs manpage beschrijft de exacte semantiek van -P en -I als je wilt wisselen naar GNU parallel of de substitutiestijl wilt aanpassen.

De header X-Cache-Warm: 1 is voor je eigen logs en voor Varnish als je warme en koude hits apart wilt bijhouden. Hij mag de cache hash niet veranderen. In je VCL: voeg hem niet toe aan vcl_hash en strip hem niet voor vcl_backend_fetch, anders vervuilt de warmer de cache met een aparte variant per URL. Gebruik hem voor telemetrie, niet voor routing.

Een reboot overleven

Een bash-script in cron sterft in stilte zodra de mail spool volloopt. Een bash-script in een tmux-sessie gaat dood bij een reboot. Het hele punt van deze playbook is dat de warmer na de volgende reboot (kernel patch, security advisory, onderhoudsvenster van je cloudprovider) terugkomt zonder dat jij hoeft in te loggen.

Zet het script op /usr/local/bin/magento-warmer.sh, maak het executable en schrijf een one-shot unit:

# /etc/systemd/system/magento-warmer.service
[Unit]
Description=Magento 2 full page cache warmer
After=network-online.target varnish.service
Wants=network-online.target

[Service]
Type=oneshot
User=www-data
Group=www-data
Environment=URL_FILE=/var/lib/magento-warmer/urls.txt
Environment=CONCURRENCY=4
Environment=SLEEP_MS=250
ExecStart=/usr/local/bin/magento-warmer.sh
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=2h

[Install]
WantedBy=multi-user.target

De interessante vlaggen zijn Nice=10 en IOSchedulingClass=idle. Die vertellen de kernel dat warmer-verkeer voorrang moet geven aan alles wat een echte klant aan het doen is. TimeoutStartSec=2h sluit aan op het plafond van twee uur uit de titel: draait de warmer na twee uur nog steeds, dan is er iets mis en mag systemd hem killen in plaats van hem te laten overlappen met de volgende run.

Koppel hem aan een timer:

# /etc/systemd/system/magento-warmer.timer
[Unit]
Description=Run Magento warmer at boot and every six hours

[Timer]
OnBootSec=90s
OnUnitActiveSec=6h
Persistent=true

[Install]
WantedBy=timers.target

Beide aanzetten:

systemctl daemon-reload
systemctl enable --now magento-warmer.timer
systemctl list-timers magento-warmer.timer

Persistent=true is de regel die telt. Stond de host uit op het moment dat een geplande run had moeten draaien, dan vuurt systemd de warmer eenmalig af bij de volgende boot om bij te trekken. OnBootSec=90s geeft Varnish en PHP-FPM de tijd om te settelen voor de warmer ze raakt. De volledige systemd.timer-documentatie dekt de edge cases rond drift, accuracy windows en kalenderevents als je liever een cron-achtig schema wilt.

Verifiëren dat de cache ook echt warm is

Een warmer die zonder fouten draait is niet hetzelfde als een warmer die ook warmt. Magento 2 zet een debug-header op de full page cache die je met curl kunt uitlezen. Zet dit in je post-warm check:

curl -sI -H 'X-Cache-Warm: 1' \
  https://example.com/some-product.html \
  | grep -iE 'x-magento-cache-debug|age|x-cache'

Je wilt X-Magento-Cache-Debug: HIT op de tweede request, en op Varnish wil je dat Age groter is dan nul. Is de eerste request HIT en de tweede MISS, dan wordt je cache halverwege het warmen ongeldig gemaakt. Waarschijnlijke verdachten: een voorraad-update-cron die getagde entries flusht, of een admin die tijdens de run live producten aan het editen is. Zet de warmer in een venster waarin de admin slaapt en de indexers niet draaien, dan klimt de HIT rate vanzelf.

Voorbeeld van een logregel uit een gezonde run:

2026-06-09T03:14:22+02:00	https://example.com/red-leather-chair.html	200 0.184 41327

Dat is HTTP 200, 184 ms tot first byte, 41 KB overgedragen. Alles boven de 1,5 seconde op een warme pagina betekent dat de warmer de cache niet raakt, en dan moet je dat eerst uitzoeken voordat je meer URL's aan de lijst toevoegt. Een simpele awk-oneliner over de log geeft je de p95 in ongeveer een seconde:

awk -F'\t' '{split($3,a," "); print a[2]}' /var/log/magento-warmer.log \
  | sort -n | awk 'BEGIN{c=0}{v[c++]=$1}END{print v[int(c*0.95)]}'

Als de warmer niet de bottleneck is

Warm je 5000 URL's en serveren de warme pagina's nog steeds op 1,8 seconde TTFB, dan is de warmer niet je probleem. De cache vult zich prima. De bottleneck zit in de renderlaag: een query zonder index in een layout block, een Elasticsearch-suggester die synchroon draait, of een third-party module die in collection_load_after hangt en per product een externe API aanroept. Hoe veel je ook vooraf opwarmt, dat lost het niet op, want elke klant die op een verse URL landt, betaalt nog steeds de koude prijs.

Dat is het punt waarop het script van twee uur niet meer genoeg is. Dan moet je de codebase in, het DI-graaf lezen en de dader vinden. Dat is het werk dat wij doen als een bureau ons een trage legacy site aanreikt en vraagt of we de rendering willen fixen in plaats van er nog meer cache tegenaan te gooien. Toen we Pier bouwden, liepen we hier precies tegenaan op een Magento-shop: de trage query stond duidelijk in de log, maar de layout XML die hem veroorzaakte zat drie modules diep. We hebben het uiteindelijk opgelost door de MySQL editor naast de bestandsboom te zetten, zodat je core_config_data, de layout XML en de slow query log in één venster kunt lezen, met één regel version history op het moment dat je opslaat.

Het kleinste wat je vandaag kunt doen: ls -la /var/log/magento-warmer.log. Bestaat het bestand niet, dan heb je geen warmer. Tik de dertig regels hierboven uit, zet het script in /usr/local/bin en draai het één keer met de hand tegen de sitemap voordat je de systemd unit aansluit. De rest is administratie.

— Vragen —

Waarom geen betaalde Magento 2 cache warmer-extensie gebruiken?

De meeste zijn ondoorzichtig, draaien binnen Magento (en delen dus PHP-FPM workers met echt verkeer) en kosten meer dan dertig regels bash. Een script dat je zelf in handen hebt, debug je om 3 uur 's nachts een stuk makkelijker.

Hoe agressief mag de warmer zijn zonder de site pijn te doen?

Vier parallelle requests met 250 ms tussenpoos is veilig op een single-node VPS. Hou de PHP-FPM workers tijdens de eerste run in de gaten met htop. Blijven ze structureel boven de 70%, schaal de concurrency terug of zet de sleep omhoog.

Werkt de warmer met Hyva of Magento PWA Studio?

Ja voor Hyva: dat wordt nog steeds server-side gerendered en gebruikt dezelfde full page cache. PWA Studio is anders: de HTML warmen heeft weinig zin omdat het renderen client-side gebeurt. Pre-renderen tijdens de build is daar de aanpak.