systemd 依赖、顺序与条件检查详解
Requires、Wants、After、Before、ExecStartPre 和 Condition 系列职责不同。本文解释各自保证,并排查 unit 为何从未运行。
Requires 不等于 After
systemd 的依赖关系和条件检查是 4 种相互独立的机制,但许多 unit 文件会把它们当成一个整体使用。Requires= 和 Wants= 决定拉取哪些其他 unit。After= 和 Before= 决定 unit 的启动顺序。ExecStartPre= 执行一项检查,该检查失败时可使 unit 失败。Condition 和 Assert 系列参数决定 unit 是否运行。每种机制都独立于其他机制,因此一个 unit 可以要求另一个 unit,同时仍与它在同一时刻启动。
上一句正是几乎所有“手动启动正常,但开机时失败”问题的根源。
[Unit]
Description=Inventory API
Requires=postgresql.service
[Service]
ExecStartPre=/usr/bin/pg_isready -h 127.0.0.1 -t 5
ExecStart=/usr/local/bin/inventory-apiRequires=postgresql.service 会将 PostgreSQL 拉入同一个启动事务,但不会等待它。systemd 会并行启动这两个任务,因此 PostgreSQL 仍在打开数据目录时,pg_isready 就已开始运行。此时尚未有进程监听,pg_isready 以退出码 2 退出,导致该 unit 失败,甚至无法执行到 ExecStart。一小时后运行 sudo systemctl start inventory-api 可以成功,因为此时 PostgreSQL 已经运行。unit 文件没有任何变化,所以这个文件看起来没有问题。
修复只需添加一行。
[Unit]
Requires=postgresql.service
After=postgresql.service同一位置还隐藏着一个容易忽略的细节。只有同时在该依赖关系上设置 After= 时,失败的 Requires= 依赖才会阻止你的 unit 启动。如果没有排序关系,另一个 unit 失败时,systemd 可能已经启动了你的 unit,因此没有可取消的任务。单独使用 Requires= 并不能提供人们以为它能提供的保护。除非有明确理由不这样做,否则应在每个 Requires= 和每个 Wants= 旁边写上 After=。
What Requires, Wants, Requisite and BindsTo promise
这些都是依赖关系设置,不负责定义启动顺序。
Wants=:拉入另一个单元。如果该单元启动失败或不存在,本单元仍会启动。这是systemctl enable在.wants/目录中创建符号链接时实现的效果。Requires=:拉入另一个单元。如果该单元启动失败,并且您还为其配置了After=,本单元不会启动。如果之后显式停止另一个单元,本单元也会随之停止。Requisite=:不拉入另一个单元。如果该单元尚未处于 active 状态,立即使本单元失败。BindsTo=:作用类似于Requires=,但无论另一个单元因何停止,本单元也会停止,包括硬件消失导致的停止。PartOf=:另一个单元的停止和重启会向下传播到本单元。启动不会传播。Conflicts=:启动本单元会停止另一个单元。
对于需要与另一个守护进程通信的守护进程,通常应使用 Wants= 加 After=。Requires= 会关联两个单元的生命周期:如果为维护而停止数据库,应用也会随之停止;数据库恢复后,应用不会自动恢复。Wants= 加 After= 可以提供启动顺序,而不会关联生命周期;如果依赖项之后消失,则由重启策略处理。
您还会继承自己没有编写的依赖关系。使用默认设置 DefaultDependencies=yes 时,普通服务会自动获得 Requires=sysinit.target、After=sysinit.target basic.target 和 Conflicts=shutdown.target。因此,即使服务的 [Unit] 部分几乎为空,它仍会在启动过程中较晚启动,并在关机时正常停止。
After 和 Before 只决定事务中的顺序,不执行其他操作
After= 和 Before= 只用于排序,不包含任何依赖要求。在没有其他单元拉入 Redis 的单元中设置 After=redis.service 不会产生任何作用:如果 redis.service 不在事务中,就没有需要等待的对象,因此您的单元会立即启动。
这一点值得重复说明,因为后文的 network-online.target 错误正是这种情况。排序只会等待同一事务中已经被启动的单元。
这两个指令是对称的。在 a.service 中写入 After=b.service,与在 b.service 中写入 Before=a.service 的含义相同。因此请选择其中一个,并将其写入您负责维护的单元中。停止服务时,排序会自动反转,因此 After=b.service 也表示您的单元会在 b.service 之前停止。
After=等待“已启动”,而 Type=定义其含义
After=会等待另一个单元完成启动。这里“完成启动”的具体含义完全由该单元的 Type= 决定。
Type=simple:systemd fork 出进程后立即视为已启动。此时程序可能还没有解析配置,更不用说打开套接字。Type=exec:execve()成功后立即视为已启动。条件稍强,但仍不表示服务已就绪。Type=forking:原始父进程退出时视为已启动。Type=oneshot:进程退出时视为已启动。此时“已启动”确实表示工作已完成。Type=notify:服务通过其通知套接字发送READY=1时视为已启动。只有这种类型能够报告真正的就绪状态。
因此,在使用 Type=simple 守护进程时,After= 只是一个较弱的保证。这也是第一个示例中竞争条件的另一半。如果所依赖的单元以 Type=simple 方式提供,那么在其后进行排序并不表示它已经接受连接。有两种可靠的处理方式。改为在其套接字单元之后排序,让内核在守护进程仍在启动时排队接收连接。或者让自己的服务重试,并由重启策略负责恢复。单元使用的类型可在 systemctl cat 中查看;在依赖排序之前,建议阅读Type=设置以及每个值向 systemd 传达的信息。
ExecStartPre 是可能导致单元失败的闸门
ExecStartPre= 在 ExecStart= 之前运行。如果它以非零状态退出,激活过程将中止,单元进入 failed。ExecStart= 永远不会运行。许多单元在实际程序没有任何消息的情况下失败,原因就在于程序根本没有启动。
以下几点容易被忽略:
- 它不是 shell。不支持管道、重定向、通配符或
&&。第一个标记必须是绝对路径。需要使用 shell 语法时,请将该行包装在/bin/sh -c '...'中。 -前缀会让非零退出状态不再表示致命错误:ExecStartPre=-/usr/bin/optional-check。- 每个
ExecStartPre=都必须退出后,下一个才会运行。它不能启动长期运行的进程。 - 所有
ExecStartPre=行都与ExecStart=共享TimeoutStartSec=。如果预检查循环等待数据库,就会消耗启动超时时间;随后单元会在日志中出现start operation timed out. Terminating.后,以Result: timeout失败。
失败行显示的是控制进程,而不是主进程:
inventory-api.service: Control process exited, code=exited, status=2/INVALIDARGUMENT
inventory-api.service: Failed with result 'exit-code'.请仔细读取这个符号名称。systemd 会通过固定表映射较小的退出代码,因此无论程序原本用它表示什么,2 始终都会输出 INVALIDARGUMENT。status=203/EXEC 才包含真正的信息:systemd 根本无法执行该二进制文件,原因可能是路径错误,或文件没有执行权限。
不要使用 ExecStartPre= 创建目录。RuntimeDirectory=、StateDirectory=、LogsDirectory= 和 CacheDirectory= 会以正确的所有者和权限创建目录;服务停止时,RuntimeDirectory= 会将其清理。它们在 DynamicUser= 下也能正确运行,而手写的 mkdir 无法做到这一点。
条件失败时会静默跳过。断言失败时会明确报错。
Condition 和 Assert 系列执行相同的测试。它们的区别仅在于测试失败后的处理方式。
Condition...= 失败时会跳过该单元。启动作业会报告为成功。该单元保持为 inactive (dead),不会标记为失败,不会触发告警,并且 journal 只记录一行:
Condition check resulted in Inventory API being skipped.在 systemd 250 及更高版本中,systemctl status 会直接输出原因:
Active: inactive (dead)
Condition: start condition unmet at Thu 2026-08-20 09:14:02 UTC; 2min ago其中的缩进行会指出失败的确切指令,例如 ConditionPathExists=/etc/inventory/api.conf was not met。
Assert...= 失败时会使该单元失败。journal 会显示 Assertion failed for Inventory API.,该单元最终处于 failed (Result: assert),监控系统可以检测到这一状态。
选择两者时,应先判断测试失败代表什么。Condition 表示“此单元不适用于这台机器”。Assert 表示“此条件必须成立;如果不成立,请通知相关人员”。大多数单元应使用 Condition。只有在静默不执行比单元失败更糟糕时,才使用 Assert。
Condition 系列有两个需要注意的问题。
第一,条件失败不会使依赖该单元的单元失败。如果 a.service 使用 Requires=b.service,而 b.service 因条件不满足而跳过,则 b.service 的启动作业仍会计为完成。因此,a.service 仍会正常启动,但此时 b 并未运行。条件只保护写入该条件的单元。
第二,每次单元启动时,都会在作业执行的时刻重新评估条件。由 VPS 上的 systemd 定时器触发的单元可能连续跳过 100 次,却从未显示为失败。这与运行但不执行任何操作的 cron 作业属于同一类静默空操作。排查方式也相同:读取该单元的 journal,不要只相信其退出状态。
服务器上值得了解的条件包括:
ConditionPathExists=/etc/inventory/api.conf,以及其否定形式ConditionPathExists=!/etc/inventory/api.conf。ConditionFileNotEmpty=和ConditionDirectoryNotEmpty=,用于检查软件包创建但留空的配置文件或数据目录。ConditionVirtualization=,使需要实际内核接口的单元可以使用ConditionVirtualization=!container。使用systemd-detect-virt检查本机报告的内容。ConditionHost=匹配主机名或机器 ID,可让同一个共享单元文件在两台服务器上执行不同的行为。ConditionKernelCommandLine=和ConditionKernelVersion=,用于绑定启动参数或要求最低内核版本的单元。
空赋值会清空列表。drop-in 可以借此移除软件包提供的条件:
[Unit]
ConditionPathExists=
ConditionPathExists=/srv/inventory/api.conf为什么 network.target 不表示网络已就绪
network.target 是同步点,不是状态。在启动过程中,将单元排在它之后,只表示网络管理软件已经启动。这不表示某个接口已经获得地址,也不表示已经存在通往 Internet 的路由。该目标主要用于另一个方向:关机时,排在 After=network.target 之后的单元会在网络被拆除前停止。
network-online.target 才会等待网络就绪。它由您所使用的网络管理器提供的 wait-online 服务支持:
systemd-networkd-wait-online.service:由 systemd-networkd 管理链路时使用。在通过 netplan 配置的 Ubuntu 服务器上,这通常是默认情况。NetworkManager-wait-online.service:由 NetworkManager 管理链路时使用。
较旧的 ifupdown 配置则通过 networking.service 实现相同效果。无论使用哪一个,正确使用该目标都需要两行配置,而不是一行。
[Unit]
Wants=network-online.target
After=network-online.targetnetwork-online.target 不属于默认启动事务,也没有任何单元会自动拉取它。只写 After= 时,您实际上是在将单元排在一个从未加入队列的单元之后,因此该排序完全不起作用。这就是前文所述的无操作,只是代价最高的一种形式。Wants= 这一行会将目标加入事务,这样 After= 这一行才有实际等待的对象。
还需要注意,"online" 的定义来自 wait-online 实现,而不是 systemd。systemd-networkd-wait-online 会在其管理的链路达到已配置状态后返回。它不会检查 DNS 是否能解析,也不会检查任何远程主机是否可访问。
这个定义会导致 VPS 上常见的启动故障。如果主机有一个用于私有网络的第二个接口,该接口已在 netplan 中声明但从未配置地址,wait 服务就会一直等待,直到超时:
systemd-networkd-wait-online[612]: Timeout occurred while waiting for network connectivity.
systemd-networkd-wait-online.service: Failed with result 'exit-code'.由于默认超时时间为 120 秒,启动会额外耗时 2 分钟。有两种修复方法。在 netplan 文件中将未使用的接口标记为 optional: true,让 networkd 不再等待该接口。或者,为 wait 服务添加 drop-in,通过 --interface= 指定需要关注的链路;也可以传递 --any,使服务在任意一个链路就绪后立即返回。
更好的方法是避免依赖该目标。许多服务之所以排在 network-online.target 之后,只是因为它们绑定某个特定地址,并在启动时因类似以下配置而失败:
nginx: [emerg] bind() to 203.0.113.10:443 failed (99: Cannot assign requested address)由于该地址尚未就绪,内核拒绝绑定。设置 net.ipv4.ip_nonlocal_bind=1 后,进程即可绑定主机当前尚未持有的地址,再配合重启策略即可处理后续情况。对于通常只是单个套接字的问题,延迟整个启动过程以等待网络就绪是一种过重的手段。
如何读取运行中系统上真实的 systemd 依赖关系
不要只根据 unit 文件进行判断。Drop-in、.wants/ 符号链接以及隐式默认依赖都会添加文件中未显示的依赖边。
systemctl cat inventory-api.service此命令会按生效顺序输出 unit 文件和所有 drop-in,并在每个配置块上方显示源路径。先运行此命令。/etc/systemd/system/inventory-api.service.d/ 中的 5 行覆盖配置会优先生效,覆盖软件包提供的文件,否则无法直接看到。
systemctl show inventory-api.service -p Requires -p Wants -p After -p Before -p ConditionResult -p AssertResult此命令会输出经过解析的值,包括应用 drop-in 后的值,以及 systemd 添加隐式依赖后的值。ConditionResult=no 直接回答了“unit 报告成功但什么也没做”的问题。
systemctl list-dependencies inventory-api.service
systemctl list-dependencies --reverse inventory-api.service
systemctl list-dependencies --after inventory-api.service
systemctl list-dependencies --before inventory-api.service不带参数的形式会向下遍历 Requires= 和 Wants=。--reverse 显示哪些 unit 拉入了当前 unit,因此可用于查找开机时启动它的目标 unit。--after 和 --before 显示启动顺序;当问题是“是否确实有其他 unit 等待它”时,应查看这两项。
journalctl -b -u inventory-api.service --no-pager
journalctl -b -o short-precise -u inventory-api.service -u postgresql.service第二个命令会以毫秒时间戳交错显示两个 unit 的信息。这样可以证明启动顺序竞争,而不是凭猜测判断。pg_isready 的失败发生在 PostgreSQL 记录 database system is ready to accept connections 之前,两者之间的时间间隔会直接显示在输出中。
systemd-analyze verify /etc/systemd/system/inventory-api.service
systemd-analyze critical-chain inventory-api.serviceverify 会按照 systemd 的加载方式加载该 unit,并报告未知指令、对不存在 unit 的依赖、排序循环以及无法解析的语法。它不会修改系统。critical-chain 会输出导致该 unit 延迟的排序链,并显示每一步变为活动状态的时间;它仅适用于当前启动过程中已启动的 unit。
编辑任何 unit 文件后,运行 sudo systemctl daemon-reload。如需修改软件包提供的 unit,请使用 sudo systemctl edit inventory-api.service,它会自动为您创建 drop-in。直接编辑 /usr/lib/systemd/system/ 下的软件包文件只能维持到下一次软件包升级,因为升级会替换该文件。通过相同的 drop-in 机制,您可以为服务设置内存和 CPU 限制,而无需修改软件包管理的文件。
Ordering 循环及其在日志中留下的记录
在两个方向都添加排序依赖后,systemd 会删除其中一个任务来打破循环:
systemd[1]: Found ordering cycle on inventory-api.service/start
systemd[1]: Job postgresql.service/start deleted to break ordering cycle starting with inventory-api.service/startsystemd 会选择要删除的任务,但它选择的任务可能不是您希望删除的任务。结果表现为:某些重启后服务缺失,另一些重启后服务又存在,从外部很难排查。大多数循环都源于以下情况:单元设置了 DefaultDependencies=no,却仍将自身排在 basic.target 之前或之后;或者为已经存在 After=、且该依赖又指回当前单元的单元添加了 Before=。无需重启,使用 systemd-analyze verify 即可找到这些循环。
固定单元
[Unit]
Description=Inventory API
Wants=postgresql.service network-online.target
After=postgresql.service network-online.target
ConditionPathExists=/etc/inventory/api.conf
[Service]
Type=notify
StateDirectory=inventory
ExecStart=/usr/local/bin/inventory-api
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target每一行只负责一项功能。Wants= 将两个依赖项都加入事务,但不会将本单元的生命周期绑定到它们。After= 负责等待,因此必须重复写出两个名称,因为依赖关系和启动顺序是独立设置的。ConditionPathExists= 表示机器已安装软件包但缺少配置时,静默跳过本单元,而不是报告错误;对于由配置驱动的服务,这是正确的行为。Type=notify 表示按顺序排在本单元之后的任何单元,都会等待实际就绪,而不是等待进程 fork。Restart=on-failure 处理系统启动很久后数据库停止工作的情况,因为启动顺序只适用于首次启动。重试应多积极,由Restart= 和 RestartSec= 设置控制。
在信任它之前先检查:
sudo systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/inventory-api.service
systemctl list-dependencies --after inventory-api.service
sudo systemctl start inventory-api.service
systemctl show inventory-api.service -p ConditionResult -p ActiveState -p Result正常的单元会通过 ActiveState=active 读取 ConditionResult=yes,而 Result=success 会确认上次运行未失败。ConditionResult=no 与 ActiveState=inactive 同时出现时,表示本单元被跳过;日志中指出条件的那一行会告诉您哪个测试失败。
FAQ
Requires= 是否会等待另一个单元启动?
不会。Requires= 和 After= 是相互独立的设置。Requires= 会将另一个单元加入同一个事务,然后 systemd 并行启动这两个作业。要等待另一个单元,请添加 After=,并将其设置为同一个单元。添加它还有第二个原因:只有同时设置 After= 时,失败的 Requires= 依赖才会阻止当前单元启动;如果没有排序关系,另一个单元失败时当前单元已经启动。
应该在 network.target 之后排序,还是在 network-online.target 之后排序?
启动时,network.target 仅表示网络管理软件已启动,因此不保证地址或路由已经就绪。当服务在启动时需要可用的网络地址时,请使用 network-online.target,并同时写入 Wants=network-online.target 和 After=network-online.target,因为该 target 不在默认启动事务中,仅使用 After= 会等待一个根本未被加入队列的单元。如果服务仅因需要绑定某个特定 IP 而失败,使用 net.ipv4.ip_nonlocal_bind=1 和 Restart=on-failure 会比延迟启动更轻量。
为什么我的单元报告成功,却始终没有运行?
Condition...= 测试失败时,systemd 会跳过该单元,并将启动作业报告为成功,因此不会有任何单元被标记为失败。运行 systemctl show <unit> -p ConditionResult,ConditionResult=no 会确认这一点。然后查看 journalctl -b -u <unit>,找到包含 Condition check resulted in <description> being skipped 的行。在 systemd 250 及更高版本中,systemctl status <unit> 还会指出未满足的具体指令。
Condition 和 Assert 有什么区别?
它们执行完全相同的测试。Condition 失败时,systemd 会静默跳过该单元,作业仍然成功。Assert 失败时,单元会失败,记录 Assertion failed for <description>.,并保持在 failed (Result: assert) 状态。对于“此单元不适用于这台机器”的情况,请使用 Condition,这几乎涵盖所有实际场景。只有在缺少前置条件必须让监控失败单元的人员看到时,才使用 Assert。
为什么 ExecStartPre 会以 status=203/EXEC 失败?
203/EXEC 表示 systemd 完全无法执行该命令。常见原因包括路径不是绝对路径、该计算机上不存在目标二进制文件、文件没有执行权限,或者脚本的 #! 行指向不存在的解释器。systemd 的其他小型错误代码来自固定表,因此 status=2/INVALIDARGUMENT 仅表示命令以 2 退出,不能说明参数有问题。请注意,ExecStartPre= 不会通过 shell 执行,因此管道和通配符需要使用 /bin/sh -c '...'。