Systemd 故障排除:理解服务依赖与排序指令

通过正确使用 Requires、Wants、After、Before 以及诊断命令,解决 systemd 依赖和排序问题。

Systemd 故障排除:理解服务依赖与排序指令

大多数令人困惑的 systemd 依赖错误源于混淆两个独立的概念:需求与顺序。Requires=Wants= 回答“应该引入哪个其他单元?”;After=Before= 回答“该单元应相对于另一个单元何时启动?”如果你只从本指南中记住一件事,那就是 After=postgresql.service 并不会为你启动 PostgreSQL。它仅表示如果两个单元都在启动,你的单元应等待 PostgreSQL 的启动任务运行完毕。

这一区别是许多“手动启动正常,重启后失败”事件的根源。Web 应用在数据库套接字接受连接之前启动;工作进程在 /mnt/jobs 挂载之前启动;服务等待 network.target 却因 IP 地址尚未分配而失败。这些并非罕见的 systemd 问题,而是需要明确表达的常见启动假设。

核心依赖指令:RequiresWants

Systemd 使用两个主要指令来定义单元之间的直接依赖关系:RequiresWants。这些指令放置在单元文件(例如 .service 文件)的 [Unit] 部分。

Requires=

Requires= 指令建立强依赖关系。如果单元 A Requires= 单元 B,则 systemd 在启动 A 时也会启动 B。如果 B 无法启动,A 的启动任务也会失败。如果 B 在 A 运行时被显式停止,A 通常也会被停止,因为所需的依赖关系不再成立。

示例:

考虑一个关键依赖数据库服务(mariadb.service)的 Web 应用服务(myapp.service)。myapp.service 单元文件可能包含:

[Unit]
Description=我的 Web 应用
Requires=mariadb.service

[Service]
ExecStart=/usr/bin/myapp

[Install]
WantedBy=multi-user.target

在此场景中,如果 mariadb.service 启动失败或被手动停止,systemd 也会停止 myapp.service。如果你尝试启动 myapp.servicemariadb.service 未运行,systemd 会先尝试启动 mariadb.service。如果 mariadb.service 失败,myapp.service 将不会启动。

Wants=

Wants= 指令定义较弱的可选依赖关系。如果单元 A Wants= 单元 B,systemd 会在启动 A 时尝试启动 B,但即使 B 启动失败或未运行,A 仍会激活。这对于那些受益于另一服务但可以独立运行(可能功能减少或发出警告)的服务很有用。

示例:

假设一个监控代理(monitoring-agent.service)可以在没有特定日志服务(app-logger.service)的情况下运行,但理想情况下希望其可用。monitoring-agent.service 单元文件可能如下:

[Unit]
Description=监控代理
Wants=app-logger.service

[Service]
ExecStart=/usr/bin/monitoring-agent

[Install]
WantedBy=multi-user.target

这里,systemd 会在 monitoring-agent.service 激活时尝试启动 app-logger.service。然而,如果 app-logger.service 启动失败,monitoring-agent.service 仍会成功启动。

Requires=Wants= 对比

  • Requires=:强依赖。如果所需单元失败,依赖单元也会失败或停止。
  • Wants=:弱依赖。依赖单元会尝试启动所需单元,但即使失败也会继续。

需要注意的是,Requires= 隐含了 Wants=。如果一个单元需要另一个单元,它也隐式地“想要”它。

排序指令:AfterBefore

虽然 RequiresWants 定义了什么需要运行,但 AfterBefore 定义了单元应何时相对于彼此启动。这些指令控制系统启动过程中或按需激活单元时的操作顺序。它们通常与依赖指令结合使用。

After=

After= 指令指定当前单元的启动任务应排在所列单元的启动任务之后。它本身不会将这些单元拉入事务。它也不证明依赖关系在逻辑上已为你的应用准备就绪;它仅使用 systemd 对单元激活状态的视图。

示例:

一个依赖网络的服务(custom-network-app.service)应在网络完全配置后启动。这通常通过确保它在网络目标(network.target)之后启动来处理。

[Unit]
Description=自定义网络应用
Requires=network.target
After=network.target

[Service]
ExecStart=/usr/bin/custom-network-app

[Install]
WantedBy=multi-user.target

