重启策略决定当前进程结束后,服务管理器是否应该再启动一个进程。它可以帮助恢复短暂故障,但修不好缺失配置或必然重复的程序错误。一个有用的契约需要分别回答三个问题:哪些结束方式应该重试、重试前等多久、一个时间窗口内允许启动多少次。这些设置各管一件事,不能把它们笼统理解为“失败后会自己恢复”。

坏掉的样子

Restart=on-failure 不代表任何信号结束都会重启。对于 oneshot 以外的服务类型,通常的 SIGHUP、SIGINT、SIGTERM 和 SIGPIPE 终止,默认被视为干净结束;非零退出码,以及 SIGKILL 这样的异常信号,才符合默认失败规则。SuccessExitStatus 和专门的重启例外指令还能改变分类,因此要检查最终生效的单元,不能只看其中一行策略名字。

on-abnormal 也不是 on-failure 的“更严格同义词”:它排除了普通的非零进程退出码,保留异常信号以及部分超时或看门狗故障。选择依据应是程序怎样报告可恢复失败与主动结束。如果进程始终存活,却已经不再提供有用工作,还需要单独配置发现这种状态的机制;基于退出的重启逻辑不会自动推断业务是否健康。

RestartSec 提供下一次重启前的等待时间。在下面的 systemd 252 实验里,它是固定延迟,不是越来越长的指数退避。StartLimitIntervalSec 和 StartLimitBurst 限制窗口内的启动次数,手动启动也算在内;它们既不是整个生命周期的重试预算,也不会在超限后自动预约下一窗口边界再尝试一次。限流与恢复触发应分别理解。

快速验证

在一次性的 Linux systemd 环境中,以管理员身份运行这个 Python 脚本。它创建带有唯一名字的运行时单元和临时工作程序,完成后删除。实测环境为 Debian 12、systemd 252.39。工作程序起初每次都以状态七退出,同时记录每次真实调用:

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

unit='xbcatm-restart-lab-'+uuid.uuid4().hex[:8]+'.service'
path=Path('/run/systemd/system')/unit
assert not path.exists()
def ctl(*args):
    result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10)
    assert result.returncode==0,(args,result.stderr)
    return result.stdout.strip()
def value(key):return ctl('show',unit,'-p',key,'--value')
def wait_for(check):
    deadline=time.monotonic()+5
    while not check():
        assert time.monotonic()<deadline,'state transition timed out'
        time.sleep(0.02)
with TemporaryDirectory(prefix='xbcatm-restart-',dir='/run') as directory:
    root=Path(directory);worker=root/'worker.py';calls=root/'calls';healthy=root/'healthy'
    worker.write_text("""from pathlib import Path
import sys,time
root=Path(__file__).parent
with (root/'calls').open('a') as output: output.write('started\\n')
if not (root/'healthy').exists(): sys.exit(7)
time.sleep(60)
""")
    path.write_text(f'''[Unit]
StartLimitIntervalSec=10s
StartLimitBurst=3
[Service]
Type=exec
ExecStart=/usr/bin/python3 {worker}
Restart=on-failure
RestartSec=100ms
''')
    try:
        ctl('daemon-reload');ctl('start',unit)
        wait_for(lambda: value('ActiveState')=='failed')
        assert calls.read_text().splitlines()==['started']*3
        assert value('Result')=='exit-code'
        subprocess.run(['journalctl','--sync'],check=True)
        logs=subprocess.run(['journalctl','-u',unit,'--no-pager','-o','cat'],capture_output=True,text=True,check=True).stdout
        assert 'Start request repeated too quickly' in logs
        print('three starts; failed/exit-code with start-rate-limit journal message')
        healthy.touch();ctl('reset-failed',unit);ctl('start',unit)
        wait_for(lambda: len(calls.read_text().splitlines())==4)
        first=value('MainPID');assert first!='0'
        ctl('kill','--kill-who=main','--signal=TERM',unit)
        wait_for(lambda: value('ActiveState')=='inactive')
        assert value('NRestarts')=='0'
        print('on-failure treats ordinary SIGTERM as clean; no restart')
        ctl('start',unit);first=value('MainPID')
        ctl('kill','--kill-who=main','--signal=KILL',unit)
        wait_for(lambda: value('MainPID') not in ('0',first))
        assert value('NRestarts')=='1'
        print('SIGKILL triggers one restart')
        ctl('stop',unit);time.sleep(0.2)
        assert value('ActiveState')=='inactive'
        print('explicit stop remains stopped')
    finally:
        ctl('stop',unit)
        if value('ActiveState')=='failed':ctl('reset-failed',unit)
        path.unlink();ctl('daemon-reload')

第一阶段允许十秒内启动三次,自动重启间隔设得很短且固定。程序自己写出的文件恰好包含三项记录,所以这里统计的是实际进程启动,而不是先猜某个管理器计数器的含义,再把那个数字当作执行证据。之后单元留在失败状态,日志明确说明启动请求过于频繁,新的启动被速率规则挡住。

在本次版本中,最终 Result 仍为 exit-code,保留先前的进程退出失败;启动限流的原因则出现在日志里。如果断言结果字段一定等于 start-limit-hit,反而会漏掉已经实际发生的这个情况。应结合单元状态、程序调用记录和相应日志判断,不要假定不同版本、不同失败路径都给出完全相同的单一诊断标签。

随后脚本通过创建健康标记修复样本程序,清除失败状态和速率计数,再次启动。这一次进程保持运行。向它发送普通 SIGTERM 后,单元变为非活动,on-failure 没有安排自动重启;之后另一次运行收到 SIGKILL,则确实产生一个替代进程。实验由此区分信号分类与“只要进程消失就重启”的错误理解。

最后,显式执行 systemctl stop,等待超过重启延迟,服务依然保持停止。管理器要求的关闭不会被当成需要立刻逆转的崩溃。清理时也只在失败状态仍存在时重置它,因为没有引用的非活动单元可能已经被管理器卸载,不能把不存在的失败状态当作必须成功清除的对象。

恢复服务,而不是只清空计数

清除证据前先阅读失败原因。检查配置命令、退出状态、最近一次运行日志和依赖错误,修正原因或部署已知可工作的版本,再决定是否值得立即重试并清除限流。反复清空计数,却不改变导致失败的条件,只会把原本有界的崩溃循环变成由操作者手动驱动的循环,服务本身并没有因此更可靠。

持续不可用和过量重启应交给实际负责该服务的监控系统告警。这里展示的单元设置并不会自动创建告警接收渠道。保留足够诊断上下文,用来区分程序无法执行、应用自行退出、资源耗尽和主动停止,同时避免把敏感配置写进日志。计数和退出码应帮助定位故障,而不是替代故障含义本身。

延迟与窗口大小应匹配启动成本和依赖行为。一次很快的重试可能恢复单点短暂错误,也可能在共享依赖故障时放大负载。应在真正部署的配置中验证恢复和终止,包括所有覆盖文件。只有应用恢复了有用工作,而且仍能受控停止,策略才达到目的;进程编号反复变化,并不是成功交付服务的充分证据。

延伸阅读

来源