Linux工作原理6用户空间如何启动

  • Linux工作原理6用户空间如何启动已关闭评论
  • 51 次浏览
  • A+
所属分类:linux技术
摘要

内核启动 init(第一个用户空间进程)的时刻意义重大–不仅仅是因为内存和 CPU 终于可以正常运行系统了,还因为在这个时刻,你可以看到系统的其他部分是如何作为一个整体建立起来的。在此之前,内核的执行路径都是由相对较少的软件开发人员定义好的。用户空间的模块化和可定制程度要高得多,而且也很容易看到用户空间的启动和运行过程。如果你喜欢冒险,可以利用这一点,因为了解和改变用户空间的启动不需要底层编程。


6用户空间如何启动

内核启动 init(第一个用户空间进程)的时刻意义重大--不仅仅是因为内存和 CPU 终于可以正常运行系统了,还因为在这个时刻,你可以看到系统的其他部分是如何作为一个整体建立起来的。在此之前,内核的执行路径都是由相对较少的软件开发人员定义好的。用户空间的模块化和可定制程度要高得多,而且也很容易看到用户空间的启动和运行过程。如果你喜欢冒险,可以利用这一点,因为了解和改变用户空间的启动不需要底层编程。

用户空间的启动顺序大致如下

  • init
  • 基本的底层服务,如 udevd 和 syslogd
  • 网络配置
  • 中高级服务(cron、打印等)
  • 登录提示、图形用户界面和高级应用程序,如网络服务器

6.1 init 简介

init和Linux系统中的其他程序一样,是一个用户空间程序,你可以在/sbin中找到它和许多其他系统二进制文件。它的主要用途是启动和停止系统中的基本服务进程。

在当前所有主流 Linux 发行版中,init 的标准实现是 systemd。本章主要介绍 systemd 的工作原理以及如何与之交互。

在旧版系统中,你可能会遇到另外两种 init。System V init 是一种传统的序列化 init(Sys V,通常发音为 “sys-five”,源于 Unix System V),存在于 7.0 版之前的 Red Hat Enterprise Linux (RHEL) 和 Debian 8 上。 Upstart 是 15.04 版之前的 Ubuntu 发行版上的 init。

还有其他版本的 init,尤其是在嵌入式平台上。例如,Android 有自己的 init,而名为 runit 的版本在轻量级系统上很流行。BSD 也有自己的 init 版本,但你不太可能在当代 Linux 机器上看到它们。(有些发行版还修改了 System V 的 init 配置,使其与 BSD 风格相似)。

为了解决 System V init 的几个缺陷,人们开发了不同的 init 实现。要了解这些问题,请先看一下传统 init 的内部工作原理。基本上,init 会依次运行一系列脚本,一次一个。每个脚本通常会启动一个服务或配置系统的一个单独部分。在大多数情况下,解决依赖关系相对容易,而且可以通过修改脚本灵活地满足不寻常的启动要求。

不过,这种方案也有一些明显的局限性。这些限制可归纳为 “性能问题 ”和 “系统管理麻烦”。其中最重要的问题如下:

  • 性能问题,因为启动序列的两个部分通常不能同时运行。
  • 管理运行中的系统可能很困难。启动脚本需要启动服务守护进程。要查找服务守护进程的 PID,需要使用 ps、服务专用的其他机制或记录 PID 的半标准化系统(如 /var/run/myservice.pid)。
  • 启动脚本往往包含大量标准的 “模板 ”代码,有时很难阅读和理解它们的作用。
  • 几乎没有按需服务和配置的概念。大多数服务都是在启动时启动的;系统配置在很大程度上也是在启动时设置的。传统的 inetd 守护进程曾一度可以处理按需网络服务,但现在已基本不再使用。

当代的 init 系统通过改变服务的启动方式、监督方式和依赖关系的配置方式来解决这些问题。你很快就会看到 systemd 是如何做到这一点的,但首先,你应该确保正在运行 systemd。

6.2 确定你的init

确定系统的 init 版本通常并不困难。查看 init(1) 手册页面通常就能知道,但如果不确定,请按以下步骤检查系统:

如果系统中有 /usr/lib/systemd 和 /etc/systemd 目录,则说明系统中有 systemd。
如果你的 /etc/init 目录包含多个 .conf 文件,那么你可能正在运行 Upstart(除非你运行的是 Debian 7 或更早版本,在这种情况下,你可能使用的是 System V init)。我们不会在本书中介绍 Upstart,因为它已被 systemd 广泛取代。
如果以上两种情况都不存在,但你有一个 /etc/inittab 文件,那么你可能正在运行 System V init。请转至第 6.5 节。

6.3 systemd

systemd init 是 Linux 上最新的 init 实现之一。除了处理常规的启动过程外,systemd 还旨在整合一些标准 Unix 服务的功能,如 cron 和 inetd。它从苹果公司的 launchd 中汲取了一些灵感。

systemd 与其前辈相比,真正与众不同的地方在于其先进的服务管理功能。与传统的启动程序不同,systemd 可以在单个服务守护进程启动后对其进行跟踪,并将与某项服务相关的多个进程组合在一起,从而让你有更多的权力,更深入地了解系统中正在运行的进程。

systemd 以目标为导向。在最高层,你可以为某些系统任务定义一个目标,称为一个单元。一个单元可以包含普通启动任务的指令,例如启动守护进程,它也有依赖关系,即其他单元。当启动(或激活)一个单元时,systemd 会尝试激活其依赖项,然后继续处理该单元的细节。

在启动服务时,systemd 并不遵循严格的顺序,而是在单元准备就绪时就激活它们。启动后,systemd 可以通过激活其他单元对系统事件(如第 3 章中概述的 uevents)做出反应。

