— Artikel — № 053

053 —Operations

Rogue cron vult /tmp: een lek van 12 GB aan PHP-sessions

Op een dinsdagnacht om 3:14 kwam de alert binnen. /tmp op een kleine VPS was over 12 GB aan losse PHP-sessions gegaan. Zo vonden we de cron erachter.

Bovenaanzicht op linnen: cron-schema, logprint, papieren labels, messing CRON-plaat, stalen liniaal, potlood, rode lakzegel.
Hero · gestileerd stilleven№ 053

De alert kwam binnen om 03:14 op een dinsdag. /dev/sda1 op een kleine VPS met vier WordPress-sites was over de 92% gegaan. Nog niet vol, maar duidelijk de verkeerde kant op. De on-call engineer (wij, in dit geval, omdat het bureau dat de server beheert juist voor dit soort avonden een retainer heeft) SSH'te in en deed de standaard triage.

De boosdoener was /tmp. 12,4 GB ervan. Bijna alleen maar bestanden met namen als sess_a3f9b..., teruggaand tot achttien maanden geleden.

Hieronder de walkthrough van hoe we die stapel hebben teruggevolgd naar één regel in de crontab van één gebruiker, en waarom het standaardadvies (verhoog session.gc_probability, zet systemd-tmpfiles op) wel het symptoom maar nooit de oorzaak aanpakt.

Wat 12 GB aan sess_-bestanden eigenlijk betekent

De standaard session handler van PHP schrijft één bestand per session weg, met de naam sess_<ID>, in het pad waar session.save_path naar verwijst. Bij de meeste standaard builds is dat pad /tmp. Elk bestand bevat de geserialiseerde $_SESSION-array van één bezoeker.

Een schone WordPress-install roept session_start() helemaal niet aan (wp-login werkt met cookies, geen sessions). Maar heel veel plugins wel: WooCommerce van oudsher, Wordfence in bepaalde configuraties, contactformulier-plugins die een CSRF-nonce nodig hebben, elk "members area"-thema. Drupal 7-sites gebruiken sessions standaard. Net als de meeste custom PHP.

Twaalf gigabyte aan session-bestanden is geen configuratieprobleem. Het is een creatieprobleem. Bij ruwweg 4 KB per bestand zijn dat circa drie miljoen sessions, en die haalt geen enkel menselijk bezoekersaantal op een kleine WordPress-site in achttien maanden. Iets machinaals hamert op een pagina die session_start() aanroept.

Het mtime-cluster dat het verraadde

Het eerste bruikbare ding was een histogram van de modification times van de bestanden. Niets bijzonders:

find /tmp -maxdepth 1 -name 'sess_*' -printf '%TY-%Tm-%Td %TH\n' \
  | sort | uniq -c | tail -40

De output zag er zo uit:

   1440 2026-05-25 22
   1440 2026-05-25 23
   1440 2026-05-26 00
   1440 2026-05-26 01
   1440 2026-05-26 02

1440 bestanden per uur. Exact 24 per minuut. Dat is geen Googlebot-patroon en geen menselijk patroon. Dat is een cron die elke minuut afgaat en 24 losse curl-aanroepen doet, elk goed voor één verse session.

Vanaf daar waren twee commando's genoeg om de bron te vinden:

for u in $(cut -d: -f1 /etc/passwd); do
  crontab -u "$u" -l 2>/dev/null | sed "s/^/$u: /"
done
ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/

De hit zat in de crontab van een niet-root-gebruiker, achtergelaten door een voormalige contractor en nooit nagekeken:

* * * * * /usr/bin/curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null
* * * * * /usr/bin/curl -s https://example.com/?warm=home > /dev/null
* * * * * /usr/bin/curl -s https://example.com/shop/?warm=1 > /dev/null
# 21 more lines like this, hitting various "cache warm" URLs

Het curl-zonder-cookies-patroon

Dit is het deel dat het waard is om in je geheugen te prenten, want we hebben deze exacte failure mode in de laatste twee jaar nu op vijf verschillende klantservers gezien.

Als je curl aanroept zonder -b en -c (de cookie-jar-flags), is elke aanroep een gloednieuwe client zonder cookies. Als de URL die hij aanspreekt ergens in z'n execution path session_start() aanroept (een plugin-hook, een theme-functie, een header-include), reserveert PHP een nieuwe session-ID en schrijft een nieuw bestand naar session.save_path. De cron die elke minuut draait, raakt 24 verschillende URL's, elk daarvan triggert session_start(), en om 03:14:00 staan er 24 verse bestanden in /tmp.

