10年后的systemd
译自:darknedgy.net。
1 | 我并不确定自己是否真的热衷于重新实现NetworkManager…… |
—— Lennart Poettering,2011年3月
十年前,systemd 首次出现在世人面前,迅速成为近代历史上最持久、最具争议性和两极分化的软件之一,尤其是在 GNU/Linux 世界中。然而,自 2012 年至 2014 年的大规模社区论战以来,围绕 systemd 的辩论并未在质量上有所提升。尽管 systemd 受到了广泛关注,但从技术和社会层面来看,人们对它的理解和研究仍然匮乏。
我撰写这篇文章,既是为自己寻求一份慰藉——让我能够彻底放下对它的纠结,也是希望我的分析能为这场长达十年的闹剧提供一些新的思考,而不至于让它如 Benno Rice 著名的描述那样,”不过是一场悲剧”。
在第一章中,我将基于当时的邮件列表,探讨在 systemd 出现之前,为现代化 init 程序、rc 脚本和服务管理方法所做的努力,以及那些推动变革的主要动机。我会从不同类型的 Linux 用户之间的文化分歧开始谈起。
第二章中,我将讨论 systemd 的早期历史和设计哲学,以及促使其被广泛采用的因素。
第三章将对 systemd 进行技术层面的批评。我假设读者已经熟悉 systemd,并将重点放在其实现细节上。我还会包含一些基于 Bug 报告的“案例研究”,以更深入地阐释一些较为枯燥的理论。
第四章将探讨在自由开源软件(FOSS)开发中与 systemd 类似的其他历史事件,综合第一章和第二章中的一些主题,并对 Linux 低层用户空间(Low-level Userspace)的未来前景提出一些猜想。
在 systemd 之前的 init 现代化努力
Linux 文化之争的根源
关于Linux的碎片化本质——如同无政府状态的市场一样,各类独立的白盒组件在这里被拼凑成各种发行版——以及对这一固有问题的“解决”之道,几乎与第一个 Linux 发行版的诞生一样古老。
Linux TCP/IP 协议栈的早期贡献者 Fred van Kempen 在 1994 年 6 月接受《Linux Journal》采访时表示:
我个人认为,Linux 社区必须习惯以下两点:
- 为他们使用的软件支付一定费用(例如,共享软件和商业应用程序)。
- 系统本身的开发环境将变得更加封闭。
许多人会不同意这一点,正是这种反对态度阻碍了 Linux 在现实世界中取得重大突破。
这位早期先驱者的观点是,活跃的集市必须向大教堂和专有软件妥协。
1998 年,Eric S. Raymond 发表了著名的《大教堂与集市》(The Cathedral and the Bazaar)一文。随后,Christopher B. Browne 在一片批评声中撰写了名为《Linux 与去中心化开发》的文章,开篇指出:“在过去的几年里,许多人抱怨说应该有一个‘中央’的 Linux 组织。”
尽管 Browne 为去中心化开发模式的优点辩护,但他最终还是承认了“Linux 基金会”(Linux Foundation)的必要性,并详细描述了这样一个组织的资金来源。这个组织在不久之后确实成为了现实。
除了令人遗憾的 Linux Standard Base,还有几个早期尝试统一 “Linux” API 的标准化项目。其中之一是如今已被遗忘的EL/IX规范,由 Cygnus Solutions 于 1999 年底制定——就在他们即将被红帽(Red Hat)收购之前。该规范专为当时所谓的“深度嵌入式”平台设计,包括“汽车控制、数码相机、手机、寻呼机”,旨在与 eCos 等实时操作系统竞争。值得注意的是,在 FAQ 中我们可以读到:“红帽致力于确保 Linux 的可移植性和维护用户选择的自由”——在我们这个时代,这种观点并不常见。当时的一篇EE Times文章透露,其他开发嵌入式 Linux 的供应商对 EL/IX 的发布反应不一。
Linux“社区”的主要文化分歧可以归结为两点:一是 Linux 与 GNU 以及自由软件运动的历史纠葛,二是 Linux 作为“革命性操作系统”的形象——一个由业余爱好者和志愿者共同开发的自由文化产品,旨在摆脱商业软件供应商,或者在更现代的语境中,摆脱云服务提供商的束缚。
因此,专业的 Linux 系统管理员和普通的业余爱好者实际上生活在两个截然不同的世界。那些在桌面 Linux 和 DevOps 中间件前沿工作、领取薪水的从业者,与那些使用 Suckless 软件、从头构建基于 MUSL 的发行版、推崇极简主义和自给自足价值观的亚文化群体之间,几乎没有共同点。对于后者中的许多人来说(他们大多是通过自由软件渠道了解 Linux 的),他们最终会意识到现实世界的 Linux 开发越来越受到大型云提供商(如 Linux 基金会的白金会员)的商业利益主导。这种幻灭感几乎就像一个真正的共产主义者目睹托洛茨基残酷镇压喀琅施塔得起义的水手后所感受到的那样——无论表面上如何宣称进步和平等,寡头政治的铁律依然如故。
尽管崇尚自给自足的业余黑客时代早已远去,但其形象仍令人怀念。自由软件的集体主义精神永远无法从 GNU/Linux 的 DNA 中被抹去,但它越来越多地被视为一种无关紧要的兴趣。理查德·斯托曼(Richard Stallman)、GNU 和 FSF 等人物或组织,逐渐被视为需要被克服的障碍,以创造一个更加“专业”和“包容”的社区环境——这很可能意味着在专业层面上,将用户视为全景监狱中的一个数据输入,而非自由独立的人,这与最初人们设想的乌托邦式梦想相去甚远。
现实很快给了业余黑客们当头一棒。2000 年 4 月,红帽创始人 Bob Young 接受《渥太华公民报》对他的采访表示:
关于 Linux,有两个普遍的误解。第一,认为只有一个 Linux 操作系统。实际上,Linux 是 Corel、红帽等公司制作的 600 兆字节操作系统中的一个 16 兆字节的内核。Linux 可以比喻为汽车的发动机,但如果你只把发动机放在车道上,是无法开车送孩子上学的。
我们的工作是让人们明白,这场革命是关于开源软件的,而非仅仅是关于 Linux 的。Linux 只是这场运动的“代言人”而已。第二个谬论是认为 Linux 是由 18 岁的孩子们在地下室编写的,但实际上,大部分代码是由专业的工程团队编写的。
另一个迹象是互联网泡沫时期——为了搭上 IPO 顺风车而成立的 Linux 公司数量多得令人难以置信。Turbolinux、LynuxWorks、Stormix、Linuxcare、Cobalt Networks 和 LinuxOne 只是众多昙花一现的创业公司中的一部分。当时,LWN.net会发布股票清单,详细报道与 Linux 相关的所有金融工具。然而,那个时代最重大的新闻可能是 IBM 宣布将在 2001 年后的三年内向 Linux 投资 10 亿美元。关于 IBM 开源战略的讨论可在 2005 年左右的这里找到。
2020 年的流行词是“云原生”,这意味着操作系统被隐藏在多层中间件之下,这些中间件旨在将操作系统抽象为一个可重复使用的应用服务器,以便轻松部署最新、最先进的网络服务。正如 Rudolf Winestock 在《永恒的主机》一文中所述,这引发了“服务取代软件”的伦理问题,让理想主义的业余爱好者们感到颇为不安。然而,老练的专业人士则要么漠不关心,要么充满热情。
多年来,GnomeOS一直是 Linux 社区的一个宏伟目标。这是因为许多专业人士对 Linux 发行版作为应用部署的中间人角色感到不满——这些发行版拥有自己的打包指南、政策、对上游默认设置的更改,而且还具备“包管理”的概念。Tobias Bernard 在《不存在的“Linux”平台》一文中表达了这种情绪。Bernard 认为,碎片化带来的损害之一是下游维护人员会对桌面环境进行修改,例如“随意添加 Dock、桌面图标,或者启用/禁用系统托盘”。显然,这种不负责任的行为可能会导致未来的灾难,就像维护程序的源代码一样。
无论如何,最近一次实现 GnomeOS 愿景的努力包括 Flatpak、OSTree、BuildStream 和 Freedesktop SDK(基本上就是一个由 BuildStream 文件组成的发行版),这次似乎是最有可能成功的尝试。在这个崭新的世界中,“传统”发行版将如何适应,仍然是一个悬而未决的问题。
GNOME 开发者 Emmanuele Bassi 对于统一主义观点发表了最为明确的声明:
如果桌面环境是操作系统朝着集成化方向发展的结果,旨在为用户(而非一定是贡献者)提供全面、一体化的功能,那么将模块拆分到各自的仓库,采用各自独立的发布周期、构建系统、选项、编码风格和贡献政策,显然与这种集成化的努力背道而驰。这种去中心化会在项目之间、维护者之间引发冲突;它会制造模块化和 API 的障碍,导致依赖性问题,增加冲突的可能性,并阻碍贡献、分发和升级。
那么,为什么会出现这种情况呢?
主流的自由和开源软件分析框架告诉我们,一旦软件达到一定规模,社区成员会自觉地将组件拆分,而非集中功能;他们更倾向于组合具有明确边界和交互关系的组件,而不是在缺乏抽象的层次结构上堆砌功能和 API。他们钟爱小型组件,因为维护者推崇这种设计哲学——让用户在使用软件时拥有选择权,并让有品味的用户能够通过松散耦合的接口,组合出符合自身需求的操作系统。
当然,我刚才所说的一切都是胡扯。
你无法想象我为了不笑场,重录了多少次。
……
事实上,由多个贡献者开发、包含众多组件的复杂开源项目,最好划分为较小的模块,这样每个维护者就能更轻松地掌握全部内容,而不至于精疲力竭。较小的模块使项目更能抵御那些持有强烈观点的维护者的影响,并允许其他同样有强烈观点的维护者绕过他们不喜欢的部分。自包含的模块让小众问题变得可控,或者至少可以限制问题的影响范围。
当然,如果我们一开始就直言不讳,或许会让每个人都更好受些,因为这传达了明确的预期;然而,这么做的副作用就是揭开了“皇帝的新衣”——这意味着我们必须将康威定律的这个副作用包装成“关于选择”、“是机制而非政策”或者“网络对象模型”。
……
所以,如果“选择”是光谱的一端,那么另一端是什么呢?也许是一种类似于企业的结构,由少数人的愿景驱动项目,其他人(至少是那些拿薪水支持项目的人)则遵循这一愿景行事。
当然,一旦有人决定提出自己的愿景,或开始落实,或试图说服他人追随时,就是他们敞开心扉接受批评的时刻。如果你的项目没有一个基本的框架,那么就没人能指责你做错了什么;但如果你确立了框架,这种可能性就会出现,剩下的就是一些人们可以抓住的具体事物——无论好坏。
如果我们将“革命性操作系统”的比喻进一步延伸,那么 Bassi 的立场就如同斯大林在《列宁主义基础》(1924 年)中为先锋队的必要性所作的辩护,而那些持反对意见的人则相应地扮演了托洛茨基主义者、季诺维也夫派和极左分子的角色:“崇拜自发性的理论坚决反对使自发运动带有自觉的和有计划的性质,反对党走在工人阶级的前面,反对党把群众提高到自觉的水平,反对党领导运动,而主张使运动中的自觉因素不致妨碍运动循着自己的道路行进,使党只依从自发运动,做运动的尾巴。自发论是降低自觉因素在运动中的作用的理论,是尾巴主义的思想体系,是一切机会主义的逻辑基础。”
正如我们无法说服费边社的成员放弃对全球人道主义政府的追求,我们也难以改变这些梦想家的信念。然而,另一方面,普通民众的意见也无法对社会变革产生实质性的影响。人们只能无奈地屈从于不可避免的趋势。
专业人士们注定会怀揣着自负,永远踏上构建统一、集成的 Linux 生态系统的西西弗斯式征程,即便这意味着将 Linux 内核变成 BPF 虚拟机的运行时,或者像最近的趋势那样,将构建和部署流水线变成复杂的鲁布·戈德堡装置。而业余爱好者们则注定在虚空中呼喊,却无人倾听。在这场悲剧中,唯一的胜利者是伪装成“进步”的混乱和不和谐。唯一可以预见的是,我们将通过不停的创新来进行永久的革命。而所谓的革命,不过是原地踏步。那些西装革履的人早已忘记了自己曾经是嬉皮士,而嬉皮士们则成了被他人思想所左右的迷途者——而非坚守自我的践行者。
在 systemd 出现之前:2000 年代中期 Linux 发行版维护者各自为政的目标
从 2001 年到 2010 年,除了最著名的 Upstart 外,还有许多努力试图解决 sysvinit 和 init 脚本的问题。这些旧时的邮件列表,不仅提供了一个有趣的时间胶囊,还从侧面揭示了 Linux 开发者在 2000 年代中期更新守护进程管理和启动机制的动机——这与 systemd 出现后表达的动机有着显著不同。
Henrique de Moraes Holschuh 在 2002 年第三届 Debian 会议上发表了一篇题为 《Debian 操作系统中的 init 脚本》 的论文,对当时的系统启动管理的状况进行了全面概述。Holschuh 研究了当时 NetBSD 最新的 rc.d、runit、Richard Gooch 的 simpleinit、Felix von Leitner 的 minit、jinit,以及 serel 依赖管理器。
有趣的是,他当时的一个抱怨是 sysvinit 的资源占用过大:
sysvinit 占用了大量的内存空间。在 ia32 系统上,它大约需要 12kB 的虚拟空间和 48kB 的 RSS。在这个 KDE/GNOME 盛行的时代,这点内存确实微不足道,但对于内存紧张的嵌入式系统来说,这显然不是理想的选择。
此外,根据他的陈述,“大多数 System V init 脚本系统实际上还是相当不错的”,而 /etc/rc?.d 目录下大量的符号链接以及由此带来的排序问题才是主要的抱怨点。他提出的改进都是渐进式的:为 dpkg 维护者编写脚本,方便用户管理 /etc/rc?.d 目录的工具——如 invoke-rc.d 和 policy-rc.d,一个 init 脚本注册表,以及最激进但仍相当建设性的建议:用 init-provide、init-before 和 init-after 等依赖命令取代运行级别。这个设想的系统仍然基于 init 脚本,并与 telinit 命令切换运行级别的行为保持兼容。在讨论中,从未有人提出新的声明式配置格式,也没有人建议对日志或事件驱动机制进行重大改革——更不用说统一各个发行版了。
2002 年,Richard Gooch 在 Linux Boot Scripts 中为一个名为 simpleinit 的程序撰写了一份提案。在该方案中,init 脚本通过一个名为 need 的程序来协同解决自身的依赖,使得 init 可以按照任意顺序运行这些脚本。多年前,simpleinit 曾作为 util-linux 工具集的一部分存在,后来 Matthias S. Brinkmann 为其编写了一个增强版,名为 simpleinit-msb。simpleinit-msb 目前仍然是 Source Mage GNU/Linux 的默认 init 系统。关于他们选择该 init 系统的原因,早期的邮件列表讨论可以在这里找到。其优点包括简化与包管理工具的集成、更简单的 init 脚本、并行处理以及显式的依赖管理。
在 simpleinit-msb 的源代码中,有一篇由 Matthias S. Brinkmann 撰写的题为《为什么 SysVinit 很糟糕》的文章,值得完整引用:
经典的SysVinit启动模型中,使用了许多带编号的符号链接(真的很多),这存在以下几个缺点:
- 太丑陋了! 如果你觉得像 “K123dostuff” 这样的名字很美观,那你的品味可真独特。我建议在给孩子起名时多听听亲友的建议。
- 难以驾驭。 除非你是高手中的高手,否则很快就会在 SysVinit 的设置中迷失方向。大多数脚本在文件系统中至少有三种表示形式:脚本文件本身、S 符号链接和 K 符号链接(有时还会有更多)。
- 手动指定执行顺序。 你必须手动为所有 init 脚本指定执行顺序,尽管逻辑上只需要对关键的脚本进行排序。或许你自以为在掌控系统,但说实话,你真的在乎键盘映射是在系统时钟设置之前还是之后加载吗?如果你希望拥有这样的控制权,那也不是没有道理。但问题在于,SysVinit 把这些繁琐的工作强加给了你。
- 缺乏依赖管理。 当然,将服务 A 的编号设为 100,服务 B 的编号设为 200,可以确保 A 在 B 之前启动,但有时这还不够。如果服务 B 的启动需要服务 A 正在运行呢?SysVinit 对此无能为力。它只会按顺序启动 A 和 B,但如果 A 启动失败,SysVinit 仍会尝试启动 B。举例来说,如果挂载文件系统失败,它仍然会尝试启动剩余的所有服务——即使这些服务需要对磁盘进行写入。最终,你会得到一个大部分服务都启动失败的系统,但 SysVinit 的运行级别程序仍然乐观地告诉你,你处于运行级别 5,正在使用 X 窗口系统和完整的网络环境。
- 修改繁琐。 为什么我们需要编写复杂的 GUI 程序来添加和删除运行级别中的脚本?因为手动创建或删除一大堆名称各异的符号链接,哪怕打错一个字母,都可能导致系统崩溃,这简直疯了。说真的,难道你不想直接执行 mv runlevel.3/telnetd unused/ 来卸载 telnetd 服务,然后再执行 mv unused/telnetd runlevel.3/ 来重新安装它吗?
- 扩展性差。 以 LFS(Linux From Scratch)为例:它使用三位数字来表示 init 脚本编号,编号范围在 000-999。对于只有十几个 init 脚本的系统,这听起来还算合理,不是吗?但问题在于,每次添加一个 init 脚本,都需要将其插入序列中。如果你需要在编号为 N 的脚本和编号为 N+1 的脚本之间启动一个新脚本,解决方案只有一个:重新为所有 init 脚本编号。这让我想起了很久以前使用 BASIC 解释器编程的日子,有时我会需要在第 N 行和第 N+10 行之间插入超过 9 行的代码,幸运的是,当时有一个 renum 命令可以帮我完成。但当然,你现在也可以编写一个 Shell 脚本来处理符号链接的重命名。没问题,SysVinit 管理员就是喜欢折腾。
- 不适合自动化安装。 如果你想构建一个允许用户选择要安装的包并仅安装这些包的安装工具,那么对于那些带有 init 脚本的包(比如 FTP 代理),你不可避免地会遇到问题。你唯一的办法是为安装工具中的每个 init 脚本分配一个唯一的序列号,并假设用户会安装所有包。如果用户只安装了一部分包,又添加了自己的 init 脚本,然后想要安装和他自己的脚本编号相同的其他包,会发生什么呢?最糟糕的是,即使对于那些与其他脚本的启动顺序无关的脚本(大多数都是这样),这个问题也存在。
- 缺乏用户空间测试。 要测试 SysVinit 的 init 脚本和运行级别设置,必须以 root 身份执行一系列具有潜在风险的命令,有时还需要多次重启才能成功。尤其是对于那些热衷于美化 init 脚本输出的人来说,他们更希望在用户空间中以简单、安全的方式编写脚本。
- 关机不安全。 SysVinit 完全依赖 init 脚本来确保文件系统的卸载和进程的终止。这非常不安全,最糟糕的情况下可能导致数据丢失。一个好的关机程序应该有一个兜底策略来处理这种情况。
几年后,Busybox 的开发者 Denys Vlasenko 以苏格拉底对话的形式发表了他对 sysvinit 的批评,并提倡使用 daemontools 的方法。这一观点也在 2012 年的一篇文章《进程监控:已解决的问题》中得到了支持。
2003 年底,据报道,GNOME 开发者 Seth Nickell 提出了编写一个init 的替代品的想法。该提议最终并未进入实际开发阶段,而且显然是以桌面平台为驱动力的。据称,其核心思想是使用 D-Bus 作为守护进程的服务发现机制:
当你告诉 ServiceManager 启动 org.designfu.SomeService 时,它会检查该服务的依赖关系,如果需要,先加载这些依赖服务。然后,它使用 D-Bus 常规的激活方式来激活 org.designfu.SomeService。理想情况下,这意味着激活使用 D-Bus 的守护进程本身,但也可能意味着激活一个“Python 包装脚本”。随后,ServiceManager 通过 D-Bus 发送信号,宣布一个新的系统服务(并提供其名称)。此时,org.designfu.SomeService 负责向用户通知其状态。
他还补充道:
我个人的计划是鼓励守护进程未来依赖 D-Bus,从而提高它们使用 D-Bus 提供客户端接口的可能性。我担心的是,即使未来 D-Bus 存在且有意义,但由于没有人愿意在自己的项目中添加(编译时可选的)依赖来获得一些“细微”的功能(对守护进程/内核/网络黑客来说是小功能,但对桌面用户来说却是大功能!),最终可能会导致其被弃用。
当时的社区反响相当复杂。OSNews.com上的评论夹杂着困惑、冷漠,少有积极的态度。LWN.net上的评论也类似——有人为现状辩护,对桌面集成持谨慎态度,并提出了自己偏好的替代方案,例如假想的按需服务启动器、使用 Gooch 的 init 脚本或 daemontools。一位评论者甚至提到了他在 Common Lisp 中自行编写 PID 1(即初始进程)的经历,但最终因上游打包的不便而放弃。
针对 Seth Nickell 的提议,Fedora 贡献者 Shahms King 在 邮件列表 上分享了他重写 init 脚本的实验。他对 sysvinit 本身和运行级别的概念感到满意,因此希望在脚本头部添加 “depends” 字段,然后用 Python 重写 /etc/rc 脚本,以实现并行启动。Fedora 开发者 Bill Nottingham 对此回应道,他对并行启动的收益表示怀疑:
- 依赖头已经在 LSB 中定义,不妨使用这些标签。
- 我们已经测试了一些类似的替代方案。仅靠并行化并不能带来显著的加速。最多只能看到 10%-15% 的提升。
在我看来,加快启动速度的方法很简单,但又很困难:少做点事情。
同样地,红帽开发者 David Zeuthen 在 2007 年 4 月也对替换 sysvinit 所能带来的启动速度提升表示怀疑,他倡导使用 readahead:
人们普遍以为,仅通过将 sysvinit 替换为其他 init 程序就可以“加速”启动。但实际上,提升启动速度的最好、代价最小的方法是修复 readahead,使 sysvinit 能够在无需磁盘寻道的情况下读取所需的所有文件;你可以参考我 2.5 年前做的一些旧实验。好消息是,readahead 的维护者正在着手解决这个问题;参见 fedora-devel-list 存档中的讨论。
他引用了自己在 2004 年 关于启动优化的邮件列表帖子,最后还提到了替换 init(但不包括替换 init 脚本):
整个 init 系统看起来都已经过时了;也许在 D-Bus 之上构建一个更现代的 init 系统才是正确的选择——我想到了 Seth Nickell 的 SystemServices。理想情况下,需要启动的服务应该有以下依赖关系:
- 在 /usr/bin/gdm 可用之前,不启动 gdm 服务;
- 只有当 NetworkManager 报告存在网络连接时,才激活 SSH 服务;仅在存在带有 LABEL=/usr 标签的卷时才挂载 /usr 等等。
此外,这样的系统当然应该支持 LSB init 脚本。(由于这可能是一个独立的大型项目,所以我暂且不详细讨论)
从 2005 年左右开始,Fedora 多次尝试更新 init 脚本,这些尝试以FCNewInit为名进行。其目标并非取代 init 脚本,而是致力于实现与 LSB 标准的完全兼容,并对通过 D-Bus 暴露服务提供一些模糊的支持。行动页面的结论是:
考虑到这些功能,最好的方法几乎可以肯定是在服务本身中添加对 D-Bus 的支持,并为遗留的 LSB 和其他 init 脚本提供一个 D-Bus 接口的包装器。
2005 年 6 月,Harald Hoyer 编写了一个 Python 脚本,作为对 ServiceManager 方案的 概念验证。该脚本会“读取 /etc/init.d/ 目录下的所有脚本,并创建 D-Bus ‘服务’对象。这些对象解析其 init 脚本中的 chkconfig 和 LSB 风格的注释,并提供一个 D-Bus 接口来获取这些信息和进行控制。”
这项努力最终并未取得实质进展,可能是因为它只是在已然混乱的系统中又徒增了额外的复杂度。相反,到了 2007 年,Harald Hoyer 再次采取了更为保守的方案,旨在使用 LSB 依赖头来并行化 init 脚本的执行,这是借鉴了 Mandriva 的工作。值得注意的是,Hoyer 关于 init 替代方案的问题写道:
SysVinit 的替代方案(如 upstart、init-ng)也可以在 Fedora 中使用,但在改变已长期证明有效的启动机制方面,我们非常谨慎。除非确实需要“真正的”杀手级功能,否则我们希望尽可能长时间地保持向后兼容性。
所谓的“真正的”杀手级功能并未被具体说明。
2005 年,Mandriva 实施了一种基于 init 脚本头的并行化方案,名为prcsys。据称,它可节省多达 12 秒的启动时间。起初,它使用的是以 X-Parallel- 开头的 Mandriva 专有脚本头字段,但在 2006 年更新为完全符合 LSB 标准。Debian 和 openSUSE 通过 startpar 和 insserv 实现了类似的方法。2008 年,一篇 Mandriva 开发者的博客文章进一步证明,优化相关的基础工作是主要关注点,而非对启动过程进行任何根本性的重新设计。这一观点在整个主流领域都得到了认同。
2007 年,D-Bus 系统总线激活功能终于得以实现,旨在提供一种完全绕过专用服务管理器的可能性。正如之前设想的那样:
对使用 D-Bus 启动程序的兴趣已经持续数月。这将使简单的系统只启动必要的服务,并在首次请求时自动启动这些服务。这消除了对 init 系统的需求,意味着我们可以轻松地并行启动服务。这对于 OLPC 来说是当务之急,对 Fedora 和 RHEL 来说则是面向未来的需求。
然而,正如 Jonathan de Boyne Pollard 后来指出的,这是一种反模式。因为 D-Bus 守护进程“几乎没有任何守护进程管理机制,如资源限制、自动重启、日志管理、权限/账户管理等”。此外,许多 D-Bus 服务所采用或曾经采用的协作方式,都会导致上游开发者主导系统管理员的决策。
2005 年,Debian 开发者组建了一个名为 “initscripts-ng” 的工作组。其中一位重要成员是此前提到的 Henrique de Moraes Holschuh。该工作组最初的目标与 2002 年论文中的提案类似,但这次更为雄心勃勃。他们的总体目标是创建一个与发行版无关的框架,以统一各种启动方案,制定系统管理策略。组内提议以 init-ng 或 runit 为基础。尽管如此,完全取代 init 脚本的想法仍被认为是不切实际的。
多年来,Debian 小组对各种方案进行了广泛的头脑风暴,但大多数都未被采纳。其中一些较成功的成果,是通过解决一些关键问题来谨慎地改进启动速度。这在 2006 年的立场文件中有更详细的描述。提出的优化措施包括用 dash 替换 bash 作为 init 脚本的默认 Shell,实现 LSB 标准化,使用 startpar 实现并行启动,以及将某些耗时操作移至后台。Ubuntu 在 Upstart 出现之前也采用了类似的措施。
基于 LSB 依赖的启动方式直到 2009 年 7 月才成为 Debian 的默认配置,而并行启动方式直到2010 年 5 月才默认启用,这距离 2002 年最初的 DebConf 提案已经过去整整 8 年!而此时,systemd 已经出现在人们的视野中。
期间发生了一个有趣的插曲,即 metainit 项目。metainit 基本上就是一个用于生成 init 脚本的 Perl 脚本。是的,你没听错。参与该项目的人数出奇地多,其中包括 Michael Biebl,他是 Debian 和 Ubuntu 中 systemd 的包维护者,也是上游的 systemd 开发者。
metainit 脚本基本上只是一个权宜之计,旨在减轻包维护者的负担,并提高 Debian 和 Ubuntu 之间的互操作性。实际上,到这个时候,人们应该注意到,init 系统最重要的用户并不是通常认为的系统管理员和运维人员,而是发行版的维护者。正是他们的惰性决定了这种平衡。该项目在宣布进行长期测试后不久便不了了之。
2009 年 9 月,在 Debian 上完成大量 LSB 兼容性和并行化工作的 Petter Reinholdtsen 公布了 Debian 启动过程未来的初步路线图。这本身就是一份有趣的历史记录,因为距离 systemd 的发布(2010 年)仅有半年。Reinholdtsen 似乎吸收了 Upstart 关于事件驱动和热插拔启动的大部分框架。当时,Debian 启动过程的顺序性被认为是根本问题。提出的解决方案相当临时:用 Upstart 替换 /sbin/init,但需要对其进行修改,以理解 sysvinit 风格的 inittab 文件。在短期内,现有的 rc 系统和 init 脚本将保持不变,Upstart 仅作为一个高级的 init 脚本启动器,直到未来某天逐步增强脚本,以生成和响应 Upstart 事件。同时,还需要修改 insserv 以理解 Upstart 作业。即便在这个后期阶段,LSB 兼容性仍是一个强烈的目标,这意味着需要让基于事件驱动的作业清单和 init 脚本并存:
根据 LSB 规范,所有符合 LSB 的发行版都必须处理带有 init.d 脚本的包。由于 Debian 计划继续遵循 LSB,这意味着 init 系统需要继续处理 init.d 脚本。因此,我们需要一个既能在早期基于事件启动,又能在适当时机调用 init.d 脚本的 Debian 启动系统。
这是 initscripts-ng 小组对 Upstart 进行长期讨论后的最终结果。正如 Scott James Remnant 在 2006 年 5 月的一份草案中所介绍的,Upstart 旨在成为所有服务启动器的超集和替代品,其中包括当时流行的 udev、acpid、apmd、atd 和 crond,以及像 ifupdown 这样特定于发行版的辅助工具。Ubuntu 维基上的《替换 init 脚本》页面详细阐述了这一动机。
2009 年 6 月,Debian 和 Ubuntu 开发者之间进行了一次“冲刺”活动”,其中包含了将 Upstart 引入 Debian 的计划。这充分展示了 Upstart 与 sysv-rc 共存并整合到 Debian 打包基础设施中的复杂性。直到 2010 年 4 月,Petter Reinholdtsen 还在期待“在 Squeeze 的下一个版本中,用 Upstart 替换 sysvinit 的 /sbin/init,同时保留大部分 init.d 脚本和 insserv 功能”。然而,不到一个月后,他就对 systemd 产生了兴趣。
在 Fedora 从版本 9 到 14 的期间,Upstart 主要被用作 init 脚本的简单封装器。Scott James Remnant 曾在 2007 年参加了GUADEC 大会,他在会上表示自己“与 Fedora/Red Hat 和 SUSE 的成员一起参加了一个关于 Upstart 的 BOF(Birds of a Feather,志同道合者的聚会),讨论了 Upstart 如何融入 udev、HAL、D-Bus 等‘大局’中”。显然,这是当时市场领导者的主要关注点。
总而言之:
- 从 2001 年到 2010 年期间,大部分工作都集中在对 init 脚本的渐进式和临时性改进上,而非替换或以其他方式取代它们。
- 并行启动并未被普遍视为性能优化的重点。人们更多地寄希望于使用 readahead 和预加载技术,以及对常见瓶颈的深入分析。
- 那段时间里,一些较为离奇的提议包括直接将 D-Bus 守护进程用作服务管理器、让 init 脚本本身负责注册 D-Bus 接口,以及编写一个脚本来生成其他 init 脚本。令人惊讶的是,大多数主流发行版对 LSB init 脚本头规范表现出了高度的坚持和严格遵循。
- 改革 init 系统的主要瓶颈在于社会层面的因素,即发行版打包的指导方针。
- init 系统的兴衰取决于需要与之交互的包维护者的关注程度。只有当 init 脚本的复杂度不断累积,达到某个临界点时,才会激发对其进行彻底变革的渴望。
systemd:设计哲学与政治
受挫的理想主义者
在这种混乱无序的氛围中,人们渴望以果断的行动摆脱技术债务的束缚,这种心情完全可以理解,而这种混乱在很大程度上是由发行版开发者自身造成的。Debian 花了 8 年时间才实现 init 脚本的并行化——显然,是时候做出改变了。
systemd 最初以 “BabyKit” 的名字进行原型设计,寓意为进程的保姆。2010 年 3 月,systemd 正式亮相,迅速崛起,并以异常迅猛的速度确立了主导地位,在自由软件界引发了前所未有的激烈争议,至今仍未真正平息。可以说,systemd 的出现标志着一个前 systemd 时代和后 systemd 时代的分水岭。
要正确理解 systemd 的历史地位,我们至少需要回答以下三个问题:
- 首先,它是何时开始不再仅仅是一个 “init 系统”,而演变为一个通用的中间件和平台的?或者说,它的功能是否曾经被限制在特定范围内?
- 其次,其开发者的野心和自负是否在它的成功中起到了决定性作用?
- 最后,它是对现状的彻底颠覆,还是利用了当时 init 系统尚未成熟的趋势和话题热度?
围绕 systemd 的最早公开辩论出现在2010 年 5 月的 Fedora 开发者邮件列表上,就在 4 月份 Lennart Poettering 发布了《重新思考 PID1》文章后不久。在 Fedora 的帖子中,Lennart Poettering 分享道,systemd 最初的动力源于他无法说服 Scott James Remnant 在 Upstart 中采纳某些功能和原则:
我和 Kay 以及其他一些人在许多不同的 LPC 和 GUADEC 会议上出席,探讨我们希望在 init 系统中看到哪些功能。我们进行了长时间的讨论,但最终,我们的大部分想法都被 Scott 直接否决了,比如 Launchd 风格的激活和 cgroup 功能,而这些正是目前 systemd 中最出色的特性。(话虽如此,我们实际上在某些方面说服了他,比如我认为我们帮他从 D-Bus 的反对者转变为支持者。)
无论如何,这些讨论确实已经持续了数年。遗憾的是,没有留下任何书面的记录或可供参考的邮件列表。不过,你可以向 Kay、Scott 或我询问相关情况。
讨论中最有趣的部分,是 Lennart Poettering 与 Upstart 开发者 Casey Dahlin 就 cgroup 的优点、依赖关系与事件等主题展开的辩论。不仅如此,关于在退出时可能意外终止通过 nohup 和 screen/tmux 启动的会话的著名问题,也在该讨论中被富有先见之明地提出了。需要注意的是,当时 Lennart 还未决定在 Fedora 14 中引入 systemd。在讨论接近尾声时,Lennart 对 Scott James Remnant 表达了强烈的不满。
到目前为止,systemd 设计中最主要的特点——也是 Lennart 在 systemd 早期阶段经常宣传的——就是尽管 systemd 通常被认为是一个基于依赖关系的 init 程序,但实际上并非如此。(直到 2019 年 10 月,systemd 开发者 Michal Sekletar 还错误地将其描述为“基于依赖关系的执行引擎”。)相反,systemd 使用套接字激活,旨在完全避免显式的依赖信息:
systemd 的激活机制旨在并行启动(大部分的)本地服务,使手工编写依赖关系成为过时的做法。这正是它的神奇之处。一个 init 系统就应该围绕这一核心进行设计!systemd 就是这样做的。
这一点需要在整个过程中反复强调,因为它反映了 systemd 的一个愿景,尽管这个愿景从未完全实现,后来也被逐渐淡化了。
在一篇涉及上述邮件列表讨论的 LWN 文章中,我们可以看到 Lennart Poettering(网名 mezcalero)多次出现。他反复强调用各种激活机制取代依赖项的重要性。Lennart 将每种单元类型都视为一种“激活”形式:
是的,我们目前处理的激活方式包括套接字触发、D-Bus 触发、文件触发、挂载触发、自动挂载触发、设备触发、交换空间触发、定时器触发和服务触发的激活机制。
他明确表示,依赖项仅用于早期启动:
然而,对于大多数正常的服务来说,通常不需要手动配置任何依赖项,因为各种激活方式会自动处理这些依赖关系。手动配置依赖项仅在必要时才需要,比如在系统启动早期或关机时的最后阶段。
换句话说:我们在内部使用依赖关系。当然,我们也向用户公开它们,但用户通常很少需要使用。
这与 launchd 不同,launchd 实际上完全不考虑依赖关系。但为此他们付出了一定的代价:他们的早期启动过程与实际服务的启动过程完全不同。由于我们的 Linux 启动过程比他们的更复杂,我们决定同时保留两种情况:无依赖的主要启动过程,以及在系统启动极早期需要手动配置依赖的少数特殊服务。
Lennart 还驳斥了对通用事件代理的需求,而 systemd 经常被这样解释(例如,Debian 参考手册将 systemd 描述为“用于并发的事件驱动的 init 守护进程”):
因此,我不明白你为什么需要超出 systemd 提供的依赖系统和 Linux 内核已经提供的各种通知系统之外的通用事件系统。例如 inotify、netlink、udev、对 /proc/mount 的 poll() 调用等。如果应用程序需要这些事件,它们应该直接使用这些通知功能,没必要让 systemd 参与其中。
他再次强调对依赖关系的立场:
首先,你给人的印象是 systemd 的核心设计围绕着依赖关系,但实际上并非如此。依赖关系只是 systemd 为了系统早期启动而支持的功能之一。正常的服务不应该过多地使用它。systemd 的一个优点是,内核会开箱即用地为你正确设置依赖关系和启动顺序。
此外,声称 launchd 或 systemd 的核心设计是围绕按需加载服务,这也是一种误导。虽然我们也支持按需加载,但我们主要是通过基于套接字的激活来并行启动系统,从而摆脱显式配置的依赖关系。在 systemd 系统中,只有少数服务会按需启动。大多数服务会根据套接字激活逻辑并行地启动。
这也是 systemd 在 2010 年 7 月引入DefaultDependencies= 指令的原因。
无情地追求并行化的另一个结果是,systemd 的启动顺序不再具有明确的“第一”或“最后”这种时间点概念,也无法保证基于优先级的严格排序。对此,许多人感到困惑,以至于 Lennart 和 Kay 频繁地强调这一点,例如在 2010 年 11 月 的讨论中:
既然我们并行启动所有内容,并且从不进行等待,那就不存在一个确切标志着启动工作已完成的时间点。这样的时间点根本不存在。
不再有一个明确的“启动完成”时刻,这是这里最大的问题。在传统的 sysvinit 系统中,只有在系统启动过程中启动 sysv 服务时,才会在控制台上打印对应服务的启动消息。然而,在更加动态的 systemd 中,我们会为所有服务(包括 D-Bus 服务——在大多数设置中,这类服务的数量实际上超过了 SysV 服务)打印启动消息。因此,服务随时可能启动,将 getty 的启动与此同步没有意义。
2012 年 1 月,Kay Sievers 写道:“非依赖服务的顺序是未定义的。”
Lennart 在 2015 年 10 月再次指出:
在系统启动或关闭的过程中,不存在某些程序“首先”或“最后”运行的概念,也不存在如果自己的程序正在运行就阻塞其他程序执行的情况。因为在像 systemd 这样主要基于并行机制的系统中,与另一个服务建立连接,或在其上调用方法,都可能导致自动激活。
(注:systemd 早期的一个严重 Bug说明了这个问题,即 remount-rootfs.service 会激活 swap.target,但这并不等同于激活各个 .swap 单元,直到后来在上游项目中为它们创建了隐式依赖。此外,许多人在系统关闭阶段执行特定任务时遇到了困难,例如卸载 NFS 共享文件系统。)
Lennart 在 2011 年 3 月的一篇博文中分享道,并行数据流方法的另一个更微妙的含义是,无法保证为给定的目标启动哪些服务:
我不确定这是否真的有用。我们应该考虑一个完全动态的系统:正在运行的服务集合不再是系统启动时加载的那个,而是在某个时间段内触发的所有服务的总和。如果将触发器与其结合使用,其工作方式甚至可能也会不同。因此,很难回答诸如“如果我以多用户模式启动系统,这个服务是否会运行”这样的问题,因为答案通常是“视情况而定,如果用户启动了应用程序 foo,而且插入了硬件 bar 的话……”。
我真的不想给人留下这样的印象,即我们可以可靠地告诉人们,如果他们启动一个特定的目标,某个特定的服务是否会运行,因为这是不可能的。
(注:这也是最终在 systemd-228 中删除 .snapshot 单元类型的原因。)
在 systemd-8 之前,“Type=oneshot” 曾被称为 “Type=finish”,“RemainAfterExit=” 被称为 “ValidNoProcess=”,“RefuseManualStart=” 被称为 “OnlyByDependency=”。2010 年 8 月的一篇帖子表明,systemd 受到了 Upstart 中 “Task” 概念的启发,因此可以推断 Upstart 是 systemd 的主要灵感来源。使用 “Job” 对象进行状态转换也是另一个相似之处。
在引入 journald 之前,systemd 提供了一个名为 systemd-kmsg-syslogd 的小型辅助服务,它将 /dev/log 的内容转发到内核缓冲区,然后由 syslog 实现程序将数据写入磁盘。
systemd 早期开发中的一个典型例子,是 Kay Sievers 对一位 Gentoo 用户的回应。该用户询问 systemd 是否具有与 OpenRC 的 /etc/conf.d 类似的灵活性。Sievers 并未直接回答问题,而是列出了一大堆 systemd 的功能,以讽刺的方式误导对方。随后,他公开承认了对提问者的轻蔑,并补充道:“老实说,sysv——可能还有 openrc,我不太清楚这里的细节——的大部分‘灵活性’是因为从未有人提出过能彻底取代这些操作的方案。”
timedated、localed 和 logind 都是在 systemd-30 中引入的。2010 年 9 月,Sievers 在回答如何避免静态初始化 getty 的问题时,暗示 ConsoleKit 可能会被 systemd 中的一个守护进程所取代:“也许我们可以将大部分会话追踪功能从 ConsoleKit 迁移到 systemd,并彻底消除 ConsoleKit 守护进程。”
到了 2010 年 7 月,Lennart 开始疯狂提交补丁,以充分利用 systemd 的套接字激活机制:
值得注意的是,现在有许多项目已经合并了对 systemd 的原生支持。我最近开始将剩余的补丁提交给各个项目,很快就会在那些项目中看到变化。我也鼓励大家现在开始为 Fedora 默认安装的其他服务创建 systemd 服务文件,之后会在 fedora-devel 上推动这件事。如果其他发行版也能开始推动,那就太好了。
在同一时期的 fedora-devel 邮件列表上:
目前,在 rawhide 中已经更新了以下几个包,以提供基于套接字的激活功能:dbus、udev、avahi、rtkit。在 F14 发布之前,我希望至少将 rpcbind 和 rsyslog 也添加到这个列表中,可能还会将 cups 添加进来。对于 rpcbind 和 rsyslog,相关的补丁已经提交到上游代码库,有些已经合并了。
如果我们能为 Fedora 中尽可能多的包提供原生单元文件(作为当前 sysvinit 脚本的替代品),那就太好了,尤其是对于默认安装的所有服务来说。
这一部分的核心观点是:所有这些设计选择背后,都存在一个并未明说的激进愿景,即 systemd 是 Linux 版本的 Mach 引导服务器(Mach bootstrap server)。在 macOS 上,这个服务器是 launchd。launchd 没有显式的依赖指示,而是期望每个服务通过 IPC 进行自我注册,并进行按需启动,即由守护进程之间协作解决自身的依赖问题。launchd 紧密耦合于 XNU 内核的许多未公开的特性,例如用于自适应调度策略的 “coalitions”(联盟)。某些事件,如存储卷和网络接口的可用性,有专门的对象表示法用于服务属性列表。苹果文档档案中的“技术备忘录 TN2083:守护进程和代理”详细阐述了这一模型。不同的启动命名空间及其可用的服务存在于不同的用户会话中。
在 systemd 开发者所设想的这个全新世界中,每个服务都会通过 kdbus 注册自己,以便通过系统级或用户级总线进行激活,并通过 IPC 向 PID 1 中的 systemd cgroup 代理发送资源控制请求。类似于 macOS 上的 XPC 服务,将 IPC 访问控制决策委托给 systemd,通过 systemd D-Bus 服务而非直接的系统调用来执行许多配置任务,等等——所有这些都是为了实现“用户空间即新的内核”这一理念。
2011 年 3 月,Lennart 的一句话暗示了这一点:
我认为,这些目标(target)单元实际上是在一个并非所有守护进程都可以通过套接字激活,并且某些守护进程的编写方式假定网络始终可用且不会动态变化的情况下的权宜之计。
在一个所有服务都可以通过套接字激活,所有守护进程都能正确订阅 netlink 的系统中,我们不再需要 syslog.target 或 network.target。因为 syslog 守护进程始终可用,网络是否配置成功也不再重要。
当然,Netlink 会被 kdbus 取代。
然而,实践中这一愿景并未完全实现。systemd 被广泛用作基于依赖关系的 init 程序,上游供应商如 Debian 和 Ubuntu 应用了大量针对特定发行版的补丁。即使许多旗舰项目(例如 Flatpak)已经在广泛使用 systemd 的功能,守护进程的开发者也未完全遵循其指导。各大发行版在减轻维护负担的承诺下实现了大量统一,但并未丧失自身特色。随着最近的 Debian 决议(夹杂着明显的失望情绪),systemd 的许多配置管理功能很有可能会成为打包基础设施的一部分,未来可能再次发生变化,但结果还有待观察。
其中的部分原因是,作为上游项目,systemd 在运作时仍然只是市场中的一个节点,并未直接考虑特定 Linux 供应商的利益。这不可避免地引发了冲突,无论他们如何努力克服这一点,并试图将 Linux 市场变成一个垂直整合的巨型平台,从规模经济中获益。试图统一市场只会进一步强化其矛盾,因为参与者对自己在软件分发渠道中的位置有了更清醒的认识,这种关系几乎是辩证的。Lennart 在 2014 年 10 月 承认 了这一点:
systemd 是一套人们用来构建操作系统的组件。因此,它并不是一种可以直接安装在操作系统上的应用程序,而更像是一套偏向于发行版和设备构建者的工具集。如果最终用户对如何使用这些设备和发行版有疑问,那么我认为他们应该首先联系这些设备的制造商和发行版的开发者。
换句话说:我们并不是用户应该直接交互的最终产品,我们只是提供了一套组件,其他人可以基于这些组件来构建最终产品。在构建过程中,他们也需要承担提供支持的责任。
大约在 2015 年左右,随着 systemd 确立了其广泛应用的地位,以及容器化背后的商业利益将焦点转移到无状态系统、基于镜像的不可变部署等方面,开发者在很大程度上降低了这一愿景的重要性。然而,从 2012 年底到 2014 年左右,它在 systemd 崛起的过程中发挥了关键作用。我们将在下一节中详细讨论其众多广为人知的发展成果。
全面掌权
systemd 的开发者毫不犹豫地开始推广他们的系统。除了前述为守护进程添加套接字激活的补丁之外,他们还在 2010 年 7 月之前,将 systemd 设为 Fedora Rawhide 的 默认 init 系统。
在 2010 年 8 月的一篇关于 systemd 打包指南的 Fedora 开发者帖子中,Lennart 对任何对向后兼容性或稳定性有哪怕一丁点担忧的人都表现出了极为强硬的态度。针对 Matthew Miller 对 inittab 兼容性的担忧,他半开玩笑地说:
是不是我们还得检查一下AUTOEXEC.BAT?
(Stephen John Smoogen对此回复道:“Lennart this is neither helpful or ‘excellent’. Please tone it down or take a break from posting for a bit.”)
当 Daniel J. Walsh 询问是否会兼容 SELinux 标签时,Lennart 回应道:
这不公平!Upstart 都从来没做到过。
当 Bill Nottingham 指出一些问题属于上游,而非发行版层面,对 Lennart 提出了异议时,Lennart 回应:
太好了,我真感谢你不在乎。把所有问题都推给一个人,然后告诉他‘我不在乎’,这可真是让他感觉良好,而且还能让他不禁怀疑自己为什么要操心这堆破事?
最后,当 Matthew Miller 告诉他,为了构建一个集成系统,需要满足一些发布要求时,Lennart 讽刺地回答:
太棒了,我看我得成为一个 X 系统的黑客了。很显然,如果以后 KMS 出了问题,也得让我负责。真是太好了!
从整个事件可以看出,Lennart 对于系统集成方需要协调众多环节以添加他们的新开发成果这一事实,表现出了幼稚的漠视和欠考虑。当然,这场纷争也被 LWN.net 报道了。
无论如何,systemd 从一开始就展现了宏伟的抱负。2010 年 7 月,Lennart 就已确信各个发行版之间正在逐步趋同:
Q: 其他发行版也会这样做吗?
A:嗯,虽然速度没那么快,但看起来确实如此。我一直与 SUSE 的 Kay Sievers 密切合作,将 systemd 同样出色地整合到 openSUSE 中,它将在 openSUSE 下一个开发周期开始时加入。Debian、Gentoo 和 ArchLinux 的仓库中已经提供了相应的包,但目前(还?)不是默认设置。我想,对于 Debian/Gentoo 来说,要在这种默认设置上做出决策是困难的。Meego 那边也有人在做这方面的工作,但我没有密切关注。还有一些较小的发行版也采用了 systemd,据我所知,至少有一个(Pardus)计划在下一个版本中将其设为默认。至于 Ubuntu,就让你自行了解会发生什么吧(提示:你可能想知道 Upstart 的主要开发者为哪家公司工作……)。永远不要忘记,Fedora 在开发方面理应处于领先地位,因此我们应该在这里引领潮流……
2010 年 9 月,他明确地将跨发行版一致性作为目标:
嗯,我们的目标是温和地引导这些发行版朝着同一方向发展,让它们停止支持那些毫无意义的其他解决方案。
基于上述原因,我们计划在 “make install” 过程中默认启用所有功能。包维护者可以随后通过 “rm” 命令来禁用这些功能,但我们希望将这些任务转移给包维护者,以便最终在所有发行版中拥有相同的基础系统,消除发行版之间在基本功能上毫无意义的配置差异。
如果某个发行版认为我们的随机数种子保存/恢复功能不够好,那么他们有责任禁用我们的功能,并自行提供替代方案。希望他们能尽早意识到,跨发行版的统一更有价值。
推动 systemd 发展的四大趋势是:udev 的合并、kdbus、logind 以及 GNOME 对它的使用,还有统一 cgroup 层次结构的单一写入者提案。
systemd 开发者与 GNOME 整合的意图最早可以追溯到 2011 年 1 月:
我的初步计划是在 GNOME 中引入 systemd 作为会话管理器,然后对 “应用程序” 重新定义,即 “为每个应用程序创建一个 cgroup,对应一个 .desktop 文件”。这样,像 gnome-shell 这样的程序就可以获取信息,将进程与应用程序、.desktop 文件和窗口进行匹配,而不再依赖目前大家使用的启发式方法。从长远来看,最终目标是为前台应用程序提供额外的 CPU 资源,由 gnome-shell 来决定哪个应用程序是前台应用程序。
2011 年 5 月,Lennart 向 GNOME 开发者邮件列表游说,迈出了计划实施的第一步,这一过程漫长而富有争议。在这篇帖子中,Lennart 表示,localed 和 timedated 是专门为桌面小部件设计的,并提议 gdm 和 gnome-session 分别使用 logind 和每个用户的 systemd 用户实例。此外,他还声称:“尽管如此,大部分大型和小型发行版现在已经切换或计划在下一个版本中切换,或者至少在仓库中提供了 systemd 包。唯一的例外是 Ubuntu。”
在 2012 年 1 月发布的 GNOME 3.4 版本中,systemd 包中的 hostnamed、timedated 和 localed 被默认使用。
(事实上,早在 2009 年,就有一个鲜为人知的与 Freedesktop 相关的项目,名为 xdg-hostname,这是朝着创建可供桌面小部件和其他应用程序使用的 D-Bus “功能性守护进程” 方向迈进的独立项目。如果遵循这个项目,而不是将功能性守护进程纳入 systemd 的源代码中,或许能避免许多政治上的敌意——尽管会以削弱 systemd 的影响力为代价。)
2012 年 10 月,随着 gnome-settings-daemon 从 GNOME 3.8 开始正式迁移到 logind,争议进一步加剧。来自 Gentoo、Ubuntu、OpenBSD、Solaris 等项目的开发者提出了反对意见。随后,为实现用户会话管理,进一步添加 systemd 集成的工作很快展开(参考),这影响了 gnome-session、gnome-shell 等组件。从 GNOME 3.34 开始,gnome-settings-daemon 完全由 systemd 管理,如果不使用 systemd,就需要进行大量的修改。
当时的主要困惑源于对依赖组件的不确定性。哪些是依赖的组件?这些依赖是构建时依赖还是运行时依赖?依赖的是 systemd,还是 systemd 套件中某个组件提供的接口?整个语义都存在歧义。当时,systemd 接口兼容性和稳定性图表将 logind 列为不可独立重新实现的组件。GNOME 开发者 Olav Vitters 当时也对这个问题含糊其辞。
logind 对 .scope 和 .slice 单元的使用,使得这个问题进一步与 cgroups 子系统的未来纠葛在一起。这在 2013 年和 2014 年成为热点话题,下面将对此进行讨论。
到了 2013 年和 2014 年,Debian 和 Ubuntu 几乎是主流发行版中仅存的“顽固分子”,而 GNOME 问题也是 Debian 接受 systemd 的一个重要原因。
(Arch Linux 在 2012 年 8 月经历了一些戏剧性的变化,但主要是内部事务。在 Arch Linux 内部,当时负责管理 init 脚本的 Tom Gundersen 是 systemd 的主要支持者,也是上游 systemd 的开发者,后来成为 systemd-networkd 的主要架构师。他表示“跨发行版协作”是他的一个重要目标:“在我看来,一个不错的目标是加强跨发行版的协作。你们的项目中,不同主流发行版的贡献者占比如何?我认为,systemd 的一个优点是它几乎吸引了所有主流发行版的活跃贡献者(包括 Gentoo 和 Arch Linux,但可能不包括 Ubuntu?我不太确定)。”)
例如,Debian 开发者 Ansgar Burchardt 在 2014 年 1 月的技术委员会会议上谈到了围绕桌面 Linux 的生态系统正在逐渐倾向于 systemd:
另一方面,即使我们使用 upstart 作为 init 的替代品,我们仍然会继续使用大量的 systemd 组件(比如 logind 和其他 D-Bus 服务)。我个人认为,“少即是多”的论点只有在我们确实不需要这些额外功能的情况下才有说服力。
我还有一个问题:你的邮件没有提及在使用 upstart 而非 systemd 作为 init 的系统中集成 logind 面临的问题。你不认为这会是一个问题吗?鉴于这意味着未来需要持续的工作投入,而不是一次性投资,这是我对 upstart 的主要不满之一。我觉得,为了 init 替代方案之间微小的技术差异,长期维护一个脱离 systemd 的 systemd-logind 分支并不值得。我们完全可以将资源投入到更有趣的领域。
值得注意的是,这可能也包括未来的会话管理功能。正如你在 [1] 中提到的,许多桌面环境正在考虑使用高级的会话监控功能。到目前为止,KWin 和 GNOME 似乎都将目标指向了 systemd。因此,这将是我们需要投入资源在 upstart 上重新实现的另一个领域。
Josselin Mouette(当时是 Debian 的 GNOME 打包者)于 2013 年 10 月发表了他的声明:
systemd 正在成为 Linux 发行版(至少是 Fedora、SuSE 和 Arch)的事实标准,并且在许多软件包中得到了优秀的上游支持。到目前为止,只有 Gentoo 使用 OpenRC(但它不具备我所需的大部分功能),只有 Ubuntu 使用 upstart。因此,使用 OpenRC 意味着我们需要自行维护许多补丁,而使用 upstart 则意味着我们的上游将只有 Ubuntu。
……最后,作为 GNOME 软件包的维护者之一,我想说的是:在 Jessie 版本中,GNOME 需要 systemd 作为 init 系统来实现其所有功能,就像需要 NetworkManager 来进行网络配置一样。虽然仍然可以(并将继续)在不使用 systemd 的情况下安装 GNOME,但要求默认的 GNOME 安装不使用 systemd 是不合理的。
更重要的是,2013 年 12 月,Russ Allbery 在其极具影响力的总结文章《Debian 的 init 情况》中,在第 3.1 节“生态的现实情况”中承认,真正的争论点并不是“systemd 与其他 init 系统”,而是“使用多少 systemd 的组件”:
我认为,在讨论中可能有一个非常重要的要点被忽视了,那就是几乎所有参与者都同意 Debian 采用 systemd 的大部分功能。systemd 是一个包含多个组件的项目,其中一些组件比其他组件更为关键。这些组件中的大多数显然比我们目前在 Linux 平台上使用的任何其他实现都更优越,并将在未来的发行版中广泛使用。
换句话说,这场争论实际上并不是 systemd 与 upstart 的对决。如果我们把选择缩小到这两个竞争者之间,那么问题是:是采用 systemd 的所有主要组件,包括 init 系统?还是采用 systemd 的大部分主要组件,但用 upstart 替换掉 init 系统?无论哪种方式,我们都将不得不运行 udev、logind、一些 systemd 的 D-Bus 服务,以及很可能用于桌面环境的 timedated 和 hostnamed。
这在很大程度上改变了讨论的性质。我们不再是在两种相互竞争的生态系统之间做出选择。相反,我们是在讨论是否要将现有的集成生态系统中的一个核心组件替换为我们更喜欢的组件。
然后,该来的还是来了。
Allbery 随后进一步阐述了在社区项目中志愿者劳动的本质。他的建议是将这个问题上升到 GR(通用决议)级别,原因是一种社会意识:“这不是一个技术问题;这是一个关于项目整体方向的问题,也是在上游合作伙伴更倾向于紧密耦合的情况下,我们是否仍然要强求采用松散耦合的模式的问题。”
另一个主要推动因素是 udev 的合并。这个合并于 2012 年 4 月正式宣布。当时,Kay Sievers 承诺,在非 systemd 系统上使用的 udev 构建将得到官方支持,合并主要是构建系统的变更。然而,仅仅几个月后的 8 月,Lennart 就公开表示,非 systemd 系统上的 udev 是一条“死胡同”:
是的,如果你还没有注意到的话,我们认为在非 systemd 系统上使用 udev 是一条死路。我期待着我们完全放弃这种支持的那一天。
这一令人担忧的声明,加上其他各种不满,导致了备受争议的 eudev 的诞生。
这种不确定性在 2014 年 5 月进一步加剧,当时 Lennart 发布了著名的“Gentoo 用户们,这是你们的警钟”信息,其中讨论了 udev 将过渡到使用 kdbus 作为传输方式:
……还请注意,我们打算将 udev 迁移到使用 kdbus 作为传输方式,并放弃至今使用的用户空间到用户空间的基于 netlink 的传输方式。除非那些反对 systemd 的人在此之前准备了另一个 kdbus 用户空间部分的实现,否则这将意味着从那时起我们将不再支持非 systemd 系统上的 udev。Gentoo 用户们,这是你们的警钟。
Lennart 进一步明确,这将涉及对 libudev 的更改,这明显违背了之前的兼容性承诺:
无论如何,一旦 kdbus 合并完成,我们将按照这种方式维护 udev。你们有足够的时间来找到适合你们的解决方案,但我们将不再支持基于 netlink 的 udev。我目前能想到三种选择:
- 创建分支。
- 与 systemd 共存。
- 如果你非常反感 systemd,但又非常依赖 udev,那么可以为 kdbus 实现一个替代的用户空间部分,以进行初始化、策略和激活操作。
还要注意的是,这不仅仅是 udev 和 libudev 内部的更改。我们预计客户端很快就会开始像使用其他系统服务一样,直接向新的 udev 发送总线调用,而不是使用 libudev。
KDBus(首次亮相于 2014 年 1 月),其出现有以下几个原因:首先,旨在解除 systemd 对 D-Bus 守护进程的循环依赖,从而可以移除 /run/systemd/private 套接字的代码,并解决关机时日志的顺序问题。此外,还旨在提高日志吞吐量和实现更广泛的性能优化。
(注:基本的依赖循环是,D-Bus 需要一个日志服务,而日志服务又需要 systemd,systemd 又需要 D-Bus。实际上,直到今天,在系统启动的早期阶段,systemd 仍会在 /run/systemd/units 目录中专门暴露某些单元属性供日志消费,以便在查询 D-Bus 之前能够正常使用。正如 src/core/unit.c 中的一段注释所述:“理想情况下,journald 应该像其他程序一样,通过 IPC 查询此信息,但是只要 IPC 系统本身和 PID 1 也要向日志中写入数据,这将是一件非常困难的事情。”如果存在内核级的 D-Bus,那么它在系统启动的早期阶段就可用,从而打破这个循环。尽管正确的做法是不应该(滥用)D-Bus 来记录 PID 1 的信息,但现状如此。)
kdbus 最初的动机似乎是作为一种基于能力(Capability)的进程间通信机制,用于应用程序沙箱化,特别是为 GNOME 中名为“Portals”的应用功能而设计,类似于能力安全机制中的 powerboxes。该方案最终通过 xdg-app(即后来的 Flatpak)上的纯 D-Bus 实现。
此外,Lennart 在 2013 年 10 月发表的一篇 Google+ 博文中还透露,该技术将用于服务发现,帮助守护进程之间进行通信:
另外,还有 kdbus。kdbus 的用户空间部分几乎完全位于 systemd 内部。kdbus 的激活使用与 systemd 的套接字激活相同的机制,而且你无法将这一部分从 systemd 中剥离。基本上,D-Bus 守护进程已经被 systemd(以及内核)吸收,没有它就无法实现这一逻辑。事实上,这一逻辑甚至扩展到各个守护进程,因为如果它们想要实现套接字激活,就需要编写 systemd 的 .busname 和 .service 单元文件,而不是旧的套接字激活文件。然后,整个过程中最复杂的部分之一是重新编排服务,其任务是将旧的 dbus1 消息转换为 kdbus GVariant 编组格式,而后者是一个 systemd 套接字服务,因此无法将其从 systemd 中分离。
截至 2015 年 6 月,在 systemd-221 版本中,kdbus 支持已成为可强制的构建时选项,并被鼓励在上游发行版的开发和测试分支中使用。从 2015 年 7 月开始,kdbus 被包含在 Fedora Rawhide 内核中,直到 2015 年 11 月才被移除。
最后,但同样重要的是,cgroupv2 朝着统一 cgroup 层次结构的方向进行了重新设计。当时对此的概述是:不再将每个 cgroup 控制器与独立的树/层次结构相关联,而是将它们全部合并到一个层次结构中。然而,更大的问题是,在 2013 年提出这一想法时,Lennart 设想 cgroups 应该具有严格的单一写入者约束,即系统上只允许一个进程写入树,不允许对子树进行委派。由于 systemd(即 PID 1 进程)将 cgroups 作为核心功能,并在大多数 GNU/Linux 系统中被广泛采用,这使得 systemd 成为了 cgroups 使用上的事实垄断者,这对容器运行时开发人员和高级用户不利——除非他们放弃 systemd。
这是 2013 年一件不可忽视的事件,LWN 对此进行了大量报道。但令人惊讶的是,作为 systemd 传奇中的关键时刻,它却被许多人遗忘了。cgroupv2 与 GNOME/logind、kdbus 和 udev 一起渗透进 GNU/Linux,形成了一个相互关联的系统。GNOME 和 systemd 开发者的主要辩护之一是,GNOME 并不依赖于 systemd,而只依赖于 logind,理论上 logind 可以通过其他实现来模拟(尽管 systemd 自身的接口稳定性图表并非如此)。但在这些新进展面前,这种说法变得毫无意义。GNOME 的 Olav Vitters 承认了这一点。在 2013 年 12 月,负责 Debian GNOME 打包的 Josselin Mouette 解释说:
systemd 开发者正在与内核 cgroups 开发者密切合作,为 cgroup v1 被废弃后的第三阶段做准备。我不确定 cgmanager 是否能够做到同样的事情:据我与更了解情况的人的讨论得知,它只是将当前的 cgroups API 以 D-Bus 调用的形式暴露出来,这种方法在 cgroups API 发生变化时无法透明地工作。因此,我们最终可能只有一个可用的 cgroups 仲裁者:systemd。
其他部分必须迁移到基于 D-Bus 的接口。问题是,到目前为止,systemd 和 cgmanager 的开发者尚未就一个公共 API 达成一致。对于那些使用 cgroups 的服务,其后果不难推断——一些服务将仅支持 systemd,一些将通过编写更复杂的代码以同时支持两者,还有一些将等待一个“标准”出现,而不会在过渡方面投入精力。
同样,在 2014 年 1 月,他与 Upstart 开发者 Steve Langasek 的交流中,也放弃了 logind 可作为独立于 systemd 的组件的幻想:
实际上,logind 在未使用 systemd 作为 init 程序时能够正常工作,完全是巧合,因为 logind 从一开始就被设计为 systemd 的不可分割部分(特别是由于 cgroups 的设计)。具体的变化可能与预期的内核变更有关(但目前尚未实现),但即使不是因为 cgroups,也会由于其他原因导致这一结果。
Lennart 本人在 2013 年 10 月发表的 Google+ 文章中写道:
内核开发者希望用户空间有一个单一的仲裁组件来管理 cgroups。在 systemd 系统中,systemd 就是这个组件,你无法将其从 systemd 中分离。Upstart 在这一领域完全没有取得任何成果,甚至没有具体的计划。有人幻想让一个辅助守护进程承担 cgroups 仲裁者的角色,但这是一个复杂的任务,不是说说就能完成的。我非常确定 Ubuntu 团队甚至根本不了解这种复杂性。控制组当然是现代服务器领域的核心工作之一。服务的资源管理是服务管理的主要部分(如果不是最大的部分)。如果你想在服务器领域保持相关性,就必须在这一领域有所作为。systemd 提供的 cgroups API 非常特定于 systemd,因此,无论 Upstart 将来提出何种解决方案,都不太可能与之兼容。当然,到那时,Linux 生态系统的大部分应该都已经在使用 systemd API……
2013 年 6 月,在 systemd 邮件列表上的一篇帖子讨论了 systemd 作为 cgroups 单一写入者的 D-Bus API 应该如何设计。值得注意的是,内核黑客 Andy Lutomirski 提出了一种可能的解决方案:通过加强 subreaper
系统调用以可靠地跟踪进程,从而完全避免使用 systemd 的 cgroups,允许将 cgroups 的管理权移交给不同的进程——对于这种建议,systemd 的开发者毫不感兴趣。
无论如何,Lennart 宣布即将到来的这一变化时的措辞颇为夸张:
cgroups 层次结构已成为 systemd 的私有财产。systemd 将负责构建、维护和重新组织它。其他想要使用 cgroups 的软件只能通过 systemd 的 API 来实现。这种单一写入者的逻辑是绝对必要的,因为各控制器、属性和 cgroups 之间的相互依赖关系并不明显,我们不能允许 cgroup 用户独立地改变树结构。因此,“Pax Control Groups” 文件已成为过去,已经过时。
这毫无疑问导致了大规模的争论,尤其是在 LKML(Linux 内核邮件列表)上,Lennart Poettering 与谷歌开发者 Tim Hockin 展开了激烈的辩论。其中的亮点之一是,当模块化工具包(如 libcgroup)即将被废弃时,Lennart 突然插话说:“systemd 绝对不是任何意义上的单一系统。”Tim Hockin 后来表示,他试图在 systemd 开发者和 cgmanager 开发者之间制定一个通用的 cgroup API 标准,但遭到了拒绝。cgmanager 是当时 LXC 和 Ubuntu 感兴趣的一种替代性 cgroup 管理工具。
因此,当 Lennart 在 2013 年 10 月表示 Linux 低层用户空间位于 systemd 中时,他的说法完全正确:
我认为,归根结底,这实际上归结为以下几点:如今的 Linux 低层用户空间在很大程度上是在 systemd 的源代码树中构建的。忽视这一点意味着你必须不断地处理一个拼凑的系统,需要将不该放在一起的过时组件组合起来。尽管从许多方面来看它们可能运作良好,但它们几乎没有任何整合性或一致性。换句话说,你处于一个十字路口:要么选择大多数参与 Linux 核心操作系统开发的人(无论他们在 Red Hat、Intel、SUSE 还是三星等其他地方工作)所采用的路径,要么选择 Canonical 正在使用的路径(如果我还不够明确,那么就是……“有限”的路径)。
邮件列表上的一些人声称我们别有用心。实际上,这是完全正确的,毕竟每个人都有自己的意图。我们的目标是创建一个出色的、相对统一、集成的操作系统。这就是我们的全部野心。然而,我们的目标中并不包括所谓的“摧毁 UNIX”、“掠夺 Linux 市场”或“绑架”等内容。请注意,logind、kdbus 或 cgroups 等都是新技术,我们只是编写了这些技术,并未破坏过去的任何东西。因此,我们并没有退步,只是在添加我们认为对人们非常有趣的新组件(显然如此,因为现在有很多人在使用)。对我们而言,拥有简单的设计和代码库,远比适应那些想要将所有现成组件拼凑在一起的发行版更重要。我理解这对许多 Debian 用户来说有多重要,但坦率地说,这并不是我们的首要任务。
此外,2014 年 10 月,systemd 核心开发者 Zbigniew Jędrzejewski-Szmek 将运行不同的 init 系统比作运行不同的处理器架构,这展示了 systemd 对世界的高度概括性愿景:
对于影响整个操作系统的这种基本功能,如果维护者使用不同的 init 系统,对于用户来说就像是运行在不同的架构上一样。
在 Debian 或其他社区发行版中,2013-2014 年的情况是,主要桌面环境依赖于 logind 进行电源管理。这意味着,由于 cgroups 的单一写入者约束,也就必须依赖于 systemd。此外,systemd 还负责管理用户空间和服务激活层,这为 kdbus(以及 udev 提供的热插拔功能)提供了支持,在此之上,为服务的使用者提供了注册良好的接口。这是当时似乎不可避免的方向,因此在讨论之前,决策就已经被确定下来了。
满足与迷茫
当然,事情并未按预期发展。在受到 Andy Lutomirski 和 Eric W. Biederman 等内核维护者的阻碍后,kdbus 最终变成了 “kdbustwreck”,这让所有对桌面 Linux 充满期望的人们感到非常失望。随后的尝试,例如 BUS1,也未取得成功。
cgroupv2 API 最终确实实现了子树委派机制,虽然 systemd 以自己的方式对其进行了暴露,但大部分关于单一写入者的设计仍被保留下来。此外,从 2013 年 9 月开始的四年里,开发者在他们的 Wiki 上保留了一份关于统一 cgroup 层次结构的误导性文档,这似乎是一个已完成的任务,而事实上是一项尚未实现的提案。直到 2017 年 11 月,该文档才被更新,提及了 systemd 的子树委派(即 Delegate=yes)选项。
无论如何,这些发展都发挥了它们的宣传价值。到 2015 年,systemd 已经牢牢确立了自己的地位。
systemd 的胜利是以牺牲其一大部分愿景为代价的。随着 kdbus 和具有单一写入者限制的 cgroupv2 API 的失败,“systemd 作为 Mach 服务器”的世界早已崩塌。依赖项被大量滥用于系统早期启动之外,并在各种条件激活范式中占据主导地位。然而,许多辅助工具,如 hostnamed、timedated、localed 和 logind,以及容器工具如 machined 和 nspawn,都取得了成功。tmpfiles(以及后来的 sysusers)也在配置管理中得到广泛采用。
一旦征服的激情耗尽,项目就会进入长期的平庸状态。然而,关于“进步”和“推动事物向前发展”的意识形态诉求始终存在,因此,必须找到新的方向和理由来维持开发者及其追随者的热情。
当 systemd 开发团队在 2015 年举办了首届年度会议“systemd.conf” 时,就意味着他们已经陷入了这种状态。到了 2017 年,该会议更名为 “All Systems Go!”,讨论重点转向更广泛的 Linux 用户空间。不过,观察这个项目本身多年来的市场演变历程,仍颇具启发性。
自 2010 年以来,实现跨发行版的统一一直是 systemd 的目标,本文前面也有记录。在 2011 年 1 月的 LCA 演讲《超越 init》 中,Poettering 将 systemd 描述为 “Linux 的系统和会话管理器”,同时又称其为 “操作系统的基础构建模块” 以及 “跨发行版标准化的基础”。当时列出的未来任务相当有限:会话管理和自动 initrd 备份。
在 2012 年的 LinuxCon Europe 上,LWN 发表了一篇出人意料的赞扬文章,称 “……开发者对 systemd 进行了一些重新定义,使其不仅仅是一个 init 系统,也是一个平台。” 此外:
遗憾的是,Lennart 的时间不够,他无法详细阐述他对 systemd 未来发展的想法。然而,经过两年,很明显 systemd 已经成为 Linux 生态系统中不可或缺的一部分,越来越多的迹象表明它正朝着成为操作系统核心部分的方向发展。
2013 年,Lennart 提交了一份题为《systemd:两年后的进展》的报告,文中 systemd 既被称为 init 系统,也被称为平台。它面向所有硬件平台:移动设备、嵌入式设备、桌面和服务器。因此,未来的任务也变得更加雄心勃勃,这与我们对 2013-2014 年的分析相符——即认为,那个时期是 systemd 开发的“关键时期”或巅峰时期:容器支持、云/集群支持、kdbus,以及模糊定义的 “应用程序” 被承诺为未来的发展方向。
在 Lennart 2014 年(在中国北京进行)的 GNOME.Asia 演讲中,systemd 被描述为 “系统和服务管理器,一个平台,一个连接应用程序和内核的粘合剂”,并已实现被 Linux 发行版广泛采用的目标。其终极目标则更为自负和宏大:“将 Linux 从一堆零散的组件转变为一个具有竞争力的通用操作系统”、“构建互联网的下一代操作系统”、“消除发行版之间的无谓差异”、“将创新带回核心操作系统”、“自动发现、即插即用是关键”。此外,Lennart 明确表示 systemd 是一个开放式项目:“永无止境,永不完成,永远追随技术的进步。” 并且还带着一个狡黠的暗示:“systemd 不是大教堂,只是用于建造它的砖块。” 当时列出的未来方向包括:网络管理、kdbus、NTP、容器、沙箱化、无状态系统/可实例化系统/出厂重置、与云的集成。
从各种流行语的涌现可以看出,systemd 开发团队已经开始缺乏专注——在当时的胜利之后,他们开始无边无际地畅想未来的辉煌。
2014 年 10 月,我们可以读到:“我们开发 systemd 的目的是提供一个强大的平台,一个唯一的平台。如果人们想在其他环境中使用我们的代码,那当然没问题,但是请理解,我不会为此做任何协助,也不会维护它,我不想在我的代码中看到这些东西。”
2014 年底,我们有幸听到了一场关于无状态系统的演讲。在这场演讲中,具体的内容其实不多,主要介绍了 tmpfiles 和 sysusers,其余部分则是关于 btrfs 子卷和动态填充 /etc 和 /var 等推测性的讨论。实际上,“无状态 Linux” 是 Red Hat 开发人员早在 2004 年就开始断断续续尝试的项目。
2015 年是 systemd 首次举办会议的一年,但在开发进度上相对来说是比较缓慢的。将 gummiboot 合并到 systemd-boot 是一个亮点,此外 networkd 的改进以及 systemd-importd 的引入也值得一提。systemd-resolved 是从 networkd 中衍生出来的,因为后者从 2013 年 11 月开始扩展了其工作范围。
2016 年的亮点是便携式服务,这是一种基于原始磁盘镜像或 btrfs 子卷的系统服务容器格式。
2017 年,systemd.conf 更名为 “All Systems Go!”,内容也开始变得越来越乏味。那一年,Lennart 的演讲主题是《无需容器管理器的容器》,主要介绍了 systemd 的命名空间、seccomp-bpf 和绑定挂载等功能。此外,还推出了一个名为 “动态用户”(Dynamic Users)的 UID 随机化功能,但反响褒贬不一。
2018 年和2019 年的演讲同样乏善可陈且杂乱无章。systemd 的新增亮点是 systemd-homed,它实际上是一个类似于 90 年代 Sun 的 NIS/YP 命名服务的新命名服务,但专门用于主目录。
总的来说,随着 systemd 在 2014 年左右达到发展巅峰并随后失去其光芒,systemd 似乎已将重点转向改进容器化部署的工具,以符合 Linux 基金会成员当前的商业利益。Poettering 本人早在 2015 年 11 月就给出了颇有启示性的暗示:
sysusers 绝对是我们应该在 Fedora 中默认使用的东西,因为它可以在广泛的发行版中使用,使用户注册变得便携,同时这也是 Atomic 所需要的机制。
“Atomic” 在这里指的是红帽的云发行版项目 “Project Atomic”,它与诸如 rpm-ostree 等项目重叠,后来成为 Fedora CoreOS 和 Silverblue 的基础。CoreOS 团队在 2015 年 9 月的一次问答中更明确地谈到了这一转变。
这一方向也在 2014 年 9 月发表的《重新审视我们如何构建 Linux 系统》一文中有所预示,该文概述了 systemd 在未来几年内的高层开发目标。其中许多工作并非直接出自 systemd 项目:例如 Flatpak 和 OSTree 等。在 systemd 内部,这一方向的成果包括便携式服务和 systemd-homed 等。
systemd 今后将如何随着诸如 pidfds、重新设计的挂载 API 等内核新发展,以及通过 eBPF 将 Linux 转变为某种可扩展混合内核的总体趋势而演进,尚待解答。它是否能够继续前进,抑或其活力已经枯竭?无论如何,目前看来,只有内部的分裂才有可能打破其主导地位。
让我们总结一下有关systemd的历史环节:
- systemd 从一开始就肩负着实现跨发行版标准化的宏大抱负,早在 2011 年 1 月就已自称为 “操作系统的基本构建块”。与 GNOME 的集成也在同一时间开始规划。systemd 仅作为 init 系统存在的时间窗口非常短暂,最多只有半年。
- 它被采用的根本原因在很大程度上既是社会和网络效应,也是技术评估的结果。许多发行版的开发者因分散的集市开发方式而感到疲惫,渴望有机会将低层用户空间整合到一个中央的上游代码库中。多年来 init 脚本的冗余累积导致了越来越难以维护的混乱代码。systemd 通过单元文件的 “干净重写” 以及对各大主要项目的深入整合,最终推动发行版维护者加快了停滞不前的 init 现代化工作。Upstart 效果较差的部分原因是,它允许在 Job 配置文件中逐字执行 init 脚本(算是一种妥协),且其事件模型晦涩难懂。
- 许多具体的或计划中的发展,例如 GNOME 对 systemd 不同组件依赖的不断增加、kdbus 看似不可避免的到来、整个 D-Bus 生态系统的彻底改造、停止在非 systemd 系统上支持 udev 的计划、将 kdbus API 用于 libudev 的计划,以及重新设计的 cgroupv2 API 的单一写入者限制——所有这些发展在相对较短的时间内交汇在一起,造成了一种不可阻挡的趋势——发行版要么被整合,要么变得无关紧要。
- systemd 围绕其原作者的宏伟愿景构建,这个愿景并未被完全阐述,但可以通过阅读其开发者的早期材料来重建。其预期的使用方式(普遍的套接字和 D-Bus 激活)与实际使用情况不符,再加上几次被内核维护者挫败,导致项目整体方向丧失,开发变得越来越随意、被动,甚至接近于停滞(这通常被更委婉地称为 “成熟”)。
- 由于 GNOME/Red Hat/SUSE 在过去几年里不断尝试通过 D-Bus 接口和 D-Bus 激活进行服务管理,但均未取得成功,因此 systemd 先天对它们具有一定的吸引力。相反,直到相对较晚的时候,这项工作才成为 Upstart 或其他任何 init 系统的关注重点。
- 即使软件是自由的,马基雅维利主义和联盟政治也不会消失。
systemd的技术批评
systemd 不是一个基于依赖关系的 init 系统,这或许听起来有些离奇。毕竟,它为编写单元文件的开发者提供了丰富的依赖类型。确切的数量难以精确计算,因为 systemd 内部对依赖的理解与它向用户展示的有所不同,且许多配置选项要么具有类似依赖的副作用,要么最终被转换为实际的依赖关系。
十年来,仍没有一份令人满意的 systemd 架构系统化概述。我们不得不通过浏览邮件列表、Bug 报告和源代码来深入理解。例如,在这个 Bug 报告中,一位用户对于一个失败的服务仍能满足Wants=
依赖感到困惑。对此,Poettering 模糊地回应道:“systemd 是一个作业引擎”,随后 Andrei Borzenkov 进一步解释:“systemd 确实是一个作业引擎,并且在作业之间定义了依赖关系。”systemd 的开发者和几乎所有公开文档都围绕着“以单元为中心”来描述 systemd(除非在讨论 Bug 时)。但我认为,对于作为服务管理器的 systemd 而言,最核心的结构不是单元,而是作业。因此,理解 systemd 应从“以作业为中心”的角度出发。systemd 的许多复杂性源于单元与为其排队的作业在语义上并非一一对应。
基于以上理解,我对 systemd 作出如下简要定义:
systemd 的具体定义
systemd 是一个事件驱动的对象管理器,具备类似于依赖关系的副作用。它将原始的内核资源和用户空间子系统封装为一种通用的对象类型——“单元”(Unit)。这些单元对象通过“作业”(Job)的状态传播机制进行调度,并由名为 Manager 的单例对象动态管理。Manager 负责在“事务”(Transaction)中启动作业,执行合并、循环排序和一致性检查,并作为引入单元依赖关系的主要节点。单元的启动以非幂等的并行数据流方式执行,在作业层面提供弱排序保证,通常与依赖单元的活动状态(Active State)无关。
单元(Unit)
单元是 systemd 为终端用户建模时采用的核心抽象概念。
单元表示具有通用方法(如启动、停止、重载等)的工作单元对象。这些方法被分派到针对每种单元类型的多态虚函数表(Vtable)中。目前共有 11 种类型:.service
、.socket
、.target
、.device
、.mount
、.automount
、.swap
、.timer
、.path
、.slice
和.scope
。每个单元都关联到一个 Manager 对象,描述了 systemd 自身的一个实例,可能是系统范围的,也可能是每个用户会话的。单元包含加载状态(Load State)、活动状态(Active State)、描述(Description)和文档(Documentation)等元数据,一个哈希表用于存储其依赖项(Dependency),以及条件(Condition)和断言(Assertion)检查的列表。每个单元都有一个表示状态变更请求的作业槽(Job Slot)。他们还引用了关联 Manager 的加载队列(Load Queue)、运行队列(Run Queue)和 D-Bus 队列(D-Bus Queue)。此外,还有与单元执行状态相关的各种数据,以及在特殊情况下导致行为临时变更的布尔值。
单元的活动状态包括 “active”、“activating”、“inactive”、“deactivating”、“failed”、“reloading”和“maintenance”。每种类型的单元都有一个状态映射表,将特定于类型的活动状态映射到通用的活动状态。
单元依赖关系可分为以下几类:顺序依赖(Ordering)与需求依赖(Requirement)、正向依赖(Forward)与反向依赖(Inverse)、重载传播依赖(Reload Propagate)等。顺序依赖在并行启动过程中至关重要,会影响作业的完成顺序,可能触发不同的失败状态。需求依赖用于触发作业的状态变化传播。
顺序依赖由Before=
和After=
控制。正向依赖有Requires=
、Wants=
、BindsTo=
和Requisite=
,其对应的反向依赖是RequiredBy=
、WantedBy=
、BoundBy=
和RequisiteOf=
,这些反向依赖仅在 systemd 内部使用。PropagatesReloadTo=
及其反向ReloadPropagatedFrom=
是独立的一类。Conflicts=
及其反向ConflictedBy=
是一种特殊的“负向”依赖。PartOf=
实际上是一种反向依赖,其正向形式ConsistsOf=
纯粹供内部使用。OnFailure=
和JoinsNamespaceOf=
也被 systemd 视为单元依赖类型。
套接字(Socket)、路径(Path)、定时器(Timer)和自动挂载(Automount)的激活机制通过Triggers=
和TriggeredBy=
依赖实现,这些依赖对终端用户不可直接使用。References=
和ReferencedBy=
用于垃圾回收单元。
RequiresMountsFor=
路径依赖存储在独立的哈希表中,与其他依赖类型分开处理。
每当添加一个单元依赖项时,都会标记一个单元依赖掩码,以指示其来源。通过单元文件添加的依赖被标记为UNIT_DEPENDENCY_FILE
。但大多数掩码是为 systemd 通过编程合成的单元设计的(包括隐式和默认的,分别代表隐式依赖项和默认依赖项),无需用户直接输入,例如设备的UNIT_DEPENDENCY_UDEV
、交换分区的UNIT_DEPENDENCY_PROC_SWAP
、挂载点的UNIT_DEPENDENCY_MOUNTINFO_*
等。
单元文件(Unit Files)只是通过显式元数据清单加载单元对象的一种方式。它们具有独特的“单元安装”逻辑([Install]
指令),该逻辑遵循纯字典排序,与 systemd 的单元和作业机制的其他部分完全不同。这使得 systemd 可以启动类似默认目标(Default Target)的“目标服务”,从中递归加载单元及其依赖项,以构建初始启动事务。
除了在传播状态变化时作为通用的“节点”对象之外,单元并不是一个高度内聚的抽象——它们在各方面可能存在差异。例如,引入的默认依赖和隐式依赖、是否可以通过文件创建、是否是长期存在的、是否仅能运行一次、是否支持排序或其他特定依赖(如触发器)、是否支持启动、停止或重载、是否封装进程(是否有“主进程”、“控制进程”或两者皆有)、是否可作为临时单元(Transient Unit)生成、甚至是否存在失败状态。
作业(Job)
作业是与 Manager 对象关联的单元状态变更请求,其副作用包括为单元依赖关系提供解决方案。
一个作业包含四个属性:类型(Type)、状态(State)、模式(Mode)和结果(Result)。
作业类型是使单元过渡到不同状态的动作,包括JOB_START
、JOB_STOP
、JOB_RESTART
(即一开始是JOB_STOP
,随后变为JOB_START
)、JOB_RELOAD
、JOB_TRY_RESTART
和JOB_VERIFY_ACTIVE
。实际上,最后一个就是Requisite=
队列的内容——并非状态转换,而是状态检查。同一时间只能为给定的单元运行一个作业。在 systemd 中,依赖关系主要涉及作业如何传播和影响其他单元。
一些复杂的作业类型如JOB_TRY_RESTART
、JOB_TRY_RELOAD
和JOB_RELOAD_OR_START
,可能会根据单元的活动状态(Active State)分别被合并为JOB_RESTART
、JOB_RELOAD
和JOB_RELOAD
。
作业状态(Job State)非常简单,要么是 “waiting”,要么是 “running”。例如,一旦停止作业达到 “done” 结果,重启作业就会进入 “waiting” 状态,并将其类型更改为启动作业。
作业模式(Job Mode),如systemctl
中的--job-mode
选项所示,影响作业应如何抢占其他已排队的作业。这不仅关系到与待处理作业的冲突(例如,一个处于 waiting 状态的启动作业可能被转换为停止作业)是否应该失败或成功替换,还涉及单元级别的全局变化。例如,JOB_ISOLATE
用于停止除被隔离单元之外的所有其他单元,JOB_IGNORE_DEPENDENCIES
则强制执行作业,忽略顺序依赖和需求依赖。
作业结果(Job Result)即作业的最终状态,包括多种情况,如JOB_DONE
、JOB_CANCELED
、JOB_DEPENDENCY
、JOB_TIMEOUT
、JOB_SKIPPED
等。单元方法状态机(启动 / 停止 / 重载等)的错误代码会传递到作业结果中,使得作业结果通常能反映出单元方法的错误,并具有不同的含义,例如 “单元未加载”、“单元不支持启动”、“无法再次启动”、“操作已在进行中”等。
作业可以通过服务管理器显式触发,无论是通过总线,作为事务依赖添加的一部分,还是由 Manager 在构建事务时以其他方式引入——例如,从文件启动一个单元的常规流程。此外,每种单元类型都会调用一个unit_notify
方法,并可选择传递特定于单元类型的通知标志。这适用于所有低级或特定类型的状态变更(如进程退出、发送终止信号或超时到期),因此包括那些并非源自作业但仍导致作业被排队的变更。例如,这就是为具有自动重启(Auto-restart)功能的服务单元(.service
单元)传播OnFailure=
依赖的方式。我们可以将这些作业称为“隐式作业”。
事务与 Manager 对象
Manager 是一个单例对象(每个 systemd 辅助守护进程,如logind
和networkd
,也有类似的对象),负责在运行、加载和 D-Bus 队列过程中调度作业和事务。它还包含设备、挂载点和 Swap 特定的数据。所有显式作业,包括通过systemctl
启动的作业,都通过 Manager 进行。Manager 对象还负责全局系统状态转换,例如poweroff
、reboot
、halt
、isolate
等。
Manager 触发的作业在所谓的“事务”(Transaction)中启动。实际上,直到 systemd-183 之前,事务构建器都与 Manager 的实现位于同一个源文件中。事务总是从一个“锚定作业”(即调用者请求的作业)开始,递归地为依赖单元添加作业。事务旨在执行某些合理性检查,如检测排序循环、防止冲突作业运行,并尝试通过作业合并规则解决冲突。例如,一个单元上的JOB_VERIFY_ACTIVE
和JOB_START
作业将被合并为后者。由此可见,systemd 中的依赖关系通常不是累加性的约束,而是遵循一定的优先级层次。
systemd 事务的一个重要微妙之处在于,它们在计算时独立于当前单元的运行状态,因此启动作业是非幂等的——它们总是会“唤醒”单元的依赖项,即使该单元已经启动。这正是设计使然,systemd 开发者 Zbigniew Jędrzejewski-Szmek 如此解释:
从 systemd 的角度来看一些背景信息:当启动一个服务时,systemd 会递归遍历完整的依赖树,即使对于已经启动的服务和目标也是如此。因此,例如,在某个时刻我们有一个像
httpd.service/start
这样的作业,我们将遍历它的所有依赖,通常包括sysinit.target
,然后是local-fs.target
,并为依赖树中任何未运行的单元(或在Type=oneshot
/RemainAfterExit=yes
的情况下未运行过的单元)调用启动作业。这样做增加了系统的鲁棒性,因为即使没有明确地重启该单元,新的依赖项也需要在配置后被启动。而且如果发生故障,它们通常需要被再次启动。另一方面,这使得 PID1 每次启动某个作业时都确保遍历整个单元树。此外,它还有一个缺点,即即使我们不期望,依赖树中的单元也会被启动。这个问题之前已经讨论过,我认为如果我们尝试改变这种行为,将会是很有趣的探索。但这将对基础产生非常重大的改变,我不确定是否能带来更好的结果。因此,在可预见的未来,这种现状都不会改变。
直到 2019 年 1 月,在 Jonathon Kowalski 提交的 PR 之后,systemd 文档才得到更新,声明如下:
请注意,事务的生成独立于单元的运行时状态,因此,例如,如果请求启动一个已经启动的单元,它仍然会生成一个事务并唤醒任何非活动的依赖关系(并根据定义的关系传播其他作业)。这是因为排队的作业在执行时会与该单元的状态进行比对,并在条件满足时被标记为成功和完成。然而,这个作业也会因为定义的关系而触发其他依赖项,因此在我们的例子中,依赖的任何非活动单元的启动作业也会被排队。
同时在systemctl
中的 --show-transaction
:
请注意,输出中只会包含与事务请求直接相关的作业。由于已排队的作业可能会触发启动服务的程序代码,因此可能会进一步引入更多作业。这意味着列出的作业的完成最终可能涉及到更多作业。
一个伪唤醒和非幂等性的典型例子是JOB_ISOLATE
,这是systemctl isolate
背后的作业模式,用于模拟运行级别的功能。一个隔离作业将重新运行未设置RemainAfterExit=yes
的一次性(Type=oneshot
)服务,终止用户在当前作用域中的服务,停止套接字激活的服务,以及终止特定硬件的目标单元。这使得即使是systemd 核心开发者也不愿推荐使用它。回想 Lennart 在 2011 年 3 月的声明:“正在运行的服务集合不再是系统启动时加载的那个,而是在过去某个时间段内触发的所有服务的总和。如果将触发器与其结合使用,其工作方式甚至可能也会不同。”
此外,由于:
- 顺序依赖是在作业而非单元级别上评估的(这一点甚至让开发 Snappy 的 Canonical 开发者们感到困惑)。
- systemd 的“事务”实际上无法同时合并处理多个单元,因此你调用多个单元进行启动 / 停止操作的顺序很重要。因此,服务的启动和重启结果可能是不确定的,正如这里在一个简单的案例中描述的。
在rpcbind.service
和rpcbind.socket
单元文件中,出现了一个更加微妙的问题。有时执行systemctl restart rpcbind.service rpcbind.socket
命令会成功,有时则会失败,这导致了 Debian 系统升级时出现的一个严重 Bug。
Poettering 在解释时,提到了syslog.socket
和rsyslog.service
类似的情况:
需要注意的是,该命令首先为第一个提及的单元添加重启作业,然后为第二个单元添加重启作业。然后它将等待两个作业完成。在执行
systemctl restart rsyslog.service syslog.socket
的情况下,可能会发生这样的情况:服务首先被停止,然后是套接字被停止,然后服务再次启动。现在,当套接字即将再次启动时,服务已经以非套接字激活的方式启动了,在这种情况下,套接字单元将拒绝启动,以防止在使用套接字激活的情况下,服务自己创建的套接字被破坏。这个问题可以通过在服务单元和套接字单元之间添加更严格的依赖,以确保始终要求套接字在服务之前启动来解决。不过,这是上游各个包维护者需要解决的问题。
替代的解决办法是,只重启服务单元,而不理会套接字单元……
因此,顺序依赖和特定单元类型的策略之间的相互作用会产生一些有趣的竞争条件。
命名的不一致与失败的抽象
所有 systemd 的官方文档、公开可用的单元文件指令和systemctl
工具,揭示了 systemd 在展示其内部机制的方式上的矛盾之处。总体而言,当前的 systemd 希望你纯粹从“单元”和“单元之间的依赖关系”来考虑问题。
然而,与此同时,systemctl
又允许你在排队作业类型时选择作业模式(但从未明确解释过作业类型和作业结果本身)。systemd.unit
提供了一个CollectMode=
指令来调整单元垃圾回收逻辑,以及一个OnFailureJobMode=
指令,主要用于上层封装的目标单元,将其模式设置为 “replace-irreversibly”。
对于依赖指令的解释相当模糊,并未明确说明哪些作业类型会传播,哪些则会引发失败状态。例如,你永远不会看到Requisite=
排队一个JOB_VERIFY_ACTIVE
作业,尽管这会使该指令的意义更加清晰。
这意味着大多数人对 systemd 的运行机制存在一种错误的“民间”认知模型。尽管 systemd 已经成为一个根深蒂固的标准并且过去了十年,但开发人员却认为没有必要编写一份完整的规范说明。
让我们从一些更简单的例子开始,然后再深入探讨 systemd 依赖指令的细节。
套接字单元不仅封装套接字,而且还封装其他 IPC(进程间通信)端点,如 POSIX 消息队列、FIFO(先进先出队列)、字符设备和虚拟文件。最令人惊奇的是,它还封装了像 USB GadgetFS 描述符这样特定的对象。这意味着套接字单元在可扩展性方面存在一定的不足。
挂载单元有些复杂,其特性根据其生成方式的不同而有所不同。systemd 会自动根据/proc/self/mountinfo
生成挂载单元;然而从.mount
单元文件加载的挂载单元实际上会让 systemd 直接执行util-linux
中的/bin/mount
二进制程序(MountExecCommand=
),这可以在 D-Bus 属性中的ExecMount
中看到,也可以通过systemctl status
查看。我们必须记住这种区别,因为在前一种情况下,为挂载单元编写插入替换(Drop-in)单元是没有意义的,而在后一种情况下则不同。
Swap 单元也有类似的区别,可分为根据/proc/swaps
生成的 Swap 单元,以及通过.swap
单元文件配置的 Swap 单元,后者会让 systemd 执行/sbin/swapon
二进制程序(SwapExecCommand=
)。此外,还有几种“外部挂载”,它们不会生成挂载单元。但据我所知,用户无法让自己的挂载点被 systemd 视为这种“外部”类型,以便摆脱挂载单元状态机。
此外,根据/proc/self/mountinfo
创建单元的逻辑导致了一个臭名昭著的长期问题,即挂载风暴(Mount Storm,在这篇 Jane Street 的文章中也有描述),这种问题可能会轻易让一台机器陷入拒绝服务攻击(DoS)。2018 年,systemd 开发组曾尝试解决这个问题,但由于回归测试失败而未能成功。挂载单元目前也没有独立的启动和停止超时机制。
设备单元有许多来源,但其主要来源是 udev 标签,因此在某种程度上,它们是 udev 的依赖传播者。设备单元不支持Before=
和After=
这样的顺序依赖。此外,由于设备没有与之关联的停止或重启作业,所以PartOf=
在设备上不起作用,因为设备永远不会经历 stopping 状态,而是直接变为 inactive。但是,BindsTo=
却是有效的。
挂载单元和设备单元之间的交互长期受到一个 Bug 的困扰:systemd 由于无法更新挂载单元和设备单元关系之间过时的信息,而意外地卸载手动挂载点。
设备单元和目标单元没有失败状态(u->can_fail
没有被设置为true
),因此显然不能成为OnFailure=
依赖的来源。尽管如此,多年来,直到不久前的 systemd-245,上游 systemd 仍然在像local-fs.target
和initrd.target
这样的目标单元文件中提供了OnFailure=
指令,也就是说它们根本不会起作用!
OnFailure=
和JoinsNamespaceOf=
在内部被视为依赖类型。按照这个逻辑,我们至少还必须包括OnFailureJobMode=
和StopWhenUnneeded=
。此外,所有的*Directory=
指令都会被归为RequiresMountsFor=
依赖项,该依赖项本身使用单独的哈希表进行处理。路径单元和定时器单元中的Unit=
选项实际上是为该单元创建了一个Triggers=
依赖,而该依赖又有自己的JOB_TRIGGERING
模式,这与依赖倾向于作为“作业类型”而不是“作业模式”进行传播的事实并不一致。当然,Triggers=
对终端用户来说并不直接可用,即使原则上可以用来创建通用的懒加载激活关系。
DefaultDependencies=
的含义被过度赋予了,这与 systemd 整体上对“依赖”的含义被过度赋予直接相关,它涵盖了任何作业类型或状态传播的机制。尽管默认依赖可以在需要时被关闭,但每种单元类型也包含它们自己的隐式依赖,这些依赖是无法关闭的。
PartOf=
的独特之处在于它是唯一一个仅向用户提供反向逻辑的依赖类型,虽然其正向依赖ConsistsOf=
在创建“虚拟服务”(即传统的 “Provides” 关系)方面具有潜在的实用性,但它仍然完全是内部使用,用户无法直接访问。要实现这一点,我们还可以使用单元文件模板(Unit File Templating)和预设(Preset),但这些选项实际上完全不涉及作业和事务依赖传播逻辑。
systemd 的作业结果JOB_DONE
实际上并不意味着“成功”,即使存在Condition*=
失败,也会返回这个作业结果。正如 Lennart 所澄清的:
单元 A 包含
Requires=B
,并不意味着它真的关心 B 是否启动。重要的是 B 的启动作业是否成功。即使ConditionXYZ=
条件失败了,B 实际上没有启动,但它的启动作业可能成功。毕竟,条件指令被认为是“非致命”的,它们允许一个单元的启动作业即使条件不满足也能成功完成,当然,该单元之后并不会启动。
systemd 没有内置的OnDependencyFailure=
选项,来强制在 Manager 对象触发停止作业或失败状态时重启对应的服务,而不是在监视到进程异常时触发。这是因为手动的systemctl stop
和为了满足依赖的传播而在事务中排队的停止作业之间没有区别。目前针对这一问题存在许多 脆弱的临时解决方案。
顺序依赖与需求依赖按理来说应该是正交的,但是目标单元(Target Unit)并非如此:它们会自动获得所有Wants=
、PartOf=
或Requisite=
单元的After=
依赖!
作用域(Scope)单元只能被启动一次,甚至有一个特殊的作业结果JOB_ONCE
专门用于报告这一限制。出于某种原因,Slice 单元并没有同样的限制。作用域单元和 Slice 单元都是长期存在的单元,无法停止。
Conflicts=
是一个隐含ConflictedBy=
的双向关系。它们在事务构建逻辑中有特殊的集成方式。它臭名昭著地不可靠且不起作用,很容易导致 Manager 对象在系统启动时启动两个互相冲突的服务,这使得它根本无法可靠地用于创建互斥服务。由于Conflicts=
实际上只是在互相冲突的单元上排队一个停止作业,而后续事务可以抢占它,因而它根本不是一个硬性的约束。Conflicts=
主要用于shutdown.target
的默认依赖,以便计算与系统启动时完全相反的关闭顺序。事实上,Lennart Poettering 本人特别强调建议不要将其用于任何其他用途。由此,我们感觉到:它本来就不应该暴露给终端用户,但出于兼容性原因不得不这样做。
由 Manager 对象隐式传播的重载(Reload)作业(如响应设备状态变化的作业),以及通过PropagatesReloadTo=
显式触发的作业,似乎都会以JOB_IGNORE_DEPENDENCIES
模式排队,这使得它们具有某种独特性。Poettering 多次称JOB_IGNORE_DEPENDENCIES
为“糟糕的发明”、“极其丑陋”和“可怕的发明”,由此可见一斑。我相信导致这种令人畏惧的设计的原因是,systemd 中的重载作业是同步的,正如链接的邮件列表帖子中所讨论的,这可能会导致死锁,就像这个例子一样。
实际上,PropagatesReloadTo=
和ReloadPropagatedFrom=
的起源,以及它们令人匪夷所思的设计,本质上都是因为BindsTo=
不传播重载作业,这是作为一个特性被请求合并的。然而,考虑到 systemd 中“依赖”的含义,明智的做法应该是为所有作业类型公开Propagates=
指令,虽然这可能会使单元文件变得更加难以理解,但这就是 systemd 架构的本质。
类似地,我们可能期望存在RefuseManualRestart=
和RefuseManualReload=
指令,就像存在RefuseManualStart=
和RefuseManualStop=
一样,但实际上并不存在。其他不一致之处还包括没有ExecReloadPre=
/ExecReloadPost=
或ExecRestartPre=
/ExecRestartPost=
,TimeoutStopSec=
并非适用于所有类型的单元,以及Type=oneshot
的服务单元不支持ExecStopPost=
和RestartForceExitStatus=
(而且直到最近之前,它们根本不支持Restart=
)。
依赖的地狱
启动一个单元与为其排队一个启动作业,是两个截然不同的操作。一个单元是否可以启动,和是否可以为其排队启动作业,完全是两回事。在 systemd 的unit.c
源代码注释中,有这样一段话:“这是因为.device
单元和其他类似的单元并不能由我们启动,但可能会由于外部事件而出现,因此允许为其排队作业是有意义的。”这表明,systemd 的管理器(Manager)经常会根据环境自动引入用户无法操作的单元。而且,依赖关系是在单元层面还是在作业层面(作为事务的一部分)解决的,并没有明确的界限。例如,BindsTo=
指令会在底层单元状态发生任何变化时重新检查——我认为这就是它可以在设备单元中使用,而PartOf=
不能的原因。
此外,取消启动作业并不一定能阻止单元的激活,因为JOB_CANCELED
的结果并不等同于失败条件。事实上,它被明确用于在执行JOB_ISOLATE
的作业时(例如隔离进入某个目标时),避免触发OnFailure=
依赖。
有关 systemd 依赖的基本概念,最好的描述可以参见 Jonathon Kowalski 的文章。尽管文章有些杂乱无章,但它可能是最接近 systemd 作业引擎非正式规范的文档。
Requires=
具有三种不同的效果:它会排队启动作业,将停止作业传播到RequiredBy=
单元,并使依赖单元以JOB_DEPENDENCY
的结果失败。它通常与After=
一起使用,原因如下:
单独使用
Requires=
而不使用After=
时,会与 systemd 的作业机制产生有趣的互动。当你单独使用
Requires=
,例如让 a 具有Requires=b
,事务中会为这两个单元分别排队两个启动作业,但 a 的启动作业不会等待 b 的启动作业完成。因此,两者将并行进行,如果 a 的启动作业在 b 的启动作业完成之前就完成了,那么它将正常启动。但是,如果 b 的启动作业在 a 的启动作业完成之前失败了,那么 a 的启动作业将伴随着JOB_DEPENDENCY
作业结果被取消。总之,在你的情况下,作业会立即被派发,并且 a 的启动作业可能在 b 的启动作业失败之前就已经完成。这也是为什么通过文件系统符号链接获取依赖项的目标单元通常会具有隐式的顺序依赖。因为通过上述机制定义依赖项的顺序是不可能的。
现在,明确回答一下为什么停止 b 会导致停止 a?因为这正是
Requires=
应该发挥的作用。实际上,这可能是用户在Requires=
和BindsTo=
之间唯一能注意到的区别。
传统观点认为,在没有After=
的情况下使用Requires=
并没有实际用例。然而,由于它具有多种副作用,实际上还是存在一些用例的。Requires=
与Before=
结合使用,可以通过拒绝等待依赖的作业完成,有效地避免当前服务启动失败,同时在启动时传播启动作业的行为。这类似于Wants=
,但会在RequiredBy=
的单元上传播停止作业,这与Wants=
不同。
Wants=
非常特殊。systemd.unit
将其称为需求依赖(Requirement Dependency),描述为Requires=
的弱化版本。一些 systemd 核心开发者表示,没有After=
的Requires=
实际上会被降级为Wants=
,这其实是相当误导的。Wants=
唯一的作用就是排队启动作业,仅此而已。更有趣的是Wants=
不做的事情:它不会将“单元未找到”、“单元被屏蔽”或“作业类型不适用”视为错误,并且无论如何都会完成作业。因此,Wants=
实际上并不是真正的“依赖”,它是一个无条件的启动作业传播。
这也是为什么依赖项倾向于通过WantedBy=
将自己引入目标单元,因为目标单元没有失败状态,而Wants=
的这种性质意味着可以轻松打破顺序依赖中的循环,这是确保目标作为同步点始终可达的一种方式。同时,Wants=
的宽松性也使其容易产生完全合法的无限循环。
systemd 在Wants=
和Requires=
这两个极端之间,没有提供任何惯用的中间选项。
我们在上文中已经讨论了Conflicts=
。它唯一真正有效的用例是获取相对于shutdown.target
的ConflictedBy=
关系,除此之外,它并不是一个有效的排他机制。关于重载传播器(Reload Propagator),我们也已有充分讨论。
PartOf=
(在 systemd-188 中引入,用于对目标单元进行分组)通过传播停止和重启作业来扩展Requires=
,但不支持那些不支持显式停止和重启操作的单元,如设备单元。这与“被停止和重启作业触发”不同,后者可以通过unit_notify
发送的底层单元状态更改隐式发生,每种单元类型可能各不相同。PartOf=
也不会像Wants=
和Requires=
那样触发启动作业。它是唯一仅作为反向依赖可用的 systemd 依赖指令,其正向等价物ConsistsOf=
对用户不可访问。
BindsTo=
基本上是一个“万能”选项,它跟踪所有显式和隐式的启动、停止和重启作业,因此可以在设备单元中使用。与PartOf=
不同,它还会排队启动作业,因此它并不是PartOf=
的补充。有人批评它“混淆了两个正交的概念(通过主体的启动作业的传播,以及所有导致单元进入 inactive/failed 状态的状态变化的传播)”。BindsTo=
的一个微妙之处在于,如果没有与After=
一起使用,它会在单元级别上跳过检查,而不仅仅是在作业级别上,这与大多数其他依赖项不同。更广泛地说,它会跟踪单元的激活状态,而直接从 activating 转变为 inactive 的单元将会失败,这与Requires=
不同。
Requisite=
根本没有特殊的依赖处理,它只是触发JOB_VERIFY_ACTIVE
。它更应该被称为AssertStarted=
或类似名称。如果在没有顺序依赖的情况下使用,它会高度竞争,引用 Kowalski 的话:
Requisite=
在内部会为你引用的单元触发一个JOB_VERIFY_ACTIVE
类型的作业。如果JOB_VERIFY_ACTIVE
作业没有成功,那么之后将会导致你的单元的(举例来说)启动作业失败。然而,不指定顺序依赖意味着在你请求启动单元时,这些作业可能按照任意顺序被调度,取决于哪个作业先完成,可能会,也可能不会导致作业失败,返回JOB_DEPENDENCY
作业结果。
After=
确保调度器将你的单元的作业放到JOB_WAITING
状态,直到另一个作业完成,这意味着它可以确保使你的作业失败。然而,这意味着你将等待该单元的每个作业,无论是启动作业、停止作业还是其他。因此,如果你使用了Wants=
而没有使用After=
(只是为了触发一个单元,而不等待它,如果它停止也不失败),并且还想使用Requisite=
,你就需要放弃不等待它的属性,这其实是内部实现不佳的结果。
Requisite=
的一个后果是,对于没有达到 “active” 状态的单元,例如未设置RemainAfterExit=yes
的一次性服务单元(Type=oneshot
),它会始终失败,因为对于这样的单元,verify-active
作业总是会失败。然而,由于作业合并规则(Job Merging Rules),如果你同时使用Requisite=
和Wants=
,前者的JOB_VERIFY_ACTIVE
将与后者的JOB_START
合并,产生JOB_START
并始终成功,相当于Requisite=
被降级为Wants=
。参见这个例子。
Requisite=
的例子充分说明了 systemd 的依赖关系并非累加性的,也不是人们直观上预期的那种约束、不变量或“检查”。将依赖关系组合在一起,会覆盖和取代行为,而不是施加更多的约束,因为所有这些依赖指令都是粗粒度的、临时性的,具有事件、关系、顺序依赖和自身传播效果等多种副作用。这些特性都难以理解。
此外,与Wants=
不同,Requisite=
会使依赖作业以JOB_DEPENDENCY
结果失败,这意味着它们将触发OnFailure=
条件,这也意味着我们不能简单地使用这个指令异步排队verify-active
作业,并通过软检查的方式忽略依赖失败。
只有Wants=
/ WantedBy=
在其最小性和宽容性方面显得独树一帜。几乎所有 systemd 中的依赖操作要么过于粗糙,要么过于薄弱,实际上没有办法让你向 Manager 明确表达你希望从其状态机中获得的确切期望行为。
案例分析
以上的讨论可能显得有些理论化和过于挑剔,下面我将通过几个示例来说明。
对于不存在的单元的合法事务
假设我有一个服务单元文件foo.service
:
1 | [Unit] |
如果我使用systemctl start foo
命令手动启动服务,那么对于不存在的服务单元nonexistent.service
的依赖关系将无法找到,服务将因 “找不到单元” 而失败。
然而,如果我通过systemctl enable foo
命令启用它,字典排序解析不会发现任何问题并且成功执行[Install]
段的指令。当我重启系统时,systemd 将在初始启动事务中触发不存在的依赖单元nonexistent.service
,加载并正常启动它。
如果尝试重启该单元,会因 “找不到nonexistent.service
单元” 而失败。但幸运的是,停止操作可以正常发送 SIGTERM
信号并将其停止。
在我的 Manjaro Linux 系统上,更有趣的是,当我将WantedBy=
指令更改为graphical.target
(这是我系统上的默认目标),并将Type=
更改为oneshot
时。在进入显示管理器后,我的系统上会有四个待处理的启动作业:一个处于 “activating” 状态的不存在的依赖项,以及处于 “waiting” 状态的multi-user.target
、graphical.target
和tlp.service
。手动取消可以将其删除,而不会触发故障状态,正如上面所讨论的。
这个问题已经被多次报告:1、2、3。也有人报告说它会影响BindsTo=
和RequiresMountsFor=
。
据我所知,这里发生了以下几件事情。首先,回想一下,Wants=
和WantedBy=
在设计上是“几乎没有任何合理性或错误检查的无条件启动传播”,因为它们忽略了“单元未找到”、“单元被屏蔽”和“作业类型不适用”等错误。其次,目标单元在设计上没有失败状态,以便它们总能到达。因此,你得到的就是你所期望的,因为 systemd 的作业引擎只是相信你为这种依赖关系提供了合理的输入。作业传播既不是累加的,也不是原子的(“当所有约束满足时提交”),因此目标的Wants=
会阻止更严格的要求。这种情况在Requires=
一个不可用的挂载单元时会格外令人惊讶。
一个相关的有趣问题是:由于垃圾回收逻辑中的某些不明确缺陷,systemd 在启动服务时会根据服务启动顺序,不一致地传播嵌套的Wants=
和Requires=
依赖关系。
包含互斥的作业类型的事务
基于 issue #11440,假设我们有one.service
:
1 | [Unit] |
two.service
:
1 | [Unit] |
three.service
:
1 | [Unit] |
我们有一个服务one
,一个与之冲突的服务two
,以及顺序上位于one
和two
之后的服务three
。服务three
还会将重启和停止作业传播给one
和two
,但不会触发它们的启动,这正是PartOf=
的语义。
执行systemctl start one two three
时一切正常,因为two
和 three
已经启动,而one
在被two
传播的停止作业停止后处于 inactive 状态。
现在执行systemctl restart two
。它会报错:“无法重启two.service
:事务包含对three.service
的冲突作业 ‘restart’ 和 ‘stop’。可能配置了相互矛盾的需求依赖。”
这里有两点需要注意:再次强调,作业传播不是原子的,而且要记住,事务是在不考虑单元的活动状态的情况下生成的。或者更具体地说,单元状态是在作业被调度时检查的,而不是在作业最初在事务中开始排队时检查的。
由于three
有PartOf=one.service two.service
,one
和two
现在都有ConsistsOf=three.service
。现在,我们请求在two
上执行重启作业。这个重启作业就成为我们的锚点。我们遵循Conflicts=one.service
,为one
排队一个停止作业。注意,one
已经处于 inactive 状态,所以我们是在一个已经停止的单元上传播停止作业。由于one
包含ConsistsOf=three
,所以three
也会被停止。然后我们再次回到two
,two
也包含ConsistsOf=three
,并向three
传播 JOB_TRY_RESTART
。
在同一单元中,停止作业在try-restart
作业之前被传播,这违反了可合并性规则,导致事务出错。
这可能看起来像是一个人为制造的特殊情况,但实际上,同样的场景是fail2ban
和firewalld
服务单元文件中长期存在的一个 Bug 的根源。它在 2016 年被 Fedora 报告,在 2017 年被 Debian 报告,又在 2019 年被 openSUSE 报告。fail2ban.service
中有PartOf=iptables.service firewalld.service
,而firewalld.service
中有Conflicts=iptables.service
。这样的逻辑推理是有效的:fail2ban
可以与firewalld
或iptables
一起工作,但同时只能有一个处于运行状态。此外,如果重启firewalld
或iptables
,fail2ban
也应该被重启。直观上,这似乎应该正常工作,但这是建立在 systemd 依赖关系作为不变量的错误基础之上设计的。
依赖传播覆盖显式的重启策略
假设我们有一个专门用于特定服务的 foo.target
单元文件,它绑定了 foo.service
:
1 | [Unit] |
foo.service
:
1 | [Unit] |
然后我们执行systemctl start foo.target
命令,foo.service
服务就会启动并运行。
现在手动使用-KILL
、-TERM
、-SEGV
或其他信号杀死foo.service
的主进程。由于这是一个触发低层单元状态变化的 kill 信号,与明确由 Manager 触发的作业不同,我们可能期望Restart=always
触发重启。然而,实际上并没有,服务会保持关闭状态。
我们遇到了一个意想不到的依赖传播,它与服务单元状态机的行为发生了奇特的相互作用。当foo.service
停止运行时,与其BoundBy=
的目标单元会排队一个停止作业,并且也会被停止。而foo.service
中的PartOf=
捕获到了这一点,并通过foo.target
中的ConsistsOf=
排队一个对foo.service
本身的停止作业。由于这是一个由 Manager 触发的作业,它抑制了重启计时器的触发。因为ExecStopPost=
指令(可以是任何内容)将服务保持在停用状态,从而导致最终过渡到 inactive/failed 状态的原因归结为一个明确由 systemd 排队的作业,而不是外部状态变化。
这一问题在 issue #11456 中有所讨论。
Kowalski 还澄清道:
单元只有在进程退出、被信号杀死或达到为其设置的超时时才会重启。然而,如果用户明确要求停止一个单元,或者通过
BindsTo=
触发其停止(即两者都是由 Manager 操作的结果),那么该单元就不会重启。这是因为Restart=
只对隐式状态变化起作用,而不对导致状态变化的显式作业起作用(所有的传播依赖都是明确的,即由 systemd 强制执行的作业)。
从用户的角度来看,显式的systemctl stop
命令和通过BindsTo=
传播的停止请求似乎是不同的,一个是显式的,另一个是隐式的。但从内部来看,两者都是显式的。因此,对于缺乏OnDependencyFailure=
选项,又无法选择性地覆盖重启策略以包括由 Manager 触发的作业的情况,需要采取一些变通措施。
Bug即特性:破坏性事务、隐式.wants
和PartOf=
的非传递性
多年来,人们已经习惯于,或找到了一些有趣的方法,利用 systemd 的故障条件、漏洞和怪癖来实现功能。这意味着尝试将其语义“修复”为更一致的,实际上可能会破坏许多依赖这些不一致性的用例。
例如,RHEL7 的 Red Hat 客户门户官方建议故意在 systemd 中创建一个破坏性事务,作为“重启保护”功能,以防止 root 用户在某些操作完成之前重启系统。如果 systemd 的作业合并规则被调整,或多单元原子事务成为现实,这个方案可能就无法工作了。
在 systemd-242 之前,设备单元会隐式地获得与其对应的挂载单元的.wants
依赖,因此每当出现一个新设备时,它就会立即被挂载。这导致挂载单元在被用户明确停止后,由于设备单元状态变化(如 “changed” 事件),而再次被触发启动。在移除这一“特性”后,人们发现许多人将其作为廉价的热插拔解决方案来使用。
就在我撰写本文时,从 systemd-245 开始,一个修改作业 GC 逻辑的补丁被合并。这个补丁旨在解决 PartOf= 依赖关系不传递的问题:以前如果存在 A→B→C 的依赖关系,当 B 处于 inactive 状态时停止 C,这种操作不会传播到 A,但会传播到处于 inactive 的 B。
该补丁引发了一系列大规模的回归 Bug,目前这一问题仍在持续中。截至本文撰写时,Debian 的 systemd 包中已包含了一个撤销此更改的补丁。该补丁的一些奇怪副作用包括在图形会话期间重启 Plymouth 启动画面,为此,Debian systemd 维护者 Michael Biebl 向 systemd 开发组大发雷霆。此外,具有失败条件检查的服务单元会不断重启(此处也有报告),导致日志文件被填满。
这场灾难表明,对 systemd 状态机的微小改进可能会对整个用户生态系统产生不成比例的影响,核心开发者在实际部署更改并观察结果之前无法预见这些影响。这引发了人们对 systemd 能否通过重大变更来进行改革的严重疑虑。
声明式配置的幻觉
值得注意的是,大多数人对所有这些复杂性都不熟悉,原因很简单:大部分人只使用 systemd 功能中的一小部分——这与多数主流 Linux 发行版的默认设置相符。
最近,我在/usr/lib/systemd/system/
中非正式地使用grep
进行了搜索,并在 Manjaro、QEMU 上的 Ubuntu Server 20.04 和 JSLinux 上的 Fedora 29 中通过systemctl
进行了探查。我发现,除去 systemd 自身的服务,只有大约四分之一到五分之一的服务是套接字激活的(而且只使用了极少数 Socket 单元选项)。Cron 作业和计时器单元通常仍然并存,重载传播器几乎未被使用,Conflicts=
在上游预设的单元文件中被大量使用以与shutdown.target
互斥,BindsTo=
主要用于设备单元,但也大量存在于与 libvirtd 相关的套接字和服务单元中,Requisite=
只在systemd-update-utmp-runlevel
中使用过一次,PartOf=
在 nfs-utils 之外很少被使用,RequiresMountsFor=
在上游预设的单元文件之外也同样罕见。
显然,人们通过尽量少用这些功能,来避免被作业引擎搞得晕头转向。
然而,systemd 单元文件通常被视为“声明式”配置的优点,很难与其依赖模型相协调,因为它不允许你像在声明式编程中那样,围绕“目标”、“约束”和“不变量”来思考。我注意到,如今“声明式”一词被广泛用于指代任何没有显式控制流结构的简单配置语言——这会使这个词变得毫无意义。
systemd 的作业语义使其具有高度的状态性和副作用,其非幂等事务的行为实际上与 systemd 开发者 对 Upstart 的批评 如出一辙:
当我们仔细研究 Upstart 时,我们最终意识到,至少在我们看来,其基本设计是反向的。我们认为,系统管理器应该在系统启动期间计算出所需的最小工作量,而 Upstart 实际上(在某种程度上)是被设计为执行最大工作量的,并让开发人员和系统管理员负责计算在特定情况下应该执行哪些操作。
systemd 以 11 种不同的单元类型为基础,这些单元类型具有各种独特的属性和相互作用。因此,systemd 通常会将全局系统状态与服务及其他许多由 systemd 维护的、实际上是虚构的对象的状态交织在一起,以便在其模型中强制执行顺序依赖关系。其架构使得依赖图成为完全暂时且不可复现的构件,取决于来自 “周围” 系统环境中隐式传播的状态变化。
大多数 systemd 的依赖类型将主体传播的作业类型、传播到客体的作业类型以及返回给调用者的作业结果结合在一起。这些操作是非原子的,并且根据单元类型特定的状态,可能产生不同的结果。因此,无法通过其显式配置的清单来预测或复制系统状态。你的依赖图并不等同于你的参考图。
这种粗粒度的临时性指令并不仅限于单元依赖项,还适用于所有修改执行状态的选项(可与链式加载的方法进行比较)。用户对自定义 systemd 动词/操作的请求一直很频繁,但一直被开发组拒绝,例如 1、2、3。
以后会怎样?
被放逐的乌托邦:HAL、DeviceKit 和那个未曾实现的愿景
开源开发的历史有一种自我重复的趋势——每次都是一场闹剧。
大约在 2004 年,一个名为 “Project Utopia”(乌托邦计划) 的倡议出现了。它的目标是彻底重塑当时 Linux 上的热插拔和硬件自动检测的现状——这在当时是一个相当棘手的难题,涉及到/sbin/hotplug
程序、大量的 Shell 脚本、特定于发行版的工具(如 RHEL 和 Fedora Core 上的 Kudzu),以及像 “supermount” 这样的内核模块。
正如其两位主要开发者之一、Novell 的 Robert Love(另一位是 Joe Shaw)所描述的:
Joey 和我决定创建一个伞式项目——一个元项目。计划是激发能够在桌面环境中提供硬件策略的 HAL 感知型应用程序的开发。用户不应该手动配置硬件,硬件的配置应该在用户插入硬件时自动发生。用户(甚至是开发者)不应该与设备节点和晦涩的配置打交道。HAL 应该实时地为应用程序提供所有这些功能。用户不应该猜测如何使用新硬件,如果我插入一台摄像机,我的照片应用程序应该自动运行;如果我插入一张 DVD,它应该自动播放。所有这些操作都应该自动、神奇地发生。
我将其命名为 “乌托邦计划”(Project Utopia)。毕竟,这个想法有点乌托邦的味道。
我们没有一个中央网站、源代码仓库或可爱的 Logo。乌托邦计划既是一种事业,也是一种思考方式。我们有的只是目标、用例,以及对无法正常工作的组件日益增长的不满。我们会写博客、在会议上提出倡议、编写代码。一点一点地,我们会在 HAL 的基础上构建一套策略,遵循以下规则:
- 让硬件正常工作。
- 使用 HAL、udev、sysfs 和 Linux 2.6 版本内核作为基础。
- 使用 D-BUS 将所有工作联系起来。
- 没有轮询,也没有 Hack——一切都应该是事件驱动的、自动的。
- 谨慎地将基础设施分为系统级和用户级。
- 系统级应该与平台无关;用户级则基于 GNOME。
这个 “事件驱动的自动化乌托邦” 背后的关键是 “HAL 化”,即 “将程序转换为使用 HAL,无论是为了减少代码量,还是添加新功能(理想情况下,两者皆有)。”
这也是一个以 GNOME 为中心的愿景,GNOME Volume Manager 是其核心。协同工作的组件包括 HAL、D-Bus、udev、GNOME Volume Manager 和 NetworkManager。
尤其是 HAL——硬件抽象层(Hardware Abstraction Layer)。从大约 2005 年到 2010 年,HAL 是一个无处不在的 RPC 巨兽(实际上有一些相关的规范),被许多应用程序用来查询硬件元数据。HAL 是一个系统守护进程,维护着一个(持有唯一标识符、属性和接口的)设备对象数据库,可以通过 D-Bus 进行内省,并读取设备信息文件。它通过 “Addons”(绑定到 HAL 设备对象的守护进程,HAL 会对其进行按需启动)和 “Callouts”(在设备添加和删除事件发生时添加元数据的一次性作业)来实现自己的特定服务管理。
Robert Love 自信地宣称:
现在,乌托邦计划的心态正在激发新的应用程序、有趣的黑客和新项目,使硬件正常工作。来自 Novell、Red Hat 等公司的 Linux 发行版都拥有强大的基于 HAL 的基础设施。GNOME 项目正在全面集成 HAL 和 D-Bus。乌托邦计划的影响也蔓延到 GNOME 之外,其他平台也在实现类似的基于 HAL 的解决方案。
然而,Linux 的开发从不停滞,就像一只狂怒的猎豹,向着更好、更快、更简单的解决方案飞奔。对新硬件的支持将持续不断地涌入,本着乌托邦计划精神的解决方案将不断实现,为用户提供无缝的体验。
诸如当你使用蓝牙连接的手机接收到来电时,音乐播放器自动静音的黑客技术,将不再是梦想,而是我们的现实。明天将带来什么样的奇思妙想?我们将支持哪些新硬件?哪些应用程序将被 HAL 化?加入进来,自己回答这些问题吧!
2004 年 4 月,Love 在邮件列表上谈到了他希望围绕乌托邦计划统一 Linux 生态系统的愿景:
从一个非常高的层次上讲,除非发行版统一他们的设置,否则这些事情将继续取决于发行版维护者。例如,考虑一下网络配置,我现在正在处理相关的 Callout 代码。显然,不同的供应商可以共享 HAL、我编写的 Callout 脚本以及其他的胶水代码,但是目前,由于我们有不同的网络配置程序和不同的配置文件,这些东西会继续保持分离。
这与今天的现状相比没有任何变化——无论一个发行版是否实现了乌托邦计划,用户都得使用发行版特定的配置程序。但是如果供应商们统一使用一个统一的配置程序,那么乌托邦计划的代码也会被共享。
组件栈越往上,就越特定于发行版维护者和维护策略,因此能共享的东西也就越少。我认为我们的目标是使基础设施尽可能丰富、灵活、强大,以便尽可能减少不会被共享的非平凡内容。
例如,以 Red Hat 中的当前系统组件栈为例:MAKEDEV、kudzu(以及与它相关的所有东西)和 redhat-config 工具。除此之外,还有诸如 init 脚本、网络脚本、配置文件和其他奇奇怪怪的 Red Hat 特定内容。
乌托邦计划可以统一上述大部分内容,但不是全部。在我看来,上面这些东西中最需要去掉的就是 kudzu。
因此,“乌托邦计划” 在很大程度上依赖 HAL 的成功与否。然而到了 2008 年 5 月,HAL 的核心开发者 David Zeuthen 发布了一篇回顾文章。
在这篇文章中,他表示 HAL 是 “一个充满混乱代码的巨大垃圾桶,从未被真正重写过”,“因为它承担了太多工作,所以没有一个开发人员对代码库有 100% 的了解”,“过于抽象/整体化”,“效率低下”,并且 “与底层组件(即 udev)功能重叠太多”。
然而,尽管持有这样的观点,他仍然表达了对 “理念” 的信心,认为乌托邦计划在 “概念” 层面上是正确的。后来的发展,如 ConsoleKit、PolicyKit 和 D-Bus 系统总线,都证明了这一 “真正的趋势”。他还宣布引入 DeviceKit,一个在 sysfs 之上的简化层,从长远来看将取代 HAL。DeviceKit 后来演变成了 udisks2 和 upower。
这一声明引发了 Ubuntu 中所谓的 “HALsectomy”,许多曾经使用 HAL 后端的程序被切换到使用 libudev 或直接从 sysfs 中读取。Fedora 也领导了HAL 移除计划,将现状总结为 “HAL 是一个重量级的、包罗万象的硬件访问守护进程。它现在已经被 udisks、upower 以及用于设备发现的 libudev 所取代。”
2009 年 4 月,Kay Sievers 预告了即将到来的过渡:
如果一切按计划进行,DeviceKit 守护进程将消失。子系统守护进程将直接使用 libudev 订阅设备事件。udev/内核将执行事件多路复用/过滤,不会涉及 D-Bus。这将成为 udev 的主要部分,而不是 udev-extras 的一部分。
因此,庞大的 HAL 守护进程以及围绕它的 D-Bus 服务栈,最终被一个在内核 Netlink 套接字上工作的轻量级得多的事件多路复用器所取代。devtmpfs 的引入 是一个重要的里程碑。
HAL 的故事相当有趣,它讲述了一个在 Linux 用户空间的雄心勃勃、过度设计的尝试,其根本原因在于内核机制的表达能力不足。当 /sys、devtmpfs 和其他内核子系统得到改进和重写后,人们发现这整个过程根本就是一个死胡同,接下来需要进行大规模的外科手术式切除,以清除 HAL 主守护进程存在过的痕迹。Wayland 取代 X 的过程也有类似的轮廓。
如果 systemd 是另一个 HAL,那么推动 Linux 领导开发者进行改变的主要动力,就不会仅仅是取代现有的另一个 “平台” 或 “基本构建模块”,而是更广泛的内核变化,其目的在于推动开发者的开发范式转变——即,将尽可能多的工作转移到内核,同时保持一个轻量级的事件代理作为用户空间的接口。
而这,很有可能就是 BPF。
日渐封闭的思想
从这一点看,systemd 是一个平台,凭借其网络效应——包括 Josh Triplett 愉快地罗列出来的:
(systemd 具备的功能有)用户会话、套接字激活、sysusers(自动化用户创建)、动态用户、homed、tmpfiles(临时目录生成)、临时单元、与 slice 单元(或称为 cgroup API)通信的任何工具、容器化、firstboot(首次启动初始化)、系统范围的预设配置和策略机制,以便管理员实现 “哪些服务在安装时就被启用,哪些服务在明确进行手动配置前必须处于停止状态”、“无状态系统” 能力等,我可能还忘记了其他很多功能。
基于这些影响,即使作为 init 系统的 systemd 让人觉得不那么出色,也不会对这个项目的广泛影响范围产生太大改变。仅仅拥有一个“更好的 init 系统”本身也并没有多大意义。当发行版决定使用 systemd 时,他们不是在采用一个“更好的 init 系统”,而是在 采用一个平台。像 Flatpak 和 Snappy 这样的项目现在已经与 systemd 有了深入的集成,这是一个既定的事实。
Upstart 被许多令人讨厌的 Bug 困扰,从本质上说,这些 Bug 可以归结为其 Ad-hoc 作业和事件引擎与底层内核进程模型不同步,从而产生了荒谬的系统状态。systemd 也面临着同样的问题。
另一方面,我们也必须问自己:init 系统真的那么重要吗?
我认为并不是那么重要。
首先,我们讨论一个小难题:我们知道主流发行版多年来一直使用脆弱的 init 脚本——远远超过了它们的有效期——因此在需要进行重大重新设计时不得不进行低效的增量更改。那么,为什么这么长时间以来一直在做错误决定的人们,会突然在同一时间点都选择做了另一件正确的决定(切换到 systemd)呢?我们又凭什么相信这是正确的呢?我认为唯一诚实的答案是,发行版只是响应上游打包者对 systemd 的热情姿态,因此我们无法从他们的决定中得出技术进步的结论。
但是,让我们看看,比如说,ChromeOS。直到今天,它仍然在广泛地使用 Upstart,尽管 Upstart 已经被原始开发者抛弃了很长时间。使用这样一个漏洞百出的、脆弱的 init 系统是否阻碍了 Chromebook 在公共教育领域以及日益扩大的笔记本电脑市场的霸主地位?Chromebook 是不是完全无法使用呢?显然不是。
Android 仍然使用单个 init.rc 文件进行初始化。Rob Landley 曾经贴切地将其描述(转述)为 “看起来像是一个 Shell 脚本,但实际上不是”。它还有一个基于 actions、events 和 triggers 的反向依赖模型,与 Upstart 非常相似。除了有强迫症的偏执狂,谁会关心他们移动设备上的 init 系统呢?
是的,init 系统真的不那么重要。但是,systemd 的独特之处恰恰在于它确实很重要。这很奇怪——为什么会这样?它凭什么这么重要呢?
也许随着 BPF 将 Linux 整合为一个带有受控运行时的混合内核,一个子系统越来越组件化(在提案中,已经出现了将 BPF 程序挂钩到 LSM 的早期迹象)。随着 pidfd/进程描述符使得可靠的进程监视能够在自包含的进程之间传递,随着 Linux 原生挂载 API 本身变得更加事件驱动,以及一群狂热的 Rust 信徒在永不熄灭的宗教热情驱使下坚持认为所有人都有义务为借用检查器献上祭品,一个新的转变可能会再次出现,届时,init 系统将再次变得无关紧要。
有一件事我可以肯定,这种转变不可能来自业余爱好者、外行人和所谓的 “地下黑客”。要想推翻一个平台,就必须先成为生态系统中最大的节点上的决策者,决定哪些功能可以在哪里集成。
正如 Poettering 等人如雄狮般崛起,推翻了那些自满的狐狸,现在,他们也终将被自己培养出的新一代狮子所推翻。这些新一代的狮子会是谁?会从哪里来?会做什么?我们现在都不得而知,只能拭目以待,静观其变。