Timer systemd al posto di cron: cosa cambia su un server

Il problema: il backup che non è partito
La riga era questa, in crontab:
0 3 * * * /usr/local/bin/backup.sh
Per mesi ha funzionato. Poi una notte il server è stato riavviato alle 2:55 per un aggiornamento
del kernel, e alle 3:00 era ancora in fase di boot. Il backup di quella notte non esiste. Nessuno
se ne è accorto per sei giorni, perché di un job che cron non ha eseguito non resta niente: non c’è
una riga da cercare, non c’è un errore, non c’è un exit code. C’è solo un file che manca.
Il secondo problema è dove finisce l’output. crontab(5) lo dice: se MAILTO è definito e non
vuoto la mail va a quell’indirizzo, se è definito vuoto non va da nessuna parte, altrimenti va
“to the owner of the crontab”. Su un VPS senza MTA configurato quella mail non esce: il messaggio
d’errore dello script esiste, ed è irraggiungibile.
Due file al posto di una riga
Un timer systemd sono due unit: il servizio che fa il lavoro e il timer che decide quando. Non
serve collegarli a mano — systemd.timer(5) documenta che “by default, a service by the same name
as the timer (except for the suffix) is activated”, quindi backup.timer attiva backup.service
e basta chiamarli allo stesso modo.
/etc/systemd/system/backup.service:
[Unit]
Description=Backup notturno di /var/www
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=backup
WorkingDirectory=/var/www
EnvironmentFile=/etc/backup.env
RuntimeMaxSec=2h
PrivateTmp=true
ProtectHome=true
NoNewPrivileges=true
/etc/systemd/system/backup.timer:
[Unit]
Description=Esegue il backup ogni notte alle 3
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15m
FixedRandomDelay=true
[Install]
WantedBy=timers.target
Poi:
systemctl daemon-reload
systemctl enable --now backup.timer
Type=oneshot è quello giusto per uno script che parte, fa il suo lavoro e termina: systemd
“will consider the unit to be in the state ‘starting’ until the program has terminated”. Non è un
demone, e non va trattato come tale.
Le tre righe che risolvono il problema di prima
Persistent=true è la risposta all’esecuzione mancata. Il manuale: “the time when the service
unit was last triggered is stored on disk”, e “the service unit is triggered immediately if it
would have been triggered at least once during the time when the timer was inactive” — “useful to
catch up on missed runs of the service when the system was powered down”. Il default è false,
quindi va scritto: non lo si ottiene per il fatto di usare un timer. Quel timestamp si azzera con
systemctl clean --what=state.
RuntimeMaxSec=2h è quello che cron non ha in nessuna forma: “if this is used and the service
has been active for longer than the specified time it is terminated and put into a failure state”.
Il default è infinity. Uno script di backup che si blocca su un mount NFS che non risponde, sotto
cron, ci resta per giorni — e la notte dopo ne parte un altro.
RandomizedDelaySec=15m serve quando i server sono più di uno e scrivono tutti sulla stessa
destinazione: “delay the timer by a randomly selected, evenly distributed amount of time between 0
and the specified time value” (default 0). Con FixedRandomDelay=true lo scostamento diventa
deterministico, cioè lo stesso ogni notte invece di saltare da una parte all’altra.
I log stanno dove stanno tutti gli altri
Niente redirect verso un file in /var/log che poi nessuno ruota:
journalctl -u backup.service
journalctl -u backup.service --since yesterday
journalctl -u backup.service -o cat
-o cat mostra solo il messaggio, “with no metadata, not even a timestamp”: comodo quando lo
script ha già i suoi timestamp e quelli del journal raddoppiano.
E c’è la domanda a cui con cron non si risponde senza fare i conti a mente:
systemctl list-timers
Colonne NEXT, LEFT, LAST, PASSED, UNIT, ACTIVATES: quando parte la prossima volta,
quanto manca, quando è partito l’ultima volta, quanto è passato e quale servizio attiva. I timer
non attivi si vedono con --all.
Verificare l’espressione prima di metterla in produzione
La sintassi non è quella di cron, e non ci si può andare a intuito. Oltre alla forma completa
*-*-* 03:00:00 ci sono le scorciatoie, tutte documentate in systemd.time(7) con la loro forma
normalizzata: daily è *-*-* 00:00:00, hourly è *-*-* *:00:00, weekly è
Mon *-*-* 00:00:00, monthly è *-*-01 00:00:00. * vale qualsiasi valore, / indica una
ripetizione, .. un intervallo, ~ l’ultimo giorno del mese. Se il fuso orario non c’è si usa
l’ora locale.
Prima di salvare, si controlla:
systemd-analyze calendar --iterations=5 'Mon..Fri 09,13..17:00'
Stampa la forma originale, quella normalizzata, il prossimo elapse (anche in UTC) e quanto manca
da adesso. Con --iterations= le prossime N volte, con --base-time= gli orari calcolati a
partire da un altro momento. Trenta secondi ora, contro la scoperta fra un mese che quel job non
è mai partito.
Errori da evitare
Abilitare il servizio invece del timer. systemctl enable backup.service non pianifica
niente. Va abilitato il .timer.
Aspettarsi il secondo esatto. AccuracySec= “defaults to 1min”, e il timer scatta in una
finestra che parte dall’orario configurato e finisce AccuracySec più tardi. Per un backup è
irrilevante; se invece deve incastrarsi con qualcosa d’altro, il manuale è esplicito: “to get best
accuracy, set this option to 1us”.
Dare per scontato l’ambiente. cron imposta SHELL a /bin/sh e prende LOGNAME e HOME da
/etc/passwd; una unit non dà nemmeno quello, se non glielo si scrive. Percorsi assoluti in
ExecStart=, il resto in EnvironmentFile=, che viene letto “at the time the service is started”
— quindi una modifica vale dalla corsa successiva.
Restare su cron senza conoscerne le due trappole. Un % nel comando, se non è preceduto da un
backslash, diventa un newline, e tutto quello che sta dopo il primo % viene passato al comando
come standard input: un date +%F in crontab non fa quello che sembra. E se i campi giorno-del-mese
e giorno-della-settimana sono entrambi valorizzati, il comando parte quando ne coincide uno dei
due, non tutti e due.
Migrare tutto. Se una riga di crontab funziona, l’output non serve a nessuno e nessuno deve
sapere quando parte la prossima volta, va bene com’è. Il lavoro di conversione si giustifica dove
serve il recupero delle esecuzioni perse, un timeout, o log che si possano leggere a distanza di
settimane.
Fonti: systemd.timer(5),
systemd.time(7),
systemd.service(5),
systemd.exec(5),
systemd-analyze(1),
systemctl(1),
crontab(5).
Se hai dei job pianificati su un server e non sai dire se sono partiti tutti,
scrivici: è il tipo di cosa che si sistema in mezza giornata.
