BSD 中实用的 rc.d 脚本编写

商标

FreeBSD 是 FreeBSD 基金会的注册商标。

NetBSD 是 NetBSD 基金会的注册商标。

许多制造商和销售商用来区分其产品的名称被声称是商标。在这些名称出现在本文档中且 FreeBSD 项目知晓商标声明的情况下,这些名称后已加上“™”或“®”符号。

摘要

初学者可能难以将 BSD rc.d 框架的正式文档中的事实与 rc.d 脚本编写的实际任务联系起来。在本文中,我们将考虑几个复杂程度不断提高的典型案例,展示适用于每个案例的 rc.d 功能,并讨论它们的工作原理。这样的检查应该为进一步研究 rc.d 的设计和高效应用提供参考点。


1. 绪论

历史上的 BSD 具有一个单一的启动脚本,/etc/rc。它由 init(8) 在系统启动时调用,并执行多用户操作所需的所有用户级任务:检查和挂载文件系统、设置网络、启动守护进程等等。每个系统执行的任务列表并不相同;管理员需要对其进行自定义。除了一些例外情况,必须修改 /etc/rc,真正的黑客喜欢这样做。

单体方法的真正问题是它没有提供对从 /etc/rc 启动的各个组件的控制。例如,/etc/rc 无法重启单个守护进程。系统管理员必须手动找到守护进程,将其杀死,等待它真正退出,然后浏览 /etc/rc 以查找标志,最后输入完整的命令行以重新启动守护进程。如果要重启的服务包含多个守护进程或需要额外的操作,任务将变得更加困难,并且更容易出错。简而言之,单个脚本未能实现脚本的本意:让系统管理员的生活更轻松。

后来,有人试图将 /etc/rc 的某些部分分离出来,以便单独启动最重要的子系统。臭名昭著的例子是 /etc/netstart,用于启动网络。它确实允许从单用户模式访问网络,但它没有很好地集成到自动启动过程,因为其代码的某些部分需要与本质上与网络无关的操作交织在一起。这就是 /etc/netstart 变成 /etc/rc.network 的原因。后者不再是普通的脚本;它包含大型、混乱的 sh(1) 函数,这些函数在系统启动的不同阶段从 /etc/rc 调用。然而,随着启动任务变得多样化和复杂,这种“准模块化”方法变得比单体 /etc/rc 更加拖累。

如果没有干净且设计良好的框架,启动脚本就必须竭尽全力才能满足快速发展的基于 BSD 的操作系统的需求。最终,很明显需要采取更多措施才能实现细粒度且可扩展的 rc 系统。因此 BSD rc.d 诞生了。它公认的父亲是 Luke Mewburn 和 NetBSD 社区。后来它被导入到 FreeBSD 中。它的名字指的是单个服务的系统脚本的位置,位于 /etc/rc.d 中。很快,我们将了解 rc.d 系统的更多组件,并了解如何调用各个脚本。

BSD rc.d 背后的基本理念是细粒度模块化代码重用细粒度模块化意味着每个基本“服务”,例如系统守护进程或原始启动任务,都有自己的 sh(1) 脚本,该脚本能够启动服务、停止服务、重新加载服务、检查服务状态。通过传递给脚本的命令行参数来选择特定操作。/etc/rc 脚本仍然驱动系统启动,但现在它只是依次调用较小的脚本,并附带 start 参数。使用 stop 参数运行同一组脚本可以轻松执行关闭任务,这是由 /etc/rc.shutdown 完成的。注意,这与 Unix 的方式非常接近,即有一组小型专门的工具,每个工具尽其所能完成自己的任务。代码重用意味着常见的操作实现为 sh(1) 函数,并收集到 /etc/rc.subr 中。现在,典型的脚本可能只有几行 sh(1) 代码。最后,rc.d 框架的重要组成部分是 rcorder(8),它帮助 /etc/rc 有序地运行小型脚本,以符合它们之间的依赖关系。它也可以帮助 /etc/rc.shutdown,因为关闭序列的正确顺序与启动序列的顺序相反。

BSD rc.d 的设计在 Luke Mewburn 的原始文章 中进行了描述,rc.d 组件在 各自的手册页 中进行了详细的文档说明。但是,对于 rc.d 新手来说,如何将众多片段组合在一起,为特定任务创建一个格式良好的脚本,可能并不明显。因此,本文将尝试采用不同的方法来描述 rc.d。它将展示在一些典型案例中应该使用哪些功能,以及为什么。请注意,这不是一篇操作指南,因为我们的目标不是提供现成的配方,而是展示进入 rc.d 领域的几个简单入口。本文也不是相关手册页的替代品。在阅读本文时,请不要犹豫,参考这些手册页以获取更正式和完整的文档。

理解本文需要一些先决条件。首先,您应该熟悉 sh(1) 脚本语言,才能掌握 rc.d。此外,您应该了解系统如何执行用户级启动和关闭任务,这在 rc(8) 中进行了描述。

本文侧重于 rc.d 的 FreeBSD 分支。不过,对于 NetBSD 开发人员来说,它也可能很有用,因为 BSD rc.d 的两个分支不仅共享相同的设计,而且在对脚本作者可见的方面保持相似。

2. 概述任务

