Init系统历代记-Upstart
Upstart是Canonical开发的,事件驱动的init系统。
Upstart是为了解决SysV init系统的一些固有问题而诞生的,在Upstart的开发者看来,SysV init至少有以下问题:
- 面向服务器场景设计,建立在硬件不变的基础上。
- (在当时)不支持并行,性能低下。
- Init脚本的资源开销较大。
而这些问题源于其根本上的限制:
……(SysV init)没有意识到现代Linux的动态性本质,针对每一个可能的硬件Event,如果希望进行处理,那么就必须要有一个独立的守护进程对其进行轮询监听。
因此,Upstart的想法是:
在条件满足时,启动服务。服务(也就是Job)本身只需要描述它在启动时需要的条件,以及在启动时执行的程序。
设计
在本质上,Upstart是一个Event引擎,它通过“事件”(Event)来编排“作业”(Job):它创建Event,处理Event触发的结果,并根据需要启动和停止进程。
Upstart使用了LibNIH,这是一个“针对Init环境优化”的C库。这一举动在Canonical员工自己眼里是“以性能为核心考量”的结果,但是在社区眼里,NIH是一种很不受待见的行为。
Upstart的开发者认为,Bare-metal服务器的用户空间初始化速度并不重要,因为它们的瓶颈不在这里,而在RAID阵列联机耗费的时间。但是,考虑云场景,用户空间初始化的速度就会变成重中之重,因为它直接影响新实例的部署时间,而部署新服务的速度越快,客户体验就越好。
他们还认为,Event是一个“相当抽象的概念”,所以它们很适合做构建高级结构的高度灵活的基本单元。更重要的是,由于Event概念是动态的,所以“系统理所当然地可以为无数可能的系统行为和故障模式配置响应机制”,例如在发生故障的事件时,可以主动触发某些Job以进行处理,从而有可能实现“自愈”的逻辑。
从这些论述中,不难看出Upstart的两个核心概念是Event和Job。
Job
Job是Upstart的一个工作单元,可以是启动一个持续运行的载荷进程,也可以是执行一组批处理命令。每个Job都会监听一个或多个Event,一旦Event发生,Upstart就触发该Job的执行。
按生命周期分,Job分为两类:
- Task Job:单次运行的短生命周期Job,它在执行完毕后便会退出。
- Service Job:长期运行的长生命周期Job,它不应该主动退出。
此外,还有一些非用户可操控的隐藏Job,Upstart将其称为Abstract Job,这些Job纯供Upstart内部使用。
每个Job都是一个状态机,Job内部实际上保存了两种状态:当前状态(state
)和目标状态(goal
),goal
实际上只有stop
和start
,state
则更为细化,其原因在于,在Job的state
即将发生变化时,Upstart就会发出对应的Event,Upstart实际上通过这种手段创造了更多用户可用的Event,以提高灵活性。具体状态名称,可参考下图:
![[upstart-states.png]]
Upstart在历史上的一大贡献是将会话管理的功能集成到了Init系统内部,按照作用域区分,Upstart的Job也可以分为两类:
- 系统级别的系统Job。其定义文件保存在
/etc/init/*.conf
。 - 用户级别的会话Job。其定义文件保存在(主要是)
$HOME/.init/*.conf
。
Job定义文件中包含多个段落(Stanza),其中最关键的配置项无非就四个:
start on EVENT
:监听指定的Event,在出现时启动当前Job。stop on EVENT
:监听指定的Event,在出现时停止当前Job。exec 路径
:执行程序。script ... end script
:使用/bin/sh
执行内联的脚本段。
Event
顾名思义,Event 就是一个Event。Event在 Upstart 中以广播消息的形式存在。一旦某个Event发生了,Upstart 就向整个系统创建并发送一个消息。没有任何手段阻止Event消息被 Upstart 的其它部分知晓,也就是说,Event一旦发生,整个 Upstart 系统中所有工作和其它的Event都会得到通知。
根据对Event发布者的影响,Event 可以分为三类:
- Signal:异步的非阻塞Event,不会导致发布者阻塞。
- 这意味着Event的发布者不关心任何订阅者,也不关心Event是否被消费。
- 通过
initctl emit --no-wait Event名
发送,或是Upstart内部发送。
- Method:同步的阻塞Event,发布者发送后会阻塞等待Event结果是否成功。
- 这意味着Event的发布者会等待Event触发的Job执行完成,并收集其执行结果。
- 通过
initctl emit Event名
发送,或是Upstart内部发送。
- Hook:阻塞Event,但是发布者只关心Event是否完成,不关心Event是否成功。
- Upstart内部发送。
每一个Job的运行都由其监听的Event发生而触发的,Upstart在系统启动之初会发出startup
Event,该Event随之触发大量Job执行,这些Job又会使 Upstart 发出更多Event,通过这种树状发散的方式使得系统达到工作状态。而工作状态不过就是一组运行中的Job的集合。
Upstart并不主动适应外部的事件概念,恰恰相反,它期望自己成为系统中所有类型的事件的唯一总代理,其他服务产生的事件应当通过某种方式接入Upstart,由Upstart代为管理,这样的组件叫做Bridge。Upstart实现了很多Bridge,例如将D-Bus事件转换为Upstart事件的upstart-dbus-bridge
,将Udev事件转换为Upstart事件的upstart-udev-bridge
,这无疑给自己增加了工作量。
运行等级
Upstart兼容SysV init的运行等级概念,并重新实现了telinit
和runlevel
工具。
和SysV init相同,Upstart在系统启动/停止过程中会设置$RUNLEVEL
和$PREVLEVEL
变量。在使用telinit
命令切换运行等级时,会触发runlevel 目标运行等级 原运行等级
事件。
Upstart是怎么兼容SysV init风格的init脚本的呢?以CentOS 6为例,在/etc/init/
下的Job定义文件中,有一个rcS.conf
,它的启动条件是:
1 | start on startup |
也就是说该Job会在系统启动的早期就被Upstart发出的startup
事件触发启动。而它会执行这条命令:
1 | exec telinit $runlevel |
我们知道,telinit
命令会触发runlevel 目标运行等级 原运行等级
事件。因此,rc.conf
这个Job:
1 | start on runlevel [0123456] |
就会被触发启动,从而执行rc
脚本:
1 | exec /etc/rc.d/rc $RUNLEVEL |
并由rc
脚本按照传统方法执行init脚本。
Upstart的问题
诡异的逻辑运算符
Job没有排序保证。也就是说,多个监听某个Event的Job可能按照任意顺序被启动。Upstart在设计上就没有考虑过对这种情况的兼容,官方对用户推荐的解决方案,也仅仅只是执行sleep
通过时间进行排序。
Job监听的Event之间可以使用逻辑运算符(and
和or
)连接,也可以使用小括号表示优先级。但是由于监听行为并不是一个静态的行为,Upstart的逻辑运算符实际上并不完全符合我们的直觉。
举例来说:
1 | start on (started A and started B) |
这样一个Job C会收到在A启动和B启动这两个Event后启动,在用户停止了A以后,C也会因收到stopping A
这个Event而停止。但是,如果此时再启动A,那么C并不会被再次启动。这是因为,对于Upstart来说,Event是没有时效性的,也不存在重放机制。Upstart只是按照用户的设定监听并等待and
的两侧Event。
另一个例子是:
1 | start on started A and (started B or started C) |
如果C启动了,那么Job将不会再监听和B或C有关的任何Event,仅仅只等待A启动的Event。
其根本原因在于,在逻辑运算符一侧的Event被消费后,该侧条件会立刻被Upstart计算和抛弃。对于and
来说,如果一侧的Event发生,那么该侧Event会立刻被抛弃并不再被监听,等待另一侧的Event发生才会通过逻辑检测。
难以理解的Ptrace
Upstart使用Ptrace来追踪进程,它暴露给用户设定追踪进程行为的指令是expect
,这个配置项接受的值在普通用户看来根本就无法理解:
fork
:Fork一次创建主进程的服务。daemon
:Fork两次创建主进程的服务。
这也就是说,如果用户希望自行创建一个Job定义文件,那么他就必须理解进程Fork的含义,并且还需要用下面这个麻烦的办法来确定主进程到底是通过几次Fork被创建出来的:
1 | strace -o strace.log -fFv /usr/bin/myapp --arg foo --hello wibble & |
这导致了很多不满。
不彻底的并行化
Upstart的并行化根本就是不彻底的,并行化的程度完全取决于用户将init脚本转换为Upstart Job的意愿——转换的多,并行化程度就高;转换的少,并行化程度就很低,因为通过rc
脚本启动的init脚本总是串行的(在没有接入startpar
的情况下)。参考CentOS 6,大部分系统守护进程和批处理脚本仍然是通过init脚本实现的。
结
回顾Upstart短暂的寿命(2006-2014),我们可以看出它更多地是一个承上启下的作品,它存在着很多不成熟的技术幻想和不彻底的变革。这自然有一部分Canonical和Upstart本身的问题,但更多的是时代的局限性——那时人们的一个工作重心,就是尽可能保持对传统init脚本的渐进改动。而只要这种守旧的思想还存在,彻底的并行化就无法实现。