SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

systemd为什么成为主流?从SysV到四年普及史

了解SysV init无法解决的五个问题,以及Upstart和launchd的先行尝试。文章解释各发行版为何在四年内转向systemd,并区分哪些反对意见确实成立。

systemd 为什么成为主流

systemd 的历史始于 SysV init 无法完成的两件事。SysV init(System V init,Linux 从 AT&T Unix 继承的启动系统)无法描述服务依赖哪些组件,也无法在服务运行后确定哪些进程属于该服务。systemd 利用 shell 脚本无法访问的内核功能解决了这两个问题:使用控制组跟踪进程,使用预先打开的监听套接字控制启动顺序。之后,这两种机制逐渐扩展到用户空间的其他部分。争议也由此开始,而且其中一些质疑确实有道理。

SysV init 实际执行的操作

在 SysV 系统中,PID 1(进程 ID 1,即内核启动的第一个进程)读取 /etc/inittab,选择一个运行级别,然后运行该运行级别对应的脚本。脚本位于 /etc/init.d//etc/rc3.d/ 中的符号链接决定运行哪些脚本及其顺序,因此 /etc/rc3.d/S20nginx 指向 /etc/init.d/nginx,并以参数 start 调用。

#!/bin/sh
### BEGIN INIT INFO
# Provides:          nginx
# Required-Start:    $local_fs $remote_fs $network $syslog
# Required-Stop:     $local_fs $remote_fs $network $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
### END INIT INFO

case "$1" in
  start)
    start-stop-daemon --start --quiet --pidfile /run/nginx.pid \
      --exec /usr/sbin/nginx
    ;;
  stop)
    start-stop-daemon --stop --quiet --retry TERM/30/KILL/5 \
      --pidfile /run/nginx.pid
    ;;
esac

S20nginx 中的 20 表示顺序位置,而不是依赖关系。它表示此脚本在 S19 之后、S21 之前运行。但它没有说明原因,因此没有任何机制可以验证这一点;对于两个互不相关的脚本,也无法在没有人工确认安全性的情况下并行运行。

rc 程序依次运行每个脚本,并等待脚本退出。某个脚本因等待网络地址而阻塞 30 秒时,整个启动过程都会被阻塞 30 秒,即使其他服务完全不使用网络。

该脚本顶部的 LSB(Linux Standard Base)标头,旨在从 SysV init 内部解决这个问题。Debian 6.0 在 2011 年将 insserv 设为默认值:它从每个脚本中读取 Required-Start,构建依赖关系图,并重新编号符号链接。这样,Debian 就可以通过 startpar 同时运行相互独立的脚本。这有所帮助,但没有解决更深层的问题。依赖关系仍然建立在脚本退出的基础上。S20nginx 返回 0 只表示 shell 函数已返回,并不表示 nginx 已开始接受连接。

init 脚本无法解决的五个问题

  • 并行启动。按文件名排序会为机器上的所有服务建立一个全序,因此启动时间等于各部分耗时之和。
  • 就绪状态。启动脚本在派生出守护进程后就会退出,而不是等到守护进程可以处理请求,因此下一个脚本通常会启动得过早。
  • 进程监督。守护进程执行两次 fork,父进程退出后,守护进程会脱离终端并重新挂载到 PID 1。init 只能看到子进程退出,无法可靠关联仍在运行的进程。
  • 按需启动。inetd(Internet 超级服务器)可以在连接到达时启动守护进程,但它是一个独立系统,使用自己的配置文件,而且无法处理启动时其他服务之间的顺序问题。
  • 资源控制。init 脚本无法限制服务的内存使用量或 CPU 占用比例。ulimit 只能作用于单个进程,nice 只能调整调度器,因此服务派生出的失控子进程看起来与系统上的其他进程没有区别。

进程监督缺失是日常使用中影响最大的问题。PID 文件是当时的解决办法:守护进程将进程 ID 写入 /run/nginx.pid,停止函数再读取该文件。如果守护进程被强制终止,文件仍会保留。内核随后可能将这个编号重新分配给其他进程,而 start-stop-daemon --stop --pidfile 会向当前占用该编号的进程发送信号。过期的 PID 文件会导致 init 脚本终止错误的进程。