在启动 $EDITOR 之前进行一点考虑不会有什么坏处。为了为系统服务编写一个格式良好的 rc.d 脚本,我们应该首先能够回答以下问题。

  • 服务是强制性的还是可选的?

  • 脚本将服务于单个程序(例如,守护进程)还是执行更复杂的操作?

  • 我们的服务将依赖哪些其他服务,反之亦然?

从接下来的例子中,我们将看到为什么了解这些问题的答案很重要。

3. 一个虚拟脚本

以下脚本在系统每次启动时都会发出一条消息。

#!/bin/sh (1)

. /etc/rc.subr (2)

name="dummy" (3)
start_cmd="${name}_start" (4)
stop_cmd=":" (5)

dummy_start() (6)
{
	echo "Nothing started."
}

load_rc_config $name (7)
run_rc_command "$1" (8)

需要注意的是。

➊ 解释型脚本应该以魔术“shebang”行开头。该行指定脚本的解释器程序。由于 shebang 行的存在,该脚本可以像二进制程序一样被调用,前提是它具有执行位。 (参见 chmod(1)。)例如,系统管理员可以从命令行手动运行我们的脚本。

# /etc/rc.d/dummy start

为了能够被 rc.d 框架正确管理,它的脚本需要使用 sh(1) 语言编写。如果您有一个服务或端口使用二进制控制实用程序或用另一种语言编写的启动例程,请将该元素安装到 /usr/sbin(对于系统)或 /usr/local/sbin(对于端口)中,并从相应 rc.d 目录中的 sh(1) 脚本中调用它。

如果您想了解 rc.d 脚本必须使用 sh(1) 语言编写的详细原因,请查看 /etc/rc 如何通过 run_rc_script 调用它们,然后研究 /etc/rc.subrrun_rc_script 的实现。

➋ 在 /etc/rc.subr 中,定义了许多 sh(1) 函数供 rc.d 脚本使用。这些函数在 rc.subr(8) 中进行了文档说明。虽然理论上可以编写一个从未使用过 rc.subr(8)rc.d 脚本,但它的函数非常方便,并且可以使工作变得容易得多。因此,毫不奇怪,每个人都在 rc.d 脚本中使用 rc.subr(8)。我们也不例外。

rc.d 脚本必须在调用 rc.subr(8) 函数之前“source”/etc/rc.subr(使用“.”包含它),以便 sh(1) 有机会学习这些函数。首选的风格是首先 source /etc/rc.subr

另一个包含文件 /etc/network.subr 提供了一些与网络相关的有用函数。

必需变量 name 指定我们脚本的名称。它是 rc.subr(8) 所需的。也就是说,每个 rc.d 脚本在调用 rc.subr(8) 函数之前必须设置 name

现在是为我们的脚本一次性选择一个唯一名称的时候了。我们在开发脚本时会在很多地方使用它。名称变量的内容需要与脚本名称匹配,FreeBSD 的某些部分(例如,服务监狱 和 rc 框架的 cpuset 特性)依赖于此。因此,文件名也不应该包含在脚本编写中可能造成麻烦的字符(例如,不要使用连字符“ - ”和其他字符)。

当前的 rc.d 脚本编写风格是在双引号中包含分配给变量的值。请记住,这只是一个风格问题,并不一定总是适用。您可以放心地省略简单单词周围的引号,这些单词不包含 sh(1) 元字符,而在某些情况下,您需要使用单引号来防止 sh(1) 对值进行任何解释。程序员应该能够区分语言语法和风格约定,并明智地使用它们。

rc.subr(8) 的主要思想是,rc.d 脚本为 rc.subr(8) 提供处理程序或方法来调用。特别是,startstoprc.d 脚本的其他参数都是这样处理的。方法是存储在名为 argument_cmd 的变量中的 sh(1) 表达式,其中 argument 对应于可以在脚本命令行上指定的内容。我们将在后面看到 rc.subr(8) 如何为标准参数提供默认方法。

为了使 rc.d 中的代码更加统一,通常使用 ${name} 来代替适当的位置。因此,可以将许多行从一个脚本复制到另一个脚本。

➎ 我们应该记住,rc.subr(8) 为标准参数提供默认方法。因此,如果我们希望它什么都不做,我们必须使用一个空操作的 sh(1) 表达式覆盖标准方法。

➏ 复杂方法的主体可以实现为函数。最好使函数名有意义。

强烈建议在我们的脚本中定义的所有函数名称前面加上 ${name} 前缀,这样它们永远不会与 rc.subr(8) 或另一个常用包含文件中的函数发生冲突。

➐ 此对 rc.subr(8) 的调用会加载 rc.conf(5) 变量。我们的脚本目前没有使用它们,但仍然建议加载 rc.conf(5),因为可能会有 rc.conf(5) 变量控制 rc.subr(8) 本身。

➑ 通常,这是 rc.d 脚本中的最后一个命令。它调用 rc.subr(8) 机制来执行请求的操作,使用我们的脚本提供的变量和方法。

4. 可配置的虚拟脚本

现在让我们为我们的虚拟脚本添加一些控制。您可能知道,rc.d 脚本由 rc.conf(5) 控制。幸运的是,rc.subr(8) 为我们隐藏了所有复杂性。以下脚本使用 rc.conf(5) 通过 rc.subr(8) 来查看它是否首先被启用,并获取在启动时显示的消息。这两个任务实际上是独立的。一方面,rc.d 脚本可以只支持启用和禁用其服务。另一方面,一个必需的 rc.d 脚本可以具有配置变量。不过,我们将在同一个脚本中完成这两件事

