Chips

systemd 单元的依赖语义与 reload 的真实行为

· 约 6 分钟 · 2012 字

Wants/Requires/PartOf 的区别在于故障方向与传播方向;enabled 与「当前生效」是两件事;生成器可以用任意输出目录直接调用,因此能低成本复现开机行为。附本次实际遇到的三个反直觉现象。

三个反直觉现象

都是实际遇到的,且都在事后才明白为什么:

  1. systemctl is-active ufw 返回 active,规则也在内核里生效——但 ufw.service 根本没有在这次开机时运行过。规则是后来手动 ufw enable 时加上去的。
  2. 一个 enabled 的单元,ActiveState=inactive——这组合合法,且意味着它下次开机才会第一次生效
  3. 改完 /etc/fstab 后想知道"下次开机还会不会报这个错",不必重启——直接调用生成器二进制即可。

三条都归结到同一件事:systemd 的"状态"有好几个维度,把它们混为一谈就会误判。

依赖关键字:先分清"故障方向"与"传播方向"

这四个词经常被混用,但各自回答的是不同问题:

关键字 语义 方向
Wants= 弱依赖:尝试启动对方,对方失败不影响自己 启动时向下
Requires= 强依赖:对方启动失败则自己也不启动 启动时向下
PartOf= 只影响重启/停止的传播:对方重启时自己也重启。自己启动不会带动对方 重启时向上
BindsTo= Requires 更强:对方停止就立刻停止自己 生命周期绑定

最容易搞错的是 PartOf。它不是依赖关系,而是"跟随重启"的声明。

一个真实场景:往内核里插入的自定义过滤规则,在 dockerd 重启时会被 Docker 重建自己链的动作清掉。于是需要一个单元在 docker 重启后重新应用规则:

[Unit]
Description=Re-apply ingress guard after docker restarts
After=docker.service
PartOf=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/guard.sh

PartOf=docker.service 正是为此:docker 重启 → 本单元跟着重启 → 规则重新应用。若写成 Requires,含义就变成"docker 起不来我就不起",与意图不符(我们要的是跟随,不是依赖)。

After= 只决定顺序,不决定依赖——单独写 After= 不会让对方被启动,也不会在自己启动时等对方就绪(除非配合 Wants/Requires)。

enable 与"当前生效"是三件不同的事

三个独立的维度:

维度 查询方式 含义
是否已 enable systemctl is-enabled <u> 有没有创建开机启动的符号链接
本次开机是否运行过 systemctl show <u> -p ActiveState,ExecMainStartTimestamp 当前这个 boot 里它到底跑没跑
现在功能是否在 直接验证功能本身 规则在不在、端口通不通

第 1 项与第 2 项可以不一致且完全合法

ActiveState=inactive
UnitFileState=enabled
ExecMainStartTimestamp=n/a          ← 关键:从未启动过

原因很直白:如果一个单元是在本次开机之后才被 enable 的,那么本次开机时它当然没运行过。它下一次开机才会第一次生效。

这解释了一个很坑的现象:服务功能正常,但服务单元是 inactive。

反面例子更危险——反之亦然:ufw status 显示 active(因为规则已经加载到内核了),但 ufw.service 从未运行过,于是重启后防火墙不会自动恢复。这是本次实际踩到的:

ufw status            -> Status: active          (这是内核里的现状)
systemctl is-enabled  -> enabled                 (这是下次开机的期望)
ExecMainStartTimestamp -> n/a                    (本次开机根本没跑)

只看第一条就会得出错误结论。 判断"重启后还在不在",必须看第 1 与第 2 项,或者更彻底——重启一次看结果。后者才是最终证据。

这个坑的本质是:"配置存在"和"配置已被应用"是两个时间点的事。 enable 只安排了未来,不改变现在。

Type=oneshot + RemainAfterExit=yes 是什么状态

这类单元跑完之后,systemctl status 显示:

Active: active (exited)

不是"正在运行"。oneshot 表示进程会跑完退出;RemainAfterExit=yes 表示退出后把结果标记为"已完成",单元保持 active 状态

这么设计的意义是让依赖它的单元能正确判断"前置动作已完成"。例如"应用防火墙规则"这个动作只执行一次就结束,但后续单元需要知道它做过了——所以用 RemainAfterExit=yes

