Ottimizzare cron job su VPS unmanaged: tecniche avanzate per prevenire colli di bottiglia e downtime
A marzo 2025 il titolare di un e-commerce B2B marchigiano - Laravel 10 su un Hetzner CPX31 con 4 vCPU e 8 GB di RAM - mi ha segnalato che ogni mattina tra le 5:00 e le 6:30 il sito era "lentissimo, quasi inutilizzabile". Gli operatori dei clienti business che accedevano presto per inserire gli ordini del giorno trovavano pagine che caricavano in 15-20 secondi, timeout sulle ricerche prodotto, e checkout che fallivano con errore 504. Il problema scompariva da solo verso le 7:00, e durante il resto della giornata il sito funzionava perfettamente. Il pattern si ripeteva ogni mattina da circa sei settimane, e nessuno aveva capito perché.
La diagnosi è stata immediata. Ho fatto SSH sul server alle 5:15 del mattino successivo e ho lanciato uptime: load average 47.3 su un VPS con 4 vCPU. Il htop mostrava decine di processi PHP e MySQL in competizione per CPU e I/O. Il colpevole era il crontab: 23 cron job - tutti schedulati tra le 02:00 e le 02:30 - che includevano import listini da 6 fornitori, sincronizzazione prezzi, generazione PDF catalogo, aggiornamento indici di ricerca, pulizia sessioni, backup database, invio email report e rotazione log. Otto di questi job non avevano protezione contro la sovrapposizione e, poiché ciascuno impiegava più tempo del previsto (il database era cresciuto nel tempo), alle 5:00 del mattino giravano ancora tutti contemporaneamente, tre di loro in doppia istanza perché il cron li aveva rilanciati alle 02:00 senza che l'esecuzione precedente fosse terminata.
In questo articolo ti racconto come ho ristrutturato l'intero scheduling di quel server - e le tecniche che applico come standard su ogni VPS che gestisco con più di cinque cron job - perché è uno dei problemi più frequenti e più sottovalutati nell'amministrazione di server unmanaged.
Stai cercando un Consulente Informatico esperto per ottimizzare la tua infrastruttura VPS? Nel mio profilo professionale trovi l'esperienza concreta su Hetzner, OVH, Contabo e Digital Ocean. Contattami per una consulenza diretta.
Come si diagnostica un problema di cron job sovrapposti?
Il primo segnale è un pattern temporale ripetitivo: il server è lento sempre alla stessa ora, ogni giorno o ogni settimana. Se il rallentamento coincide con l'orario di esecuzione dei cron, la causa è quasi certamente lì. La diagnosi si fa in tre passaggi.
Primo: inventariare tutti i cron job attivi sul sistema. Non basta guardare un solo crontab - i job possono essere distribuiti in almeno cinque posti diversi:
# Crontab di tutti gli utenti con shell
for u in $(cut -d: -f1 /etc/passwd); do
crontab -u "$u" -l 2>/dev/null | grep -v "^#" | grep -v "^$" && echo "^^^ utente: $u"
done
# Crontab di sistema
cat /etc/crontab
# Directory di sistema
ls -la /etc/cron.d/
ls -la /etc/cron.daily/ /etc/cron.hourly/
# Systemd timer attivi
systemctl list-timers --all --no-pagerSecondo: verificare quanto tempo impiega realmente ogni job. La percezione del titolare ("ci vuole un attimo") raramente corrisponde alla realtà. Wrappare temporaneamente ogni job con time e redirigere su un log:
# Misurare il tempo reale di un cron job
0 2 * * * /usr/bin/time -v /var/www/html/artisan import:listini 2>> /var/log/cron-timing.logTerzo: verificare se ci sono sovrapposizioni. Il comando più rapido è cercare nel syslog:
# Cercare cron job in esecuzione simultanea
grep CRON /var/log/syslog | grep "$(date +%b\ %d)" | awk '{print $1,$2,$3,$NF}' | sortNel caso marchigiano, l'inventario ha rivelato 23 job distribuiti tra il crontab di www-data (14 job, tutti quelli di Laravel), il crontab di root (7 job di sistema) e due file in /etc/cron.d/. Sedici dei 23 job erano schedulati tra le 02:00 e le 02:30. Il time ha rivelato che l'import listini del fornitore principale - quello che il titolare pensava durasse "pochi minuti" - impiegava 2 ore e 47 minuti per processare 340.000 righe di listino.
La prima regola: flock per impedire le sovrapposizioni
flock è un'utility POSIX inclusa in ogni distribuzione Debian e Ubuntu che implementa il file locking a livello di sistema operativo. Il principio è semplice: prima di eseguire un job, flock tenta di acquisire un lock su un file. Se il lock è già tenuto da un'altra istanza dello stesso job, il nuovo tentativo fallisce immediatamente (con -n) o attende (con -w SECONDI). Questo garantisce che un job non si sovrapponga mai a sé stesso.
# PRIMA (pericoloso: può sovrapporsi)
*/15 * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1
# DOPO (sicuro: flock impedisce la sovrapposizione)
*/15 * * * * /usr/bin/flock -n /tmp/laravel-scheduler.lock \
/usr/bin/php /var/www/html/artisan schedule:run >> /dev/null 2>&1Il flag -n (non-blocking) significa che se il lock è già acquisito, il job semplicemente non parte - nessun errore, nessuna attesa, nessuna coda. Per job che devono necessariamente completare (come un backup), uso -w 300 (attendi fino a 5 minuti per il lock, poi rinuncia):
# Backup con attesa massima di 5 minuti per il lock
0 2 * * * /usr/bin/flock -w 300 /tmp/backup.lock /root/scripts/backup.shUn dettaglio importante: il path del lock file deve essere unico per ogni job. Se due job diversi usano lo stesso lock file, si bloccano a vicenda. Uso la convenzione /tmp/{nome-job}.lock con nomi descrittivi.
La seconda regola: staggering - distribuire il carico nel tempo
Schedulare tutti i job alle 02:00 è l'errore più comune che vedo sui VPS di PMI. La ragione è comprensibile - "di notte non c'è traffico, facciamo tutto di notte" - ma il risultato è che alle 02:00 il server deve fare contemporaneamente backup, import, pulizia, rotazione e report, come se fosse un'ora di punta invece che un'ora morta.
La soluzione è lo staggering: distribuire i job in fasce orarie separate, ordinati per priorità e peso. Il mio schema standard per un VPS con applicazione Laravel è:
# === FASCIA 01:00-01:30: BACKUP (priorità massima, da eseguire per primo) ===
0 1 * * * flock -w 300 /tmp/backup.lock nice -n 10 /root/scripts/backup.sh
# === FASCIA 02:00-02:30: IMPORT DATI (pesante, CPU+I/O intensivo) ===
0 2 * * * flock -n /tmp/import-fornitore1.lock nice -n 15 ionice -c2 -n7 \
php /var/www/html/artisan import:fornitore1
30 2 * * * flock -n /tmp/import-fornitore2.lock nice -n 15 ionice -c2 -n7 \
php /var/www/html/artisan import:fornitore2
# === FASCIA 03:30-04:00: ELABORAZIONI (medie) ===
30 3 * * * flock -n /tmp/catalogo-pdf.lock php /var/www/html/artisan generate:catalogo
0 4 * * * flock -n /tmp/search-index.lock php /var/www/html/artisan scout:import
# === FASCIA 04:30-05:00: PULIZIA E MANUTENZIONE (leggere) ===
30 4 * * * php /var/www/html/artisan cache:prune-stale-tags
45 4 * * * /root/scripts/clean-old-logs.sh
# === OGNI MINUTO: SCHEDULER LARAVEL (protetto con flock) ===
* * * * * flock -n /tmp/laravel-scheduler.lock php /var/www/html/artisan schedule:runL'ordine è intenzionale: il backup va per primo perché è l'operazione più critica e deve completare su un database in stato stabile (prima che gli import lo modifichino). Gli import pesanti vanno dopo il backup, in sequenza - mai in parallelo se competono per lo stesso database. Le elaborazioni leggere vanno alla fine, quando il carico pesante è terminato.
La terza regola: nice e ionice per il controllo delle risorse
nice controlla la priorità di scheduling della CPU (da -20 massima priorità a 19 minima), ionice controlla la priorità di I/O disco. Combinati, permettono di eseguire job pesanti senza impattare le richieste web degli utenti:
# Job pesante con priorità CPU e I/O minime
nice -n 19 ionice -c3 /root/scripts/elaborazione-pesante.shLa classe ionice -c3 è "idle" - il job usa il disco solo quando nessun altro processo ne ha bisogno. In pratica, su un VPS con traffico web attivo, un job con ionice -c3 rallenta significativamente ma non impatta mai le query MySQL o le pagine PHP servite agli utenti. Lo uso per tutti i job che possono permettersi di durare di più pur di non disturbare la produzione.
Per job time-sensitive che devono comunque completare entro una finestra, uso ionice -c2 -n7 (best-effort con priorità bassa) che è un compromesso: il job ha accesso al disco ma con priorità inferiore rispetto ai processi interattivi.
Quando passare da cron a systemd timer
Per VPS con job complessi che richiedono gestione dei fallimenti, logging strutturato o limiti di risorse, i systemd timer sono superiori a cron sotto diversi aspetti. Primo: il logging va automaticamente nel journal, consultabile con journalctl -u nome-servizio. Secondo: Persistent=true riesegue automaticamente un job che è stato perso perché il server era spento o in manutenzione. Terzo: RandomizedDelaySec aggiunge un jitter casuale all'orario di esecuzione, eliminando il problema del "thundering herd" senza dover fare staggering manuale.
Un systemd timer equivalente a un cron job di backup:
# /etc/systemd/system/backup-giornaliero.timer
[Unit]
Description=Backup giornaliero VPS
[Timer]
OnCalendar=*-*-* 01:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target# /etc/systemd/system/backup-giornaliero.service
[Unit]
Description=Esecuzione backup giornaliero
[Service]
Type=oneshot
ExecStart=/root/scripts/backup.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
CPUQuota=50%
MemoryMax=1GIl vantaggio del service unit è CPUQuota=50% e MemoryMax=1G - limiti rigidi imposti dal cgroup che nessun nice o ionice può offrire. Se il job tenta di usare più di 1 GB di RAM, systemd lo uccide. Se tenta di usare più del 50% della CPU totale, systemd lo rallenta. Questi limiti sono particolarmente utili su VPS con risorse limitate dove un singolo job fuori controllo può portare l'intero sistema in OOM.
Il caso specifico del Laravel scheduler: perché serve attenzione
Il Laravel scheduler (artisan schedule:run) è un caso particolare che merita attenzione. Laravel raccomanda di schedulare il comando ogni minuto (* * * * *), ma questo crea un rischio: se un task definito nello scheduler impiega più di un minuto, la prossima esecuzione di schedule:run potrebbe rilanciarlo prima che il precedente sia terminato. Laravel ha un meccanismo interno di withoutOverlapping() per gestire questo caso, ma funziona solo se il task è definito nel Kernel dello scheduler - non se il cron job è stato inserito direttamente nel crontab come comando artisan separato.
L'errore più frequente che vedo nelle PMI è un mix di entrambi gli approcci: alcuni task schedulati nel Kernel di Laravel con $schedule->command(), altri come righe separate nel crontab. Il risultato è che i task nel Kernel rispettano le regole di overlap di Laravel, ma quelli nel crontab no, e i due gruppi competono per le risorse senza sapere l'uno dell'altro. La mia regola è: se usi il Laravel scheduler, tutti i task devono passare dallo scheduler. Nessuna eccezione.
// In app/Console/Kernel.php - esempio di scheduling corretto
protected function schedule(Schedule $schedule): void
{
// Import pesante: una volta al giorno, con overlap protection
$schedule->command('import:listini')
->dailyAt('02:00')
->withoutOverlapping(120) // lock di 120 minuti massimo
->runInBackground();
// Pulizia cache: ogni 6 ore, leggera
$schedule->command('cache:prune-stale-tags')
->everySixHours()
->runInBackground();
// Report: ogni mattina, dopo gli import
$schedule->command('report:daily')
->dailyAt('05:30')
->withoutOverlapping();
}Il runInBackground() è fondamentale per i task lunghi: senza di esso, Laravel esegue i task in sequenza all'interno della stessa invocazione di schedule:run, il che significa che se il primo task impiega 3 minuti, tutti quelli successivi partono con 3 minuti di ritardo. Con runInBackground(), ogni task viene lanciato come processo separato e lo scheduler procede immediatamente al successivo.
Monitoraggio: sapere se un cron job fallisce silenziosamente
Un cron job che fallisce senza notifica è peggio di un cron job assente - crea la falsa sicurezza che tutto funzioni. Ho visto backup che fallivano silenziosamente da mesi perché il cron job restituiva exit code 0 anche quando il dump era incompleto, e nessuno controllava. La soluzione minima che installo su ogni VPS è uno script wrapper che cattura exit code e output, e notifica in caso di fallimento:
#!/usr/bin/env bash
# /root/scripts/cron-wrapper.sh - wrapper con notifica errori
set -uo pipefail
JOB_NAME="$1"; shift
LOG="/var/log/cron-jobs/${JOB_NAME}.log"
mkdir -p /var/log/cron-jobs
echo "=== $(date '+%Y-%m-%d %H:%M:%S') START ===" >> "$LOG"
if "$@" >> "$LOG" 2>&1; then
echo "=== $(date '+%Y-%m-%d %H:%M:%S') OK ===" >> "$LOG"
else
EXIT_CODE=$?
echo "=== $(date '+%Y-%m-%d %H:%M:%S') FAILED (exit ${EXIT_CODE}) ===" >> "$LOG"
tail -20 "$LOG" | mail -s "CRON ALERT: ${JOB_NAME} failed" [email protected]
fiL'uso nel crontab:
0 2 * * * flock -n /tmp/backup.lock /root/scripts/cron-wrapper.sh backup /root/scripts/backup.shQuesto pattern - flock per la sovrapposizione, wrapper per il logging e l'alerting - copre il 90% dei problemi di gestione cron job che incontro sulle PMI. Per un monitoring più sofisticato, nell'articolo sul monitoraggio IT proattivo ho descritto come integrare l'esito dei cron job in Prometheus con metriche custom.
Il risultato sul VPS marchigiano
Dopo la ristrutturazione dello scheduling, i numeri del server sono cambiati drasticamente. Il load average alle 5:00 del mattino è passato da 47 a 1.8. Il tempo totale di completamento di tutti i job notturni è passato da "ancora in corso alle 6:30" a "tutto completato entro le 4:45". Il response time del sito durante le ore di punta mattutine è tornato sotto i 400ms. E soprattutto, il titolare ha smesso di ricevere lamentele dai clienti business che entravano presto la mattina.
La lezione è sempre la stessa: su un VPS unmanaged i cron job sono la causa nascosta della maggior parte dei problemi di performance intermittenti. Se il tuo server è lento "a volte" e non riesci a capire perché, la prima cosa da controllare sono i cron - quanti sono, quando girano, quanto durano, e se si sovrappongono. Ho descritto altri pattern di ottimizzazione performance per server PHP su Hetzner e OVH in un articolo dedicato, e le tecniche di gestione strategica dei log che completano il quadro dell'amministrazione di un VPS in produzione.
Se il tuo VPS ha più di cinque cron job e non hai mai fatto un audit dello scheduling, è quasi certo che ci siano sovrapposizioni, mancanze di lock o distribuzioni di carico subottimali. Il costo di un audit è qualche ora di consulenza; il costo di non farlo è il rallentamento progressivo del tuo business ogni volta che i job entrano in conflitto. Contattami e facciamo un check completo.