launchd 首先解决了套接字问题

Apple 于 2005 年在 Mac OS X 10.4 中发布了 launchd,由 Dave Zarzycki 编写。一个进程取代了 init、rc、xinetd、crond 和 watchdogd。

其中值得借鉴的是套接字激活。launchd 会先创建所有监听套接字,然后再启动各个守护进程。客户端连接尚未启动的守护进程时,不会收到连接被拒绝的错误,因为内核会将连接保存在该套接字的 backlog 队列中,直到守护进程调用 accept()。两个守护进程之间的启动顺序不再需要由管理员显式声明。套接字会负责处理。

launchd 构建于 Mach IPC(进程间通信)之上。Mach IPC 属于 Apple 的 XNU 内核,在 Linux 中没有等价实现。移植这段代码从未现实可行,但这个设计思想仍然被采用。

Upstart 将事件作为工作单元

Canonical 的 Upstart 由 Scott James Remnant 编写,于 2006 年 10 月随 Ubuntu 6.10 发布。Fedora 9 至 Fedora 14 使用了 Upstart,RHEL 6 和 Chrome OS 也同样如此。Upstart 用事件替代运行级别,作业定义哪些事件应启动或停止它。

# /etc/init/example.conf
start on filesystem and net-device-up IFACE!=lo
stop on runlevel [!2345]
respawn
respawn limit 10 5
exec /usr/local/bin/exampled

随着作业数量增加,出现了两个问题。第一个是依赖方向。作业会说“发生此事件时启动我”,因此依赖关系的信息位于错误的文件中:服务知道自己需要什么,却不可能知道明年会有哪些服务需要它。添加服务通常意味着编辑现有作业,使其发出新事件。

第二个是进程跟踪。Upstart 通过使用 ptrace 统计派生守护进程的 fork() 调用来跟踪该进程,相关配置方式是 expect forkexpect daemon。如果错误估计派生次数,Upstart 可能会监管一个已经退出的进程,或者等待一个已经完成的派生。其表现是 initctl start 无错误地挂起,而作业文件无法说明原因。

Upstart 还要求贡献者签署 Canonical 的贡献者协议。这不是工程缺陷,但确实影响了参与开发的人员。

重新思考 PID 1,2010 年 4 月

2010 年 4 月 30 日,Lennart Poettering 发布了一篇名为“重新思考 PID 1”的文章。Kay Sievers 与他共同参与了该项目。这一主张包含四个方面。

  • 减少启动内容。许多服务可以等到确实有请求时再启动。
  • 当套接字可以表达启动顺序时,不要额外声明顺序。先一次性打开所有套接字,再同时启动所有服务。
  • 使用控制组跟踪进程,而不是使用 PID 文件。
  • 在声明式文件中描述服务,使同一份描述可在所有发行版上使用。

首个版本于当年发布。Fedora 14 在 2010 年 11 月将 systemd 作为可选项提供,Fedora 15 则在 2011 年 5 月将其设为默认选项。

cgroups 为何使进程监管可靠

cgroup(控制组)是内核用于对进程分组的功能,于 2008 年并入 Linux 2.6.24。systemd 会将每个服务放入独立的 cgroup。子进程会继承父进程的 cgroup,非特权进程无法将自身移出该 cgroup。因此,双重 fork 不会隐藏任何进程:PID 1 始终持有属于某个单元的完整进程集合。停止服务意味着终止其 cgroup 中的所有进程,这正是默认的 KillMode=control-group 所执行的操作。

systemctl status 会输出该进程组:

● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Tue 2026-08-11 09:14:22 UTC; 3min ago
   Main PID: 1042 (nginx)
      Tasks: 3 (limit: 4653)
     Memory: 6.1M (peak: 7.4M)
     CGroup: /system.slice/nginx.service
             ├─1042 "nginx: master process /usr/sbin/nginx"
             ├─1043 "nginx: worker process"
             └─1044 "nginx: worker process"

这就完整解决了 PID 文件过期的问题。不会有文件过期,因为进程列表属于内核状态。

同一棵进程树还承载资源限制,因为 cgroup 最初是为资源记账而构建的,之后才被用于跟踪进程。MemoryMax=CPUQuota=TasksMax= 各占一行。为服务设置内存和 CPU 硬限制如今只需创建一个 drop-in 文件;而在 2009 年,这需要修改一个没人编写的 shell 脚本。

