systemd timer 把调度规则与执行工作的服务分开。定时器决定何时请求激活,服务则定义命令、执行身份、环境、超时和结果。因此,维护任务可以使用普通单元工具检查,但一个按时发出的启动请求,并不能证明备份、清理或报表已经正确完成。调度是否运转与工作是否成功,需要不同证据;只有两者都符合要求,周期任务才真正达到目的。

坏掉的样子

活动的定时器可以与失败的服务同时存在。如果监控只检查 timer 是否活动,就会漏掉真正执行工作的失败。同样,最近触发时间只说明调度发生过,不代表业务输出成功。应检查服务结果,以及任务本来要生成的产物或状态变化,并保留足够上下文区分前后两次激活,避免把昨天留下的文件误当成今天任务已经完成。

除非通过 Unit 指向别的目标,定时器通常激活同名的 service。如果目标服务已经活动,就不会再启动另一份独立副本。这阻止的是同一个单元重叠激活,不是分布式锁,也不能阻止另一个服务执行相同命令。oneshot 如果使用 RemainAfterExit=yes 保持活动,后续计划执行就可能一直不再运行命令;保留状态是否合适,取决于任务真正需要表达什么。

停止定时器会停止后续调度,但不会自动停止已经开始的服务调用。事故期间暂停维护工作时,这个区别尤其重要:需要检查目标服务,再有意决定让当前工作结束还是中断它。即使停止命令成功,先前已经发生的文件或数据库修改也不会因此自动回滚,因此不能把“停了 timer”当成系统已经没有维护副作用。

快速验证

在 PID 1 为 systemd 的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。实测环境是 Debian 12、systemd 252.39。它创建唯一命名的定时器与服务,以及临时工作程序。工作程序记录真实进程进入和完成时刻,成功任务则通过显式释放文件等待,以便直接观察调度行为:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,time,uuid

name='xbcatm-timer-'+uuid.uuid4().hex[:8];service=name+'.service';timer=name+'.timer'
paths={unit:Path('/run/systemd/system')/unit for unit in (service,timer)}
assert all(not p.exists() for p in paths.values())
def run(*args):
    result=subprocess.run(args,text=True,capture_output=True,timeout=10)
    assert result.returncode==0,(args,result.stderr)
    return result.stdout.strip()
def value(unit,key):return run('systemctl','show',unit,'-p',key,'--value')
def wait_for(check):
    deadline=time.monotonic()+5
    while not check():
        assert time.monotonic()<deadline,'transition timed out'
        time.sleep(0.02)
with TemporaryDirectory(prefix=name+'-',dir='/run') as directory:
    root=Path(directory);worker=root/'worker.py'
    worker.write_text(r'''from pathlib import Path
import json,sys,time
root=Path(__file__).parent;number=len(list(root.glob('start-*.json')))+1
def record(kind):
    temp=root/'record.tmp';temp.write_text(json.dumps(time.monotonic()))
    temp.replace(root/(kind+'-'+str(number)+'.json'))
record('start')
if (root/'fail').exists():sys.exit(7)
while not (root/('release-'+str(number))).exists():time.sleep(0.01)
record('end')
''')
    paths[service].write_text(f'[Service]\nType=oneshot\nTimeoutStartSec=10s\nExecStart=/usr/bin/python3 {worker}\n')
    definition='[Timer]\nOnActiveSec=100ms\nOnUnitInactiveSec=10s\nAccuracySec=1ms\n'
    paths[timer].write_text(definition);(root/'fail').touch()
    try:
        run('systemd-analyze','verify',*[str(p) for p in paths.values()])
        run('systemctl','daemon-reload');run('systemctl','start',timer)
        wait_for(lambda:value(service,'ActiveState')=='failed')
        assert value(service,'ExecMainStatus')=='7' and value(timer,'ActiveState')=='active'
        run('systemctl','stop',timer)
        print('timer active while its first job failed with exit status 7')
        (root/'fail').unlink();run('systemctl','reset-failed',service)
        paths[timer].write_text(definition.replace('OnUnitInactiveSec=10s','OnUnitInactiveSec=250ms'))
        run('systemctl','daemon-reload');run('systemctl','start',timer)
        wait_for(lambda:(root/'start-2.json').exists())
        time.sleep(0.65)
        assert len(list(root.glob('start-*.json')))==2
        (root/'release-2').touch();wait_for(lambda:(root/'start-3.json').exists())
        gap=json.loads((root/'start-3.json').read_text())-json.loads((root/'end-2.json').read_text())
        assert gap>=0.25,gap
        print('no overlapping launch; next job starts at least 250ms after prior deactivation')
        pid=value(service,'MainPID');assert pid!='0'
        run('systemctl','stop',timer)
        assert value(service,'MainPID')==pid and value(service,'ActiveState')=='activating'
        print('stopping timer leaves its already running oneshot alive')
        (root/'release-3').touch();wait_for(lambda:value(service,'ActiveState')=='inactive')
        time.sleep(0.4)
        assert len(list(root.glob('start-*.json')))==3 and value(service,'Result')=='success'
        print('released job completes; stopped timer schedules no further run')
    finally:
        run('systemctl','stop',timer,service)
        for unit,path in paths.items():
            if value(unit,'ActiveState')=='failed':run('systemctl','reset-failed',unit)
            path.unlink()
        run('systemctl','daemon-reload')