让我们从单元、激活和初始启动过程的顶层视图开始。然后,你就可以看到单元配置的具体细节和多种单元依赖关系。在这一过程中,你将掌握如何查看和控制运行中的系统。

6.3.1 单元和单元类型

systemd 比以前版本的 init 更有野心的地方在于,它不仅能运行进程和服务,还能管理文件系统挂载、监控网络连接请求、运行计时器等。每种功能都被称为一个单元类型,每个特定功能(如服务)都被称为一个单元。当你打开一个单元时,你就激活了它。每个单元都有自己的配置文件,我们将在第 6.3.3 节探讨这些文件。

这些是在典型 Linux 系统中执行启动时任务的最重要的单元类型:

  • 服务单元 控制 Unix 系统中的服务守护进程。
  • 目标单元 控制其他单元,通常是将它们分组。
  • 套接字单元代表传入网络连接请求的位置。
  • 挂载单元 表示将文件系统挂载到系统上。

你可以在 systemd(1) 手册页面找到单元类型的完整列表。

其中,服务和目标单元最常见,也最容易理解。让我们来看看它们在启动系统时是如何组合在一起的。

6.3.2 启动和单元依赖关系图

启动系统时,会激活一个默认单元,通常是一个名为 default.target 的目标单元,它将许多服务和挂载单元作为依赖关系组合在一起。因此,很容易就能部分了解启动时会发生什么。你可能以为单元依赖关系会形成一棵树--树顶是一个单元,下面是启动过程后期阶段的几个单元--但它们实际上形成了一个图。在启动过程后期出现的单元可能会依赖于之前的几个单元,从而使依赖树的早期分支重新连接在一起。你甚至可以使用 systemd-analyze dot 命令创建依赖关系图。在典型系统上,整个图的体积相当大(需要强大的计算能力才能渲染),而且很难阅读,但有一些方法可以过滤单元,将个别部分归零。

Linux工作原理6用户空间如何启动

在大多数系统中,default.target 是指向其他高级目标单元的链接,例如代表用户界面启动的单元。在图 6-1 所示的系统中,default.target 对启动图形用户界面所需的单元进行了分组。

该图是一个经过极大简化的视图。在你自己的系统中,你不会发现仅仅通过查看顶部的单元配置文件并逐级向下就能勾勒出依赖关系。我们将在第 6.3.6 节中详细介绍依赖关系的工作原理。

6.3.3 systemd 配置

systemd 配置文件分布在系统的多个目录中,因此在查找特定文件时可能需要花点功夫。systemd 配置有两个主要目录:系统单元目录(全局配置;通常为 /lib/systemd/system 或 /usr/lib/systemd/system)和系统配置目录(本地定义;通常为 /etc/systemd/system)。

为避免混淆,请遵守以下规则:避免更改系统单元目录,因为发行版会为您维护该目录。在本地更改系统配置目录。这条一般规则也适用于整个系统。在修改 /usr 和 /etc 目录中的内容之间做出选择时,一定要修改 /etc。

使用此命令可以查看当前 systemd 配置搜索路径(包括优先级):

$ systemctl -p UnitPath show UnitPath=/etc/systemd/system.control /run/systemd/system.control /run/systemd/transient /etc/systemd/system /run/systemd/system /run/systemd/generator /lib/systemd/system /run/systemd/generator.late 

要查看系统中的系统单元和配置目录,请使用以下命令:

$ pkg-config systemd --variable=systemdsystemunitdir /lib/systemd/system $ pkg-config systemd --variable=systemdsystemconfdir /etc/systemd/system 
  • 单元文件

单元文件的格式源自 XDG Desktop Entry 规范(用于 .desktop 文件,与微软系统上的 .ini 文件非常相似),方括号([])中包含部分名称,每个部分中包含变量和值分配(选项)。

以桌面总线守护进程的 dbus-daemon.service 单元文件为例:

[Unit] Description=D-Bus System Message Bus Documentation=man:dbus-daemon(1) Requires=dbus.socket RefuseManualStart=yes  [Service] ExecStart=/usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only ExecReload=/usr/bin/dbus-send --print-reply --system --type=method_call --dest= org.freedesktop.DBus / org.freedesktop.DBus.ReloadConfig 

有两个部分:[单元]和[服务]。单元]部分提供了该单元的一些详细信息,并包含说明和依赖关系信息。特别是,该单元需要依赖 dbus.socket 单元。

在类似的服务单元中,你可以在 [Service] 部分找到有关服务的详细信息,包括如何准备、启动和重新加载服务。在 systemd.service(5) 和 systemd.exec(5) 手册中,以及在第 6.3.5 节关于进程跟踪的讨论中,你可以找到完整的列表。

许多其他单元配置文件也同样简单明了。例如,服务单元文件 sshd.service 可通过启动 sshd 实现远程安全 shell 登录。

系统中的单元文件可能略有不同。在本例中,Fedora 使用的是 dbus-daemon.service,而 Ubuntu 使用的是 dbus.service。实际文件中可能也会有变化,但通常都是表面上的。

  • 变量

你经常会在单元文件中发现变量。下面是另一个单元文件中的一节,这一节是关于安全 shell 的,你将在第 10 章中学习到:

[Service] EnvironmentFile=/etc/sysconfig/sshd ExecStartPre=/usr/sbin/sshd-keygen ExecStart=/usr/sbin/sshd -D $OPTIONS $CRYPTO_POLICY ExecReload=/bin/kill -HUP $MAINPID 

所有以美元符号($)开头的都是变量。虽然这些变量的语法相同,但它们的起源却不同。$OPTIONS 和 $CRYPTO_POLICY 选项是在环境文件(EnvironmentFile)设置所指定的文件中定义的,你可以在单元激活时将它们传递给 sshd。在这种特殊情况下,可以查看 /etc/sysconfig/sshd 来确定变量是否已设置,如果已设置,其值是多少。

