約 4 分鐘

用 systemd timer 取代 cron:完整範例與五個坑

cron 能跑就好,但沒有日誌、沒有重試、不能處理停機補跑。systemd timer 補上這些,代價是設定檔多幾個。這篇是可直接複製的完整範例與實際踩到的坑。

  • #Linux
  • #systemd
  • #自架
  • #自動化

cron 用了五十年,語法簡潔到可以背。但它有幾個結構性問題,在自架環境會真的咬人:

  • 沒有日誌:任務失敗,你只會在某天發現資料沒備份
  • 沒有重試:一次網路抖動就跳過一整天
  • 停機不補跑:機器維修三小時,那段時間該跑的任務直接消失
  • 環境變數不繼承:cron 的 PATH 常常跟你手動執行時不同
  • 不能表達依賴:A 必須在 B 之後,只能自己 sleep

systemd timer 把這些補起來。代價是設定檔變長,但換來的是「失敗會告訴你」。


一、最小可用範例:每天凌晨三點備份

需要兩個檔案:一個 service(做什麼)、一個 timer(什麼時候做)。

/etc/systemd/system/backup.service

[Unit]
Description=Daily backup
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=lewis
WorkingDirectory=/home/lewis
Environment="BACKUP_DIR=/mnt/nas/backup"
ExecStart=/home/lewis/scripts/backup.sh

/etc/systemd/system/backup.timer

[Unit]
Description=Run backup daily at 03:00

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

啟用

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer

注意:你 enable 的是 .timer,不是 .service。service 由 timer 觸發,不要另外 enable 它。

驗證

systemctl list-timers backup.timer     # 下次執行時間
sudo systemd-analyze calendar "*-*-* 03:00:00"   # 確認語法
journalctl -u backup.service -n 50     # 看執行結果

二、三個關鍵設定,cron 做不到

Persistent=true(停機補跑)

機器在 03:00 時是關機的,開機後會立刻補跑一次。

cron 的 @reboot 只能「開機跑一次」,不能判斷「我錯過了幾次」。systemd 會記錄上次執行的時間戳,開機後發現逾期就補上。

對備份、憑證更新、資料同步這類任務,這個設定是必要的。

RandomizedDelaySec=300(隨機延遲)

把執行時間打散在 5 分鐘內。用途:

  • 打外部 API(避免整點撞牆被限流)
  • 多台機器的同類任務(避免同時上傳塞爆頻寬)
  • 對外抓取(看起來不像機器人)

cron 要達到同樣效果,只能自己 sleep $((RANDOM % 300))。

OnFailure=(失敗通知)

[Unit]
OnFailure=notify-failure@%n.service

搭配一個通知 service(例如呼叫 Telegram bot),任務失敗時你會當下知道,而不是三天後發現備份是空的。

這是 systemd timer 最大的實際價值:把「靜默失敗」變成「會吵的失敗」。


三、兩種 timer,別搞混

類型 設定 行為 適合
日曆型 OnCalendar= 固定時間(每天 3 點) 備份、報表、憑證續約
單調型 OnBootSec= / OnUnitActiveSec= 相對時間(開機後 10 分、每 30 分) 監控、同步、快取更新

OnUnitActiveSec=30min 很適合「每 30 分鐘檢查一次」的任務,它會自動排下一次,不需要寫 cron 的 */30。

兩者可以並存,但同一件事不要用兩種方式表達,否則會出現重複執行。


四、我實際踩到的五個坑

坑 1:User= 之後 PATH 不一樣

User=lewis
ExecStart=/home/lewis/scripts/backup.sh

腳本裡用了 rclone、docker、node,手動跑正常,timer 跑就 command not found。

原因:systemd 的預設 PATH 比你的 shell 精簡。

修法:明確指定,不要靠繼承。

Environment="PATH=/home/lewis/.local/bin:/usr/local/bin:/usr/bin:/bin"

更好的做法:腳本裡全部用絕對路徑,或在腳本開頭自己 export PATH。

坑 2:環境變數不會自動繼承

你在 ~/.bashrc 裡設的 API key,systemd 完全看不到。

修法(三選一):

# 單一變數
Environment="API_TOKEN=xxx"

# 從檔案載入(推薦,權限可設 600)
EnvironmentFile=/home/lewis/.config/backup.env

# 或直接在腳本裡 source

用 EnvironmentFile 最好,因為 secret 不會出現在 systemctl cat 的輸出裡(雖然還是看得到路徑)。

坑 3:Type=oneshot 忘了寫

腳本跑完之後 systemd 以為它還在執行,狀態卡在 activating (start),下一次 timer 就不會觸發。

[Service]
Type=oneshot

只要是「跑完就結束」的腳本,一律 Type=oneshot。

坑 4:OnCalendar 語法寫錯,但它不會報錯

OnCalendar=Mon..Fri 09:00 是對的,OnCalendar=Mon-Fri 09:00 在某些版本會被解讀成別的意思。

養成習慣:寫完先驗證。

systemd-analyze calendar "Mon..Fri 09:00" --iterations=3

它會印出接下來三次的實際時間。看得到具體日期,才代表語法正確。

坑 5:長時間任務重疊執行

備份腳本偶爾跑 90 分鐘,但 timer 每小時觸發一次 → 多個實例同時跑,I/O 打爆。

修法:

[Service]
Type=oneshot
# 同一個 unit 不允許同時跑兩份

其實 systemd 預設就不會同時執行同一個 service(第二次觸發會排隊或跳過),但如果你在腳本裡 fork 了子程序,或用了 Type=forking,就可能踩到。

保險做法:腳本開頭加 flock。

exec 9>/var/lock/backup.lock
flock -n 9 || { echo "already running"; exit 0; }

五、常用 OnCalendar 速查

需求 寫法
每天 03:00 *-*-* 03:00:00
每小時 hourly 或 *-*-* *:00:00
每 15 分鐘 *:0/15
週一到週五 09:00 Mon..Fri 09:00
每月 1 號 *-*-01 04:00:00
每季第一個月 *-01,04,07,10-01 04:00:00
開機後 10 分鐘 用 OnBootSec=10min

六、什麼時候還是該用 cron

不是要你全部搬家。以下情況 cron 反而更合適:

  • 極簡環境(容器裡沒有 systemd)
  • 一次性、不重要的排程
  • 你已經有一套 cron 監控(如 cronitor)在跑

但只要你需要「停機補跑」「失敗通知」「執行日誌」,systemd timer 就是更好的工具。


七、結論

cron 和 systemd timer 的差別,本質上是:

cron 假設任務會成功;systemd 假設任務會失敗,並且要讓你知道。

自架的東西大多在半夜跑,你不會盯著。既然看不到,就得讓它會叫你——這一點上,systemd timer 明顯勝出。

延伸閱讀:如果你的排程任務是「抓資料 → 推通知」,可以參考從 PaaS 搬回自架的遷移順序,裡面有服務分層的建議。

最後更新:2026年10月18日