为何所有发行版都在 2011 年至 2015 年间完成切换

  • Fedora 15,2011 年 5 月。
  • openSUSE 12.1,2011 年 11 月。
  • Mageia 2,2012 年 5 月。
  • Arch Linux,2012 年 10 月起新安装默认使用。
  • RHEL 7,2014 年 6 月。
  • SLES 12,2014 年 10 月。
  • Debian 8,2015 年 4 月。
  • Ubuntu 15.04,2015 年 4 月。

原因大多并不复杂,因此切换过程很快。

  • 同一个 unit 文件可在所有发行版上使用,因此上游项目开始随软件发布一个 .service 文件,发行版也不再为每个软件包的每个版本维护一个 shell 脚本。
  • 2012 年前后,ConsoleKit 停止维护,桌面会话跟踪转移到 systemd-logind。GNOME 需要 logind,因此不使用 systemd 的发行版必须寻找替代方案。这个替代方案 elogind 是从 systemd 中提取出来、单独维护的 logind。
  • 设备管理器 udev 于 2012 年 4 月合并到 systemd 源代码树中。发布 udev 的发行版此后需要跟踪 systemd 的代码仓库。Gentoo 因此分叉了 eudev
  • 容器使可靠的进程跟踪和每个服务的资源限制变得更加重要,因为这两项功能都依赖 cgroup。哪个 supervisor 负责管理容器进程,至今仍是一个实际问题,尤其是在您 让 Docker Compose 堆栈在重启后自动恢复 时。

Debian 的决定引发了最大反响。Technical Committee 于 2014 年 2 月进行投票,结果平票;委员会主席 Bdale Garbee 投下决定性一票,支持 systemd。几天后,Ubuntu 宣布将跟随 Debian,而不是继续使用 Upstart。2014 年 11 月,一群 Debian 开发者将该发行版分叉为 Devuan,并于 2017 年 5 月发布 Devuan 1.0。

公平陈述这些反对意见

范围。 如今,一个项目同时提供 PID 1、日志守护进程、登录会话管理、设备管理器、网络配置守护进程、DNS(域名系统)解析器、NTP(网络时间协议)客户端、容器运行器和引导加载程序。常见的辩护是:这些组件是彼此独立的二进制文件,您不必全部安装。这一点属实,但并不能回应反对意见。桌面环境需要 logind,而 logind 又从 systemd 的代码树中发布后,选择就不再完全自由。这正是争论中“耦合”的含义,而且这种情况已经发生。

二进制日志。 journald 写入带索引的二进制格式,而不是纯文本。它提供了文本日志无法提供的功能:按单元和优先级过滤、结构化字段,以及发送程序无法伪造的元数据,因为 journald 会自行记录单元和 cgroup。journalctl -u nginx -p err --since "-1h" 可以替代使用日期正则表达式的 grep。但代价也确实存在。在无法启动的机器上,您无法从救援 shell 使用 less 读取日志。应将已挂载的磁盘指定给 journalctl:

sudo journalctl --directory /mnt/var/log/journal --boot -1 --priority err

这里还有一个容易被忽略的陷阱。除非 /var/log/journal 存在,否则 journald 会将日志保存在 /run/log/journal 中,而该路径位于内存中。在没有该路径的机器上,重启后 journalctl -b -1 将没有任何内容可显示;但这正是您最需要日志的时候。请检查并修复:

journalctl --disk-usage
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald

现在,journalctl --disk-usage 应报告 /var/log/journal 下的已归档日志。如果还需要纯文本日志,请在 /etc/systemd/journald.conf 中设置 ForwardToSyslog=yes,并保留已安装的 rsyslog。

启动过程的可调试性。 单元卡住时,控制台只显示一行,之后没有任何信息:

[  *** ] A start job is running for Wait for Network to be Configured (1min 32s / no limit)

用于进一步排查的工具确实存在:卡住期间使用 systemctl list-jobs,之后使用 systemd-analyze blamesystemd-analyze critical-chain,并在内核命令行中加入 systemd.log_level=debug。公平地说,任何了解 sh 的人都可以从头到尾阅读 init 脚本;而排查卡住的单元,则需要知道应从十几个命令中选择哪些命令。这确实增加了成本。每位管理员都要承担一次,而当时有大量管理员同时承担了这项成本。