相比之下,$MAINPID 包含服务跟踪进程的 ID(参见第 6.3.5 节)。在单元激活时,systemd 会记录并存储此 PID,以便以后使用它来操作特定服务的进程。当要重新加载配置时,sshd.service 单元文件会使用 $MAINPID 向 sshd 发送挂起(HUP)信号(这是处理重新加载和重启 Unix 守护进程的常用技术)。

  • 占位符

指定符是一种类似变量的功能,通常出现在单元文件中。指定符以百分号 (%) 开头。例如,%n 表示当前单元名,%H 表示当前主机名。

您还可以使用指定符从单个单元文件创建多个单元副本。其中一个例子是控制虚拟控制台(如 tty1 和 tty2)登录提示的 getty 进程集。要使用这一功能,请在单元名称末尾、单元文件名中的点之前添加 @ 符号。

例如,在大多数发行版中,getty 的单元文件名是 getty@.service,这样就可以动态创建单元,如 getty@tty1 和 getty@tty2。@后面的内容称为实例。查看这些单元文件时,还可能会看到 %I 或 %i 指定符。从包含实例的单元文件激活服务时,systemd 会用实例替换 %I 或 %i 指定符,创建新的服务名称。

6.3.4 systemd 操作

systemd 的操作主要通过 systemctl 命令进行,它允许你激活或停用服务、列出状态、重新加载配置等。

最基本的命令可以帮助你获取设备信息。例如,要查看系统中的活动单元列表,请执行 list-units 命令。(这是 systemctl 的默认命令,因此技术上不需要 list-units 参数)。

$ systemctl list-units UNIT                      LOAD   ACTIVE SUB       DESCRIPTION -.mount                   loaded active mounted   Root Mount 

输出格式是典型的 Unix 信息列表命令。例如,-.mount(根文件系统)的页眉和行内容如下:

unit load active sub description
-.mount已加载已激活已加载根文件系统

默认情况下,systemctl list-units 会产生大量输出,因为一个典型的系统有许多活动单元,但这仍然是一个简略的形式,因为 systemctl 会截断任何非常大的单元名称。要查看单元的全名,请使用 --full 选项;要查看所有单元(不只是活动单元),请使用 --all 选项。

一个特别有用的 systemctl 操作是获取特定单元的状态。例如,下面是一条典型的状态命令及其部分输出:

$ systemctl status sshd.service · sshd.service - OpenBSD Secure Shell server    Loaded: loaded (/usr/lib/systemd/system/sshd.service; enabled; vendor preset: enabled)    Active: active (running) since Fri 2021-04-16 08:15:41 EDT; 1 months 1 days ago  Main PID: 1110 (sshd)     Tasks: 1 (limit: 4915)    CGroup: /system.slice/sshd.service            ⌙1110 /usr/sbin/sshd -D 

在输出结果之后还可能会出现大量日志信息。如果你已经习惯了传统的 init 系统,那么你可能会对这一条命令所提供的大量有用信息感到惊讶。你不仅能看到单元的状态,还能看到与服务相关的进程、单元启动时间,以及一些日志信息(如果有的话)。

其他单元类型的输出也包含类似的有用信息;例如,挂载单元的输出包括挂载发生的时间、使用的确切命令行及其退出状态。

输出中一个有趣的部分是控制组(cgroup)名称。在上例中,控制组为 /system.slice/sshd.service,c 组中的进程显示在其下方。不过,如果某个单元(例如挂载单元)的进程已经终止,也可能会看到以 systemd:/system 开头命名的控制组。你可以使用 systemd-cgls 命令查看与系统相关的 c 组,而不显示其他单元状态。你将在第 6.3.5 节了解 systemd 如何使用 cgroups,并在第 8.6 节了解 cgroups 的工作原理。

status 命令也只显示单元的最新诊断日志信息。您可以像这样查看单元的所有信息:

$ journalctl --unit=unit_name 

关于 journalctl 的更多信息,请参阅第 7 章。

注意:根据系统和用户配置,运行 journalctl 可能需要超级用户权限。

要激活、停用和重启单元,需要使用 systemctl start、systemctl stop 和 systemctl restart 命令。不过,如果更改了单元配置文件,可以通过以下两种方式之一告诉 systemd 重新加载文件:systemctl reload unit 只重载单元的配置;systemctl daemon-reload 重载所有单元配置。

激活、重新激活和重启单元的请求在 systemd 中称为作业,它们本质上是单元状态的改变。你可以通过以下方式查看系统上的当前工作。如果系统已经运行了一段时间,你可以合理地认为不会有活动任务,因为启动系统所需的所有激活工作都已完成。不过,在启动时,有时登录速度足够快,可以看到启动速度很慢的单元的作业。例如

$ systemctl list-jobs JOB UNIT                      TYPE            STATE      1 graphical.target          start           waiting    2 multi-user.target         start           waiting   71 systemd-...nlevel.service start           waiting   75 sm-client.service         start           waiting   76 sendmail.service          start           running  120 systemd-...ead-done.timer start           waiting 

在本例中,作业 76(sendmail.service 单元启动)耗时很长。其他列出的工作都处于等待状态,很可能是因为它们都在等待工作 76。当sendmail.service完成启动并完全处于活动状态时,作业76就会完成,其他作业也会完成,作业列表就会清空。

作业一词可能会让人感到困惑,尤其是因为其他一些初始系统会用它来指代更像 systemd 单元的功能。这些作业也与 shell 的作业控制无关。

参见第 6.6 节,了解如何关闭和重启系统。

  • 向systemd 添加单元

