声明依赖以让初始化系统调度启动顺序

systemd 依赖要区分激活、顺序与就绪

依赖图需要表达的不只是“先启动数据库”。一种关系决定是否把另一个单元拉进本次任务,一种关系决定启动任务的先后,服务类型则决定怎样才算启动完成。应用就绪还是程序必须真正兑现的承诺。把这些层次混在一起,得到的开机流程可能看起来很有顺序,依赖方实际却仍然在所需条件成立之前就开始运行。 坏掉的样子 After=provider.service 在双方都有启动任务时,把当前单元排在提供方之后,但它不会主动请求启动提供方。如果本次只激活消费者,单靠这条排序关系,提供方完全可能继续保持非活动。只有消费者确实应当带着提供方一起启动时,才需要另外声明合适的激活依赖;“排在它后面”与“把它启动起来”不能互相替代。 Wants 会请求启动另一个单元,但容忍对方启动失败。Requires 表达更强的关系;与 After 配合时,如果提供方激活失败,依赖它的单元就不会启动。但这两个关键字各自都不会自动建立启动顺序。应根据服务的真实需要选择组合,而不是把所有可选组件都变成硬性前提,也不能把弱依赖误当作一定成功的就绪屏障。 有序启动的强度取决于提供方怎样报告完成。Type=exec 表示程序已经成功执行,不表示数据库完成初始化;Type=notify 可以把启动任务保持到程序发送 READY=1;oneshot 则可以等待有限的设置工作结束。应用必须在承诺的就绪条件真的满足后才发送通知,管理器无法替它判断某个业务表或外部依赖是否已经可用。 快速验证 在一次性的 Linux systemd 环境中,以管理员身份运行下面的完整 Python 脚本。它安装两个临时运行时单元,依次验证六种配置。记录中的环境为 Debian 12、systemd 252.39。确认两个实验单元名尚未使用;临时文件分别记录提供方是否进入程序、是否就绪,以及消费者实际观察到了什么: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,time provider='xbcatm-provider-lab.service';consumer='xbcatm-consumer-lab.service' paths={name:Path('/run/systemd/system')/name for name in (provider,consumer)} assert all(not p.exists() for p in paths.values()) def ctl(*args,success=True): result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10) assert (result.returncode==0)==success,(args,result.stderr) return result.stdout.strip() def value(name,key):return ctl('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-dependency-',dir='/run') as directory: root=Path(directory) (root/'provider.py').write_text(r"""from pathlib import Path import os,socket,sys,time root=Path(__file__).parent;mode=(root/'mode').read_text() if mode=='fail': sys.exit(7) (root/'entered').touch() if mode in ('exec','notify'): while not (root/'gate').exists(): time.sleep(0.01) (root/'ready').touch() if mode=='notify': address=os.environ['NOTIFY_SOCKET'] if address.startswith('@'): address='\0'+address[1:] with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as notification: notification.connect(address);notification.sendall(b'READY=1') if mode in ('exec','notify'): time.sleep(60) """) (root/'consumer.py').write_text("from pathlib import Path\nr=Path(__file__).parent\n(r/'observed').write_text(str((r/'ready').exists()))\n") def install(mode,requirement): (root/'mode').write_text(mode) kind='oneshot' if mode in ('oneshot','fail') else mode paths[provider].write_text(f'[Service]\nType={kind}\nExecStart=/usr/bin/python3 {root}/provider.py\nTimeoutStartSec=5s\n'+('RemainAfterExit=yes\n' if kind=='oneshot' else '')) paths[consumer].write_text(f'[Unit]\nAfter={provider}\n'+(f'{requirement}={provider}\n' if requirement else '')+f'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 {root}/consumer.py\n') subprocess.run(['systemd-analyze','verify',*[str(p) for p in paths.values()]],check=True) ctl('daemon-reload') def reset(): ctl('stop',consumer,provider) for name in paths: if value(name,'ActiveState')=='failed':ctl('reset-failed',name) for name in ('entered','gate','ready','observed'):(root/name).unlink(missing_ok=True) pending=None install('oneshot','') try: ctl('start',consumer) assert (root/'observed').read_text()=='False' and value(provider,'ActiveState')=='inactive' print('After alone: provider not pulled in; consumer ran before readiness') reset();install('fail','Wants');ctl('start',consumer) assert value(provider,'ActiveState')=='failed' and (root/'observed').read_text()=='False' print('Wants plus After: consumer runs despite provider failure') reset();install('fail','Requires');ctl('start',consumer,success=False) assert value(provider,'ActiveState')=='failed' and not (root/'observed').exists() print('Requires plus After: failed provider prevents consumer execution') reset();install('oneshot','Requires');ctl('start',consumer) assert (root/'observed').read_text()=='True' print('oneshot provider: consumer sees completed setup') reset();install('exec','Requires');ctl('start',consumer) assert (root/'observed').read_text()=='False' and value(provider,'ActiveState')=='active' (root/'gate').touch();wait_for(lambda:(root/'ready').exists()) print('Type=exec provider: process started before application readiness') reset();install('notify','Requires') pending=subprocess.Popen(['systemctl','start',consumer],text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE) wait_for(lambda:(root/'entered').exists()) assert pending.poll() is None and not (root/'observed').exists() assert value(provider,'ActiveState')=='activating' (root/'gate').touch();out,err=pending.communicate(timeout=10) assert pending.returncode==0,err assert (root/'observed').read_text()=='True' print('Type=notify provider: dependent waits for actual READY=1') finally: reset() if pending is not None and pending.poll() is None:pending.communicate(timeout=5) for path in paths.values():path.unlink() ctl('daemon-reload') 只有 After 时,消费者运行并记录尚未就绪,而提供方保持非活动。这不是提供方启动得慢,而是没有任何激活关系把它拉进来。使用 Wants 加 After 时,提供方故意以状态七退出,消费者仍然执行并记录否。样本程序接受这个观察结果;真实消费者如果确实必须依赖它,随后可能在自己的业务操作中失败,弱依赖不会替业务消除这个事实。 ...

