背景
本文是 SONiC 实战解析系列的一部分,主要用来记录一些从解决 Issues 里学到的知识,以免之后忘记。
相关 Issue:
- Bug: Cosmetic systemd [DEPEND] Dependency failed messages on warm-reboot
- Bug: [Trixie] systemd-networkd.socket starts on non-smartswitch platforms
相关 Pull Request:
本文基于以下提交:
sonic-net/sonic-buildimageMaster 分支
文中所有 GitHub 链接均为基于上述提交的永久链接。
热重启的报错信息
这段时间对 SONiC 操作系统比较感兴趣,于是花了点时间啃sonic-buildimage的源代码来学习项目的整体架构。 由于我一向相信“学中做,做中学”,于是选了一个看上去比较简单的 Issue #25091 ,想要把它修好给项目做一个贡献。
问题的内容很简单:普通交换机热重启的时候控制台日志会显示来自 systemd 的报错信息,只是报错并不影响实际功能:
Sep 03 19:12:42 sonic systemd[1]: systemd-networkd-persistent-storage.service: Job systemd-networkd-persistent-storage.service/st
Sep 03 19:32:00 sonic systemd[1]: systemd-networkd-persistent-storage.service: Bound to unit systemd-networkd.service, but unit i
Sep 03 19:32:00 sonic systemd[1]: Dependency failed for systemd-networkd-persistent-storage.service - Enable Persistent Storage i
Sep 03 19:32:00 sonic systemd[1]: systemd-networkd-persistent-storage.service: Job systemd-networkd-persistent-storage.service/st
Sep 03 20:20:58 sonic systemd[1]: systemd-networkd-persistent-storage.service: Bound to unit systemd-networkd.service, but unit i
Sep 03 20:20:58 sonic systemd[1]: Dependency failed for systemd-networkd-persistent-storage.service - Enable Persistent Storage i
Sep 03 20:20:58 sonic systemd[1]: systemd-networkd-persistent-storage.service: Job systemd-networkd-persistent-storage.service/st
...............
出错的服务是 systemd-networkd-persistent-storage.service,是systemd-networkd.service的附属服务,后者在 Linux 系统中负责网络配置与管理。 按理来说,只有配备了专门的 NPU(网络处理单元)或 DPU(数据处理单元)的智能交换机才需要这些服务,普通交换机仅负责基础的二层/三层数据包转发,并不需要更复杂的网络管理服务。
第一次尝试: 使用 ExecCondition 阻断服务启动
我看上这个 Issue 很大一部分原因是代码库里已经有解决类似问题的先例了。
在代码库里搜索systemd-networkd.service,不难找到 systemd-networkd.override.conf:
[Service]
ExecCondition=/bin/bash /usr/local/bin/is-npu-or-dpu.sh
ExecStartPre=+/bin/bash /usr/local/bin/define-npu-specific-netdevs.sh
这个文件覆盖了 systemd 的默认配置,添加了一个ExecCondition。 文件里提到的 Bash 脚本is-npu-or-dpu.sh用来检测交换机上有没有NPU或者DPU,如果没有的话ExecCondition就会把systemd-networkd.service停掉不让它正常启动。 这样我们就可以只在需要网络配置与管理功能的智能交换机上运行这个服务了!
问题是虽然主服务被停掉了,但它的附属服务systemd-networkd-persistent-storage.service貌似并没有被停掉,而是等到启动完毕后才发现主服务不在。这就导致了 Issue 中提到的报错。
从这个角度出发,问题很好修复:用类似的模式加一个文件把出错的服务也停掉就好了……吗?
不对!加了一个类似的systemd-networkd-persistent-storage.override.conf并没有解决问题,热启动时报错依然出现。
想要了解背后的原因,我们必须理解 systemd 服务的具体实现流程:
- 事务构建(构建依赖树,
Requires,Wants,BindsTo, etc.) — 所有单元包括附属服务在此全部入队 - [按顺序逐个执行各单元]
- 条件检查(
ConditionXXX,AssertXXX) ExecConditionExecStartPreExecStart
- 条件检查(
这里的重点是我们在systemd-networkd-persistent-storage.service加的ExecCondition要等到它的第三步才运行, 可是它通过BindsTo绑定了主服务,一旦主服务不活跃,BindsTo就会直接把附属服务也拉倒。 主服务由于依赖关系,肯定会在附属服务之前达到第三步后被ExecCondition停掉,提前导致附属服务报错。
换句话说,其实附属服务根本没机会“等到启动完毕”再报错,不仅如此,我们甚至在有机会到达附属服务的ExecCondition之前就报错了……
第二次尝试: 在构建镜像时提前阻拦
虽然第一次尝试没能成功,但是找到原因后下一步的方向很明确:既然ExecCondition太慢了,那我们尽量把这个判断左移不就好了? 如果在运行时解决这个问题太麻烦,干脆在构建镜像的时候就直接把这个问题解决掉!把报错的服务从普通交换机的镜像里面提前删掉,直接把问题斩草除根不留后患。
但这就引出了另一个问题:在构建镜像的时候我们并不知道最后的镜像会在什么机器上被部署。 我们只知道构建的平台,但平台这个分类的颗粒度又太粗了,同一个平台下可以同时存在智能交换机和普通交换机。
平台在这里指厂商级别的硬件分类,同一平台下的设备共用构建产物,包括镜像,不过硬件配置可以相差很大。 举个例子,mellanox平台全都是 Mellanox Technologies 生产的硬件,这个平台下既有 SN4280 这种有DPU的智能交换机,也有 MSN4700 这样的普通交换机。但如果我们看这个构建配置文件 platform/mellanox/one-image.mk 就会发现整个平台的所有交换机共享同一个sonic-mellanox.bin镜像。
既然同一个镜像既可以在智能交换机上运行,也可以在普通交换机上运行,那我们就陷入了两头堵的窘境: 删掉这个服务,智能交换机就缺少了必要的网络依赖;不删,普通交换机就会带着用不上的服务启动。无论怎么选,总有一边出问题。
虽然我们第一次用ExecCondition介入得太晚,但第二次想在构建时解决问题又太早了。我们把判断左移过头了。
回头看看源代码库,这也合理——如果判断左移真的有我想象的这么简单,那当初负责支持智能交换机的原作者也不需要专门修改 systemd 配置, 再专门在运行时写一个is-npu-or-dpu.sh决定要不要运行systemd-networkd.service了。 毕竟如果可以的话,直接在构建时解决问题是个很自然的决定。既然原作者没有选它,背后多少是有点坑在的。
第三次尝试: 使用 Generator 进行屏蔽
为了能有足够的信息决定需不需要网络功能,我们还是要在运行时下功夫。 不过考虑到之前ExecCondition的失败经历,做决定的时间依旧要尽量越早越好。
在 systemd 的工具箱里,一个很合适的选择是使用 Generator 。 Generator 是 systemd 派生的短暂子进程,在 systemd 开始解析和加载任何 unit 文件之前运行,能保证在我们开始处理任何服务之前运行。 换句话说,它刚好晚到有足够的信息判断运行的机器是不是智能交换机,也刚好早到能在任何 systemd 服务的启动流程开始前抢先执行。完美!
翻翻代码库里 Generator 相关的代码,原作者貌似已经在 Generator 中实现了智能交换机的检测,实现的细节比我想象中简单:交换机里会有描述硬件配置的 JSON 文件,直接调出来看看里面有没有描述 DPU 或者 NPU 就好: systemd-sonic-generator.cpp
知道了当前设备是否为智能交换机,就可以决定是否启用 mid-plane 桥接网络以及其他智能交换机专用的初始设置。
static int render_network_service_for_smart_switch(const std::filesystem::path& install_dir) {
if (!smart_switch_npu) {
return 0;
}
// Render Before instruction for midplane network with database service
for (int i = 0; i < num_dpus; i++) {
auto unit_override_dir = install_dir / std::format("database@dpu{}.service.d", i);
std::filesystem::create_directory(unit_override_dir);
auto unit_ordering_file_path = unit_override_dir / "ordering.conf";
std::ofstream unit_ordering_file;
unit_ordering_file.open(unit_ordering_file_path);
unit_ordering_file << "[Unit]\n";
unit_ordering_file << "Requires=systemd-networkd-wait-online@bridge-midplane.service\n";
unit_ordering_file << "After=systemd-networkd-wait-online@bridge-midplane.service\n";
}
return 0;
}
既然代码库里已经有了先例,依葫芦画瓢再加一个智能交换机专用的指令把服务提前屏蔽掉也不是什么难事:
static int mask_networkd_persistent_storage_for_non_smart_switch(const std::filesystem::path& install_dir) {
if (smart_switch) {
return 0;
}
auto service_path = install_dir / "systemd-networkd-persistent-storage.service";
int r = symlink("/dev/null", service_path.c_str());
if (r < 0) {
if (errno == EEXIST)
return 0;
log_to_kmsg("Error masking %s: %s\n", service_path.c_str(), strerror(errno));
return -1;
}
return 0;
}
这里我们可能需要解释一下这段代码做了什么。逻辑上我们在“屏蔽服务”,但实际上我们只需要加一个文件。 systemd 在加载服务时会按优先级依次在多条路径下查找对应服务的 unit 文件名,优先级从高到低大致如下:
- Generator 高优先级配置(
/run/systemd/generator.early/) - 管理员设置(
/etc/systemd/system/) - 运行时单元(
/run/systemd/system/) - Generator 配置(
/run/systemd/generator/) - 厂商默认配置(
/usr/lib/systemd/system/) - Generator 低优先级配置(
/run/systemd/generator.late/)
如果当前优先级里能找到对应的文件,那就用它启动服务,跳过所有更低优先级路径下的设置; 如果当前优先级里找不到对应的文件名,相当于“弃权”,那就前往更低优先级的目录里查找。 但如果找到了指向/dev/null的符号链接,那就意味着这个服务已被屏蔽,直接跳过,这样启动流程压根不会被触发,自然也不会产生任何报错。
由于我们只想越过镜像文件里的默认配置,没必要把管理员设置也给覆写掉,在默认优先级的 Generator 路径下把不想要的服务屏蔽掉就够用了。 用虚拟交换机尝试了一下,发现屏蔽服务后热重启时的报错确实消失了,好耶!
级联效应的源头
虽然上述的补丁确实解决了一个问题,但很快我就发现很多其他networkd相关的服务都有类似的问题,比如 systemd-networkd-wait-online.service也会在热重启时报错.
不仅如此,一些不是服务的单元也有着类似的报错:在 Issue #24523 中,systemd-networkd.socket 也出现了在非智能交换机上启动并报错的现象。由于并非服务,我们甚至没办法给它加上ExecCondition。
就在我大手一挥想要把它们全部用 Generator 屏蔽之前,我突然意识到一个很古怪的问题:为什么报错的东西全都是systemd-networkd.service这个主服务的附属服务? 尤其是主服务已经被ExecCondition阻断了,为什么这些附属服务还会被拉起来, 难道有哪行代码指名道姓不要主服务,专门要用特定的附属服务吗?
等等,不对!还是ExecCondition的问题!
众所周知,ExecCondition的介入太慢了。它几乎刚好赶在服务正式启动之前运行(更确切地说,在ExecStartPre前运行)。 等到我们在ExecCondition里发现主服务不该被启动,已经太晚了: 服务之间的依赖树已经构建完毕,一大堆我们不需要的附属服务已经被排进启动队列做好准备挨个报错了。
了解了这一点后,用 Generator 挨个屏蔽报错的附属服务无疑是头痛医头脚痛医脚,完全忽视了真正的病因。 问题的核心并不在吵闹着报错的附属服务,而是看似被屏蔽但却偷偷拉起一大堆附属服务的systemd-networkd.service!
我们依旧要用 Generator 屏蔽服务,但屏蔽的是真正的罪魁祸首:
static int mask_networkd_for_non_smart_switch(const std::filesystem::path& install_dir) {
if (smart_switch) {
return 0;
}
auto service_path = install_dir / "systemd-networkd.service";
int r = symlink("/dev/null", service_path.c_str());
if (r < 0) {
if (errno == EEXIST)
return 0;
log_to_kmsg("Error masking %s: %s\n", service_path.c_str(), strerror(errno));
return -1;
}
return 0;
}
大功告成!
这次处理的 Issue 表面上只是需要处理几条报错信息,但真正的解法并不只停留在把报错压下去。这么做只是治标不治本。 只有顺着逻辑链条一路追下去,理解 systemd 的启动时序,才能真正地去除病根一劳永逸。
很多时候,修 Bug 最省力的路是压掉报错后提交代码。但如果只是把症状掩盖掉而不去理解背后的逻辑,下一个坑可能就在不远处等着。 真正的解法往往要求你多问一句:这个问题的病根在哪里?