向 systemd 添加单元主要是创建单元文件、激活单元文件并在可能的情况下启用单元文件。通常,你应该将自己的单元文件放在系统配置目录(/etc/systemd/system)中,这样就不会与发行版中的文件混淆,也不会在升级时被发行版覆盖。

因为创建不实际执行任何操作或不干扰系统的目标单元很容易,所以不妨一试。要创建两个目标,其中一个依赖于另一个,请按以下步骤操作:

在 /etc/systemd/system 中创建名为 test1.target 的单元文件:

[Unit] Description=test 1 

创建依赖于 test1.target 的 test2.target 文件:

[Unit] Description=test 2 Wants=test1.target 

这里的 Wants 关键字定义了一个依赖关系,当激活 test2.target 时,test1.target 将被激活。激活 test2.target 单元,查看其运行情况:

# systemctl start test2.target 

确认两个单元都已激活:

# systemctl status test1.target test2.target · test1.target - test 1    Loaded: loaded (/etc/systemd/system/test1.target; static; vendor preset: enabled)    Active: active since Tue 2019-05-28 14:45:00 EDT; 16s ago  May 28 14:45:00 duplex systemd[1]: Reached target test 1.  · test2.target - test 2    Loaded: loaded (/etc/systemd/system/test2.target; static; vendor preset: enabled)    Active: active since Tue 2019-05-28 14:45:00 EDT; 17s ago 

如果单元文件中有 [Install] 部分,则需要先 “启用 ”单元,然后再激活它:

# systemctl enable unit 

Install] 部分是创建依赖关系的另一种方法。我们将在第 6.3.6 节详细介绍它(以及整个依赖关系)。

  • 从 systemd 中移除单元

要删除单元,请按照以下步骤操作

必要时停用单元:

# systemctl stop unit 

如果单元有 [Install] 部分,则禁用单元以移除依赖系统创建的任何符号链接:

# systemctl disable unit 

然后,您就可以删除单元文件了。

注意:禁用隐式启用的单元(即没有 [Install] 部分)不会产生任何影响。

6.3.5 systemd 进程跟踪和同步

systemd 希望对其启动的每个进程都有合理的信息量和控制权。这在历史上一直是个难题。一个服务可以以不同的方式启动;它可以分叉出新的实例,甚至是守护进程并从原始进程中分离出来。此外,也不知道服务器会产生多少个子进程。

为了轻松管理已激活的单元,systemd 使用了前面提到的 cgroups,这是 Linux 内核的一项功能,可以对进程层次结构进行更精细的跟踪。cgroups 的使用还有助于最大限度地减少软件包开发人员或管理员创建工作单元文件的工作量。在 systemd 中,你不必担心每一种可能的启动行为,你只需知道服务启动进程是否会分叉。在服务单元文件中使用 “类型 ”选项来指示启动行为。有两种基本启动方式:

  • Type=simple(简单) 服务进程不会分叉和终止;它仍然是主服务进程。
  • Type=forking 服务forks,systemd 希望原始服务进程终止。终止后,systemd 会认为服务已就绪。

Type=simple 选项没有考虑到服务启动可能需要一些时间,因此 systemd不知道何时启动绝对需要该服务就绪的依赖单元。解决这一问题的方法之一是使用延迟启动(参见第 6.3.7 节)。不过,某些类型的启动样式可以指示服务本身将在准备就绪时通知 systemd:

另一种服务启动方式是 Type=oneshot 类型;在这种情况下,服务进程在启动后会完全终止,没有子进程。这与 Type=simple 类似,只是 systemd 在服务进程终止前不会认为服务已启动。任何严格的依赖关系(很快就会看到)在服务终止前都不会启动。使用 Type=oneshot 的服务还会获得一个默认的 RemainAfterExit=yes 指令,这样 systemd 即使在服务进程终止后也会将其视为活动服务。

最后一个选项是 Type=idle。该选项的作用与简单类型类似,但它指示 systemd 在所有活动任务结束之前不要启动服务。这样做的目的只是推迟服务的启动时间,直到其他服务也已启动,以防止服务之间相互踩踏。请记住,一旦服务启动,启动它的 systemd 作业就会终止,因此等待所有其他作业结束可确保没有其他服务启动。

cgroups参见8.6节

6.3.6 systemd 依赖关系

一个灵活的启动和运行依赖系统需要一定程度的复杂性,因为过于严格的规则会导致系统性能低下和不稳定。例如,您希望在启动数据库服务器后显示登录提示,因此您定义了从登录提示到数据库服务器的严格依赖关系。这就意味着,如果数据库服务器出现故障,登录提示也会失效,甚至无法登录机器解决问题!

Unix启动时任务具有相当高的容错性,发生故障时通常不会给标准服务带来严重问题。例如,如果您删除了系统的数据磁盘,但保留了其 /etc/fstab 条目(或 systemd 中的挂载单元),启动时文件系统挂载就会失败。虽然这种故障可能会影响应用服务器(如网络服务器),但通常不会影响标准系统的运行。

为了满足对灵活性和容错性的需求,systemd 提供了多种依赖类型和样式。让我们先来看看这些基本类型,它们以关键字语法标示:

  • Requires 严格依赖。激活带有 Requires 依赖关系单元的单元时,systemd 会尝试激活依赖关系单元。如果依赖单元失败,systemd 也会停用依赖单元。
  • Wants 仅激活需要依赖单元。激活单元时,systemd 会激活单元的 Wants 依赖项,但不会在意这些依赖项是否失败。
  • Requisite:systemd 会首先检查依赖关系的状态。如果依赖关系尚未激活,systemd 在激活带有依赖关系的单元时就会失败。
  • Conflicts  如果对立依赖项处于激活状态,systemd 会自动停用该依赖项。冲突单元的同时激活会失败。

只要指定依赖类型,如 Wants 或 Requires,就能使用 systemctl 命令查看单元的依赖关系:

# systemctl show -p type unit 
  • 排序

到目前为止,您所看到的依赖语法还没有明确指定顺序。例如,激活大多数带有 Requires 或 Wants 依赖关系的服务单元会导致这些单元同时启动。这是最理想的情况,因为您希望尽快启动尽可能多的服务,以减少启动时间。但在某些情况下,一个单元必须在另一个单元之后启动。例如,在图 6-1 所示的系统中,默认目标单元(default.target)被设置为在多用户目标单元(multi-user.target)之后启动(图中未显示这一顺序区别)。

要按特定顺序激活单元,可使用以下依赖修饰符:

  • Before 当前单元将在所列单元之前激活。例如,如果 Before=bar.target 出现在 foo.target 中,systemd 将在 bar.target 之前激活 foo.target。

  • After 当前单元将在所列单元之后激活。
    使用排序时,systemd 会等到某个单元处于激活状态后才激活其从属单元。

  • 默认和隐式依赖关系

在探索依赖关系的过程中(尤其是使用 systemd-analyze),你可能会开始注意到,有些单元获得的依赖关系并没有在单元文件或其他可见机制中明确说明。你最有可能在具有 Wants 依赖关系的目标单元中遇到这种情况--你会发现 systemd 会在任何列为 Wants 依赖关系的单元旁添加一个 After 修饰符。这些额外的依赖关系是 systemd 内部的,是在启动时计算出来的,并不存储在配置文件中。

添加的 “After ”修饰符被称为默认依赖,是自动添加到单元配置中的,旨在避免常见错误并保持单元文件的小巧。这些依赖关系因单元类型而异。例如,systemd 为目标单元添加的默认依赖和为服务单元添加的默认依赖就不一样。这些差异列在单元配置手册页面的默认依赖关系部分,如 systemd.service(5) 和 systemd.target(5)。

你可以在单元配置文件中添加 DefaultDependencies=no 来禁用单元中的默认依赖关系。

  • 条件依赖关系
    你可以使用几个条件依赖关系参数来测试各种操作系统状态,而不是 systemd 单元。例如
ConditionPathExists=p  True if the (file) path p exists in the system. ConditionPathIsDirectory=p  True if p is a directory. ConditionFileNotEmpty=p  True if p is a file and it’s not zero-length. 

当 systemd 试图激活单元时,如果单元中的条件依赖关系为 false,则单元不会激活,但这只适用于出现该条件依赖关系的单元。也就是说,如果你激活了一个有条件依赖关系和一些单元依赖关系的单元,那么无论条件是真还是假,systemd 都会尝试激活这些单元依赖关系。

其他依赖关系主要是前面几种依赖关系的变体。例如,RequiresOverridable 依赖关系在正常运行时与 Requires 类似,但在手动激活单元时,它的作用与 Wants 依赖关系类似。有关完整列表,请参阅 systemd.unit(5) 手册页面。

  • [Install]部分和启用单元

到目前为止,我们已经了解了如何在依赖单元的配置文件中定义依赖关系。我们也可以 “反其道而行之”,即在依赖单元的单元文件中指定依赖单元。您可以在[安装]部分添加一个 WantedBy 或 RequiredBy 参数。通过这种机制,您可以更改单元的启动时间,而无需修改其他配置文件(例如,当您不想编辑系统单元文件时)。

要了解这种机制的工作原理,请参考第 6.3.4 节中的单元示例。我们有两个单元:test1.target 和 test2.target,其中 test2.target 依赖于 test1.target。我们可以修改它们,使 test1.target 看起来像这样:

[Install] WantedBy=test2.target 

test2.target 看起来像这样:

[Unit] Description=test 2 

因为现在有了一个带有 [Install] 部分的单元,所以在启动它之前,需要用 systemctl 启用该单元。下面是 test1.target 的工作原理:

# systemctl enable test1.target Created symlink /etc/systemd/system/test2.target.wants/test1.target → /etc/systemd/system/test1.target. 

请注意这里的输出--启用单元的效果是在 .wants 子目录中创建一个符号链接,与从属单元(本例中为 test2.target)相对应。因为依赖关系已经到位,所以现在可以使用 systemctl start test2.target 同时启动这两个单元。

注意:启用单元并不会激活它。

要禁用单元(并删除符号链接),请按如下步骤使用 systemctl:

# systemctl disable test1.target Removed /etc/systemd/system/test2.target.wants/test1.target. 

本例中的两个单元还为您提供了尝试不同启动方案的机会。例如,看看只启动 test1.target 或在不启用 test1.target 的情况下启动 test2.target 会发生什么。或者,尝试将 WantedBy 改为 RequiredBy(记住,可以使用 systemctl status 查看单元的状态)。

在正常运行期间,systemd 会忽略单元中的 [Install] 部分,但会注意到它的存在,默认情况下会认为单元已禁用。禁用单元可以在重启后继续运行。

Install] 部分通常负责系统配置目录(/etc/systemd/system)中的 .wants 和 .requires 目录。不过,单元配置目录([/usr]/lib/systemd/system)中也包含 .wants 目录,你也可以在单元文件中添加与 [Install] 部分不对应的链接。这些手动添加的链接是一种简单的方法,可以在不修改单元文件的情况下添加依赖关系,而单元文件将来可能会被覆盖(例如软件升级),但我们并不特别鼓励这种方法,因为手动添加的链接很难跟踪。

参考资料

6.3.7 systemd 按需启动和资源并行启动

systemd 的功能之一是将单元启动延迟到绝对需要的时候。这种设置通常是这样的

  • 创建一个 systemd 单元(称作单元 A),用于提供系统服务。
  • 确定单元 A 用来提供服务的系统资源,如网络端口/套接字、文件或设备。
  • 创建另一个 systemd 单元 R 来表示该资源。这些单元分为不同类型,如套接字单元、路径单元和设备单元。