所有人都会受到影响的默认设置。 systemd 230 于 2016 年更改了 logind 的默认行为:用户注销时终止遗留的用户进程。分离的 tmuxscreen 会话会在启动它们的会话结束时终止。各发行版在 /etc/systemd/logind.conf 中提供了 KillUserProcesses=no,而受支持的解决方案是 loginctl enable-linger <user>。一个项目中的一个默认设置,就改变了数百万人所依赖的习惯。这就是“用户空间组件过多地集中在一个地方”在实践中的含义。

默认依赖项构成安全攻击面。 2024 年 3 月,xz-utils 中的后门针对 Debian 和 Ubuntu 上的 sshd。上游 OpenSSH 并不链接 libsystemd。上述发行版为 sshd 打入补丁,使其能够向 systemd 报告就绪状态;而 libsystemd 又引入了后门所在的 liblzma。就绪协议本身只是向 $NOTIFY_SOCKET 指定的套接字发送单个数据报,因此从未要求使用任何库。systemd 的应对方式是通过 dlopen 加载压缩库,因此这些库不再默认链接。另一类相关漏洞也表现出相同的模式:2017 年,以数字开头的 User= 值会被视为无效,单元不会失败,而是以 root 身份运行,因此一个拼写错误变成了权限提升。后续版本会拒绝启动该单元。

在 systemctl 提示符下查看自己的历史

上文的每个问题现在都对应文件中的一条指令,您可以直接查看该文件。

  • 串行启动由 After=Wants= 完成,systemd-analyze critical-chain 显示实际阻塞启动的单元。
  • 就绪状态由 Type=notify 管理;服务可以处理请求时,会将 READY=1 写入 $NOTIFY_SOCKET。对于旧版守护进程,仍可使用带 PIDFile=Type=forking;如果 PID 文件始终未出现,这种类型会因 start operation timed out. Terminating. 失败。
  • 监督由 cgroup 负责,因此使用 RestartSec= 配置 Restart=on-failure 可以替代包装脚本,而 StartLimitBurst= 可防止崩溃循环无限运行。
  • inetd 变成了一个 .socket 单元,与 .service 单元并列。
  • ulimit 变成了 MemoryMax=CPUQuota=TasksMax=
  • init 脚本中的 su - appuser -c 行变成了 User=NoNewPrivileges=yesProtectSystem=strict,因此 以非特权用户身份运行服务 是单元的默认结构,而不需要额外配置。
[Unit]
Description=Example API
Wants=network-online.target
After=network-online.target postgresql.service
Requires=postgresql.service

[Service]
Type=notify
ExecStart=/usr/local/bin/exampled
Restart=on-failure
RestartSec=2
MemoryMax=512M
CPUQuota=50%
TasksMax=128
User=exampled
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
StateDirectory=exampled

[Install]
WantedBy=multi-user.target

文件中的一行配置是所有人都会犯一次的错误。Requires=postgresql.service 表示依赖关系,而不是启动顺序:它表示 Postgres 失败时您的单元也会失败,但不表示应先启动 Postgres。没有 After=postgresql.service 时,两个单元会同时启动,而您的服务会连接到一个尚未有进程监听的端口。两者有意分开,因为有时您只需要其中一个功能。ProtectSystem=strict 会为此服务以只读方式挂载文件系统,因此需要设置 StateDirectory=:它会在 /var/lib 下为服务提供一个可写路径。

在一台运行 Ubuntu 24.04 的 2026 服务器上,最容易看到 2005 年设计痕迹的地方是 SSH;截至 2026 年 8 月,该版本随附 systemd 255。ssh.service 默认由 socket 激活:ssh.socket 持有监听 socket,收到连接后才启动 sshd。因此,在 /etc/ssh/sshd_config 中设置 Port 2222 不会生效,因为打开该端口的进程不是 sshd。应在 socket 单元中修改配置。

sudo systemctl edit ssh.socket
[Socket]
ListenStream=
ListenStream=2222