在此配置中,如果 custom-network-app.servicenetwork.target 都属于同一事务,systemd 会将 custom-network-app.service 排在 network.target 之后。对于需要地址、DNS 或到另一主机的路由的服务,network-online.target 通常更接近意图,但前提是发行版的 wait-online 服务已启用并正确配置。

Before=

Before= 指令指定当前单元应 Before= 中列出的单元之前启动。这对于需要在关机时其他单元之后停止,或某些服务之前启动以提供环境的服务很有用。

示例:

想象一个场景,邮件服务器(postfix.service)需要在任何可能发送电子邮件的面向用户服务之前运行。你可以使用 Before= 确保 postfix.service 提前启动。

[Unit]
Description=Postfix 邮件传输代理
# ... 其他指令如 Conflicts=
Before=user-session.target

[Service]
ExecStart=/usr/lib/postfix/master

[Install]
WantedBy=multi-user.target

此设置尝试在 user-session.target 的任何部分开始启动之前启动 postfix.service。类似地,在关机期间,如果 postfix.service 有对应的 After=user-session.target,它将是最后停止的服务之一。

After=Before= 对比

  • After=:保证所列单元在当前单元启动之前处于活动状态。
  • Before=:保证当前单元在所列单元之前启动。

After=Before= 是互补的。如果单元 A 说 Before=B,则顺序是 A 先,然后 B。如果单元 B 说 After=A,结果相同。你通常只需要在一个单元文件中表达关系。在编辑自己的服务时,通常更清晰的做法是说明你的服务需要排在什么之后,因为这使推理保持局部化。

结合指令实现稳健配置

在实际场景中,你通常会结合这些指令来创建复杂的依赖图。multi-user.target 是一个常见目标,表示系统已准备好进行多用户操作。许多服务配置为 WantedBy=multi-user.targetAfter=multi-user.target(或更精确地说,After=basic.targetAfter=getty.targetmulti-user.target 依赖的目标)。

常见模式:

一个需要数据库且应在网络配置后启动的服务可能如下:

[Unit]
Description=我的应用服务
Requires=mariadb.service
Wants=other-optional-service.service
After=network.target mariadb.service

[Service]
ExecStart=/usr/local/bin/my_app

[Install]
WantedBy=multi-user.target

模式解释:

  1. Requires=mariadb.service:保证 mariadb.service 必须运行才能使 my_app.service 正常工作。如果 mariadb.service 失败,my_app.service 将停止。
  2. Wants=other-optional-service.service:尝试启动 other-optional-service.service,但即使失败,my_app.service 也会继续。
  3. After=network.target mariadb.service:将 my_app.service 排在网络目标和 MariaDB 启动任务之后。如果应用需要通过 TCP 连接到另一主机上的数据库,请使用适当的 network-online 设置,而不是假设 network.target 意味着“网络可用”。
  4. WantedBy=multi-user.target:启用时(systemctl enable my_app.service),此指令添加一个符号链接,以便系统达到 multi-user.target 状态时启动 my_app.service

高级考虑与最佳实践

  • WantedByRequiredBy:类似于 WantsRequiresWantedBy 是弱排序,RequiredBy 是强排序。大多数服务使用 WantedBy=multi-user.target
  • Conflicts=:此指令指定不应与当前单元同时运行的单元。如果当前单元启动,冲突单元将被停止,反之亦然。
  • 传递依赖:依赖关系是可传递的。如果 A 需要 B,B 需要 C,则 A 间接需要 C。Systemd 会自动处理这些链。
  • Condition*= 指令:使用 ConditionPathExists=ConditionFileNotEmpty=ConditionVirtualization= 等,根据系统状态使单元激活有条件,进一步增强稳健性。
  • 使用 systemctl list-dependencies <unit>:此命令对于可视化单元的依赖树(包括直接和间接依赖)非常宝贵。
  • 使用 systemctl status <unit>:更改配置后始终检查服务状态。它通常会显示失败原因,包括依赖问题。
  • 避免循环依赖:虽然 systemd 会尝试解决,但直接循环依赖(A Requires BB Requires A)可能导致启动循环或失败。仔细设计依赖关系以避免这种情况。

实用调试过程

