理解 Systemd 依赖关系:预防和解决单元冲突

学习 systemd 的依赖、排序、目标和冲突机制,确保服务可靠启动并简化故障排查。

理解 Systemd 依赖关系:预防和解决单元冲突

Systemd 依赖关系很容易只理解一半。一个单元文件设置了 Requires=postgresql.service,但应用程序仍然启动过早,每个人都在疑惑为什么 systemd 忽略了依赖关系。它并没有忽略任何东西。Requires=After= 回答的是不同的问题。

这种区别是大多数 systemd 依赖问题的核心。一个指令控制另一个单元是否被拉入同一事务。另一个不同的指令控制排序。其他指令关联关闭行为、将一个单元的生命周期绑定到另一个单元,或使两个单元互斥。一旦你区分了这些概念,单元冲突就会变得不那么神秘。

基础:Systemd 单元依赖指令

Systemd 在单元文件(通常位于 /etc/systemd/system//lib/systemd/system/)中使用特定指令来规定一个单元何时启动、停止或等待另一个单元。理解这些指令是正确管理依赖关系的第一步。

核心依赖指令

这些指令控制单元之间的关系。它们本身并不总是控制启动顺序:

  • Requires=
    • 建立强依赖关系。如果所需单元启动失败,当前单元也会失败。
    • 它不隐含 PartOf=,也不自动意味着“在此单元之后启动”。
  • Wants=
    • 弱依赖关系。如果想要的单元失败,当前单元仍会尝试启动。用于可选依赖。
  • BindsTo=
    • 类似于 Requires=,但在停止方面更强。如果绑定的单元停止(无论何种原因),当前单元也会停止。
  • PartOf=
    • 表示当前单元是另一个单元的从属部分(例如,与主服务相关的特定套接字激活)。如果上级单元停止,从属单元也会停止。
  • Conflicts=
    • 表示两个单元不应同时处于活动状态。启动一个会导致 systemd 在同一事务中停止另一个。

核心启动同步指令

这些指令规定了依赖单元相对于所需单元何时启动:

  • After=
    • 指定当前单元的启动作业在所列单元的启动作业之后排序。它本身不会拉入该单元。
  • Before=
    • 指定当前单元应在所列单元之前启动。

最佳实践: 对于典型的服务启动排序,Wants= 结合 After= 是最常见且最安全的模式。Requires= 应保留用于依赖失败必须导致依赖服务失败的情况。

示例:在服务文件中定义依赖

考虑一个自定义应用程序服务 myapp.service,它需要与由 PostgreSQL(postgresql.service)管理的数据库通信。

# /etc/systemd/system/myapp.service
[Unit]
Description=我的自定义应用程序

# 确保 PostgreSQL 在尝试启动我之前运行
Requires=postgresql.service
After=postgresql.service

[Service]
ExecStart=/usr/bin/myapp

[Install]
WantedBy=multi-user.target

这个例子故意设置得很严格。如果 PostgreSQL 无法启动,myapp.service 也应该失败。对于可以在没有数据库的情况下以降级模式运行的服务,请使用 Wants=postgresql.service 并配合相同的 After=postgresql.service 排序。应用程序仍然要求 systemd 先启动 PostgreSQL,但如果 PostgreSQL 不可用,它被允许继续运行。

当实际情况匹配时,选择较弱的依赖关系并无不妥。指标导出器、日志收集器或缓存预热器在其依赖存在时可能有用,但不应阻止主系统启动。硬依赖最好保留给在没有另一个单元的情况下启动将是错误或危险的情况。

对于网络应用程序,要更加小心。network.target 通常意味着基本的网络堆栈已存在,而不是 DHCP 已完成或 DNS 可用。如果你的应用程序在启动时确实需要配置好的网络,请使用:

[Unit]
Wants=network-online.target
After=network-online.target

然后确认你的发行版已启用匹配的 wait-online 服务,例如 systemd-networkd-wait-online.service 或 NetworkManager 的 wait-online 单元。如果没有,network-online.target 可能不会等待你认为它等待的东西。

诊断依赖问题

当服务启动失败时,systemd 通常会在日志中提供足够的信息,但依赖链可能会掩盖根本原因。以下是用于故障排除的基本工具和命令。

1. 检查单元状态和日志

基本的起点是在启动尝试失败后立即检查服务状态并查看其日志。

# 检查整体状态,通常会提到依赖失败
systemctl status myapp.service

# 查看与该单元特别相关的详细日志
journalctl -u myapp.service --since "5 minutes ago"

2. 分析依赖树

Systemd 提供了强大的可视化工具来精确查看什么在等待什么

systemctl list-dependencies

此命令显示指定单元所需或想要的单元,遍历整个依赖链。

要查看 myapp.service 启动需要什么:

# 正向依赖(必须在我之前启动的内容)
systemctl list-dependencies --after myapp.service

# 反向依赖(依赖于我的内容)
systemctl list-dependencies --before myapp.service

当树形格式妨碍查看时使用 --plain,当你需要查看非活动单元时添加 --all

systemctl list-dependencies --plain --all myapp.service

systemd-analyze dot

对于更大的依赖问题,生成一个图表并进行可视化检查:

systemd-analyze dot 'myapp.service' | dot -Tsvg > myapp-deps.svg

在没有安装 Graphviz 的服务器上,文本输出仍然有用,因为你可以搜索单元名称并查看 systemd 知道的边。对于启动时间问题,systemd-analyze critical-chain myapp.service 通常比完整图表更容易阅读。

3. 检测冲突和排序问题

依赖冲突通常表现为服务因启动过早或意外停止而失败。

循环依赖: 这是最危险的冲突,即单元 A 需要 B,而单元 B 需要 A。Systemd 尝试解决此问题,但通常会导致一个或两个单元无限期地保持在 failedactivating 状态。

要查找可能影响整个系统的潜在问题,你可以在日志中搜索与排序相关的特定失败消息:

journalctl -b | grep -E "failed|refused to start|dependency was not satisfied"

另外,在假设运行时行为是问题之前,先对自定义单元运行 systemd-analyze verify

systemd-analyze verify /etc/systemd/system/myapp.service

它可以及早标记排序循环、未知指令和无效的单元引用。它不能证明你的设计是正确的,但可以避免你将一个拼写错误当作复杂的依赖问题来排查。

修复常见的依赖问题

一旦识别出来,可以通过调整相关单元文件中的指令来解决依赖问题。

场景 1:服务在其先决条件准备好之前启动

症状: 你的应用程序日志显示数据库连接错误,但 postgresql.servicesystemctl status 中显示为 active

诊断: 服务可能缺少 After=postgresql.service,或者 PostgreSQL 可能在你应用程序所需的特定数据库、套接字、凭据或模式准备好之前就已激活。Systemd 可以对单元进行排序,但它无法自动理解每个应用程序级别的就绪条件。

修复: 从简单的单元关系开始:

[Unit]
Requires=postgresql.service
After=postgresql.service

如果应用程序仍然与数据库竞争,请在正确的层面解决就绪问题。某些服务支持 Type=notify,并且仅在初始化完成后报告就绪。某些应用程序需要重试逻辑,因为依赖关系可能随时重新启动,而不仅仅是在启动期间。对于狭窄的本地检查,ExecStartPre= 命令是合理的:

[Service]
ExecStartPre=/usr/bin/pg_isready -q -h 127.0.0.1 -p 5432
ExecStart=/usr/local/bin/myapp

谨慎使用这种预检查。如果它演变成一个包含 sleep 和循环的 shell 脚本,那么应用程序可能需要适当的重试行为。

避免使用 sleep 30 作为依赖修复。它可能会在安静的开发机器上隐藏竞争,并在较慢的存储、繁忙的 VM 或等待 DNS 的主机上再次失败。一个真正的排序指令、就绪通知、套接字激活或应用程序重试循环为你提供了服务就绪的理由,而不是希望已经过去了足够的时间。

场景 2:启动/停止顺序冲突

症状: 停止系统导致关键进程挂起或突然失败。

诊断: 这通常表明在兄弟服务中误用了 BindsTo=Before=After= 指令之间存在复杂交互。

修复: 审查作为兄弟的服务(例如,由同一目标启动的服务)。确保如果服务 A 必须在服务 B 运行时运行,请使用 BindsTo=Requires=。如果服务 A 必须在其任务完成之后服务 B 才能开始清理,请验证 After= 顺序是否正确。

请记住,关闭顺序是启动顺序的逆序。如果 app.serviceAfter=database.service,那么在关闭时,systemd 会在 database.service 之前停止 app.service。这通常是你想要的:应用程序在数据库消失之前停止接受工作。许多关闭错误源于缺少启动排序,而不是来自单独的仅关闭设置。

场景 3:移除不必要的依赖

症状: 系统启动缓慢,因为不必要的服务被拉入启动链。

诊断: 你可能在只需要可选连接时使用了 Requires=

修复:Requires= 更改为 Wants=。如果服务并非绝对需要该依赖才能运行,Wants= 允许系统即使在依赖失败或被屏蔽时也能继续。

# 之前(过于严格)
Requires=optional_logging.service

# 之后(更好)
Wants=optional_logging.service
After=optional_logging.service

这对于监控代理、指标导出器、辅助容器和可选本地缓存特别有用。如果主服务可以在没有它们的情况下执行有用的工作,Requires= 会将部分中断转变为完全中断。对真正必需的内容使用严格依赖:应用程序无法启动的本地数据库、保存应用程序数据的挂载点,或者是唯一支持的激活路径的套接字单元。

场景 4:挂载点或设备未就绪

症状: 服务在启动时失败,显示 No such file or directory,但路径在你登录后存在。这通常发生在从 /mnt/data/srv/app、可移动磁盘、加密卷或网络文件系统读取的服务上。

诊断: 服务在挂载单元激活之前启动,或者挂载是可选的并且失败但没有停止服务。

修复: 找到挂载单元名称:

systemd-escape -p --suffix=mount /mnt/data

对于 /mnt/data,这通常会产生 mnt-data.mount。然后,将你的服务排序在其之后,并且如果服务没有数据就无法运行,则要求它:

[Unit]
Requires=mnt-data.mount
After=mnt-data.mount

如果挂载来自 /etc/fstab,诸如 nofailx-systemd.automount_netdev 等选项会影响启动行为。除非你真的希望该挂载失败阻止服务,否则不要向可选挂载添加硬性的 Requires=

场景 5:目标拉入了太多内容

症状: 启用一个服务似乎启动了不相关的单元,或者禁用一个服务并没有阻止它在启动时出现。

诊断: 服务可能通过 [Install] WantedBy=... 被目标拉入,或者被另一个服务的 Wants=、套接字、定时器、路径或挂载单元拉入。enable 创建用于启动时激活的符号链接;这不是单元可以启动的唯一方式。

修复: 检查单元和反向依赖:

systemctl cat myapp.service
systemctl list-dependencies --reverse myapp.service
systemctl list-timers --all | grep myapp
systemctl list-sockets --all | grep myapp

如果套接字单元激活了服务,仅禁用 myapp.service 可能不够。根据所需行为,你可能还需要禁用或屏蔽 myapp.socket

应用更改并重新加载

每当你修改单元文件时,必须在测试更改之前指示 systemd 重新加载其配置。

# 1. 重新加载 systemd 管理器配置
sudo systemctl daemon-reload

# 2. 重启受影响的服务
sudo systemctl restart myapp.service

# 3. 验证状态
systemctl status myapp.service

一个小的心理检查清单

当依赖问题感觉混乱时,将其简化为几个简单的检查。

首先,询问另一个单元是否应该启动。如果是,使用 Wants=Requires=。如果当前服务可以在没有它的情况下运行,优先使用 Wants=。如果该单元的失败意味着此服务必须失败,则使用 Requires=

其次,询问启动顺序是否重要。如果是,添加 After=Before=。不要期望依赖指令自己处理排序。

第三,询问启动后的生命周期耦合是否重要。如果一个单元停止应该停止另一个,请查看 BindsTo=PartOf=。不要随意使用它们;它们可能使常规重启的级联范围超出预期。

最后,检查 systemd 实际加载的内容:

systemctl cat myapp.service
systemctl show myapp.service -p Wants -p Requires -p After -p Before -p Conflicts

该输出通常比你认为编辑过的文件更有用。插入式文件、供应商单元、生成的单元、套接字、定时器和目标都可以改变最终行为。