用 systemd 用户级定时器替代 cron:跑一个带去重告警的 Python 监控脚本
前言:cron 够用吗?
很多人写监控/定时抓取脚本的第一反应是往 crontab 里丢一行。跑一段时间后会踩到四个坑:
- 错过即丢失:机器关机或休眠期间到点的任务,开机后不会补跑,业务上就是"今天早上的那次没推"。
- 执行环境不一致:cron 几乎不带环境变量,脚本里能跑的命令放到 cron 里
command not found,PATH、代理变量、虚拟环境全要手动补齐。 - 没有日志与状态:stdout 要么丢邮件要么丢黑洞,任务是否成功、跑了多久、上次失败在哪,只能自己在脚本里追加文本文件。
- 重入没边界:单次执行超过间隔时,两个进程会并发叠加,互相踩踏。
systemd 的 timer 单元恰好给出了对应的手段:Persistent=true 负责补跑,单元文件里的 Environment= 显式声明环境,journalctl 自动收日志,systemctl --user status 能直接看到上次跑了多久、多少 Exit code。更重要的是它不需要 root。至于并发,systemd 不会自动帮你串行(同一个 unit 被重复触发时才会排队),所以还要在脚本里补一层文件锁——见第三节。
一、两个单元文件
拆成两半:.service 描述"做什么",.timer 描述"什么时候做"。文件放在 ~/.config/systemd/user/。
# ~/.config/systemd/user/hw-watch.service
[Unit]
Description=Stock/price watcher (example)
[Service]
Type=oneshot
WorkingDirectory=%h/.config/hw-watch
Environment=PYTHONUNBUFFERED=1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=%h/.config/hw-watch/watch.py
TimeoutStartSec=300
Restart=no
[Install]
WantedBy=default.target
几个刻意的选择:Type=oneshot 表示跑完就结束,不需要常驻;TimeoutStartSec 给网络请求兜底,避免一次卡死把后续触发全压住;Restart=no 是故意的——失败由下一次自然重试来做,不要 systemctl 级别的疯狂重启。
# ~/.config/systemd/user/hw-watch.timer
[Unit]
Description=Run watch.py every 5 minutes
Requires=hw-watch.service
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
RandomizedDelaySec=30
Persistent=true
Unit=hw-watch.service
[Install]
WantedBy=timers.target
OnBootSec+OnUnitActiveSec走的是单调时钟(monotonic),受 NTP 校时不影响;需要挂钟语义("每天 3:10")时改用OnCalendar=*-*-* 03:10:00,两者可以并存。RandomizedDelaySec是关键的一行:同机上多个 timer 同时打外部接口,容易被对方当成爬虫限流,加个随机抖动错开。AccuracySec放宽到 30s,systemd 会顺便做抖动合并,省电、也减少对外部接口的整点冲击。
二、部署与开机自启
systemctl --user daemon-reload
systemctl --user enable --now hw-watch.timer
systemctl --user list-timers --no-pager # 看下一次触发时间
服务器场景还有一个必做的一步,否则用户级服务在退出登录后会被整体停掉:
sudo loginctl enable-linger $USER
loginctl show-user $USER | grep Linger # 确认 Linger=yes
三、Python 侧:三件套
定时器只解决"跑起来",脚本自己要解决"别跑重、少打扰、可回溯"。
1. 文件锁防重入
LOCK = Path("/tmp/hw-watch.lock")
LOCK.touch(exist_ok=True)
with LOCK.open("r+") as fh:
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("another run in progress, exit", flush=True)
return 0 # 不是错误,别让 systemd 记一笔 failed
注意这里返回 0:抢占失败是正常的调度现象,返回非零会让 journalctl 里堆满误报的 failed。
2. 状态文件去重告警
prev = load_prev() # JSON 落盘的上一次结果
now = {k: data[k] for k in ("available", "remain") if k in data}
if now.get("available") and not prev.get("available"):
notify(f"target back in stock, remain={now.get('remain')}")
elif prev.get("available") and not now.get("available"):
notify("target sold out")
STATE.write_text(json.dumps(now, indent=2))
只有状态翻转才推送。轮询类脚本最常见的失败形态不是"没发现",而是"每 5 分钟重复报同一件事",一周之后用户就把告警源静音了——那一刻这套东西就死了。
3. 日志交给 journald
Service 里设了 PYTHONUNBUFFERED=1,脚本只要正常 print 并 flush=True,systemd 会自动加时间戳、记录 Exit code 与耗时,无需自己写日志文件。
四、实测
下面是一台 2 vCPU / 1.7 GB 的机器上,用一个本地 mock 服务端模拟库存状态变化,连续跑四次的真实输出:
--- run1: 有货 ---
[INFO] 2026-10-27 03:14:29 -> {'available': True, 'remain': 7}
[ALERT] target back in stock, remain=7
--- run2: 状态不变,不重复告警 ---
[INFO] 2026-10-27 03:14:29 -> {'available': True, 'remain': 7}
--- run3: 变无货 -> 告警 ---
[INFO] 2026-10-27 03:14:29 -> {'available': False, 'remain': 0}
[ALERT] target sold out
--- run4: 重新有货 -> 告警 ---
[INFO] 2026-10-27 03:14:29 -> {'available': True, 'remain': 3}
[ALERT] target back in stock, remain=3
并发测试(同时跑两个进程)只会有一个真正执行,另一个打印 another run in progress, exit 后退出码 0:
$ python3 watch.py & python3 watch.py & wait
another run in progress, exit
[INFO] 2026-10-27 03:14:38 -> {'available': True, 'remain': 3}
单元文件语法可以这样先验一遍,不用等 enable 才报错:
$ systemd-analyze verify --user ./hw-watch.service ./hw-watch.timer
hw-watch.service: Command /root/.config/hw-watch/watch.py is not executable: No such file or directory
这条提示是"路径还没落地",不是语法错——把脚本放到 %h/.config/hw-watch/watch.py 并 chmod +x 就干净了。
五、排错清单
systemctl --user status hw-watch.timer hw-watch.service
systemctl --user list-timers --all --no-pager
journalctl --user -u hw-watch.service --since "1 hour ago" -o cat
systemd-analyze calendar '*-*-* 03:10:00' # 校验 OnCalendar 表达式
systemd-analyze verify --user ./hw-watch.service
journalctl --user 看不到东西时,多半是没开持久化:sudo mkdir -p /var/log/journal 并重启 systemd-journald,或者当前用户压根不具备 ~/.config/systemd/user 的写入权限。
六、几个容易忽略的坑
%必须转义:crontab 里也是坑,systemd 里%是说明符前缀,date +%F要写成date +%%F。- 说明符别指望到处都展开:
%h、%i、%H在ExecStart=里很好用,但支持范围是有限的,Environment=里不要依赖它,路径写全最保险。 - timer 与 service 同名:
OnUnitActiveSec默认激活同名.service,改名时两个文件要一起改,并更新Unit=。 - 单元修改后必须 daemon-reload:改了
.timer不 reload,触发时间还是旧的。 - 不要在 oneshot 里写死循环 sleep:那是 Type=simple 的活儿,否则进程永不退出,timer 永远不会再触发。
- linger 漏配:命令行能跑、定时任务早晚偷懒,九成是这一条。
什么时候还是用 cron
cron 一点没问题的场景:单条命令、不涉及复杂环境、漏跑一次无所谓、用户不需要权限隔离。systemd timer 的价值在于"任务已经重要到需要知道它到底跑没跑"。如果还停留在"*/5 * * * * curl xxx"这种程度,真没必要迁移。
小结
把定时从 crontab 迁到 systemd timer,收益不在"更潮",而在四件具体的事:关机不丢触发、环境写在文件里可复现、输出进 journalctl 可检索、失败有 exit code 可筛。剩下脚本内部的去重与防重入,十几行代码就能补齐——这部分是任何调度器都不会替你做的。
- 点赞
- 收藏
- 关注作者
评论(0)