技术指南
systemd 定时任务:从手动验证到失败可见
用可验证的 service/timer 分离执行与调度,处理漏跑、并发、权限、退出状态和回滚。
可靠的定时任务不是“把脚本塞进计划表”,而是一份可验证的运行契约:输入从哪里来、成功会留下什么、失败怎样显现、重复执行是否安全、停机错过后如何处理。systemd timer 只负责触发,真正的工作放在独立 service;这样手动执行、权限、日志、退出状态和调度策略可以分别检查。
先决定是否需要 systemd timer
| 需求 | 更自然的选择 | 仍要补齐的证据 |
|---|---|---|
| 简单命令、现有 Cron 已稳定运行 | 继续使用 Cron | 明确 PATH、时区、输出去向、并发与漏跑语义 |
| 要按服务用户运行并集中看 journal | systemd service + timer | unit 语法、退出状态、日志和权限 |
| 关机错过后需补跑一次 | OnCalendar + Persistent | 补跑是否会造成突发负载或重复副作用 |
| 需要跨主机编排、审批或依赖图 | 专用工作流/配置管理系统 | 控制面可用性、凭据、幂等和回滚 |
迁移的理由应是需要 systemd 的执行与可观测边界,而不是因为 timer 看起来更新。现有 Cron 若已经有明确环境、日志、锁、告警和恢复流程,迁移本身不会自动提高可靠性。
先写四项运行契约
| 项目 | 必须明确 | 示例证据 |
|---|---|---|
| 输入 | 配置、工作目录、时区、网络与凭据 | 绝对路径、服务用户、版本和配置哈希 |
| 成功 | 退出码、产物和最小完整性检查 | exit 0、0600 文件、UTC 完成时间 |
| 重复 | 重复或延迟执行是否安全 | 同一输入重复运行不破坏已有结果 |
| 失败 | 谁能看到、怎样重试、何时停止 | Result、ExecMainStatus、journal 与告警规则 |
脚本只在完整成功后提交产物
下面的最小脚本把临时文件放在最终目录中,校验命令成功后再以同一文件系统内的重命名提交。真实备份、同步或证书任务还要增加领域校验;`set -Eeuo pipefail` 不能替代显式错误分支,也不能证明外部副作用已经回滚。
#!/usr/bin/env bash
set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
state_dir=/var/lib/vpscope-snapshot
tmp=$(mktemp "$state_dir/snapshot.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT
printf 'completed_at=%s\n' "$(date --utc --iso-8601=seconds)" > "$tmp"
df -P / >> "$tmp"
test -s "$tmp"
chmod 0600 "$tmp"
mv -- "$tmp" "$state_dir/last-success.txt"
trap - EXIT
printf 'snapshot_ok path=%s\n' "$state_dir/last-success.txt"不要用 PID 文件配合“先检查、后写入”来防并发;检查与创建之间存在竞争,崩溃还会留下陈旧文件。若任务自身不能幂等,应使用内核锁、数据库唯一约束或目标系统的事务能力,并把锁冲突定义为可观察的退出状态。
把执行边界写进 oneshot service
[Unit]
Description=Write a verified VPS snapshot
[Service]
Type=oneshot
User=vpscope-snapshot
Group=vpscope-snapshot
ExecStart=/usr/local/libexec/vpscope-snapshot
StateDirectory=vpscope-snapshot
StateDirectoryMode=0750
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes`StateDirectory=` 让 systemd 建立并授权持久状态目录,避免脚本以 root 身份递归改权限。`ProtectSystem=strict`、`ProtectHome=yes` 和 `PrivateTmp=yes` 会改变可见/可写路径;必须在目标发行版实际启动 service,不能只因为 `systemd-analyze verify` 通过就认为任务可运行。
让 timer 只描述时间语义
[Unit]
Description=Schedule the verified VPS snapshot
[Timer]
OnCalendar=Mon..Fri *-*-* 03:15:00 UTC
Persistent=true
AccuracySec=1min
RandomizedDelaySec=15min
Unit=vpscope-snapshot.service
[Install]
WantedBy=timers.target`AccuracySec=` 是允许 systemd 合并唤醒的精度窗口,默认值是一分钟;`RandomizedDelaySec=` 则在触发时间后引入随机延迟以分散负载,两者不是同一个开关。需要严格时间点时先问清业务容差,不要把精度调到微秒来掩盖不必要的时钟假设。
先解析未来三次触发
systemd --version
systemd-analyze calendar --iterations=3 'Mon..Fri *-*-* 03:15:00 UTC'
systemd-analyze verify \
/etc/systemd/system/vpscope-snapshot.service \
/etc/systemd/system/vpscope-snapshot.timer本文只使用 systemd 255 已支持的指令。复制其他环境的 unit 前先核对 `systemd --version` 和该版本手册;较新手册中的参数可能在旧发行版上被忽略或报错,不能用“latest 文档能打开”代替目标版本验证。
按手动、定时、重启三步上线
- 安装脚本和两个 unit 后运行 `systemctl daemon-reload`,先不要 enable timer。
- 运行 `systemctl start vpscope-snapshot.service`,检查退出状态、journal、owner/mode 和产物内容。
- 运行 `systemctl enable --now vpscope-snapshot.timer`,用 `systemctl list-timers --all` 核对 NEXT、LAST 和触发 service。
- 在维护窗口重启测试主机,确认 timer 仍启用,并验证关机错过时的实际补跑负载。
systemctl start vpscope-snapshot.service
systemctl show vpscope-snapshot.service \
--property=Result,ExecMainCode,ExecMainStatus
journalctl --unit=vpscope-snapshot.service --since='15 minutes ago' --no-pager
stat -c '%U:%G %a %n' /var/lib/vpscope-snapshot/last-success.txt
systemctl enable --now vpscope-snapshot.timer
systemctl list-timers --all vpscope-snapshot.timer明确停机漏跑只补一次
`Persistent=true` 只影响 `OnCalendar=`:timer 不活跃期间若错过至少一次,重新激活时会触发一次补跑,并不会把每个错过时点逐一重放。补跑仍受随机延迟影响。对账、计费或逐窗口处理任务不能把它当作完整队列,应在业务数据中保存游标并逐段补齐。
长任务不会被同时重启,但可能紧接着再跑
timer 到点时,若目标 service 仍处于 active,systemd 不会为同一 unit 并发启动第二份。本批在一秒重复间隔中让任务每次运行三秒,得到三次完整 start/end、零次锁冲突;每次结束后下一次几乎立即开始。结论是同一 service 不会并发重启,但逾期触发可能形成背靠背负载,脚本仍需幂等、超时、容量保护和运行时长告警。
让退出状态保留真正原因
脚本遇到不可接受状态必须非零退出,并把短小、无 Secret 的诊断写入标准错误。不要在脚本末尾无条件发送“成功”心跳,也不要用 `|| true` 抹掉关键失败。systemd 会把主进程退出码记录为 `Result=exit-code` 与 `ExecMainStatus`,journal 保留相邻输出。
systemctl status vpscope-snapshot.service --no-pager
systemctl show vpscope-snapshot.service \
--property=Result,ExecMainCode,ExecMainStatus,ActiveEnterTimestamp
journalctl --unit=vpscope-snapshot.service \
--since='today UTC' --output=short-iso --no-pagertimer 本身处于 waiting 不代表上一轮工作成功。告警应检查 service 的失败状态和最后成功产物的新鲜度;若需要自动重试,要限定次数、退避和最大影响,并先证明操作幂等。人工恢复后同时验证产物、下一次触发和失败告警已恢复。
修改调度也要能回滚
- 在版本控制或配置管理中保存脚本与 unit,不直接在生产机留下不可追溯编辑。
- 变更前记录旧版本、下一次触发、最后成功时间和产物校验。
- 新版本先 `verify`,再手动 start;失败时停止 timer、恢复旧文件、daemon-reload 并重新手动验证。
- 删除任务前先 `disable --now` timer,确认没有 active service,再按数据保留策略处理 StateDirectory。
本次隔离演练验证了什么
VPScope 在 Ubuntu 24.04、systemd 255.4 上用唯一命名的 transient service/timer 和 `/tmp` 状态目录演练。脚本通过 `bash -n` 与 ShellCheck;日历表达式列出三个未来 UTC 触发,故意无效的表达式被拒绝,service/timer 通过 `systemd-analyze verify`。
成功运行留下 0600 产物并记录 `Result=success`;强制退出 23 被 systemd 记录为 `Result=exit-code` 与 `ExecMainStatus=23`;两秒 transient timer 成功触发。并发演练得到三次完整运行、零重叠,同时观察到长任务结束后的背靠背触发。最后停止并清除所有唯一命名单元和临时目录,生产 unit、用户、计划、网络与凭据均未修改。
上线前的验收清单
- 脚本在目标服务用户和最小环境中手动成功,失败路径返回非零。
- 成功产物可验证、权限正确,提交动作发生在完整校验之后。
- service 只拥有必要目录,硬化选项已在目标系统实际运行验证。
- calendar 表达式、时区、精度、随机延迟和未来触发均已复核。
- Persistent 的一次补跑语义符合业务,逐窗口任务另有游标。
- 长任务不会产生不可控并发,背靠背执行有容量和超时保护。
- Result、退出码、journal、产物新鲜度和通知链路都能发现失败。
- 上线、重启、失败、恢复、回滚和清理均留下 UTC 证据。