Dan de tweede helft van het probleem. De session garbage collector van PHP hoort oude bestanden weg te halen op basis van session.gc_maxlifetime. Op Debian en Ubuntu staat die GC standaard uit (session.gc_probability = 0) en is hij vervangen door een sessionclean-cron in /etc/cron.d/php. Maar die cron ruimt alleen de save_path op die in de php.ini van de FPM-pool staat. Als de site een session.save_path instelt in een per-directory .user.ini, komen de bestanden ergens terecht waar sessionclean nooit leest.

In ons geval gebeurde precies dat. De site had dit in z'n document root staan:

; .user.ini
session.save_path = "/tmp"
session.gc_maxlifetime = 86400

Vijf jaar geleden door een of andere plugin-installer neergezet en sindsdien niet meer aangeraakt. De save_path van de FPM-pool was de standaard /var/lib/php/sessions. Het Debian-opruimscript ruimde dat netjes op. /tmp bleef maar groeien.

De oorzaak oplossen, niet het symptoom

Drie dingen, in volgorde.

  1. Verwijder de crontab-regels van de contractor. De "cache warm"-loop deed niets nuttigs: de site stond achter een CDN en de origin was al warm. De wp-cron.php-regel is de standaard WordPress-workaround, maar die hoort elke vijftien minuten te draaien vanuit één regel, niet elke minuut vanuit een script dat z'n cookies weggooit. De Codex heeft het canonieke patroon.
  2. Haal de losgeslagen .user.ini-override weg. Til de directive ofwel op naar de FPM-pool-config zodat sessionclean de bestanden vindt, of accepteer de pool-default en laat alles onder /var/lib/php/sessions staan.
  3. Pas dán /tmp leeghalen. We gebruiken find /tmp -name 'sess_*' -mtime +1 -delete, niet rm -rf /tmp/*, want andere processen schrijven daar ook (uploads, ImageMagick-scratch, MySQL temp tables) en een blanket delete sloopt wat er net halverwege een upload zat.

De post-mortem-fix aan de bureaukant was een notitie van één pagina in hun handover-doc. Als je een verouderde site overneemt, zijn de eerste drie dingen om naar te kijken: de actieve user-crontabs, elke .user.ini en .htaccess in de document-boom, en de FPM-pool-config. In de interactie tussen die drie zitten de trage lekken.

Wat we nu als eerste doen op elke nieuwe server

Het onderzoek kostte ons veertig minuten in plaats van vier uur omdat we het al eerder hadden gedaan. Toen we Pier bouwden liepen we tegen exact ditzelfde aan op een klantserver, en hoe we het uiteindelijk aanpakten was de file tree en de database allebei vanuit één venster doorbladerbaar maken, met version history op elke overschrijving. De losgeslagen .user.ini die we verwijderden is met één klik terug te halen als blijkt dat iets op de site toch op die override leunde.

Het kleinste wat je vandaag kunt doen: draai op elke verouderde site die je nu onderhoudt find / -name '.user.ini' 2>/dev/null en lees elk bestand dat eruit komt. De meeste zijn leeg. De bestanden die dat niet zijn, daar komt je volgende nachtelijke piepalert vandaan.

— Vragen —

Waarom ruimt de session garbage collector van PHP deze bestanden niet automatisch op?

Op Debian-achtige distro's staat de GC van PHP uit (gc_probability=0) en is hij vervangen door een sessionclean-cron die alleen de save_path van de FPM-pool leest, en daarmee per-site-overrides in .user.ini mist.

Kan ik /tmp niet gewoon leeggooien om de ruimte terug te krijgen?

Niet veilig. Uploads, ImageMagick-scratchbestanden en MySQL temp tables staan daar ook. Gebruik find /tmp -name 'sess_*' -mtime +1 -delete om alleen de oude session-bestanden te verwijderen.

Hoe voorkom ik dat dit nog een keer gebeurt?

Audit elke user-crontab en elke .user.ini en .htaccess in de document root. Elke curl-loop zonder -b/-c die een pagina raakt die session_start() aanroept, blijft eeuwig sessions lekken.