#!/bin/sh

. /etc/rc.subr

name=dummy
rcvar=dummy_enable (1)

start_cmd="${name}_start"
stop_cmd=":"

load_rc_config $name (2)
: ${dummy_enable:=no} (3)
: ${dummy_msg="Nothing started."} (4)

dummy_start()
{
	echo "$dummy_msg" (5)
}

run_rc_command "$1"

在这个例子中,什么发生了变化?

➊ 变量 rcvar 指定 ON/OFF 开关变量的名称。

➋ 现在,load_rc_config 在脚本中被更早地调用,在访问任何 rc.conf(5) 变量之前。

在检查 rc.d 脚本时,请记住,sh(1) 会延迟在函数中的表达式的求值,直到函数被调用。因此,在 run_rc_command 之前调用 load_rc_config 并不是错误,而且仍然可以从导出到 run_rc_command 的方法函数中访问 rc.conf(5) 变量。这是因为方法函数要由 run_rc_command 调用,而 run_rc_commandload_rc_config 之后被调用。

➌ 如果 rcvar 本身被设置,但指示的开关变量未设置,则 run_rc_command 会发出警告。如果您的 rc.d 脚本用于基本系统,您应该在 /etc/defaults/rc.conf 中为开关添加默认设置,并在 rc.conf(5) 中对其进行文档说明。否则,应该是您的脚本为开关提供默认设置。后一种情况的规范方法如示例所示。

您可以使 rc.subr(8) 就像开关设置为 ON 一样工作,而不管其当前设置如何,方法是在脚本参数前加上 oneforce,例如 onestartforcestop。但请记住,force 有其他危险的影响,我们将在下面讨论,而 one 只是覆盖 ON/OFF 开关。例如,假设 dummy_enableOFF。以下命令将运行 start 方法,尽管有设置

# /etc/rc.d/dummy onestart

➍ 现在,要在启动时显示的消息不再硬编码在脚本中。它由名为 dummy_msgrc.conf(5) 变量指定。这是一个简单的示例,说明 rc.conf(5) 变量如何控制 rc.d 脚本。

我们的脚本专门使用的所有 rc.conf(5) 变量的名称必须具有相同的 前缀:${name}_。例如:dummy_modedummy_state_file 等等。

虽然可以在内部使用更短的名称,例如 msg,但将唯一的 ${name}_ 前缀添加到我们的脚本引入的所有全局名称,可以避免与 rc.subr(8) 命名空间发生可能的冲突。

通常,基本系统的 rc.d 脚本不需要为它们的 rc.conf(5) 变量提供默认值,因为默认值应该设置在 /etc/defaults/rc.conf 中。另一方面,端口的 rc.d 脚本应该提供默认值,如示例所示。

➎ 在这里,我们使用 dummy_msg 来实际控制我们的脚本,即发出可变的消息。在这里使用 shell 函数是过分的,因为它只运行一个命令;另一个同样有效的替代方案是

start_cmd="echo \"$dummy_msg\""

5. 简单守护进程的启动和关闭

我们之前说过,rc.subr(8) 可以提供默认方法。显然,这些默认值不能太通用。它们适合于启动和关闭简单守护进程程序的常见情况。现在假设我们需要为这样一个名为 mumbled 的守护进程编写一个 rc.d 脚本。这就是它

#!/bin/sh

. /etc/rc.subr

name=mumbled
rcvar=mumbled_enable

command="/usr/sbin/${name}" (1)

load_rc_config $name
run_rc_command "$1"

令人高兴的简单,不是吗?让我们检查一下我们的小脚本。唯一需要注意的新事物如下:

command 变量对 rc.subr(8) 有意义。如果它被设置,rc.subr(8) 将根据为传统守护进程提供服务的场景进行操作。特别是,将为以下参数提供默认方法:startstoprestartpollstatus

守护进程将通过运行 $command 来启动,并使用 $mumbled_flags 指定的命令行标志。因此,默认 start 方法的所有输入数据都可以在我们的脚本设置的变量中找到。与 start 不同,其他方法可能需要有关已启动进程的额外信息。例如,stop 必须知道要终止的进程的 PID。在本例中,rc.subr(8) 将扫描所有进程列表,寻找一个名称等于 procname 的进程。后者是 rc.subr(8) 的另一个有意义的变量,它的值默认为 command 的值。换句话说,当我们设置 command 时,procname 实际上被设置为相同的值。这使我们的脚本能够杀死守护进程并检查它是否正在运行。

有些程序实际上是可执行脚本。系统通过启动解释器并将脚本名称作为命令行参数传递给它来运行这样的脚本。这反映在进程列表中,这可能会使 rc.subr(8) 产生混淆。如果 $command 是一个脚本,则应另外设置 command_interpreter 以便让 rc.subr(8) 知道进程的实际名称。

对于每个 rc.d 脚本,都有一个可选的 rc.conf(5) 变量,它优先于 command。它的名称按如下方式构建:${name}_program,其中 name 是我们之前讨论过的必填变量 earlier。例如,在本例中,它将是 mumbled_program。它是 rc.subr(8)${name}_program 设为覆盖 command 的。