通常情况下,这种关系是基于单元名称的隐式关系,但也可以是显式关系,我们很快就会看到。
设置完成后,操作步骤如下:

  • 激活 R 单元后,systemd 会监控资源。
  • 当有任何东西试图访问该资源时,systemd 会阻塞该资源,并缓冲该资源的输入。
  • systemd 激活单元 A。
  • 准备就绪后,单元 A 的服务将控制该资源,读取缓冲输入并正常运行。

这里有几个值得注意的问题:

您必须确保您的资源单元涵盖了服务提供的所有资源。这通常不成问题,因为大多数服务只有一个访问点。
您需要确保您的资源单元与它所代表的服务单元绑定。这可以是隐式的,也可以是显式的,在某些情况下,许多选项代表了 systemd 与服务单元进行交接的不同方式。
并非所有服务器都知道如何与 systemd 提供的资源单元接口。
如果你已经知道 inetd、xinetd 和 automount 等传统实用程序的功能,就会发现它们有很多相似之处。事实上,这并不是什么新概念;systemd 甚至包括对 automount 单元的支持。

  • 套接字单元和服务示例

我们来看一个例子,一个简单的网络回声服务。这部分内容有些高深,在阅读过第 9 章中关于 TCP、端口和监听的讨论,以及第 10 章中关于套接字的讨论后,你可能才会完全理解,但你应该能掌握其基本概念。

回声服务的原理是在连接后重复网络客户端发送的任何内容;我们的回声服务将监听 TCP 22222 端口。我们将用一个表示端口的套接字单元来开始构建它,如下面的 echo.socket 单元文件所示:

[Unit] Description=echo socket  [Socket] ListenStream=22222 Accept=true 

请注意,单元文件中没有提及该套接字支持的服务单元。那么,对应的服务单元文件是什么呢?

它的名称是 echo@.service。这种联系是通过命名约定建立的;如果一个服务单元文件的前缀与 .socket 文件(在本例中为 echo)相同,systemd 就会在套接字单元有活动时激活该服务单元。在这种情况下,当 echo.socket 上有活动时,systemd 就会创建 echo@.service 实例。下面是 echo@.service 单元文件:

[Unit] Description=echo service  [Service] ExecStart=/bin/cat StandardInput=socket 

注意: 如果不喜欢根据前缀隐式激活单元,或需要连接具有不同前缀的单元,可以在定义资源的单元中使用显式选项。例如,在 foo.service 中使用 Socket=bar.socket 就可以让 bar.socket 将其套接字交给 foo.service。

要运行这个示例单元,需要启动 echo.socket 单元:

# systemctl start echo.socket 

现在,你可以使用 telnet 等工具连接到本地 TCP 端口 22222 来测试该服务。服务会重复你输入的内容;下面是一个交互示例:

$ telnet localhost 22222 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. Hi there. Hi there. 

如果你觉得无聊,想返回 shell,请在一行上按 CTRL-],然后按 CTRL-D。要停止服务,请像这样停止套接字单元:

# systemctl stop echo.socket 

您的发行版上可能默认未安装 telnet。

  • 实例和切换

echo@.service单元支持多个实例同时运行,因此其名称中有一个@(请注意,@表示参数化)。为什么需要多个实例?假设有多个网络客户端同时连接到服务,而您希望每个连接都有自己的实例。在这种情况下,服务单元必须支持多实例,因为我们在 echo.socket 中包含了 Accept=true 选项。该选项不仅指示 systemd 监听端口,而且还代表服务单元接受传入的连接并将其传递给它们,从而为每个连接创建一个单独的实例。每个实例都会以标准输入方式从连接中读取数据,但并不一定需要知道数据来自网络连接。

注意
大多数网络连接需要的不仅仅是一个简单的标准输入和输出网关,而是更多的灵活性,因此不要指望通过服务单元文件来创建复杂的网络服务,如这里显示的 echo@.service 单元文件。

如果服务单元可以完成接受连接的工作,就不要在其单元文件名中加上 @,也不要在套接字单元中加上 Accept=true。在这种情况下,服务单元将从 systemd 手中完全控制套接字,而 systemd 在服务单元结束之前不会再尝试监听网络端口。

由于服务单元有许多不同的资源和切换选项,因此很难对其进行分类总结。不仅如此,有关这些选项的文档还分散在几本手册中。对于面向资源的单元,请查看 systemd.socket(5)、systemd.path(5) 和 systemd.device(5)。对于服务单元来说,systemd.exec(5) 是一份经常被忽视的文档,其中包含服务单元在激活时如何接收资源的信息。

  • 使用辅助单元进行启动优化

systemd 的总体目标是简化依赖顺序并加快启动时间。套接字单元等资源单元提供了一种与按需启动类似的方法。我们仍然会有一个服务单元和一个代表服务单元所提供资源的辅助单元,只不过在这种情况下,systemd 会在激活辅助单元后立即启动服务单元,而不是等待请求。

采用这种方案的原因是,systemd-journald.service 等必要的启动时服务单元需要一些时间才能启动,而且许多其他单元也依赖于它们。但是,systemd 可以快速提供一个单元(如套接字单元)的基本资源,然后它不仅可以立即激活该基本单元,还可以立即激活依赖于它的任何单元。一旦基本单元准备就绪,它就会控制该资源。

图 6-2 显示了这在传统的顺序系统中是如何工作的。在此启动时间线中,服务 E 提供了基本资源 R。服务 A、B 和 C 依赖于此资源(但不相互依赖),必须等待服务 E 启动。由于系统在启动前一个服务之前不会启动新的服务,因此需要很长时间才能启动服务 C。

Linux工作原理6用户空间如何启动

