依赖图需要表达的不只是“先启动数据库”。一种关系决定是否把另一个单元拉进本次任务,一种关系决定启动任务的先后,服务类型则决定怎样才算启动完成。应用就绪还是程序必须真正兑现的承诺。把这些层次混在一起,得到的开机流程可能看起来很有顺序,依赖方实际却仍然在所需条件成立之前就开始运行。
坏掉的样子
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 时,提供方故意以状态七退出,消费者仍然执行并记录否。样本程序接受这个观察结果;真实消费者如果确实必须依赖它,随后可能在自己的业务操作中失败,弱依赖不会替业务消除这个事实。
改为 Requires 加 After,相同的提供方失败会使消费者的启动命令返回失败,而且没有生成观察文件。文件缺席证明消费者程序根本没有执行。把提供方改成成功的 oneshot 设置工作,观察结果则变成真,因为此时排序等待的边界就是那段有限设置工作完成,符合消费者需要读取标记的前提。
下一种配置使用 Type=exec。提供方进程已经处于活动状态,却刻意停在应用门槛处,尚未发布就绪标记;消费者仍然开始执行并记录否。之后打开门槛,提供方才就绪。这个安排用明确的状态控制复现了时间差,不依赖随意睡几秒、希望碰巧赶上竞态,因此结果能够稳定区分进程启动与应用完成初始化。
最后使用 Type=notify,提供方在同一个门槛前保持激活中。消费者启动任务仍然等待,其程序也还没有执行。门槛打开后,提供方先发布就绪标记,再由主进程向真实 systemd 通知套接字发送就绪消息。此后消费者才运行,并记录真。这里验证的是管理器实际等待通知的行为,不是把一个文件存在误当成管理器已经收到通知。
脚本逐次验证单元配置,并在结束时清理双方服务;如果断言中断,也会处理仍在等待的任务。所有结果都来自真实进程和真实通知通道,但它们不能证明某个生产数据库的就绪定义足以满足所有客户端操作。实验检查的是启动边界如何传播,业务条件本身仍须由真正的应用实现与验证。
让依赖关系对应失败语义
就绪定义应以消费者的需求为准。端口开始监听时,迁移、凭据或必要数据可能还未准备好;反过来,等待一个可选的远程组件,也可能不必要地阻止本地服务启动。应明确究竟等待什么条件,哪些失败必须阻止启动,哪些可以交给运行中的应用按既定策略处理,不能只根据组件名称猜测依赖强度。
启动曾经成功,不保证提供方以后一直健康。普通依赖关系不是持续的业务健康检查,条件未满足导致跳过也不同于激活失败。应用需要处理后续连接中断、服务重启和外部系统不可用,而不是把开机依赖图当作永久的可用性保证。已经越过启动边界之后,系统仍然可能进入新的故障状态。
诊断顺序问题时,应检查已载入的单元定义、实际生效的依赖关系和对应启动结果。更改单元文件后重新加载管理器,再验证失败场景与正常启动。没有实际先后要求的服务应保持独立,以便并行启动;只保留真正由完成契约要求的排序边,既避免过早执行,也避免无依据地把整套系统串成一条长链。
延伸阅读
来源
- systemd 252 单元依赖,排序、激活强度与条件行为,已核验 2026-09-12。
- systemd 252 服务类型,exec、oneshot 与通知式启动边界,已核验 2026-09-12。
- systemd 252 systemctl,启动任务、状态检查与管理器重载,已核验 2026-09-12。