【译】如何将 s6-svscan 用作初始进程

@zen
2016年9月29日

2015 年 6 月 17 日之后,如果你是 Linux 用户,你可以使用 s6-linux-init 包来帮助你实现同样的目标!尽管如此,还是请你首先阅读本篇文档,以理解 s6-linux-init 的工作内容。

将 s6-svscan 用作初始进程是可行的。但这并不意味着你可以直接通过 s6-svscan 引导,因为它还无法胜任完整的初始化系统所应做地全部工作。正确地替换掉初始进程,还需要对其工作内容做更多地理解。

初始化过程的三个阶段#

一个 Unix 操作系统运行时的完整生命周期,包含了三个阶段:

  1. 早期初始化阶段,从内核启动首个用户进程(一般被称作为“初始进程”)开始。在这一阶段中,初始进程是唯一会持续运行的进程,其职责是准备好环境以启动其它长期执行地进程(即:服务)。一些即时完成的工作如:挂载文件系统、设置系统时钟等等,都会在此时完成。当初始进程启动第一批服务时,此阶段结束。
  2. 运行阶段,任何一个运行中的 Unix 操作系统运行时的“普通”稳定状态。早期工作此时已完成,初始进程开始启动并维护服务,即长期执行地进程,如:getty、SSH 服务等等。在这一阶段中,初始进程的职责是“收割”“僵尸化”的「孤儿进程」,和管理服务——当然也包括管理员添加或取消服务。直至管理员开始执行关机流程,这一阶段才会结束。
  3. 关机阶段,清理运行时,停止服务,卸载文件系统,使机器可以安全断电。在这一阶段中,除了初始进程因需要执行关机流程成为例外,其它进程都被会杀掉(kill -9 -1)。

正如你所见,初始进程在每个阶段的职责都是完全不同的,而且在运行时引导和关机时承担了绝大部分的工作。这也意味着它真正工作的有效时间是极短的。唯一的共同点在于,初始进程无论何时都是不允许终止结束的。

此外,所有常用的初始化系统均是使用同一 init 程序来处理这三个不同的阶段。无论是 SysV init 还是 launchd,又或着 busybox init,均是如此。亦无论这些程序写得多么复杂,或者多么难于理解!

即便是为进程管理而设计的 runit,同样会全程保持初始进程。只是为了简化管理,每个阶段的工作被分割到不同的脚本中以子进程的方式执行。(但这又增大了第三阶段 kill -9 -1 部分的风险度。)

只能使用一个 init 程序来处理全部的工作嘛?并非如此!

s6-svscan 所扮演的角色#

初始进程没有权利自我终止,但却可以执行 execve()在第二阶段里,为什么要使用宝贵的内存(至少也需要用到交换空间)来存储仅仅用于第一阶段和第三阶段的数据?正是因为同一初始进程要先后负责第一阶段、第二阶段和第三阶段才会如此。这与 runit 使用 /etc/runit/[123] 脚本所做的相似,区别只在于 runit 是使用子进程的方式,而非都由初始进程执行。

s6-svscan 正适合于处理初始进程在第二阶段的工作。

  • 不会自我终止;
  • “收割”所有的“僵尸进程”;
  • 扫描器确保服务是可用的;
  • 可以通过 s6-svscanctl 接口发送命令;
  • 根据需要执行相应地脚本。

然而,这还不够,还需要负责第一阶段的初始进程和负责第三阶段的初始进程。幸运地是,这些进程都很容易设计!唯一的麻烦在于它们与系统是高度耦合的,因此没有办法用同一个初始进程来满足所有场合的需要。s6 的设计就倾向于尽可能地可移植,使之能在任意的 Unix 平台虚拟机环境中使用,但仅限于第二阶段。

s6-linux-init 包提供了名为 s6-linux-init-maker 的工具,能够为 Linux 自动生成合适的第一阶段 init 程序(即所谓的 /sbin/init)。生成的程序或许也能够适用于其它操作系统,但并不确定。

下面是一些通用的设计技巧。

如何设计第一阶段 init#

第一阶段 init 需要做什么#

  • 准备好初始化扫描目录,比如 /services,其中包含了一些必要的服务,如 s6-svscan 的日志,某些情况下用于调试的早期 getty 。还有就是当根文件系统只读时,在内存里创建并挂载一个可读写的文件系统。
  • 或者像第一阶段的 runit 所做的,执行所有的一次性初始化工作;
  • 或者在 s6-svscan 驱动下分出(fork)子进程用于执行大部分的一次性初始化工作;
  • 尽可能地简单,确保不会出错,因为此时是无法恢复的。

/etc/runit/1 脚本不同的是,第一阶段 init 脚本(init-stage1)作为初始进程执行是不能出错地,否则运行时就会崩溃掉。这是否意味着 runit 的方法更好?它确实更安全,但并非更好,因为第一阶段 init 脚本可以实现得超级小。真到出错失败地时候,就意味着我们只能使用 init=/bin/sh 重启运行时了。

为了让第一阶段 init 脚本尽可能地小,我们需要认识到:并非所有的一次性初始化工作都需要在 s6-svscan 启动前完成。事实上,当第一阶段 init 脚本执行到足以启动 s6-svscan 时,完全可以分出一个 init-stage2 后台子进程,然后立刻启动 s6-svscan!此方法的优势在于,如果 init-stage2 进程继续完成一次性初始化工作出错,s6-svscan 已经启动了一些关键服务,管理员可以通过 getty 登录进行调试。这样就不用在花心思在 /dev/console 上做文章了!是的,将三个阶段分割开处理会更灵活:第一阶段的一次性工作还在继续,第二阶段的初始进程就已经开始运行了。