图 6-3 显示了一个可能的等效 systemd 启动配置。服务由单元 A、B、C 和 E 代表,新单元 R 代表单元 E 提供的资源。由于 systemd 可以在单元 E 启动时为单元 R 提供接口,因此单元 A、B、C 和 E 可以同时启动。有趣的是,单元 A、B 或 C 在完成启动前可能并不需要访问单元 R 提供的资源。我们要做的是为它们提供尽快开始访问资源的选项。

Linux工作原理6用户空间如何启动

注意:像这样并行启动时,由于大量单元同时启动,系统有可能会暂时变慢。

这就意味着,虽然在这种情况下你并没有创建按需启动单元,但你使用了与按需启动相同的功能。有关常见的实际例子,请参阅运行 systemd 的机器上的 journald 和 D-Bus 配置单元;它们很可能以这种方式并行化。

6.3.8 systemd 辅助组件

随着 systemd 的普及,它也开始直接或通过辅助兼容性层支持一些与启动和服务管理无关的任务。你可能会注意到 /lib/systemd 中的许多程序;这些都是与这些功能相关的可执行文件。

下面是一些特定的系统服务:

  • udevd 你在第 3 章中已经了解过,它是 systemd 的一部分。
  • journald 一个日志服务,用于处理几种不同的日志机制,包括传统的 Unix syslog 服务。关于它的更多信息,请参见第 7 章。
  • resolved DNS 的名称服务缓存守护进程;第 9 章将对此进行详细介绍。
    这些服务的所有可执行文件都以 systemd- 作为前缀。例如,集成了 systemd 的 udevd 称为 systemd-udevd。

如果深入研究,你会发现其中一些程序是相对简单的封装程序。它们的功能是运行标准的系统实用程序,并将结果通知 systemd。systemd-fsck 就是一个例子。

如果你在 /lib/systemd 中看到一个无法识别的程序,请查看手册页面。很有可能它不仅描述了该实用程序,还描述了它要增强的单元类型。

6.4 系统 V 运行级别

在了解了 systemd 及其工作原理之后,让我们换个角度来看看传统的 System V init 的某些方面。在 Linux 系统中,任何时候都有一组基本进程(如 crond 和 udevd)在运行。在 System V init 中,机器的这种状态称为运行级别,用 0 到 6 之间的数字表示。系统大部分时间都处于一个运行级别,但当你关闭机器时,init 会切换到不同的运行级别,以便有序地终止系统服务,并告诉内核停止运行。

你可以使用 who -r 命令检查系统的运行级别,如下所示:

# who -r          run-level 3  2024-06-28 20:21 

输出结果会告诉我们当前的运行级别是 3,以及建立运行级别的日期和时间。

运行级别有多种用途,但最常见的用途是区分系统启动、关机、单用户模式和控制台模式状态。例如,传统上大多数系统的文本控制台使用运行级别 2 至 4;运行级别 5 表示系统启动图形用户界面登录。

但运行级别已成为过去式。尽管 systemd 支持运行级别,但它认为运行级别作为系统的结束状态已经过时,而更倾向于目标单元。对于 systemd 来说,运行级别主要是用来启动只支持 System V init 脚本的服务。

6.5 System V init

System V init 是 Linux 上最古老的实现之一,其核心理念是通过精心设计的启动顺序,支持有序启动到不同的运行级别。System V init 现在在大多数服务器和桌面安装中已不常见,但在 7.0 版之前的 RHEL 版本以及嵌入式 Linux 环境(如路由器和电话)中可能会遇到。此外,一些旧软件包可能只提供为 System V init 设计的启动脚本;systemd 可以通过兼容性模式处理这些脚本,我们将在第 6.5.5 节中讨论。我们将在此介绍一些基础知识,但请记住,你可能不会遇到本节所涉及的任何问题, 所以以下内容省略。

6.6 关闭系统

init 控制着系统关闭和重启的方式。无论运行哪个版本的 init,关闭系统的命令都是一样的。关闭 Linux 机器的正确方法是使用 shutdown 命令。

使用 shutdown 有两种基本方法。如果你停止系统,它就会关闭机器并使其继续运行。要使机器立即停止运行,请运行以下命令:

# shutdown -h now 

在大多数机器和 Linux 版本中,停止系统会切断机器电源。您也可以重新启动机器。重启时,使用 -r 而不是 -h。

关机过程需要几秒钟。应避免在关机过程中重置或关闭机器电源。

在上例中,现在就是关机时间。包含时间参数是必须的,但也有多种指定方法。例如,如果希望机器在未来某个时间关机,可以使用 +n,其中 n 是关机前应等待的分钟数。有关其他选项,请参阅 shutdown(8) 手册。

要让系统在 10 分钟后重启,请输入

# shutdown -r +10 

在 Linux 上,shutdown 会通知任何登录的人机器正在宕机,但它几乎不做实际工作。如果指定的时间不是现在,shutdown 命令会创建一个名为 /etc/nologin 的文件。该文件存在时,系统禁止除超级用户以外的任何人登录。

当系统关闭时间最终到来时,shutdown 会告诉 init 开始关闭进程。在 systemd 中,这意味着激活关机单元,而在 System V init 中,这意味着将运行级别更改为 0(停止)或 6(重启)。无论使用哪种 init 实现或配置,程序一般都是这样的:

init 会要求每个进程干净利落地关闭。

  • 如果某个进程过了一段时间还没有反应,init 会首先尝试发出 TERM 信号,然后将其杀死。
  • 如果 TERM 信号不起作用,init 会对任何滞留进程使用 KILL 信号。
  • 系统会将系统文件锁定到位,并为关机做其他准备。
  • 系统会卸载除根文件系统外的所有文件系统。
  • 系统以只读方式重新挂载根文件系统。
  • 最后一步是使用 reboot(2) 系统调用告诉内核重启或停止。这可以由 init 或辅助程序(如 reboot、halt 或 poweroff)完成。