当然,sh(1) 允许您从 rc.conf(5) 或脚本本身设置 ${name}_program,即使 command 未设置。在这种情况下,${name}_program 的特殊属性会丢失,它会变成脚本可以用于自身目的的普通变量。但是,不鼓励单独使用 ${name}_program,因为将它与 command 一起使用成为了 rc.d 脚本的习语。

有关默认方法的更详细的信息,请参阅 rc.subr(8)

6. 高级守护进程的启动和关闭

让我们在之前的脚本的基础上添加一些内容,使它更复杂,功能更强大。默认方法可以为我们做好工作,但我们可能需要调整其中一些方面。现在我们将学习如何根据我们的需求调整默认方法。

#!/bin/sh

. /etc/rc.subr

name=mumbled
rcvar=mumbled_enable

command="/usr/sbin/${name}"
command_args="mock arguments > /dev/null 2>&1" (1)

pidfile="/var/run/${name}.pid" (2)

required_files="/etc/${name}.conf /usr/share/misc/${name}.rules" (3)

sig_reload="USR1" (4)

start_precmd="${name}_prestart" (5)
stop_postcmd="echo Bye-bye" (6)

extra_commands="reload plugh xyzzy" (7)

plugh_cmd="mumbled_plugh" (8)
xyzzy_cmd="echo 'Nothing happens.'"

mumbled_prestart()
{
	if checkyesno mumbled_smart; then (9)
		rc_flags="-o smart ${rc_flags}" (10)
	fi
	case "$mumbled_mode" in
	foo)
		rc_flags="-frotz ${rc_flags}"
		;;
	bar)
		rc_flags="-baz ${rc_flags}"
		;;
	*)
		warn "Invalid value for mumbled_mode" (11)
		return 1 (12)
		;;
	esac
	run_rc_command xyzzy (13)
	return 0
}

mumbled_plugh() (14)
{
	echo 'A hollow voice says "plugh".'
}

load_rc_config $name
run_rc_command "$1"

➊ 可以将 command_args 中传递的额外参数传递给 $command。它们将在 $mumbled_flags 之后添加到命令行中。由于最终的命令行传递给 eval 用于实际执行,因此可以在 command_args 中指定输入和输出重定向。

决不command_args 中包含带连字符的选项,例如 -X--foocommand_args 的内容将出现在最终命令行的末尾,因此它们很可能紧随 ${name}_flags 中存在的参数;但大多数命令在普通参数之后将无法识别带连字符的选项。将额外选项传递给 $command 的更好的方法是将它们添加到 ${name}_flags 的开头。另一种方法是修改 rc_flags 如后所示

➋ 一个行为良好的守护进程应该创建一个pidfile,以便更容易、更可靠地找到它的进程。如果设置了变量 pidfile,它将告诉 rc.subr(8) 在哪里可以找到其默认方法使用的 pidfile。

实际上,rc.subr(8) 还将使用 pidfile 来查看守护进程是否已在运行,然后再启动它。可以通过使用 faststart 参数跳过此检查。

➌ 如果守护进程只有在某些文件存在时才能运行,只需将它们列在 required_files 中,rc.subr(8) 将在启动守护进程之前检查这些文件是否存在。还有 required_dirsrequired_vars 分别用于目录和环境变量。它们都在 rc.subr(8) 中进行了详细说明。

可以强制来自 rc.subr(8) 的默认方法通过使用 forcestart 作为脚本的参数来跳过先决条件检查。

➍ 我们可以在信号与众所周知的信号不同的情况下自定义发送给守护进程的信号。特别是,sig_reload 指定使守护进程重新加载其配置的信号;默认情况下是 SIGHUP。另一个信号是发送给停止守护进程的;默认是 SIGTERM,但可以通过适当设置 sig_stop 来更改它。

信号名称应在 rc.subr(8) 中指定,无需 SIG 前缀,如示例所示。FreeBSD 版本的 kill(1) 可以识别 SIG 前缀,但其他操作系统类型的版本可能无法识别。

➎➏ 在默认方法之前或之后执行额外任务很容易。对于脚本支持的每个命令参数,我们可以定义 argument_precmdargument_postcmd。这些 sh(1) 命令分别在各自的方法之前和之后调用,从它们的名称中可以很明显地看出。

使用自定义 argument_cmd 覆盖默认方法仍然不会阻止我们在需要时使用 argument_precmdargument_postcmd。特别是,前者适合检查在执行命令本身之前应该满足的自定义、复杂的条件。将 argument_precmdargument_cmd 一起使用使我们能够在逻辑上将检查与操作分开。

不要忘记,您可以将任何有效的 sh(1) 表达式塞入您定义的方法、前置命令和后置命令中。在大多数情况下,只调用一个执行实际工作的函数是一种好风格,但决不要让风格限制您对幕后发生的事情的理解。

➐ 如果我们想实现自定义参数,也可以认为是脚本的命令,我们需要将它们列在 extra_commands 中,并提供方法来处理它们。

reload 命令是特殊的。一方面,它在 rc.subr(8) 中有一个预设方法。另一方面,reload 默认情况下不可用。原因是并非所有守护进程都使用相同的重新加载机制,有些根本没有要重新加载的东西。因此,我们需要明确地要求提供内置功能。我们可以通过 extra_commands 来做到这一点。

