Why migrate away from cron at all
The problem with cron is not that it cannot schedule; it is that observability is close to zero. When a job fails you get one truncated line in /var/log/syslog, with no exit code and no captured stdout/stderr. Whether the job is running now, how long the last run took, and why it was skipped — cron answers none of these.
Environment variables are equally confusing. cron has a very short default PATH, so which java finding nothing usually just means /usr/local/bin is missing. Execution is non-interactive with no tty, which sends many programs down different code paths — a nasty time sink when debugging.
Most damaging is invisible missed runs. If the box is down at 03:00 and back at 04:00, the job that was due at 03:15 silently vanishes, and you only notice the next day when the numbers do not add up. Persistent=yes exists for exactly this: a missed elapse triggers an immediate catch-up run on boot.
Concurrency is the last gap. In crontab, if the previous run has not finished when the next slot arrives, two instances write the same file — from data corruption to lock storms. systemd units give you RuntimeMaxSec, Restart=on-failure, and proper job ordering instead.
# 现状盘点:先看清这台机器上都有什么 cron 任务
crontab -l
ls -l /etc/cron.d/ /etc/cron.daily/ 2>/dev/null
systemctl list-timers --allThe minimal shape: one timer unit plus one service unit
The mental model is one sentence: a .timer unit only decides *when* to trigger, a .service unit decides *what* to run. They share the same name and are linked by Unit=. When the timer elapses, systemd starts the service; if that service is already running, the default is to refuse the second start — exactly the de-duplication you want.
Note that WantedBy= in a timer means "who pulls in this timer", normally timers.target, whereas in a service it is multi-user.target. Getting this wrong produces no error at all, but the timer never activates — the most common reason a timer is configured yet never fires.
Type=oneshot is the default for periodic work: the process exits and systemd is done. Pair it with SuccessExitStatus or an explicit exit-code check, because oneshot does not restart by default; failure is silent unless you look at systemctl status.
Files live in /etc/systemd/system/ (system-wide) or ~/.config/systemd/user/ (per user). After any edit you must run daemon-reload, otherwise systemd keeps serving the in-memory version — forgetting this once costs half an hour.
# /etc/systemd/system/backup-db.service
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
User=appuser
ExecStart=/usr/local/bin/backup-db.sh
# 执行超时保护,避免任务卡死永久占位
RuntimeMaxSec=2h
# /etc/systemd/system/backup-db.timer
[Unit]
Description=Run database backup at 03:15
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=300
Unit=backup-db.service
[Install]
WantedBy=timers.targetOnCalendar: what it buys you over five-field cron
Five-field cron has a low ceiling: "last working day of the month", "the 15th at 02:00 UTC", and "weekends only" are simply inexpressible. OnCalendar= handles all of them — Mon..Fri 09:00, *-*-01 00:00:00, weekly, plus last, n..m ranges, and the ~ suffix that skips non-existent dates.
Timezone handling is where migrations most often break. Bare times in OnCalendar= are interpreted in the *system* timezone, and servers frequently run UTC. If the business meaning is "3 a.m. Beijing time", write OnCalendar=*-*-* 03:15:00 Asia/Shanghai explicitly rather than trusting the host timezone.
OnCalendar= accepts multiple lines and each is scheduled independently — equivalent to several crontab lines, but each with its own catch-up check when combined with Persistent=true. That is far more robust than packing ten commands into one 0 3 * * *.
Do not forget systemd-analyze calendar "*-*-* 03:15:00 Asia/Shanghai": it parses the expression into "next elapse + seconds from now", which is much faster than trial-and-error with date and is the most direct way to confirm systemd accepts your syntax.
systemd-analyze calendar "*-*-* 03:15:00 Asia/Shanghai"
systemd-analyze calendar "Mon..Fri 09:00"
# 输出示例:Next elapse: Fri 2026-10-02 03:15:00 CST
# In roughly 1 day 3h 12min 4sPersistent catch-up and the other key directives
Understand Persistent=yes precisely: systemd records the timestamp of the last successful trigger. At boot, if it finds a due moment that already passed without firing, it triggers once immediately; if the last run was normal, it simply waits for the next slot. It guarantees "ran at least once", not "ran at the originally scheduled instant".
Three companion directives handle de-duplication and jitter. AccuracySec= (default 1 minute) coalesces elapse points to reduce wakeups — set it to 1us for exact execution. RandomizedDelaySec= adds a random delay so a fleet does not hammer an external service in the same second. WakeSystem= allows waking a powered-off machine and requires root plus RTC wake support.
The two compose with randomization on top of accuracy: the real offset is a random value in [0, RandomizedDelaySec) rounded to the AccuracySec boundary. So for spreading a fleet out, RandomizedDelaySec=1800 is far more effective than fiddling with AccuracySec.
For "every N minutes" workloads, do not assemble them from OnUnitActiveSec — use OnUnitInactiveSec=, which counts from when the previous instance *finished*, so tasks can never pile up. OnUnitActiveSec counts from the start and will queue up whenever a run takes longer than the interval.
# /etc/systemd/system/heartbeat.timer
[Unit]
Description=Heartbeat every 5 minutes after previous run ends
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
AccuracySec=10s
Persistent=true
Unit=heartbeat.service
[Install]
WantedBy=timers.targetA mapping table for painless migration
The right migration is not "delete crontab and rewrite". Install and enable the timer, but comment out the old crontab entries and keep a backup (crontab -l > /root/crontab.bak). Watch a full cycle, confirm timing, exit codes, and output all match, and only then remove the originals.
Common mappings: * * * * * → OnCalendar=*:0/1; */15 * * * * → OnCalendar=*:0/15; 0 3 * * * → OnCalendar=*-*-* 03:00:00; @reboot → OnBootSec= plus Persistent=true; @daily → OnCalendar=daily — note that daily means local midnight, not 03:00.
Environment variables are no longer inherited implicitly. Put the logic in a script and inject variables and PATH explicitly via Environment= / EnvironmentFile= instead of depending on a login shell profile. Remember that EnvironmentFile= fails the unit outright if the file is missing; prefix with - to tolerate absence.
Always verify end to end after migrating: trigger once manually with systemctl start <unit>.service and read the output, confirm systemctl list-timers --all shows a sensible next elapse, then read journalctl -u <unit>.service -n 50 to confirm logs landed in the journal rather than syslog. Only after all three pass is the migration done.
systemctl daemon-reload
systemctl enable --now backup-db.timer
systemctl start backup-db.service # 手动跑一次看输出
systemctl list-timers --all
journalctl -u backup-db.service -n 50 --no-pager
systemctl status backup-db.timer