SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-27

systemd为何胜出:从SysV init到统一服务管理

了解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 脚本无法解决的五个问题

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

进程监管缺失是日常使用中影响最大的问题。PID 文件是当时的解决办法:daemon 将进程 ID 写入 /run/nginx.pid,stop 函数再读取该文件。如果 daemon 被强制终止,文件仍会保留。内核随后可能将这个编号重新分配给其他进程,而 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 fork 或 expect 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。因此,二次派生进程不会隐藏任何内容: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 月。

RHEL 7 的影响持续时间比其他版本更长,因为 CentOS 7 对其进行了重建;大多数管理员也正是在这一阶段第一次接触 unit 文件。这是 从 Red Hat Linux 延续到 CentOS、Rocky 和 AlmaLinux 的更长历程中的一个转折点。

原因大多很实际,因此切换很快。

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

Debian 的决定影响最大。Technical Committee 于 2014 年 2 月进行投票,结果票数相同。委员会主席 Bdale Garbee 投下决定票,支持 systemd。几天后,Ubuntu 宣布将跟随 Debian,而不是继续使用 Upstart。2014 年 11 月,一批 Debian 开发者将该发行版 fork 为 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 中,而该位置位于内存中。在没有 /var/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 blame 和 systemd-analyze critical-chain,并在内核命令行中使用 systemd.log_level=debug。更公允的批评是:了解 sh 的人可以从头到尾阅读 init 脚本;但遇到挂起的单元时,您必须知道应该使用十几个命令中的哪一个。这确实增加了成本。每位管理员都要承担一次,而当时许多管理员同时承担了这项成本。

影响所有人的默认设置。 systemd 230 在 2016 年修改了 logind 的默认行为:用户注销时会终止遗留的用户进程。分离的 tmux 和 screen 会话会在启动它们的会话结束时终止。各发行版在 /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。服务可以处理请求时,会向 $NOTIFY_SOCKET 写入 READY=1。对于旧守护进程,仍可使用带有 PIDFile= 的 Type=forking;如果 PID 文件始终未出现,该类型会因 start operation timed out. Terminating. 而失败。选错类型后,单元可能报告 active,但它启动的守护进程已经退出。因此,在写入该行之前,应先了解哪种 Type= 与守护进程的实际启动方式匹配。
  • 进程监管交给了 cgroup,因此 Restart=on-failure 配合 RestartSec= 可以替代包装脚本,StartLimitBurst= 则可防止崩溃循环无限运行。
  • inetd 变成了与 .service 单元并列的 .socket 单元。
  • ulimit 变成了 MemoryMax=、CPUQuota= 和 TasksMax=。
  • init 脚本中的 su - appuser -c 行变成了 User=、NoNewPrivileges=yes 和 ProtectSystem=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;截至 August 2026,该版本随附 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 持有。这就是 VPS 上的 launchd 设计,只是已经过去了 20 年。如果您更喜欢旧行为,执行 sudo systemctl disable --now ssh.socket,然后执行 sudo systemctl enable --now ssh.service,即可运行一个长期存在的 sshd,并让它再次从自身配置中读取 Port。

您会遇到哪些细节,取决于所运行的发行版版本。因此,在规划升级前,应先了解LTS 版与 Ubuntu interim 版的区别。如果需要管理多台机器,单元文件在各处完全相同,正是集中管理多台服务器如今属于配置管理问题而非 shell 脚本问题的原因。编写自定义单元时,服务与计时器单元对承担的工作,相当于您在 2009 年需要分别用 init 脚本和 cron 行完成的工作。

FAQ

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

原因有两个工程因素和一个维护因素。SysV init 按文件名顺序启动服务,但文件名位置并不表示依赖关系;它也无法跟踪已从父进程分叉出去的 daemon,因此过时的 PID 文件可能会终止错误的进程。systemd 通过 socket 激活和依赖指令解决启动顺序问题,通过 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