我们从 reload 的默认方法中获得了什么?通常情况下,守护进程在接收到信号(通常是 SIGHUP)后重新加载其配置。因此,rc.subr(8) 尝试通过向守护进程发送信号来重新加载它。该信号预设为 SIGHUP,但如果需要,可以通过 sig_reload 进行自定义。

➑⓮ 我们的脚本支持两个非标准命令,plughxyzzy。我们看到它们列在 extra_commands 中,现在是时候为它们提供方法了。xyzzy 的方法只是内联的,而 plugh 的方法则是作为 mumbled_plugh 函数实现的。

非标准命令在启动或关闭期间不会被调用。通常,它们是为了系统管理员的方便。它们也可以从其他子系统中使用,例如,devd(8),如果在 devd.conf(5) 中指定。

可以在 rc.subr(8) 在脚本没有参数的情况下被调用时打印的使用行中找到所有可用命令的完整列表。例如,以下是正在研究的脚本的使用行

# /etc/rc.d/mumbled
Usage: /etc/rc.d/mumbled [fast|force|one](start|stop|restart|rcvar|reload|plugh|xyzzy|status|poll)

⓭ 如果需要,脚本可以调用它自己的标准或非标准命令。这可能看起来类似于调用函数,但我们知道命令和 shell 函数并不总是相同的东西。例如,xyzzy 这里没有作为函数实现。此外,可能存在前置命令和后置命令,它们应该按顺序调用。因此,脚本运行自身命令的正确方法是使用 rc.subr(8),如示例所示。

rc.subr(8) 提供了一个名为 checkyesno 的实用函数。它以变量名作为参数,当且仅当该变量设置为 YESTRUEON1(不区分大小写)时返回零退出代码;否则返回非零退出代码。在后一种情况下,该函数会测试该变量是否设置为 NOFALSEOFF0(不区分大小写);如果该变量包含任何其他内容(即垃圾),则会打印警告消息。

请记住,对于 sh(1),零退出代码表示真,非零退出代码表示假。

checkyesno 函数接受变量名。不要将变量的展开传递给它;它不会按预期工作。

以下是 checkyesno 的正确用法

if checkyesno mumbled_enable; then
        foo
fi

相反,如以下所示调用 checkyesno 将不起作用 - 至少不会按预期工作

if checkyesno "${mumbled_enable}"; then
        foo
fi

我们可以通过在 $start_precmd 中修改 rc_flags 来影响传递给 $command 的标志。

⓫ 在某些情况下,我们可能需要发出一个应该发送到 syslog 的重要消息。这可以通过以下 rc.subr(8) 函数轻松完成:debuginfowarnerr。最后一个函数随后以指定的代码退出脚本。

⓬ 来自方法及其前置命令的退出代码默认情况下不会被忽略。如果 argument_precmd 返回非零退出代码,则不会执行主方法。反过来,除非主方法返回零退出代码,否则 argument_postcmd 不会被调用。

但是,rc.subr(8) 可以从命令行中指示忽略这些退出代码并无论如何调用所有命令,方法是在参数前加上 force,例如 forcestart

7. 将脚本连接到 rc.d 框架

在编写脚本之后,需要将其集成到 rc.d 中。关键的一步是将脚本安装到 /etc/rc.d(对于基本系统)或 /usr/local/etc/rc.d(对于端口)中。 bsd.prog.mkbsd.port.mk 都为此提供了方便的钩子,通常您不必担心正确的所有权和模式。系统脚本应从 src/libexec/rc/rc.d 通过那里找到的 Makefile 安装。端口脚本可以使用 USE_RC_SUBR 安装,如 Porter’s Handbook 中所述

然而,我们应该事先考虑我们的脚本在系统启动顺序中的位置。我们的脚本处理的服务可能依赖于其他服务。例如,网络守护程序在网络接口和路由启动并运行之前无法正常工作。即使一个服务似乎不需要任何东西,它也很难在基本文件系统被检查并挂载之前启动。

我们已经提到了rcorder(8)。现在是仔细看看它的时机了。简而言之,rcorder(8) 获取一组文件,检查其内容,并从该集合中打印一个依赖排序的文件列表到stdout。重点是将依赖信息保存在文件内部,以便每个文件只能代表自己。一个文件可以指定以下信息

  • 提供的“条件”(对我们来说意味着服务)的名称;

  • 需要的“条件”的名称;

  • 此文件应其之前运行的“条件”的名称;

  • 可以用来从整个文件集中选择子集的附加关键字(可以通过选项指示rcorder(8) 包含或省略列出了特定关键字的文件)。

毫不奇怪,rcorder(8) 只能处理语法接近于 sh(1) 的文本文件。也就是说,rcorder(8) 理解的特殊行看起来像 sh(1) 注释。此类特殊行的语法相当严格,以简化它们的处理。有关详细信息,请参阅 rcorder(8)

除了使用 rcorder(8) 特殊行之外,脚本可以通过强制启动另一个服务来坚持对其的依赖。当另一个服务是可选的并且不会自行启动时,这可能是必要的,因为系统管理员在 rc.conf(5) 中错误地禁用了它。

牢记这些一般知识,让我们考虑一下用依赖项增强过的简单守护程序脚本

#!/bin/sh

# PROVIDE: mumbled oldmumble (1)
# REQUIRE: DAEMON cleanvar frotz (2)
# BEFORE:  LOGIN (3)
# KEYWORD: nojail shutdown (4)

. /etc/rc.subr

name=mumbled
rcvar=mumbled_enable