当然,在 s6-svscan 刚开始启动时,扫描目录并不完整,因为绝大部分服务尚未启动,比如文件系统、网络等等。init-stage2 应在相应的一次性初始化工作完成后完善扫描目录中的相应服务配置,并触发再次扫描。当全部的一次性初始化工作都完成之后,扫描目录应该是完整状态,扫描器也已被触发,运行时正式进入第二阶段,而 init-stage2 脚本就可以自我终止了。

第一阶段 init 可以是脚本吗?#

当然可以,这也是推荐方式。当你使用 s6-svscan 作为第二阶段初始进程时,第一阶段 init 应该像 runit 里的 /etc/runit/1 一样,使用任意顺手的语言编写,也足够简单。而也因为其足够小,出于可维护性考虑,性能问题是可以忽略不计的。第一阶段 init 脚本,你值得拥有。

尽管大多数人都会使用 shell 作为编程语言,但我还是推荐使用 execline。除了前面所说地原因外,它还可以将 s6-svscan 的 stderr 通过管道转向启动了地日志服务,这是 shell 所做不到的。

如何设计第三阶段 init#

当你使用 s6-svscan 作为扫描 /service 目录地第二阶段初始进程时,第三阶段 init 一般是 /service/.s6-svscan/finish 程序。当然,这个文件可以是指向任何文件路径的符号链接,只要相应的文件存在于根文件系统即可(除非这个程序是 execline 脚本,但一般用不上)。

第三阶段 init 需要做什么#

  • 销毁管理树并停止所有服务;
  • 干掉除自身以外的所有进程,先礼(kill -1)后兵(kill -9);
  • 卸载所有文件系统;
  • 按照要求终止或重启运行时。

这也很简单,甚至更甚于第一阶段。唯一需要注意地是,在 kill -9 -1 时需要确保初始进程能够作为唯一幸存的进程,在第三阶段 init 执行完毕后拿回控制权。但当我们直接执行第三阶段 init 时,这几乎是自动地!与 runit 的 /etc/runit/3 类似,相比初始进程直接执行关机流程,这样更好。

第三阶段 init 可以是脚本吗?#

除非你是受虐狂,或者有很极端地特殊需要,否则都应该这么干。

如何记录管理树信息#

Unix 内核启动(第一阶段)初始进程时,会将 stdin、stdout 和 stderr 绑定至 /dev/console。在早期引导阶段,你确实需要能够在系统终端上看到错误消息。但到了第二阶段,系统终端应该只用于显示极端严重的错误信息,如内核错误或日志系统本身的错误,其余的错误信息都应该遵循日志链机制进入日志系统(catch-all logger)。管理树信息也不例外。(而且终端也不应该再被用于信息输入,s6-svscan 会将 /dev/null 用作 stdin 来启动服务以解决这一问题。)

日志系统也是服务,而我们又需要每个服务都处于管理树中。那么悖论在于,在 s6-svscan 启动之前,我们需要某个程序能够接收 s6-svscan 自身的输出信息,直至 s6-svscan 正常运行且开始启动后继服务。

要解决这一问题有很多种方案,其中最简单的就是使用 FIFO (命名管道)。在 s6-svscan 正常运行之前,其 stdout 和 stderr 可以转向至某个命名管道,日志系统启动之后直接从该命名管道中读取信息。但这会有两个小问题:

  • 在日志系统启动之前,FIFO 中的信息无法被读取,也就因此无法被 s6-svscan 和 s6-supervise 写入(会产生 SIGPIPE 信号)。对于 s6-svscan 和 s6-supervise 而言倒不算问题,SIGPIPE 信号会被忽略,也只有错误发生时才会向 stderr 写入信息。如果真在日志系统启动之前就有错误发生,这意味着系统出现了很严重地故障,需要使用 init=/bin/sh 进行重启。
  • 正常的 Unix 语义不允许向没有读取者的 FIFO 写入数据,在读取者出现之前,写入的 open() 系统调用会被一直阻塞。这显然不是我们所期望的,我们需要先正常地启动 s6-svscan (向 FIFO 中写入数据),然后再启动日志服务(从 FIFO 中读取数据)。

shell 脚本无法解决第二点问题,这也是为什么我们不鼓励使用 shell 语言来编写第一阶段 init 脚本的原因。

相应的,使用 execline 语言是个不错地选择,或者至少使用其发布包中的 redirfd 命令。该命令会处理 FIFO 以将初始进程的 stdout 和 stderr 正确地转向日志 FIFO 而无阻塞:redirfd -w 1 /service/s6-svscan-log/fifo

这个 FIFO 技巧同样适用于在 init-stage2 中以避免潜在的前后顺序问题(race conditions)。即便是在 init-stage2 子进程分出之后立即启动 s6-svscan,至 s6-svscan 正式运行并开始启动首批服务时,可能仍有大量的 init-stage2 一次性初始化工作已被处理,其中就有可能会向日志 FIFO 写入数据,进而导致受 SIGPIPE 影响而中止,并未完成剩余的初始化工作。为了避免这一问题,我们应该将 init-stage2 的输出正常地转向日志 FIFO,当 s6-svscan 正常运行并启动日志服务之后,FIFO 阻塞取消,init-stage2 就可以正常地继续运行了。

说起来很复杂,但其实很简单 :-)

案例#

整篇文章可能读下来会让人觉得过于晦涩,可偏偏 s6 又无法脱离系统给出实例。尽管如此,还是有一整套的脚本模板可以帮助你来实现自己的初始化脚本。

s6 发布包中的 examples/ROOT 子目录包含了能在 Linux 下工作的小型根文件系统的相关部分内容。每个文件夹中都有一个 README 文件用以说明该文件夹的用途。你可以复制并修改这些文件来满足你的需要,如果你安装了相应的软件,也做好了配置,部分脚本应该还能够正常工作。


点击这里阅读原文。