空的 ListenStream= 会清除从软件包单元继承的值。省略它会同时保留两个端口,因为 systemd 会将列表追加到原值,而不是替换原值。然后应用配置并进行检查,整个过程都要保持第二个 SSH 会话打开:

sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
sudo ss -lntp | grep 2222

ss 应显示一个由 systemd 负责、监听端口 2222 的 socket,而不是由 sshd 负责。这就是 launchd 的设计,二十年后仍体现在您的 VPS 上。如果您更喜欢旧行为,依次执行 sudo systemctl disable --now ssh.socketsudo systemctl enable --now ssh.service,即可运行一个长期运行的 sshd,并让它再次从自己的配置中读取 Port

您实际遇到哪些细节取决于所运行的发行版版本,因此在规划升级前,值得了解 LTS 版与 interim 版 Ubuntu 的区别。在多台机器上,所有位置的单元文件都完全相同,这正是 从一个位置管理多台服务器 如今成为配置问题、而不再是 shell 脚本问题的原因。编写自定义单元时,服务与计时器的组合 可以完成您在 2009 年需要分别使用 init 脚本和 cron 行来完成的工作。

FAQ

Linux 发行版为什么用 systemd 替代 SysV init?

原因包括两个工程因素和一个维护因素。SysV init 按文件名排序服务。文件名顺序代表位置,而不是依赖关系。它还无法跟踪从父进程派生后退出的 daemon,因此过期的 PID 文件可能终止错误的进程。systemd 通过 socket activation 和依赖指令解决启动顺序问题,通过 control group 解决进程跟踪问题。维护因素决定了迁移速度:同一个 unit 文件可以在所有发行版上使用,因此上游项目发布 .service 文件后,发行版维护者不再为每个软件包编写 shell 脚本。Fedora 15 于 2011 年 5 月切换,Ubuntu 15.04 是最后一个坚持使用旧方案的大型发行版,直到 2015 年 4 月才切换。

systemd 是一个巨大的二进制文件吗?

不是。源代码树会构建多个独立程序。PID 1 是 /usr/lib/systemd/systemd,而 journald、logind 和 udevd 是拥有各自二进制文件的独立进程;运行 ls /usr/lib/systemd/ 即可在本机查看这些进程。现存的批评主要针对发布耦合,而不是二进制文件大小:这些程序一起发布,并共享私有接口,因此发行版通常将它们作为一个整体采用;GNOME 等软件也逐渐要求使用 logind。

不使用 systemd 还能运行 Linux 吗?

可以。Devuan 提供 sysvinit,Gentoo 默认使用 OpenRC,Void 使用 runit,Alpine 使用带有 OpenRC 的 busybox init,Slackware 则保留 BSD 风格脚本。代价是需要处理兼容性问题。需要 logind 的桌面软件需要 elogind。它是作为独立软件包维护的 systemd logind。现在越来越多的服务器软件只发布 .service 文件,因此您需要自行编写和维护启动脚本。

journal 为什么使用二进制格式,而不是纯文本文件?

因为 journald 会存储带索引的结构化字段,从而支持按 unit 和优先级过滤,并提供发送程序无法伪造的元数据:journald 会自行记录 unit、cgroup 和真实 UID,而不是信任日志行中的内容。代价是需要使用 journalctl 读取日志,包括从救援系统中读取时也是如此。此时可以使用 journalctl --directory /mnt/var/log/journal 将其指向已挂载的磁盘。如果还需要文本日志,请在 /etc/systemd/journald.conf 中设置 ForwardToSyslog=yes

什么替代了直接编辑 /etc/init.d 脚本的做法?

使用 drop-in 文件。不要编辑 /usr/lib/systemd/system/ 中的 unit,因为软件包升级会覆盖它。运行 sudo systemctl edit nginx.service,systemd 会创建 /etc/systemd/system/nginx.service.d/override.conf,并将其合并到软件包提供的 unit 上。systemctl cat nginx.service 显示合并后的结果,systemd-delta 列出本机上的所有覆盖配置。手动完成任何编辑后,请运行 sudo systemctl daemon-reload,否则下一条命令会输出 Warning: The unit file, source configuration file or drop-ins of nginx.service changed on disk.

#systemd#linux#init#sysvinit#history