command="/usr/sbin/${name}"
start_precmd="${name}_prestart"

mumbled_prestart()
{
	if ! checkyesno frotz_enable && \
	    ! /etc/rc.d/frotz forcestatus 1>/dev/null 2>&1; then
		force_depend frotz || return 1 (5)
	fi
	return 0
}

load_rc_config $name
run_rc_command "$1"

与之前一样,详细分析如下

➊ 该行声明了我们的脚本提供的“条件”的名称。现在,其他脚本可以通过这些名称记录对我们脚本的依赖关系。

通常,脚本指定一个提供的条件。但是,没有什么可以阻止我们在那里列出多个条件,例如,出于兼容性原因。

无论如何,主要条件或唯一条件的名称应与${name} 相同。

➋➌ 因此,我们的脚本指出了它依赖于其他脚本提供的哪些“条件”。根据这些行,我们的脚本要求 rcorder(8) 将其放在提供 DAEMONcleanvar 的脚本之后,但在提供 LOGIN 的脚本之前。

BEFORE: 行不应该被滥用以解决另一个脚本中不完整的依赖列表。使用BEFORE: 的适当情况是,当另一个脚本不关心我们的脚本时,但是如果我们的脚本在另一个脚本之前运行,我们的脚本可以更好地完成其任务。一个典型的现实生活例子是网络接口与防火墙:虽然接口在执行其工作时不依赖于防火墙,但系统安全将受益于在有任何网络流量之前防火墙已准备好。

除了对应于每个单一服务的条件之外,还有元条件及其“占位符”脚本,用于确保某些操作组在其他操作组之前执行。这些用 UPPERCASE 名称表示。它们的列表及其用途可以在 rc(8) 中找到。

请记住,将服务名称放在REQUIRE: 行中并不能保证服务在我们脚本启动时实际运行。所需的服务可能无法启动或只是在 rc.conf(5) 中被禁用。显然,rcorder(8) 无法跟踪这些细节,rc(8) 也不会这样做。因此,由我们的脚本启动的应用程序应该能够应对任何所需服务不可用的情况。在某些情况下,我们可以像下面讨论的那样帮助它 below

➍ 正如我们从上面的文字中记住的那样,rcorder(8) 关键字可用于选择或省略某些脚本。也就是说,任何 rcorder(8) 消费者都可以通过-k-s 选项分别指定哪些关键字在“保留列表”和“跳过列表”中。从所有要依赖排序的文件中,rcorder(8) 将仅选择那些具有保留列表中关键字(除非为空)并且没有跳过列表中关键字的文件。

在 FreeBSD 中,rcorder(8)/etc/rc/etc/rc.shutdown 使用。这两个脚本定义了 FreeBSD rc.d 关键字的标准列表及其含义,如下所示

nojail

该服务不适用于 jail(8) 环境。如果在 jail 内,自动启动和关闭过程将忽略该脚本。

nostart

该服务应手动启动或根本不启动。自动启动过程将忽略该脚本。结合 shutdown 关键字,这可以用于编写仅在系统关闭时执行操作的脚本。

shutdown

如果服务需要在系统关闭之前停止,则应明确列出此关键字。

当系统即将关闭时,/etc/rc.shutdown 会运行。它假设大多数 rc.d 脚本此时没有要做的。因此,/etc/rc.shutdown 有选择地调用具有 shutdown 关键字的 rc.d 脚本,有效地忽略了其余脚本。为了更快地关闭,/etc/rc.shutdownfaststop 命令传递给它运行的脚本,以便它们跳过初步检查,例如,pidfile 检查。由于依赖服务应在其先决条件之前停止,因此 /etc/rc.shutdown 按反依赖顺序运行脚本。如果编写一个真正的 rc.d 脚本,您应该考虑它在系统关闭时是否相关。例如,如果您的脚本仅响应 start 命令来完成其工作,那么您不需要包含此关键字。但是,如果您的脚本管理一项服务,最好在系统继续执行 halt(8) 中描述的关闭序列的最终阶段之前停止它。特别是,如果一项服务需要相当长的时间或特殊的动作才能干净地关闭,那么应该明确地停止它。数据库引擎是这种服务的典型例子。

➎ 首先,force_depend 应该非常小心地使用。如果您的 rc.d 脚本相互依赖,通常最好修改其配置变量的层次结构。

如果您仍然无法没有force_depend,则该示例提供了一种有条件地调用它的习惯用法。在该示例中,我们的mumbled 守护程序要求另一个守护程序frotz 提前启动。但是,frotz 也是可选的;并且 rcorder(8) 对此一无所知。幸运的是,我们的脚本可以访问所有 rc.conf(5) 变量。如果frotz_enable 为真,我们希望一切顺利,并依赖 rc.d 启动了frotz。否则,我们将强制检查frotz 的状态。最后,如果发现frotz 未运行,我们将强制执行对frotz 的依赖关系。force_depend 将发出警告消息,因为它仅在检测到错误配置时才应被调用。

8. 为 rc.d 脚本提供更多灵活性