根据调用方式的不同,reboot 和 halt 程序的行为也不同,这可能会引起混淆。默认情况下,这些程序会使用 -r 或 -h 选项调用 shutdown。不过,如果系统已经处于停止或重启运行级别,程序会告诉内核立即关闭。如果你真的想匆忙关闭机器,而不考虑无序关机可能造成的损害,请使用 -f(强制)选项。

6.7 初始 RAM 文件系统

大多数情况下,Linux 启动过程都相当简单明了。不过,有一个组件总是有些令人困惑:initramfs,或初始 RAM 文件系统。可以把它看成是在正常用户模式启动前的一个小小的用户空间楔子。但首先,让我们来谈谈它存在的原因。

这个问题源于多种不同存储硬件的存在。请记住,Linux 内核并不能通过 PC BIOS 接口或 EFI 从磁盘上获取数据,因此为了挂载根文件系统,它需要底层存储机制的驱动程序支持。例如,如果根文件系统位于连接到第三方控制器的 RAID 阵列上,内核首先需要该控制器的驱动程序。不幸的是,存储控制器驱动程序非常多,发行版无法在内核中包含所有驱动程序,因此许多驱动程序都是作为可加载模块提供的。但可加载模块是文件,如果内核中没有文件系统,就无法加载所需的驱动模块。

解决方法是将少量内核驱动模块和其他一些实用程序收集到一个归档文件中。引导加载程序会在运行内核前将该压缩包加载到内存中。启动时,内核会将存档内容读入临时 RAM 文件系统(initramfs),将其挂载到 /,并在 initramfs 上执行用户模式与 init 的交接。然后,initramfs 中包含的实用程序允许内核为真正的根文件系统加载必要的驱动模块。最后,实用程序会加载真正的根文件系统并启动真正的 init。

实现方式各不相同,而且在不断发展。在某些发行版中,initramfs 上的 init 是一个相当简单的 shell 脚本,它会启动一个 udevd 来加载驱动程序,然后加载真正的 root 并在那里执行 init。在使用 systemd 的发行版中,你通常会在那里看到整个 systemd 安装,没有单元配置文件,只有几个 udevd 配置文件。

初始 RAM 文件系统的一个基本特性是,如果不需要,可以绕过它。也就是说,如果你的内核拥有挂载根文件系统所需的所有驱动程序,你就可以在引导加载器配置中省略初始 RAM 文件系统。成功的话,省略初始 RAM 文件系统会稍微缩短启动时间。您可以在启动时使用 GRUB 菜单编辑器移除 initrd 行来试试。(最好不要通过更改 GRUB 配置文件来进行试验,因为您可能会犯错,而且很难修复)。绕过初始 RAM 文件系统的难度逐渐增大,因为通用发行版内核可能不具备按 UUID 挂载(mount-by-UUID)等功能。

你可以检查初始 RAM 文件系统的内容,但需要做一些侦查工作。现在,大多数系统都使用 mkinitramfs 创建的归档文件,你可以用 unmkinitramfs 解压缩。其他的可能是较早的压缩 cpio 存档(参见 cpio(1) 手册页面)。

其中一个特别值得关注的部分是初始 RAM 文件系统 init 进程最末端的 “枢轴”。这部分负责删除临时文件系统的内容(以节省内存),并永久切换到真正的根目录。

通常情况下,你不会自己创建初始 RAM 文件系统,因为这是一个费力的过程。有很多实用程序可以创建初始 RAM 文件系统映像,你的发行版很可能自带一个。最常见的两种工具是 mkinitramfs 和 dracut。

注意:术语初始 RAM 文件系统 (initramfs) 指的是使用 cpio 存档作为临时文件系统源的实现。还有一个更早的版本叫初始 RAM 磁盘,或 initrd,它使用磁盘映像作为临时文件系统的基础。由于维护 cpio 存档要容易得多,这种方法已不再使用。不过,你经常会看到 initrd 这个术语被用来指基于 cpio 的初始 RAM 文件系统。通常,文件名和配置文件仍包含 initrd。

6.8 紧急启动和单用户模式

当系统出现问题时,你的第一选择通常是使用发行版的 “实时 ”镜像启动系统,或使用专用的应急镜像(如 SystemRescueCD)启动系统。实时镜像是指无需安装程序就能启动和运行的 Linux 系统;大多数发行版的安装镜像都兼具实时镜像的功能。修复系统的常见任务包括以下内容:

  • 系统崩溃后检查文件系统。
  • 重置遗忘的密码。
  • 修复关键文件中的问题,如 /etc/fstab 和 /etc/passwd。
  • 系统崩溃后从备份中恢复。

另一种快速启动到可用状态的方法是单用户模式。其原理是让系统快速启动到 root shell,而不是通过一大堆乱七八糟的服务。在 System V init 中,单用户模式通常是运行级别 1。在 systemd 中,它由 rescue.target 表示。通常使用引导加载程序的 -s 参数进入该模式。可能需要输入 root 密码才能进入单用户模式。

单用户模式的最大问题是它没有提供很多便利。网络几乎肯定不可用(即使可用也很难使用),没有图形用户界面,终端甚至可能无法正常工作。因此,实时图像几乎总是被认为是更好的选择。

6.9 展望未来

现在,你已经了解了 Linux 系统的内核和用户空间启动阶段,以及 systemd 如何在服务启动后对其进行跟踪。接下来,我们将深入研究用户空间。有两个领域需要探索,首先是一些系统配置文件,所有 Linux 程序在与用户空间的某些元素交互时都会用到这些文件。然后,我们将看到 systemd 启动的基本服务。