SysOps

systemd Timers in Practice: Replacing cron with Timer Units

cron gives you five environment variables, no catch-up, no dependencies, and logs scattered across syslog. systemd timers give unified logging, inspectable failures, Persistent catch-up, and a far richer scheduling language — plus a clean mapping from existing crontab entries.

By LaoHand Team·10 min read·Updated 2026-09-30

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 --all

The 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.target

OnCalendar: 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 4s

Persistent 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.target

A 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

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.