当在启动或关闭期间调用时,rc.d 脚本应该对它负责的整个子系统进行操作。例如,/etc/rc.d/netif 应该启动或停止 rc.conf(5) 描述的所有网络接口。这两个任务都可以通过单个命令参数(例如startstop)来唯一地指示。在启动和关闭之间,rc.d 脚本帮助管理员控制运行的系统,并且这是需要更多灵活性和精度的时刻。例如,管理员可能希望将新网络接口的设置添加到 rc.conf(5) 中,然后启动它,而不会干扰现有接口的操作。下次管理员可能需要关闭单个网络接口。本着命令行的精神,相应的 rc.d 脚本需要一个额外的参数,即接口名称。

幸运的是,rc.subr(8) 允许将任意数量的参数传递给脚本的方法(在系统限制内)。因此,脚本本身的更改可以最小化。

如何让 rc.subr(8) 访问额外的命令行参数。它是否应该直接获取它们?绝不。首先,sh(1) 函数无法访问其调用者的位置参数,而 rc.subr(8) 只是一个这样的函数集合。其次,rc.d 的良好礼仪规定,由主脚本决定将哪些参数传递给其方法。

因此,rc.subr(8) 采用的方法如下:run_rc_command 会原样传递除第一个参数之外的所有参数到对应的方法。第一个被省略的参数是方法本身的名称:startstop 等等。它会被 run_rc_command 移出,因此在原始命令行中为 $2 的参数会被作为 $1 传递给方法,依此类推。

为了说明这个功能,让我们修改原始的虚拟脚本,使它的消息依赖于提供的额外参数。我们开始吧

#!/bin/sh

. /etc/rc.subr

name="dummy"
start_cmd="${name}_start"
stop_cmd=":"
kiss_cmd="${name}_kiss"
extra_commands="kiss"

dummy_start()
{
        if [ $# -gt 0 ]; then (1)
                echo "Greeting message: $*"
        else
                echo "Nothing started."
        fi
}

dummy_kiss()
{
        echo -n "A ghost gives you a kiss"
        if [ $# -gt 0 ]; then (2)
                echo -n " and whispers: $*"
        fi
        case "$*" in
        *[.!?])
                echo
                ;;
        *)
                echo .
                ;;
        esac
}

load_rc_config $name
run_rc_command "$@" (3)

我们能在这个脚本中注意到哪些重要的变化呢?

➊ 在 start 后面输入的所有参数最终都会作为位置参数传递给对应的方法。我们可以根据任务、技能和喜好以任何方式使用它们。在本例中,我们只是将它们全部作为单个字符串传递给下一行的 echo(1) - 请注意双引号中的 $*。以下是如何调用该脚本:

# /etc/rc.d/dummy start
Nothing started.

# /etc/rc.d/dummy start Hello world!
Greeting message: Hello world!

➋ 同样的规则也适用于脚本提供的任何方法,而不止标准方法。我们添加了一个名为 kiss 的自定义方法,它可以像 start 一样利用额外的参数,例如:

# /etc/rc.d/dummy kiss
A ghost gives you a kiss.

# /etc/rc.d/dummy kiss Once I was Etaoin Shrdlu...
A ghost gives you a kiss and whispers: Once I was Etaoin Shrdlu...

➌ 如果我们只想将所有额外参数传递给任何方法,我们只需在脚本最后一行调用 run_rc_command 的地方用 "$@" 替换 "$1"

一个 sh(1) 程序员应该理解 $*$@ 之间的细微差别,它们是指定所有位置参数的方式。有关其深入讨论,请参阅有关 sh(1) 脚本编写的优秀手册。在完全理解它们之前,不要使用这些表达式,因为误用会导致出现错误且不安全的脚本。

目前 run_rc_command 可能存在一个错误,阻止它保留参数之间的原始边界。也就是说,包含嵌入式空格的参数可能无法正确处理。该错误源于 $* 的误用。

9. 使脚本准备好用于服务 Jail

启动长时间运行服务的脚本适合用于服务 Jail,并且应该附带一个合适的服务 Jail 配置。

以下是一些不适合在服务 Jail 中运行的脚本示例

  • 任何在启动命令中只为程序或内核更改运行时设置的脚本,

  • 或者尝试挂载某些东西,

  • 或者查找并删除文件

不适合在服务 Jail 中运行的脚本需要防止在服务 Jail 中使用。

如果长时间运行服务的脚本需要在启动之前或停止之后执行上述操作,则可以将其拆分为两个具有依赖关系的脚本,或者使用脚本的 precommand 和 postcommand 部分来执行此操作。

默认情况下,只有脚本的 start 和 stop 部分在服务 Jail 内运行,其余部分在 Jail 外运行。因此,脚本 start/stop 部分中使用的任何设置不能从例如 precommand 设置。

要使脚本准备好与 服务 Jail 一起使用,只需插入一行额外的配置行即可

#!/bin/sh

. /etc/rc.subr

name="dummy"
start_cmd="${name}_start"
stop_cmd=":"

: ${dummy_svcj_options:=""} (1)

dummy_start()
{
        echo "Nothing started."
}

load_rc_config $name
run_rc_command "$1"

➊ 如果脚本在 Jail 中运行有意义,它必须具有可覆盖的服务 Jail 配置。如果它不需要网络访问或访问 Jail 中受限的任何其他资源,那么像上面显示的空配置就足够了。

严格来说,不需要空配置,但它明确说明了该脚本已准备好用于服务 Jail,并且它不需要额外的 Jail 权限。因此,强烈建议在这种情况下添加这样的空配置。最常用的选项是“net_basic”,它启用对主机 IPv4 和 IPv6 地址的使用。所有可能的选项都解释在 rc.conf(5) 中。

