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