判断它到底做了什么、成功没有,要看退出码而不是 active 状态:

systemctl show <unit> -p ExecMainStatus,ExecMainStartTimestamp,ExecMainExitTimestamp

reload 的两种实现,以及没有 ExecReload 会怎样

systemctl reload 的行为完全取决于单元是否定义了 ExecReload

这一点值得记住:reload 失败不代表要重启,可能只是这个单元不支持 reload。先查它有没有 ExecReload

systemctl cat <unit> | grep -i Execreload

对 sshd 这类服务,正确做法是 reload 而非 restart:

[Service]
ExecReload=/usr/sbin/sshd -t                    # 先校验配置
ExecReload=/bin/kill -HUP $MAINPID              # 再发 SIGHUP
KillMode=process

sshd 收到 SIGHUP 后原地重新执行(re-exec):重读配置、重新绑定端口,而已经建立的连接由子进程继续持有,不受影响。配合 KillMode=process(不杀子进程),正在使用的 SSH 会话在 reload 后依然存活。

这是"只有一条 SSH 入口"的机器上改配置的唯一安全方式。restart 会杀掉全部子进程,直接把你踢出去。

注意 ExecReload 里先 sshd -t 的作用:配置写错时 reload 会失败并保留旧配置,而不是带着坏配置重启。这个顺序是防呆设计,值得照抄到自己的单元里。

生成器是可以直接调用的普通程序

systemd-fstab-generatorsystemd-sysctl 这类 generator,本质是开机时被 systemd 调用的普通可执行文件:读配置、往指定的输出目录写 unit 文件。

这意味着可以在不重启的情况下复现开机行为——用任意目录作为输出:

rm -rf /tmp/g{1,2,3} && mkdir -p /tmp/g{1,2,3}
/usr/lib/systemd/system-generators/systemd-fstab-generator /tmp/g1 /tmp/g2 /tmp/g3
rc=$?; echo "rc=$rc"

三个参数对应 generator 约定的三个输出目录(normal / early / late)。

这是排查"开机报错"类问题最有效的手段:改完配置立刻验证开机时还会不会报,不必等到下一次重启。产出物也能直接看:

cat /tmp/g1/swapfile.swap
ls -l /tmp/g1/swap.target.requires/

systemctl daemon-reload 也会重新运行全部 generator,所以它同样可以作为低成本的复现手段。

取证细节:注意上面用的是 rc=$? 而不是 ... | tee log; echo $?。管道的退出码是最后一个命令的——tee 恒为 0,这样写会得到完全无效的"成功"证据。要么 set -o pipefail,要么分开执行。

计时器:判断"它到底跑没跑"

看服务状态容易误导(oneshot 跑完就是 inactive)。判断周期性任务是否真的在跑,用计时器:

systemctl list-timers 'apt-daily*'
NEXT                        LEFT     LAST                        PASSED   UNIT
Tue 2026-09-15 13:17:34 UTC 6h left  Mon 2026-09-14 23:33:39 UTC 7h ago   apt-daily.timer

LAST上次实际触发时间——这是"跑过没有"的直接证据。比看服务在不在跑可靠得多。

对 Debian 的自动更新还有一层混淆:unattended-upgrades.service 显示 active (running) 时,它很可能是 unattended-upgrade-shutdown --wait-for-signal,即关机钩子,不是升级执行器。真正的执行者是 apt-daily-upgrade.timer

排查顺序

遇到"服务不对"时,按这个顺序问:

  1. 单元有没有定义systemctl cat <u>)——拼错名字会得到"找不到单元",而不是"未运行"。
  2. 这次开机跑过没有ExecMainStartTimestamp)——n/a 说明从未启动。
  3. 是否被 enableis-enabled)——决定下次开机。
  4. 功能本身对不对——规则在不在、端口通不通。这是唯一能证明"真的生效"的一步。
  5. 周期性任务看 timer 的 LASTlist-timers),不看服务状态。

第 4 步是不能省的。前三步都是关于意图和记录的,第 4 步才是关于现实的。本次那三个反直觉现象,全都是在跳过第 4 步时产生的。

← 全部文章