systemd 单元的依赖语义与 reload 的真实行为
Wants/Requires/PartOf 的区别在于故障方向与传播方向;enabled 与「当前生效」是两件事;生成器可以用任意输出目录直接调用,因此能低成本复现开机行为。附本次实际遇到的三个反直觉现象。
三个反直觉现象
都是实际遇到的,且都在事后才明白为什么:
systemctl is-active ufw返回active,规则也在内核里生效——但ufw.service根本没有在这次开机时运行过。规则是后来手动ufw enable时加上去的。- 一个
enabled的单元,ActiveState=inactive——这组合合法,且意味着它下次开机才会第一次生效。 - 改完
/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:
- 定义了 → 执行该命令
- 没定义 → 报错,
Failed to reload。它不会自动退化成 restart
这一点值得记住: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-generator、systemd-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。
排查顺序
遇到"服务不对"时,按这个顺序问:
- 单元有没有定义(
systemctl cat <u>)——拼错名字会得到"找不到单元",而不是"未运行"。 - 这次开机跑过没有(
ExecMainStartTimestamp)——n/a说明从未启动。 - 是否被 enable(
is-enabled)——决定下次开机。 - 功能本身对不对——规则在不在、端口通不通。这是唯一能证明"真的生效"的一步。
- 周期性任务看 timer 的 LAST(
list-timers),不看服务状态。
第 4 步是不能省的。前三步都是关于意图和记录的,第 4 步才是关于现实的。本次那三个反直觉现象,全都是在跳过第 4 步时产生的。