二月 18, 2028 · 2 分钟 · XB Field Notes
一次激活按顺序执行命令,并记录完成或失败。

systemd oneshot 完成的是一次激活

Type=oneshot 服务表达一段有限工作,启动命令成功结束后,本次启动才算完成。因此,它适合初始化步骤,以及依赖方需要等待任务结束的定时工作。oneshot 描述的是一次激活的进程模型,不会建立永久记录来阻止任务在重启、再次激活或机器重新开机后重跑。名字中的“一次”,不能直接推导为业务意义上的“永远只做一次”。 坏掉的样子 成功的 oneshot 通常在命令完成后变为非活动状态,这是任务已经结束的正常表现,不是守护进程崩溃的证据。设置 RemainAfterExit=yes 后,管理器会在命令退出后仍把单元记为活动。因此,active (exited) 可以完全没有主进程;如果监控假定任何活动服务都必须有一个存活 PID,就会错误判断这种单元类型。 oneshot 允许多条 ExecStart,并按顺序执行。采用普通成功规则时,某条命令失败会阻止后续启动命令运行,并让本次激活失败。但前面的命令可能已经修改文件或数据库,systemd 不会回滚这些副作用。重新尝试整个激活,就可能重复之前已经成功的部分,不能把管理器的失败状态误当作业务事务已经全部撤销。 RemainAfterExit 保存的是管理器状态,不是持久的恰好一次执行保证。再次启动已经活动的单元不会重跑,重启却会。定时器指向一个仍然活动的 oneshot 时,也不会把每个到达的时间点都变成另一次执行。只有它确实代表一个持续成立的状态时,才适合保留活动标记;需要反复触发的定时工作,应在各次执行之间回到非活动状态。 快速验证 在由 systemd 管理的临时 Linux 环境中,以管理员身份运行下列 Python 脚本。它创建一个运行时单元和临时步骤程序。实测环境为 Debian 12、systemd 252.39,执行前确认示例单元文件名尚未使用。每个步骤先记录自身名字,第二步随后故意失败: from pathlib import Path from tempfile import TemporaryDirectory import subprocess unit='xbcatm-oneshot-lab.service';path=Path('/run/systemd/system')/unit assert not path.exists() def ctl(*args,success=True): result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10) assert (result.returncode==0)==success,(args,result.stderr) return result.stdout.strip() def value(key):return ctl('show',unit,'-p',key,'--value') with TemporaryDirectory(prefix='xbcatm-oneshot-',dir='/run') as directory: root=Path(directory);worker=root/'step.py';calls=root/'calls' worker.write_text("""from pathlib import Path import sys root=Path(__file__).parent with (root/'calls').open('a') as output: output.write(sys.argv[1]+'\\n') if sys.argv[1]=='second' and not (root/'healthy').exists(): sys.exit(7) """) definition=f'''[Service] Type=oneshot RemainAfterExit=yes TimeoutStartSec=3s ExecStart=/usr/bin/python3 {worker} first ExecStart=/usr/bin/python3 {worker} second ExecStart=/usr/bin/python3 {worker} third ''' path.write_text(definition) try: subprocess.run(['systemd-analyze','verify',str(path)],check=True) ctl('daemon-reload');ctl('start',unit,success=False) assert calls.read_text().splitlines()==['first','second'] assert value('Result')=='exit-code' and value('ExecMainStatus')=='7' print('second command fails; third command does not run') (root/'healthy').touch();ctl('reset-failed',unit);ctl('start',unit) assert calls.read_text().splitlines()==['first','second','first','second','third'] assert (value('ActiveState'),value('SubState'),value('MainPID'))==('active','exited','0') before=calls.read_text();ctl('start',unit);assert calls.read_text()==before print('active/exited with PID 0; repeated start does not rerun') ctl('restart',unit) assert calls.read_text()==before+'first\nsecond\nthird\n' print('restart executes all commands again') ctl('stop',unit);path.write_text(definition.replace('RemainAfterExit=yes','RemainAfterExit=no')) ctl('daemon-reload');before=calls.read_text() for _ in range(2): ctl('start',unit) assert value('ActiveState')=='inactive' and value('Result')=='success' assert calls.read_text()==before+'first\nsecond\nthird\n'*2 print('without RemainAfterExit: two starts, two successful executions') finally: ctl('stop',unit) if value('ActiveState')=='failed':ctl('reset-failed',unit) path.unlink();ctl('daemon-reload') 第一次激活只留下 first 和 second 两项记录,启动命令返回失败,退出状态为七;第三条命令没有运行。此前的记录仍然存在,让我们直接看见部分副作用,而不是假设激活失败意味着什么都没做过。这对于会修改真实数据的任务尤其关键:管理器状态并不能描述全部业务影响。 ...

十一月 28, 2027 · 1 分钟 · XB Field Notes