如果 start/stop 的设置依赖于 rc 框架中的变量(例如,在 rc.conf(5) 中设置),则需要通过 load_rc_configrun_rc_command 处理,而不是在 precommand 中处理。

如果由于某种原因,脚本无法在服务 Jail 中运行,例如,因为它无法运行或在 Jail 中运行没有意义,请使用以下方法

#!/bin/sh

. /etc/rc.subr

name="dummy"
start_cmd="${name}_start"
stop_cmd=":"

dummy_start()
{
        echo "Nothing started."
}

load_rc_config $name
dummy_svcj="NO"		# does not make sense to run in a svcj (1)
run_rc_command "$1"

➊ 禁用操作需要在 load_rc_config 调用之后进行,否则 rc.conf(5) 设置可能会覆盖它。

10. 高级 rc 脚本编写:实例化

有时运行服务的多个实例很有用。通常您希望能够独立地启动/停止这些实例,并且希望为每个实例提供一个单独的配置文件。每个实例都应该在启动时启动,在更新后存活,并且从更新中获益。

以下是一个支持此功能的 rc 脚本示例

#!/bin/sh

#
# PROVIDE: dummy
# REQUIRE: NETWORKING SERVERS
# KEYWORD: shutdown
#
# Add these following line to /etc/rc.conf.local or /etc/rc.conf
# to enable this service:
#
# dummy_enable (bool):	Set it to YES to enable dummy on startup.
#			Default: NO
# dummy_user (string):	User account to run with.
#			Default: www
#

. /etc/rc.subr

case $0 in (1)
/etc/rc*)
	# during boot (shutdown) $0 is /etc/rc (/etc/rc.shutdown),
	# so get the name of the script from $_file
	name=$_file
	;;
*)
	name=$0
	;;
esac

name=${name##*/} (2)
rcvar="${name}_enable" (3)
desc="Short description of this service"
command="/usr/local/sbin/dummy"

load_rc_config "$name"

eval "${rcvar}=\${${rcvar}:-'NO'}" (4)
eval "${name}_svcj_options=\${${name}_svcj_options:-'net_basic'}" (5)
eval "_dummy_user=\${${name}_user:-'www'}" (6)

_dummy_configname=/usr/local/etc/${name}.cfg (7)
pidfile=/var/run/dummy/${name}.pid
required_files ${_dummy_configname}
command_args="-u ${_dummy_user} -c ${_dummy_configfile} -p ${pidfile}"

run_rc_command "$1"

➊ 和 ➋ 确保将 name 变量设置为脚本名称的 basename(1)。如果文件名是 /usr/local/etc/rc.d/dummy,则 name 设置为 dummy。这样,更改 rc 脚本的文件名会自动更改 name 变量的内容。

➌ 指定在 rc.conf 中用于启用此服务的变量名称,该名称基于此脚本的文件名。在本例中,它解析为 dummy_enable。

➍ 确保 _enable 变量的默认值为 NO。

➎ 是为服务特定框架变量提供一些默认值的示例,在本例中为服务 Jail 选项。

➏ 和 ➐ 设置脚本内部的变量(注意 _dummy_user 前面的下划线,使其与可以在 rc.conf 中设置的 dummy_user 不同)。

➎ 中的部分用于不在脚本本身内部使用,而是在 rc 框架中使用的变量。所有用作参数的变量都像 ➐ 一样分配给一个通用变量,以使其更容易引用它们(不需要在每个使用的地方都对它们进行求值)。

现在,如果启动脚本具有不同的名称,此脚本将表现出不同的行为。这允许创建指向它的符号链接

# ln -s dummy /usr/local/etc/rc.d/dummy_foo
# sysrc dummy_foo_enable=YES
# service dummy_foo start

上面的代码创建了一个名为 dummy_foo 的 dummy 服务实例。它不使用配置文件 /usr/local/etc/dummy.cfg,而是使用配置文件 /usr/local/etc/dummy_foo.cfg (➐),并且它使用 PID 文件 /var/run/dummy/dummy_foo.pid 而不是 /var/run/dummy/dummy.pid

服务 dummy 和 dummy_foo 可以相互独立地管理,同时在包更新时更新启动脚本本身(由于符号链接)。这不会更新 REQUIRE 行,因此没有简单的方法来依赖特定实例。要依赖启动顺序中的特定实例,需要进行复制而不是使用符号链接。这会阻止在安装更新时自动获取对启动脚本的更改。

11. 进一步阅读

Luke Mewburn 的原始文章 提供了对 rc.d 的总体概述以及对其设计决策的详细解释。它提供了对整个 rc.d 框架及其在现代 BSD 操作系统中的地位的见解。

手册页 rc(8)rc.subr(8)rcorder(8) 详细记录了 rc.d 组件。在没有学习手册页并在编写自己的脚本时参考它们的情况下,您无法完全使用 rc.d 的功能。

实际工作示例的主要来源是活动系统中的 /etc/rc.d。它的内容易于阅读且令人愉快,因为大多数粗糙的部分都隐藏在 rc.subr(8) 中。但请记住,/etc/rc.d 脚本并非由天使编写,因此它们可能存在错误和次优的设计决策。现在您可以改进它们!


最后修改时间:2024 年 8 月 11 日,作者 Fernando Apesteguía