服务管理器把进程启动、失败和停止变成可以观察的状态变化。使用 systemd 时,单元可以明确前台命令、重启策略与输出目的地,不必再靠一个只会反复拉起程序、却说不清为什么停止的 shell 循环。但这并不能证明运行中的应用已经能响应请求,也不能证明重新启动一定修好了原来的故障。管理生命周期与验证业务可用性,需要各自成立。

坏掉的样子

服务类型必须匹配程序的启动契约。Type=simple 很早就认为启动成功,此时可执行文件甚至还不一定已经成功执行。Type=exec 则等待执行程序成功,因此缺少可执行文件时,启动任务可以直接失败。不过,这两种类型都不会自动等待应用连接数据库、加载状态或完成业务初始化。看到启动命令返回成功,还不能据此开始向它发送必须成功的业务请求。

如果程序自行转入后台,就可能与预期前台主进程的单元不一致。应使用程序的前台模式,让管理器掌握生命周期,或者按照程序真实的守护化协议选择有文档依据的类型。不要靠进程名称搜索来猜主进程编号:名字可能重复,包装脚本也可能把错误的进程留作单元主进程,导致观察、停止和重启的对象都不符合预期。

重启策略也要有清楚边界。Restart=on-failure 覆盖的是规定的失败退出,并不是所有终止信号,更不是所有不健康状态。一个卡住却没有退出的进程,不会自动产生重启事件。应该结合有意义的应用健康观察与有限重试,而不是把单元状态为活动,直接等同于服务已经恢复对外工作。

快速验证

在 PID 1 为 systemd 的一次性 Linux 环境中,以管理员身份运行下面的 Python 脚本。它只写入明确命名的临时运行时单元和一个临时工作目录。记录中的环境是隔离容器内的 Debian 12、systemd 252.39。执行前确认两个单元文件名尚未被使用,避免与已有服务混淆:

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

unit='xbcatm-supervise-lab.service';bad='xbcatm-exec-failure-lab.service'
unit_path=Path('/run/systemd/system')/unit;bad_path=unit_path.with_name(bad)
assert not unit_path.exists() and not bad_path.exists()
def run(args,success=True):
    result=subprocess.run(args,text=True,capture_output=True,timeout=10)
    assert (result.returncode==0)==success,(args,result.returncode,result.stderr)
    return result.stdout.strip()
def value(name,key):return run(['systemctl','show',name,'-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-supervise-',dir='/run') as directory:
    root=Path(directory);worker=root/'worker.py';ready=root/'ready'
    worker.write_text("""from pathlib import Path
import os,signal,sys
signal.signal(signal.SIGTERM,lambda *_: sys.exit(0))
Path(__file__).with_name('ready').write_text(str(os.getpid()))
print('worker ready',flush=True)
while True: signal.pause()
""")
    unit_path.write_text(f'''[Unit]
Description=Disposable supervision exercise
StartLimitIntervalSec=10s
StartLimitBurst=3
[Service]
Type=exec
ExecStart=/usr/bin/python3 -u {worker}
Restart=on-failure
RestartSec=200ms
TimeoutStopSec=2s
StandardOutput=journal
StandardError=journal
''')
    bad_path.write_text(f'[Service]\nType=exec\nExecStart={root}/missing-program\n')
    try:
        run(['systemd-analyze','verify',str(unit_path)])
        run(['systemctl','daemon-reload']);run(['systemctl','start',unit])
        wait_for(lambda: ready.exists() and ready.read_text()==value(unit,'MainPID'))
        first=value(unit,'MainPID');assert first!='0'
        first_invocation=value(unit,'InvocationID')
        run(['systemctl','kill','--kill-who=main','--signal=KILL',unit])
        wait_for(lambda: value(unit,'MainPID') not in ('0',first) and ready.read_text()==value(unit,'MainPID'))
        assert int(value(unit,'NRestarts'))==1
        print('SIGKILL: replacement PID confirmed; NRestarts=1')
        run(['journalctl','--sync'])
        for invocation in (first_invocation,value(unit,'InvocationID')):
            logs=run(['journalctl','_SYSTEMD_INVOCATION_ID='+invocation,'--no-pager','-o','cat'])
            assert 'worker ready' in logs
        print('journal contains both application startups')
        run(['systemctl','stop',unit]);time.sleep(0.3)
        assert value(unit,'ActiveState')=='inactive' and value(unit,'MainPID')=='0'
        print('manager stop leaves service inactive')
        run(['systemctl','daemon-reload']);run(['systemctl','start',bad],success=False)
        assert value(bad,'Result')=='exit-code' and value(bad,'ExecMainStatus')=='203'
        print('Type=exec reports missing executable: status 203/EXEC')
    finally:
        run(['systemctl','stop',unit,bad])
        for name in (unit,bad):
            if value(name,'ActiveState')=='failed':
                run(['systemctl','reset-failed',name])
        unit_path.unlink();bad_path.unlink(missing_ok=True)
        run(['systemctl','daemon-reload'])

工作进程始终留在前台,并写出自己的启动标记。脚本等待该标记与管理器报告的主进程编号一致,再只向受管理的主进程发送 SIGKILL。随后确认编号发生变化,且自动重启次数为一,从而证明管理器确实替换了被终止的那个进程,并非只是原进程仍然存活,或者恰巧找到了同名程序。

日志检查使用前后两次运行的 invocation 标识,因此早先实验留下的同名单元日志不能替本次断言过关。每次运行都必须包含工作进程自己输出的启动消息。这同时检查了生命周期替换与两次实际进程输出的归属,不是只在多年积累的日志中找到一句熟悉文字,就认定当前启动已经正确记录。

显式执行管理器停止之后,等待超过重启延迟,单元仍为非活动状态,主进程编号为零。这是主动停止,不应被当成另一次需要自动修复的崩溃。另一个故障单元故意指向不存在的可执行文件,启动命令失败,并记录 203/EXEC。这个结果对应执行程序失败,不是数据库连通性故障,也不是应用层健康检查失败。

脚本在启动正常单元前验证定义,最后删除两个运行时单元文件。静态验证与成功运行是两类证据:前者检查管理器如何理解配置,后者观察真实进程行为。它们都不能替代一次生产端点检查,尤其不能从配置解析成功就推导应用的所有依赖已经满足。

让生命周期信息真正有用

部署时应让单元定义、运行时配置和应用就绪信号保持一致。把另一个单元排在本服务之后,排序的是管理器认定的启动完成;若依赖方需要更强的应用就绪保证,就应使用受支持的通知协议或明确的应用层检查。应用自身仍可能需要在启动期间重试短暂不可用的依赖,管理器排序并不会自动消除所有网络或外部服务故障。

使用 systemctl show 读取需要的具体状态属性,并在日志中对应到那一次运行。重复故障期间,主进程编号可能在两次观察之间变化,因此诊断要明确针对哪个实例。同时观察重启频率与有用工作是否恢复;一个成功增加的重启计数,本身不是可用性指标,也不能说明用户请求已经重新成功。

本实验把文件放在 /run/systemd/system,适合临时验证。正式服务还需要有意设计安装和启用策略、合适的运行身份,以及经过测试的停止流程。修改单元定义之后,需要让管理器重新加载配置;仅仅手动启动一次服务,不会自动安排它在下一次开机时再次启动。生命周期的每一段都应有明确的配置与验证依据。

延伸阅读

来源