重新思考 1 号进程
本文译自 systemd 作者 Lennart Peottering 的四年前写的介绍,有些内容虽然已经过时,但其核心思想依然坚挺,对理解 init 系统的演变也非常有帮助。目前 systemd 已经发布了两百多个版本,而且是 CoreOS 分布式调度器 fleet 的基础支持包。
如果你关注行业或者思维敏捷,那么你可能已经知道本文的主题。但即便如此,你也可能从本文收获一些有趣的东西。不妨沏杯咖啡,坐下来慢慢品读。
本文篇幅较长,虽然我建议通读,但全文概括起来不过一句话:我们正在试验一个新的 init 系统,它很有趣。
代码仓库可参考这里(译注:更新为迁移后的代码仓库地址),下面是正文:
1 号进程
每个 Unix 系统都有一个特殊的“ 1 号进程”。该进程是内核启动的第一个进程,也是所有其他孤儿进程的收容所(译注:一个进程通常是由其父进程负责监管,但父进程退出后,就转交给 1 号进程负责)。由于这种特殊性,1 号进程可以做其他进程无能为力的一些事情。它也扛起了其他进程职责之外的任务,例如在开机时负责启动和管理用户态进程。
在 Linux 发展历程上, 1 号进程背后是著名的 SysV init 项目。其显老态已久,宣称的替代品层出不穷,但仅 Upstart 站稳山头,进入了所有主流发行版。
正如刚刚提到的, init 系统的核心任务是启动用户态进程。一个好的 init 系统需要快速启动这些进程。但不幸的是,传统的 SysV init 系统还不够快。
对于一个快速而且高效的启动过程而言,两件事情至关重要:
- 尽可能地 少 起作业
- 让作业尽可能 多 地并行
这又如何理解?“尽可能少起作业”意味着服务进程越少越好,而且直到真正需要时才启动。有些服务我们知道是迟早需要的(如 syslog 和 D-Bus 等),但很多其他服务并非如此。举例来说,仅在插入蓝牙适配器或有程序请求使用蓝牙服务时才需要运行 bluetoothd ,只有主机连接到打印机或有程序请求打印服务时才需要运行 CUPS ,只有主机已接入网络或者有程序调用 Avahi API 时才需要运行 Avahi ,只有在收到远程登入时才需要运行 sshd (而且我们必须承认,大多数主机上 sshd 每个月仅处理几次登录,却一直在兢兢业业地侦听端口)。
“让作业尽可能多地并行”是指,如果我们的确需要运行某个服务,那最好不要像 SysV init 那样串行启动该服务,而是想办法和其他服务同时启动,这样来最大化利用 CPU 和磁盘 I/O ,从而减少开机启动时间。
硬件和软件都是动态变化的
现代系统(特别是通用操作系统)的配置和使用都是高度动态的:主机位置并不固定,一些程序可能被启动和停止,一些物理附件可能被插入或者移除。维护各种服务的 init 系统自然需要感知这些硬件和软件的变化,并根据需要来启动或停止一些服务。
大多数试图并行化启动过程的系统仍然需要守护进程之间的同步操作: Avahi 会调用 D-Bus ,所以 D-Bus 需先行启动,仅当 D-Bus 宣称启动完毕, Avahi 才能启动。类似的情况还有: libvirtd 和 X11 需要 HAL (哦,这里假设系统是 Fedora 13 ,事实上 HAL 已经废弃了),所以 HAL 需先行启动,然后才是 libvirtd 和 X11 。而 libvirtd 还依赖于 Avahi ,所以它还要等 Avahi 启动完毕。它们都要用到 syslog ,所以都要等 Syslog 启动并初始化完成。诸如此类。
并行化基于 socket 的服务
上述同步操作导致启动过程中很大一部分内容需要串行化。如果我们可以避开这些同步和串行开销,那岂不是极大的改善?嗯,这是可行的。在继续之前,我们需要理解服务进程之间的依赖关系确切是什么,为什么它们需要等候。对于传统 Unix 服务进程而言,有一个答案是,它们需要等其他服务进程提供的 socket 就绪(即开始受理连接请求)。通常这个 socket 是基于文件系统的 AF_UNIX 类型,不过也可以是 AF_INET 类型。举例来说, D-Bus 的客户进程会等待 /var/run/dbus/system_bus_socket
开始侦听连接,syslog 的客户进程会等待 /dev/log
, CUPS 的客户进程会等待 /var/run/cups/cups.sock
, NFS 挂载时会等待 /var/run/rpcbind.sock
和 portmapper 的 IP 端口,等等。仔细想想,这才是具体的依赖关系。
那么,若能设法让这些 socket 提前到位,就可以从等待进程完全启动缩短为等待 socket 就绪,更细粒度地并行作业,从而加速整个开机启动过程。具体该怎么实现呢?这在 Unix 系列的操作系统上是相当简单的:我们可以在启动服务进程之前先创建 socket ,然后在 exec()
时传递 socket fd 到服务进程。通过这个办法,我们可以在 init 系统中一次性创建所有服务进程的所有 socket ,然后再同时启动所有服务进程。若一个服务进程依赖于另一个服务进程,而被依赖的服务进程尚未就绪,这种情况是完全没问题的:连接请求会在服务方排队,发起方则在阻塞于该请求。仅该发起方进程会阻塞而且仅阻塞于该请求。此外,服务之间的依赖关系无需额外配置就可以实现并行化:如果我们一次性启动创建所有 socket ,那么一个需要发起请求的服务,是必然能够连接到 socket 的。
预创建 socket 是本节的核心,这里举例换个角度予以说明:如果同时启动了 syslog 服务及若干客户进程,那么客户进程发出的消息会被加入 /dev/log
的 socket buffer ,只要该缓冲区还没有塞满,客户进程就无需等待即可继续运行。而 syslog 就绪后会立即处理并释放缓冲区中的消息。另一个例子是,如果 D-Bus 服务及若干客户进程同时启动,发起一个同步请求等待响应时,发起方会被阻塞,直到 D-Bus 就绪并处理该请求。
事实是系统内核的 socket buffer 帮我们达成了并行化,依赖顺序和同步操作都在内核中完成,用户态无需介入。如果在服务进程就绪前 socket 就已经创建的话,那么依赖关系的管理就多此一举了(或者没那么必要了):客户进程只需要向服务进程的 socket 发起连接便是。如果该服务进程已经就绪,那么该连接请求会立即处理返回;如果该服务进程尚未就绪,而客户进程暂未发起同步请求,那不需要等待;如果服务进程根本不存在,那么服务进程会被自动创建。对于客户进程而言,这三种情况并无区别,所以依赖关系管理就没什么用了,或者说处于次要地位,于是,我们获得了并行优化,还可以按需启动。此外,我们还获得了更健壮的上层系统,因为服务进程即使临时不可用(譬如崩溃了), socket 也是可以连接的。事实上,你可以写一个服务进程,服务一段时间就退出(或崩溃),再服务一段时间然后退出,如此往复,而客户进程甚至不会觉察,也不会丢失请求。
OK,不妨休息片刻,再来一杯咖啡,后面的内容更有意思。
但首先澄清一下:这是全新的点子吗?不,当然不是。类似系统中,最有名的要数 Apple 的 launchd 系统:在 MacOS 上,所有服务进程的 socket 都是被分析出来由 launchd 创建的。无需配置依赖关系便可并行启动服务进程。这是一个天才的设计,也是 MacOS 能够在惊人的时间内完成启动过程的首要原因。在此特别推荐 launchd 开发者们出品的视频介绍。但不幸的是,除了 Apple 外,没有任何系统采纳该方法。
这个点子其实比 launchd 出现得更早。在 launchd 之前,古老的 inetd 已经这么运作了:socket 统一在 inetd 进程中创建,inetd 创建服务进程并在 exec()
时将 socket fd 传递给它。但 inetd 关注的不是本地服务进程,而是网络服务进程(虽然后来的版本也支持 AF_UNIX 类型的 socket )。它也不是并行化启动过程的工具,也不会推导隐式依赖关系。
对于 TCP socket 而言, inetd 主要用于为每个入站连接都创建新的服务进程,这种显然不是高性能服务器的工作方式。其实 inetd 从一开始就支持另一个模式,即对于第一个入站连接 inetd 会启动服务进程,但该服务进程处理完该连接后可以继续受理新的连接(这就是 inetd.conf 里的 wait/nowait 配置选项的含义,可惜因缺乏清晰文档说明而鲜为世人所知)。每个连接新起一个进程的工作方式给 inetd 带来低效的坏名声,这是不公平的。
并行化基于 D-Bus 的服务
Linux 上的服务进程逐渐趋向于通过 D-Bus 而不是 AF_UNIX socket 提供服务。那么,问题是这些服务是否可以和基于 socket 的传统服务一样,采用同样的并行化方式呢?答案是肯定的, D-Bus 提供了所需要的钩子:通过 D-Bus 激活机制,服务可以在首次请求时启动,D-Bus 的生产者和消费者也可以并行启动:如果想让 CUPS 和 Avahi 并行启动(注: CUPS 使用 Avahi 查找 mDNS/DNS-SD 打印机),那么只需要同时启动它们即可。如果 CUPS 比 Avahi 快,那么 CUPS 向 Avahi 发起请求消息会在 D-Bus 中排队等候,直到 Avahi 就绪。
总而言之,基于 socket 的服务激活和基于 D-Bus 的服务激活都支持并行启动而无需额外同步,也就是说,所有服务进程都可并行启动。而激活机制也使得我们实现懒加载:如果一个服务不经常使用,那就无需在开机时启动它,在收到第一次请求时启动它就可以了。
还有什么比这更令人激动的呢!
文件系统作业的并行化
当前各大发行版的启动过程时序图可以参考这里,不难发现除了服务进程启动顺序,还有更多的同步点,其中主要是文件系统相关的作业,如挂载、完整性检查、限额等。目前的开机过程中,在 /etc/fstab
中的所有设备就绪(完整性检查、挂载和限额完毕)之前,有大量空闲等待时间,却无法用于启动服务。
这个状况是否可以改善呢?答案是肯定的。 Harald Hoyer 指出可以利用历史悠久的 autofs 系统。
正如 connect()
系统调用表示一个服务依赖于另一个服务, open()
(或者类似的系统调用)表示一个服务依赖于某个文件或文件系统。那么,为了提高并行度,我们可以让相关进程在文件系统就绪前一直等待着:设立一个 autofs 挂载点,然后在开机时的完整性和限额检查完毕之后,将其替换为常规挂载点。在文件系统尚未就绪时,对其的访问会在内核中排队,发起访问的进程会阻塞,但仅该进程的该次访问受影响。这样的话,我们可以在文件系统完全就绪前启动服务进程,从而将并行度最大化。
如果涉及的是根文件系统,那么就无法并行了,毕竟程序文件还在根文件系统中放着呢。但像 /home
这些文件系统,通常比较大,甚至加密了,还可能是远程文件系统,而且在开机服务中使用得并不多,其并行化可以显著缩短启动时间。而 procfs 和 sysfs 这些虚拟文件系统,显然是无需经由 autofs 挂载的。
如果有读者指出在 init 系统集成 autofs 是不靠谱且不合常理的,而担忧其影响稳定性。但按我玩转多时的经验,这个办法还是很靠谱的。这里使用 autofs 意味着我们可以在具体文件系统就绪前创建挂载点,相当于将文件操作往后顺延。若有程序需要访问某个 autofs 文件系统,而将其替换为真实文件系统的过程比较漫长的话,该程序会进入可中断的睡眠状态,也就是说,该程序可以安全退出,譬如通过 C-c 按键组合触发。此外,在任何时候,只要该挂载点发生故障(可能是完整性检查失败了),autofs 可以感知并返回明确的错误码(如 ENOENT
)。所以,我认为即使在 init 系统中整合 autofs 看似有点冒进,但实验表明这个方法在实践中表现非常棒,就像是为达成正确的目标采用了正确的方法。
注意,这些必须是 direct map autofs 挂载点,则对于程序而言, autofs 挂载点和常规挂载点并无差异。
最小化第一个用户进程的 PID
另一个我们从 MacOS 启动逻辑中吸取的经验是 shell 脚本很糟糕。使用 shell 有利有弊,写起来很快,但执行起来很慢。 SysV init 系统的模型是建立在 shell 脚本之上的。无论是 /bin/bash
还是其他优化的 shell ,都逃脱不了运行时慢的宿命。在我的系统中,/etc/init.d
下的脚本调用了 77 次 grep 命令、 92 次 awk 命令、 23 次 cut 命令和 74 次 sed 命令。每次调用这些外部命令,都需要创建新的子进程,搜索库文件,进行诸如 i18n 等设置,而真正需要做的只是一些简单的字符串操作,随后子进程就会被销毁。这必定是非常低效的工作方式,而且除了 shell ,其他语言都不这么干。此外, shell 脚本大量依赖环境变量等不便查看和控制的外部因素,其适应能力可以说是很差。
所以,在启动过程中避免使用 shell 脚本吧!在开始之前,我们需要理清楚它们到底是干嘛用的:大多数情况下,它们做的事情都很无趣。大多数脚本编程都在做启动和销毁服务这些琐碎工作,完全可以用 C 重写,或者提供可执行文件,或者并入守护进程本身,或者在 init 系统中完成。
在启动过程中避免 shell 脚本可不是一蹴而就的事情:用 C 重写需要时间,有时候重写并不合适,还有些情况写 shell 脚本确实很方便。但减少其使用是完全可行的。
有一个好办法可以衡量开机启动过程中 shell 脚本的影响,即观察系统启动后你可以启动的第一个进程的 PID :开机,打开一个终端,执行 echo $$
命令。在 Linux 系统上试试,然后与 MacOS 的结果对比一下!(可能是这样的: Linux PID 1823 而 MacOS PID 154 ,这是在我们的测试系统中的观测结果。)
进程状态的跟踪
一个负责启动并管理服务的系统,其核心内容是进程管理:它需要时刻盯着服务。如果服务停止了,那就重启之。如果它们崩溃了,那就需要搜集崩溃信息,保留给系统管理员排错之用,并告知 abrt 崩溃转储系统,并在日志和审计系统中作记录。
它应该能够完整关停服务。这听起来容易,做起来难。在 Unix 操作系统中, fork 两次即可避开父进程的监视,祖孙两代进程之间就失去了联系。举例来说,一个恶意的 CGI 脚本 fork 两次之后,即使 Apache 关停了,该 CGI 脚本也会继续运行。更糟糕的是,除非其名字和用途已经有备在案,否则无法推知该 CGI 进程和 Apache 之间的关系。
那么,该如何来跟踪进程,让它们无法逃避监视,即使它们 fork 一万次我们也能进行监管?
这是一个百家争鸣的主题。这里我不会过多牵涉细节,但需要指出的是那些基于 ptrace 和 netlink connector (一种内核接口,通过它可以收到 fork 和 exit 消息通知)的方案以其丑陋和缺失扩展性而饱受非议。
那么,我们该怎么做?
好消息是,内核早些时候就提供了个称为“控制组”(又称 cgroups )的特性
。本质上,它们是树状结构的进程分组。这个分组树经由虚拟文件系统导出,访问起来很方便。组名即是该文件系统中的目录名。如果一个组下的进程 fork 了,子进程会是这个组的新成员。除非它有特权,能够修改 cgroup 虚拟文件系统,否则就不能跳出归属组。cgroups 最早是用来实现容器的:让不同的内核子系统可以按组分配资源限额,如 CPU 和内存用量。传统的资源限制基于 setrlimit()
,是进程粒度的,而 cgroups 是进程组粒度的,允许对一组进程进行资源限制。 除了容器场景外, cgroups 也有用武之地,它可以用来限制 Apache 和所有子进程的 CPU 使用量,恶意的 CGI 脚本则无法通过 fork 两次来逃避监管。
除了容器和资源限额场景, cgroups 在进程跟踪方面也大有用处: cgroup 的组成员关系是非常可靠的,子进程无法逃避跟踪。除此之外,当某个 cgroup 的所有成员进程都退出了,指定的监管进程还会收到通知。一个进程的归属 cgroup 可以从 /proc/$PID/cgroup
读取。这样分析过来, cgroup 可谓是跟踪进程的极佳方案。
进程执行环境的控制
一个服务管理系统,除了监视服务进程的启动、结束和崩溃之外,还应该负责提供安全的最简执行环境。
也就是说,需要设定进程参数,如 setrlimit, user/group ID 和环境变量,但不仅限于此。 Linux 内核给予用户和管理员许多控制进程的方式(有一些目前很少用到)。每一个进程,你可以设定 CPU 和 I/O 调度器,权限集合, CPU 亲和性和 cgroup 限额等。
举例来说, ioprio_set()
配合 IOPRIO_CLASS_IDLE
参数可以用来降低 locate
的索引更新任务 updatedb
对系统的影响。
更上层的控制会非常有用,如基于只读挂载绑定让只读文件系统相互重叠。对于某些服务进程而言,文件系统将会是全部或者部分只读,其写操作会被拒绝并产生错误 EROFS
。这样让进程的操作受限,就像是一个简陋的 SELinux 策略系统(但这显然无法代替 SELinux ,请不要理解错了)。
最后,记录日志也是运行服务的重要组成部分:理想情况下,服务产生的每项输出都需要记录下来。 Init 系统需要向服务进程提供日志记录功能,将 stdout 和 stderr 都重定向到 syslog ,甚至在一些情况下定向到 /dev/kmsg
从而取代掉 syslog (嵌入式行业的小伙伴们,这是你们感兴趣的!),特别是在内核日志缓冲区特别大的情况下。
关于 Upstart
首先我想说的是 Upstart 的代码结构很好,注释非常丰富,很容易理解,堪称项目楷模。
即便如此,我也对 Upstart 的方案不敢苟同。还是先了解一下该项目的情况:
Upstart 并没有复用 SysV init 的代码,其功能是后者的超集,在一定程度上能够兼容 SysV init 脚本。其主要特性是基于事件的机制:进程的启动和停止都是由“事件”来触发,其中“事件”是指网卡就绪或者某个软件开始运行了之类的。
Upstart 基于这些事件来决定服务启动顺序:如果 syslog-started 事件发生了,而 D-Bus 依赖于 Syslog ,那么 D-Bus 就该启动了。然后 dbus-started 事件发生, NetworkManager 依赖于 D-Bus ,所以也该启动了。
可能有人会说,这样服务进程之间的逻辑依赖关系就可以转化为事件和动作:大家熟知的“ a 依赖于 b ”的规则就变成“ b 启动时,a 也要启动”和“ b 停止时,a 也需要停止”。这是一种简化,特别是对于 Upstart 代码而言。但是,我认为这种所谓的简化其实是挖了一个大坑。首先,逻辑依赖关系并未消失,需要有人来将依赖关系翻译为事件/动作规则(而每个依赖关系还产生了两条规则)。所以,并非让计算机来根据依赖关系推导出启动顺序,反而需要人力介入完成依赖关系到事件/动作规则之间的翻译工作。而且,由于依赖关系并未编码存放,在运行时是不可获得的,这就意味着想知道为什么(即为什么 b 启动时 a 也会启动)的系统管理员,没有渠道来找到答案。
更糟糕的是,这个事件逻辑将依赖关系完全颠倒了。不是缩减了工作量(即 init 系统需要关注的,本文开始就指出),而是增加了维护工作量。或者换句话说,并非设定清晰目标并遵循必要步骤,而是先做一步,然后执行所有可能的后续步骤。
举例来说,D-Bus 的启动并不意味着 NetworkManager 也需要启动(可 Upstart 就是这么干的)。而反过来是合理的:当 NetworkManager 启动时,D-Bus 就要启动(这也是大多数人的理解,对吗?)。
一个好的 init 系统应该仅启动必要的服务,而且仅在需要时启动。或者懒式启动,或者提前并行启动。但不应该画蛇添足地启动所有可能用到该服务的东西。
最后,我发现事件逻辑其实也没什么用处。 Upstart 暴露的大多数事件都不是瞬时的,反而有延续时间:服务启动,运行,终止;设备插入,可用,移除;挂载点正在挂载,可用,正在移除;接入电源线,系统用交流电,拔掉电源线。注意 init 系统或进程管理器需要处理的事件中,只有一小部分事件是瞬时的,大多数应该是“启动,某个状态,终止”三个一组。这些信息在 Upstart 中是无法获得的,因为它关注单个事件,而忽略了延续的依赖关系。
我注意到上述问题在 Upstart 的最近修改中有所缓解,比如增加了 start on (local-filesystems and net-device-up IFACE=lo)
这种条件语法,但是这不过是在试图弥补其核心设计的固有缺陷罢了。
虽然如上问题多多,但 Upstart 拿来管理进程倒也是可行的。
除了 SysV init, Upstart 和 launchd 之外,还有很多其它 init 系统,但实质上优于 Upstart 或者 Sysv init 的几乎没有。这些系统中要数 Solaris SMF 最有意思,它支持服务间的合理依赖。但 Solaris SMF 过于复杂,大量使用 XML 和新术语,多少显得有点学院派。而且依赖于 Solaris 特性,如 contract system 。
问题终结者 systemd
OK,是时候休息一下了。前面阐述了一个好的 1 号进程应该做什么,当前的那些系统又是如何运作的,现在该切入主题了。请再来一杯咖啡,慢慢品尝。
你可能已经猜到,前文讨论的理想 init 系统已有参考实现,也就是我想在这里宣布的 systemd 。 它目前还是实验系统,这里是代码,下面是特性列表及其背后的理念:
systemd 负责启动和监管整个系统(这也是其命名的来源),实现了前文讨论的所有特性。 systemd 建模的基本单位是 unit ,每个 unit 都有一个名字和一个类型。由于配置通常是直接从文件系统加载,unit 的名字实际上是配置文件的名字。例如,名字为 avahi.service
的 unit 会从同名文件中加载,封装了 Avahi 服务进程。以下是 unit 清单:
- service 类型是最显而易见的 unit 类型,封装了服务进程,支持启动、停止、冷重启和热重启。systemd 有自己特定的配置文件,而且为了和 SysV 兼容还可以读 SysV init 脚本,解析 LSB 头信息。因此, /etc/init.d 对于 systemd 而言,不过是另一个配置源。
- socket 类型封装了基于文件系统的 socket 或者网络 socket ,目前支持 AF_INET, AF_INET6 和 AF_UNIX ,以及各自的流式、报文和有序包格式。传统的 FIFO 文件也是支持的。每个 socket unit 都关联了一个 service unit ,当 socket 或者 FIFO 收到连接请求时,关联的服务就会启动,例如
nscd.socket
收到入站请求时会启动nscd.service
服务。 - device 类型封装了 Linux 设备树中的一个设备。设备会由 udev 标定,在 systemd 中暴露为 device unit 。此外, udev 设定的属性也可以作为声明 device unit 依赖关系的配置源。
- mount 类型封装了文件系统树中的一个挂载点。 systemd 会监视所有的挂载/卸载活动,也用来执行挂载和卸载操作。
/etc/fstab
可以作为挂载点的额外配置源,就像 SysV 启动脚本可以作为 service unit 的额外配置源。 - automount 类型封装了文件系统树中的 automount 挂载点。每个 automount unit 都有一个关联的 mount unit ,一旦 automount 目录被访问,就会触发 mount unit (即开始挂载)。
- target 类型则封装了一组 unit ,它本身不做具体事情,仅是引用其他 unit ,将这些 unit 作为一个整体,例如
multi-user.target
扮演了 SysV 系统中的 5 号运行级,蓝牙适配器插入时则会启动bluetooth.target
引用的 luetoothd 和 obexd 等蓝牙相关服务进程。 - snapshot 类型和 target 类型差不多,也是引用其他 unit 。snapshot 可用于保存/回滚所有服务进程和 unit 的状态,主要有两个应用场景:其一是使得用户可以进入特定的状态(如应急 shell ),中止当前服务进程,并方便回到之前的状态,启动那些临时关闭的服务进程;其二是支持系统挂起,毕竟仍有许多服务不能妥善应对系统挂起,通常更好的办法是在挂起之前关闭这些服务,而在恢复时启动它们。
这些 unit 都可以互相依赖(可以是需要或者禁止,即 Requires
或 Conflicts
):一个 device 依赖于一个 service ,则 device 就绪时启动 service 。 mount 则除了隐式依赖其挂载的 device 外,还隐式依赖上级挂载目录,如 /home/lennart
挂载点隐式依赖于 /home
挂载点。
以下是其他特性的一个简单列表:
- 对于每个启动的进程,可以进行这些控制:环境变量、资源限制、工作目录和根目录, umask 、 OOM 参数、nice 值、 I/O 类别和优先级、 CPU 策略和优先级、 CPU 亲和性、 计时器、 用户 ID 、主组 ID 、参与组 ID 、可读/可写/无权访问的目录列表、共享/私有/从属挂载标记、权限集合、安全标记位、fork 时重置 CPU 调度器、私有的
/tmp
名字空间,以及不同子系统的 cgroup 控制设定。此外,服务进程的 stdin/stdout/stderr 可以重定向到 syslog 、/dev/kmsg
或任意 TTY 。如果 stdin 重定向到 TTY ,那么 systemd 会确保服务进程独占该 TTY 。 - 每个进程都有自己的 cgroup (目前缺省处于 debug 子系统中,因为该子系统没有其他用处,而且只提供基本的进程分组功能),很容易就配置 systemd 将服务进程放到外部设定的 cgroup 中,例如通过 libcgroups 工具集。
- 原生的配置文件使用大家熟悉的 .desktop 风格语法,其解析器已经内置于众多开发框架中。此外,我们也可以使用现有工具来做服务描述的国际化等工作。系统管理员和开发人会员都无需学习新的语法。
- 正如前文所述,兼容 SysV 启动脚本。如果有 LSB 或 Red Hat chkconfig 头信息,那就使用之,否则充分利用现有信息,如
/etc/rc.d
中启动优先级。这些启动脚本都作为原生配置外的配置源,这样升级到 systemd 会比较容易。此外,服务进程的 pid 文件会被读取,用于识别服务进程的主 pid 。注意到 LSB 头信息中的依赖关系会被翻译成 systemd 原生的依赖关系,而 Upstart 却无法利用这些信息。一个有很多 LSB SysV init 脚本的系统,用 Upstart 负责启动时无法有效并行化,但用 systemd 时可以。事实上,在 Upstart 中,所有 SysV init 脚本会被当做一个作业,而在 systemd 中,每个 SysV init 脚本都是一个可以并行的独立作业,和原生的 systemd service 差不多。 - 现有的
/etc/fstab
配置文件会被当作额外配置源,而且可以在 fstab 文件中使用 comment= 将该挂载点变为 systemd automount 挂载点。 - 如果同一个 unit 在多个配置源都有出现(如同时有
/etc/systemd/system/avahi.service
和/etc/init.d/avahi
两个文件),那么使用原生配置,传统配置则被忽略,因此,软件包中 SysV 和 systemd 两种格式的配置可以并存,从而支持比较平滑的升级过程。 - 支持一个简单的模板/实例机制。举例来说,不是为 6 个 getty 做 6 个配置,而是使用一个
getty@.service
模板,来自动产生getty@tty2.servie
等实例。接口部分甚至可以从依赖表达式中继承而来,如在表达dhcpcd@eth0.service
依赖于avahi-autoipd@eth0.service
时采用通配符代替eth0
。 - 基于 socket 的激活机制与 inetd 模式完全兼容,此外还有一个模拟 launchd 基于 socket 激活的简单模式,适用于新服务。 inetd 模式仅支持向启动的服务进程传递一个 socket fd ,而 systemd 的原生模式支持传递任意多个 socket fd 。每个连接一个服务进程的模式,以及一个进程服务所有连接的模式,都是支持的。对于前者,服务进程的归属 cgroup 命名时会计入连接参数,并且前面提到的模板机制也会起作用,例如 sshd.socket 可能会启动
sshd@192.168.0.1-4711-192.168.0.2-22.service
并置于名称为sshd@.service/192.168.0.1-4711-192.168.0.2-22
的 cgroup 下(即 IP 地址和端口都出现在实例名称中。如是 AF_UNIX socket 则用客户进程的 PID 和用户 ID)。这样,系统管理员就可以方便地找到服务进程的多个实例并调整各自的运行时参数。原生的 socket fd 传递模式在服务进程里也比较容易实现:LISTEN_FDS
环境变量表示传递的 socket 数量,这些 socket 的顺序在 .service 文件里定义,约定第一个 socket 的 fd 是 3 (服务进程中还可以用fstat()
和getsockname()
来识别 socket )。除此之外,由于环境变量会传递给子进程,可能在多次传递中造成混乱,另有一个LISTEN_PID
环境变量记录了接收服务进程的 PID 。虽然这个 socket 传递逻辑已经非常简单,我们还是提供了一个 BSD 版权的参考实现。我们也已经移植了一些服务进程支持这个传递模式。 - 尽量与
/dev/initctl
兼容。实现方式是采用一个 FIFO 激活的服务,将原请求转为 D-Bus 请求,也就是说,源自 Upstart 和 SysV init 的shutdown 、 poweroff 等命令同样可作用于 systemd 。 - 与 utmp 和 wtmp 兼容,尽管这俩挺恶心的。
- 支持 unit 之间的多种依赖。
After
/Before
可用于明确顺序,Requires
和Wants
则用于定义需要,而Conflicts
则用于定义冲突,除此之外,还有三种使用较少的依赖类型。 - 基本的事务支持。如果需要启动或关停一个 unit ,那么该 unit 及其所有依赖 unit 都会被加入临时事务,然后,该事务需要经过一些检查(即
After
/Before
不能产生循环依赖)。如果检查有问题,那么 systemd 会试图移除一些非关键作业来解开循环。此外,systemd 还会忽略那些要求停止运行中的服务的非关键作业。所谓的非关键作业,是指原始请求并未直接包含,而是由 Wants 引入的。最后,作业也不能和运行队列中的事务发生冲突,事务可因此而撤销。如果上述检查都没有问题,那么该事务将被加入运行队列。也就是说,在执行操作之前, systemd 会作充分的检查,并试图修复,只有确实无法修复时才放弃。 - 每个受控服务进程的 PID 、启动时间、退出时间和退出状态都会记录在案。这些数据及 abrtd, auditd 和 syslog 记录的数据可以用来追溯服务间的关系。想象一下有个图形界面可以高亮崩溃的服务进程,并可以方便地浏览 syslog 、 abrt 和 autditd 中该服务某次运行产生的所有关联数据。
- 支持随时重新执行 systemd 进程,其状态会被保存然后恢复,从而支持 systemd 的升级。已打开的 socket 和 autofs 挂载点都会被保存,保持其可连接状态,客户进程甚至不会觉察 systemd 重启。此外,由于绝大部分服务状态是在 cgroup 虚拟文件系统中,甚至无需访问被保存的状态就可以继续执行。重新执行的代码路径和重新加载配置的代码路径几乎相同,虽然前者极少触发而后者比较常见,基本测试也有保证。
- 正在逐步从开机启动过程中移除 shell 脚本,部分功能已用 C 重写,置入 systemd ,例如挂载 API 文件系统(即
/proc
,/sys
和/dev
这些虚拟文件系统)和设置主机名。 - 服务器状态可以通过 D-Bus 查询和修改。这个特性尚未完成,但可以逐步扩展。
- 基于 socket 和基于 D-Bus 的服务激活是我们主张的, socket 和 service 之间的依赖已然支持,但传统的服务间依赖也是支持的。一个服务可以通过多种方式来通知其进入就绪状态:服务进程 fork 后原进程退出(即
daemonize()
的行为),或者客户进程监视 D-Bus 直到该服务名出现。 - 有一个交互模式,每次 systemd 启动服务之前,都交互请求配置。可以使用
systemd.confirm_spawn=1
内核参数来激活该模式。 - systemd.default= 内核参数可以用于指定开机启动的 unit ,通常是 multi-user.target ,但也可以设为单个服务,例如 mergency.service 相当于
init=/bin/bash
,但好处是 init 系统已经启动,故可以直接从应急 shell 中启动整个系统。 - 有一个简单的 UI 支持启动/停止/查看服务,虽然功能简陋但有助于调试。取名为 systemamd ,是用 Vala 写的(耶!)。
注意 systemd 使用了一些 Linux 专有的特性,并不只用 POSIX ,较专注跨系统可移植性的设计而言,它有更多的功能。
项目状态
所有上述列出的特性均已实现。systemd 已经可以作为 Upstart 和 SysV init 的替代品(当然,别有太多原生 upstart 服务,所幸大多数发行版是这样的)。
然而,测试方面还需加强,故目前的项目主版本仍是 0 ,也就是说,目前的版本还是可能发生崩溃的。即便如此,它还是相当稳定的,我们已经在开发机上采用 systemd (不仅仅是虚拟机)。具体表现因环境而异,特别是在不同于我们开发环境的发行版中。
项目的短期规划
上述特性集合已经相当完备了,但我们还是更多事情可做。我不想空谈大论,但以下是努力的方向:
我们计划增加至少两种新的 unit 类型:swap 类型用于像控制挂载点那样控制 swap 设备,即自动依赖于设备树上的节点。 timer 类型提供类似于 cron 的功能,即基于时间点启动服务,可以支持单调时钟和日历时间(如“每周一早上 5 点”和“每 5 小时”这种事件)。
但更重要的是,我们不仅计划将 systemd 用于优化启动时间,而且计划将其作为理想的会话管理器,取代(或者增强) gnome-session 和 kdeinit 等服务。会话管理器和 init 系统非常相似:快速启动非常关键,进程管理则是核心。所以,可以考虑合二为一,采用同一份代码。Apple 已经在 launchd 中这么做了,那么我们也可以:会话服务和系统服务都可以受益于基于 socket 和 D-Bus 的服务激活,以及并行化。
目前的代码已经部分实现了这三大特性。例如,普通用户也可以运行 systemd ,它会感知普通用户身份,这从一开始就是支持的。(这对于调试来说非常重要!即使尚未切换到 systemd 来管理系统,也能这么运行。)
然而,有些特性可能需要内核来提供支持:类似于挂载点变化的统治,我们也需要 swap 状态变化的通知;当 CLOCK_REALTIME 相对于 CLOCK_MONOTONIC 发生跳动时,我们也需要收到通知;我们想让普通进程也能获得一些 init 的权力;我们需要一个创建 socket 的约定目录。这些对于 systemd 来说并非关键,但却是带来一些改善的。
想试一试吗?
目前还没有提供 tarball 下载,但你可以检出代码。此外,这里有[一些 unit 文件][f13 units]供下载把玩,它们可以在原装 Fedora 13 系统上直接使用。我们暂不提供 RPM 包。
更简单的办法是下载这个 Fecora 13 qemu 映像文件,该映像已经内置 systemd 了。在 grub 菜单中,你可以选择用 Upstart 还是 systemd 来管理系统。我们只对该系统进行了少量修改。服务描述信息是直接从现有的 SysV init 脚本中读取的,并无其他配置源,所以还不能充分发挥基于 socket 和 bus 的并行化。不过, systemd 会解析 LSB 头信息获取一些提示,故比 Upstart 要快一些,后者在 Fedora 中暂未实现任何并行化。这个映像的调试信息会记入内核日志缓冲区(可用 dmesg 查看),还会向串口输出。你需要在运行 qemu 指定一个虚拟的串行终端。所有的密码都是 systemd 。
比下载然后启动 qemu 映像更便捷的是直接看截屏。因为 init 系统通常不出现于用户界面,我们可以提供一些 systemadm 和 ps 截图:
上图是 systemadm ,展示了所有加载的 unit 和一个 getty 实例的详细信息。
上图是 ps xaf -eo pid,user,args,cgroup
的部分输出,展示了按服务进程 cgroup 分组的进程。(第四列是 cgroup ,其中的 debug: 前缀是因为 systemd 用了 debug cgroup 控制器,这在前文有陈述。这是临时措施。)
这俩图都是从最小修改的 Fedora 13 Live CD 实例上截屏产生的,只从现有 SysV init 启动脚本中解析出配置,并无原生 systemd 配置。所以,并没有使用 socket 和 bus 服务激活。
不好意思,暂不提供启动时序图或具体数据。我们会在完成 Fedora 缺省安装的启动并行化改造时再提供。届时再请大家测试性能,并提供我们的测试数据。
为了满足大家的好奇心,这里提供两个数据,但注意它们是非常不科学的,因为是在单核虚拟机上测试而且使用手表计时器来计时的。 Fedora 13 采用 Upstart 时启动耗时为 27 秒,而采用 systemd 时为 24s (从 grub 界面开始计时,gdm 界面停止计时,同样的系统和设定,连续两次测试,取时间最优结果)。注意,这仅是利用 LSB 头信息带来的加速,并没有利用基于 socket 和 bus 的服务激活,因此,这些数字并不适合支持前文阐述的观点。此外, systemd 还向串口输出详细的调试信息。重申一下,这组数据意义不大。
开发服务进程
服务进程要想与 systemd 配合好,还需做一些小小的改动。后面我们会发布一个完整的教程,本节只是一个快速说明。事实上,服务进程的开发人员工作更简单了:
- 不要再 fork 两次变成守护进程,直接在 systemd 启动的服务进程中执行主循环。此外,不要调用 setsid() 。
- 不要在服务进程中降权,这个工作交由 systemd 负责了,在 unit 文件中配置。(不过有一些特例。例如,对于有些进程而言,在服务进程中降权似乎更合适,它们的初始化阶段需要特权。)
- 无需 pid 文件
- 在 D-Bus 中创建服务名
- 可以利用 systemd 来记日志,直接写 stderr 即可。
- 让 systemd 负责创建和监视 socket ,这样基于 socket 的服务激活便可以工作了。在服务进程中读 LISTEN_FDS 和 LISTEN_PID 并如前文操作。
- 响应 SIGTERM 关闭进程。
上面这个列表和 Apple launchd 兼容的服务进程开发要点 非常相似。如果进程已经支持 launchd ,那么增加支持 systemd 是很容易的。
不过, systemd 也支持不按这种风格写的服务进程,这样做是为了向后兼容(而 launchd 几乎不支持)。如前文所述, inetd 兼容的服务进程可以无需任何修改即支持基于 socket 的服务激活。
所以,如果 systemd 证明了自己的优势并被各发行版接纳,那么移植那些开机启动的服务进程来使用 systemd 激活机制也是合理的。我们已经写了一些验证补丁,这个移植过程是非常简单的。此外,我们也可以充分利用兼容 launchd 而做的工作。而增加基于 socket 的激活支持,并不破坏与其他系统的兼容性。
常见问题
谁在干这些工作?
目前的代码主要是我写的。我叫 Lennart Peottering ,供职于 Red Hat 。但细节设计是我和 Novell 的 Kay Sievers 密切讨论出来的。对本项目有贡献的还有 Red Hat 的 Harald Hoyer 、前 IBM 员工 Dhaval Giani 和来自 Intel 、 SUSE 和 Nokia 等公司的同仁。
这是一个 Red Hat 项目吗?
不,这是我的个人项目。再次声明:本文的观点仅代表本人,和雇主没有关系,和其他人也没有关系。
它会进入 Fedora 吗?
如果实验证明方法可行,而且 Fedora 社区接受,那么,是的,我们会努力推动其进入 Fedora 发行版。
它会进入 OpenSUSE 吗?
类似地,Kay 正在推进。
它会进入 Debian/Gentoo/Mandriva/MeeGo/Unbutu/(在此加入你最爱的发行版)吗?
那就看相应社区的配合了。我们欢迎讨论,也乐意提供帮助。
为什么不直接在 Upstart 中增加这些特色呢,为什么要另外搞一套?
在我们看来, Upstart 的核心设计是有缺陷的,既然如此,为什么不从头开始写一个呢。不过,我们的确从 Upstart 的代码中学到了很多东西。
既然你这么喜欢 Apple 的 launchd ,为什么不直接用它?
launchd 是一个杰作,但我觉得它并不太适用于 Linux ,也不适用于 Linux 这种追求扩展性和灵活性的系统。
这是一个 NIH 项目吗?
我希望前文已经解释清楚为什么我们想启动一个新项目,而不是基于 Upstart 或 launchd 改进。我们发起 systemd 是因为技术原因,而非政治原因。也不要忘记是 Upstart 使用了 一个重写 glib 的 NIH 库,而不是 systemd 。
它会在(插入非 Linux 系统)上工作吗?
不太可能。我前面已经指出, systemd 使用了许多 Linux 专用的 API (如 epoll, signalfd, libudev, cgroups 等等),移植到其他操作系统上是不太现实的。此外,我们也对移植到其他系统上而受其约束不太感兴趣。不过,万一有人想移植的话,git 对分支的支持倒是很棒。 事实上,不仅不支持其他系统,我们还需要最新的 Linux 内核、 glibc 、 libcgroup 和 libudev 。对老版本的 Linux 也是不支持的,不好意思。 如果有人想在其他操作系统上实现类似特性,比较推荐的合作方式是我们来帮助定位哪些接口可以复用,从而使服务进程的开发人员能够同时支持 systemd 和对等实现。关注点应该是接口,而不是实现代码。
我听说(在此插入 Gentoo启动系统、 initng 、Solaris SMF 、 runit 、uxlaunch 等等)是一个很棒的 init 系统,也能够支持并行化,为什么不用它呢?
当我们发起本项目时已经充分调研过这些系统,但没有一个提供 systemd 类似的特色(但 launchd 除外)。如果你还不明白,那请重新头开始阅读本文。
合作
我们欢迎各种补丁和帮助。总所周知,自由软件项目只有在接受广大社区贡献时才有生命力。对于 init 系统这样的操作系统核心组成部分而言,社区贡献尤为重要。我们尊重你的贡献,并不要求版权转让(和 Canonical Upstart 的做法是完全不同的!)。此外,我们使用人见人爱的 git 来管理代码,耶!
我们特别欢迎帮助移植到除了 Fedora 和 OpenSUSE 之外的 Linux 发行版。( Debian, Gentoo, Mandriva, MeeGo 阵营的伙伴们有兴趣吗?)除此之外,我们在各种层面上期待参与:文档撰写、logo 设计、 C 开发和项目打包。
社区
(译注: systemd 早在 freedesktop 有项目主页了,已经是一个完整健壮的开源项目。)
目前我们有一个代码仓库和一个 IRC 频道(Freenode 上的 #systemd )。目前还没有邮件列表、官网或 bug 跟踪系统。我们会很快在 freedesktop.org 上准备一些东西。如果你有任何问题,请加入我们的 IRC 频道。