第一次触发的工作进程故意以状态七退出,服务变为失败,定时器却继续活动。此时相对服务结束的下一次间隔先设置为十秒,让脚本有时间检查这个状态,并在再次尝试前停止定时器。结果证明调度器完全可能正常工作,而它安排的业务任务一直失败;仅凭 timer 状态不能得出任务可靠的结论。

修正样本的故障条件后,脚本清除失败状态,把间隔改为二百五十毫秒,再次启动调度。第二个工作进程在释放门槛处等待,即使超过两个名义间隔,也没有第三个进程出现。因为 OnUnitInactiveSec 从服务结束活动时计算,尚未完成的工作还没有提供那个参考事件;它不是简单从上次进程启动就开始固定倒计时。

释放第二个工作进程后,它完成执行,第三个进程才开始,且与前一完成标记至少相隔配置的间隔。真正的单元结束活动发生在该标记之后,因此断言验证的是“相对完成”的关系,不要求某个容易受调度影响的精确启动时间。实验使用 AccuracySec=1ms 减少短间隔下的合并延迟,但这不构成实时执行保证,机器负载仍然可能推迟工作开始。

脚本在第三个进程继续等待时停止定时器,进程编号没有变化,oneshot 仍处于激活中。只有释放这个工作进程,服务才成功完成。再经过一个间隔,调用次数保持不变,说明已停止的 timer 没有继续安排工作。最后清理双方运行时单元与临时程序;暂停计划和结束当前执行的不同效果,都有真实进程状态作为证据。

同时迁移执行契约

从 cron 迁移任务时,不能只翻译时间表达式。还应检查工作目录、明确的命令路径、必要环境、合适的运行用户与执行时限。systemd 命令行不会隐式成为 shell 程序;管道、重定向和 shell 展开,需要有意调用 shell,或放入行为已经验证的独立脚本。否则同一段看似熟悉的命令,可能在新的执行环境中改变含义。

安装与激活也应区分。手动启动一次运行时定时器,不等于配置开机启用;启用服务本身,又可能额外引入一次不需要的开机运行。应按部署策略安装和启用 timer,再检查已载入定义与下次触发时间,并让服务可以独立受控执行,以便诊断真实工作流程。调度入口与人工诊断入口应遵守相同的执行契约。

失败如何重试、如何报告,需要另外决定。下一个 timer 事件可能再次尝试,服务的重启设置又有自己的规则与限制;两者叠加之前,应明确实际会发生几次调用。让重复副作用安全,并保留绑定真实结果的最近成功信号。运维目标是让有用工作在规定窗口内完成,而不是只得到一个长期显示活动的定时器。

延伸阅读

来源