用 systemd 用户级定时器替代 cron:跑一个带去重告警的 Python 监控脚本

举报
yd_297054392 发表于 2026/10/27 03:19:14 2026/10/27
【摘要】 crontab 写习惯了不一定是最优解。本文给出一对 systemd 用户级 timer/service 单元文件,配合 Python 侧的 fcntl 文件锁与状态去重,解决定时任务漏跑、环境不一致、日志无处查、重复告警四个问题,附真实运行输出与排错命令。

前言:cron 够用吗?

很多人写监控/定时抓取脚本的第一反应是往 crontab 里丢一行。跑一段时间后会踩到四个坑:

  1. 错过即丢失:机器关机或休眠期间到点的任务,开机后不会补跑,业务上就是"今天早上的那次没推"。
  2. 执行环境不一致:cron 几乎不带环境变量,脚本里能跑的命令放到 cron 里 command not found,PATH、代理变量、虚拟环境全要手动补齐。
  3. 没有日志与状态:stdout 要么丢邮件要么丢黑洞,任务是否成功、跑了多久、上次失败在哪,只能自己在脚本里追加文本文件。
  4. 重入没边界:单次执行超过间隔时,两个进程会并发叠加,互相踩踏。

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 可筛。剩下脚本内部的去重与防重入,十几行代码就能补齐——这部分是任何调度器都不会替你做的。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。