技术指南

systemd 定时任务:从手动验证到失败可见

用可验证的 service/timer 分离执行与调度,处理漏跑、并发、权限、退出状态和回滚。

可靠的定时任务不是“把脚本塞进计划表”,而是一份可验证的运行契约:输入从哪里来、成功会留下什么、失败怎样显现、重复执行是否安全、停机错过后如何处理。systemd timer 只负责触发,真正的工作放在独立 service;这样手动执行、权限、日志、退出状态和调度策略可以分别检查。

先决定是否需要 systemd timer

需求更自然的选择仍要补齐的证据
简单命令、现有 Cron 已稳定运行继续使用 Cron明确 PATH、时区、输出去向、并发与漏跑语义
要按服务用户运行并集中看 journalsystemd service + timerunit 语法、退出状态、日志和权限
关机错过后需补跑一次OnCalendar + Persistent补跑是否会造成突发负载或重复副作用
需要跨主机编排、审批或依赖图专用工作流/配置管理系统控制面可用性、凭据、幂等和回滚

迁移的理由应是需要 systemd 的执行与可观测边界,而不是因为 timer 看起来更新。现有 Cron 若已经有明确环境、日志、锁、告警和恢复流程,迁移本身不会自动提高可靠性。

先写四项运行契约

项目必须明确示例证据
输入配置、工作目录、时区、网络与凭据绝对路径、服务用户、版本和配置哈希
成功退出码、产物和最小完整性检查exit 0、0600 文件、UTC 完成时间
重复重复或延迟执行是否安全同一输入重复运行不破坏已有结果
失败谁能看到、怎样重试、何时停止Result、ExecMainStatus、journal 与告警规则

脚本只在完整成功后提交产物

下面的最小脚本把临时文件放在最终目录中,校验命令成功后再以同一文件系统内的重命名提交。真实备份、同步或证书任务还要增加领域校验;`set -Eeuo pipefail` 不能替代显式错误分支,也不能证明外部副作用已经回滚。

示例只记录根文件系统状态;先用 bash -n 与 ShellCheck 检查,再替换为经过恢复验证的真实任务
#!/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

保存为 /etc/systemd/system/vpscope-snapshot.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 只描述时间语义

保存为 /etc/systemd/system/vpscope-snapshot.timer;UTC、精度和随机延迟都应是有意选择
[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=` 则在触发时间后引入随机延迟以分散负载,两者不是同一个开关。需要严格时间点时先问清业务容差,不要把精度调到微秒来掩盖不必要的时钟假设。

先解析未来三次触发

在目标主机运行;calendar 会规范化表达式并列出未来触发,verify 检查 unit 语法和依赖
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 文档能打开”代替目标版本验证。

按手动、定时、重启三步上线

  1. 安装脚本和两个 unit 后运行 `systemctl daemon-reload`,先不要 enable timer。
  2. 运行 `systemctl start vpscope-snapshot.service`,检查退出状态、journal、owner/mode 和产物内容。
  3. 运行 `systemctl enable --now vpscope-snapshot.timer`,用 `systemctl list-timers --all` 核对 NEXT、LAST 和触发 service。
  4. 在维护窗口重启测试主机,确认 timer 仍启用,并验证关机错过时的实际补跑负载。
只有手动运行、产物和日志都通过后才启用 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 保留相邻输出。

把 UTC 时间窗、unit、Result、退出码和产物状态放进事故记录;日志中不要输出凭据
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-pager

timer 本身处于 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 证据。

返回知识库