当出现依赖错误时,首先查看 systemd 实际构建的事务:

systemctl status my_app.service
journalctl -b -u my_app.service --no-pager
systemctl list-dependencies my_app.service
systemctl show my_app.service -p Wants -p Requires -p After -p Before -p BindsTo -p PartOf

systemctl status 告诉你 systemd 是否未能启动单元、稍后终止了它,或者认为它处于活动状态(即使应用不健康)。journalctl -b 让你保持在当前启动周期内,因为依赖问题通常仅发生在启动时。systemctl show 虽然直接但有用:它显示应用了 drop-in、供应商文件和生成的依赖后的最终合并单元属性。

如果你不确定依赖来自何处,请检查完整单元:

systemctl cat my_app.service

这会显示打包的单元以及 /etc/systemd/system/my_app.service.d/ 下的任何覆盖文件。我见过生产服务的基础单元正确,但旧的覆盖文件仍包含来自先前迁移的 After=mysql.service。该服务正在等待一个不再存在的单元,而日志看起来像是应用出了问题。

对于启动时间问题,请使用:

systemd-analyze critical-chain my_app.service
systemd-analyze blame

critical-chain 比盯着时间戳更好,因为它显示哪些单元延迟了到达你的服务的路径。blame 如果被视为“坏”服务的排名可能会产生误导,但在某个依赖花费的时间远超预期时很有帮助。

在生产中有效的模式

对于本地数据库依赖,这是一个合理的起点:

[Unit]
Description=API 服务
Requires=postgresql.service
After=postgresql.service

[Service]
ExecStart=/usr/local/bin/api
User=api
Restart=on-failure

[Install]
WantedBy=multi-user.target

这表示 PostgreSQL 应与 API 一起启动,并且 API 启动应排在 PostgreSQL 之后。它不保证所有迁移已完成或数据库接受你的应用使用的确切凭据。如果这很重要,请添加应用级别的就绪检查。Systemd 可以排序进程,但无法理解你的模式状态,除非你教会你的服务检查它。

对于挂载路径,优先使用 RequiresMountsFor= 而不是手动命名挂载单元:

[Unit]
RequiresMountsFor=/srv/uploads

[Service]
ExecStart=/usr/local/bin/upload-worker
User=uploads

Systemd 将为该路径派生所需的挂载单元。这比记住 /srv/uploads 映射到 srv-uploads.mount 更容易维护。

对于可选辅助服务,使用 Wants=

[Unit]
Wants=metrics-agent.service
After=metrics-agent.service

如果指标代理失败,主服务仍可启动。这通常是日志 sidecar、可选导出器和本地通知辅助服务的期望行为。不要仅仅因为两个服务相关就使用 Requires=。仅当依赖单元确实无法在没有另一个单元的情况下完成有用工作时才使用它。

对于应一起停止的紧密耦合服务,请查看 BindsTo=PartOf=BindsTo=Requires= 更强,当服务应在绑定单元消失时消失时很有用,例如绑定到特定设备单元的服务。PartOf= 通常对组有用:重启或停止父单元可以传播到子单元。这些不是首选指令,但它们解决了 Requires= 无法清晰表达的问题。

常见陷阱

不要向使用 WantedBy=multi-user.target 启用的普通长时间运行服务添加 After=multi-user.target。这通常会产生奇怪的排序,并且很少表达作者的意图。大多数服务由 multi-user.target 引入;它们不需要在达到该目标后启动。

不要假设 network.target 意味着“互联网可访问”。它是网络管理的同步点,而不是连接性测试。如果你的应用与远程 API 通信,无论如何都要在应用内部添加重试逻辑。启动时的网络排序可以减少噪音,但无法保护你免受 DNS 故障、路由更改或远程依赖宕机的影响。

除非没有更好的选择,否则不要隐藏 ExecStartPre=/bin/sleep 30 中的长时间休眠。当依赖快速就绪时,休眠会使启动变慢,并且在依赖耗时超出预期时仍然失败。一个检查实际套接字、文件或 API 的小型就绪循环通常更清晰。

更改依赖指令后,运行 systemctl daemon-reload,重启服务,并检查同一启动周期的日志。最快的修复通常是证明哪个排序假设是错误的。