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