systemd 260 移除 SysV:怎么办
systemd 260 删除了 sysv-generator、rc-local.service 和 systemd-sysv-install。你机器上的每一个 /etc/init.d 脚本、每一份 /etc/rc.local 都进入了倒计时。本文讲清楚怎么把它们全部找出来,并且规规矩矩地转成原生 systemd 服务。
- systemd
- Linux
- 系统运维
- 迁移
差不多十五年时间里,systemd 在每一次开机时都默默把你的 /etc/init.d 脚本翻译成服务,从来没让你察觉到这件事。到了 2026 年 3 月发布的 260 版本,它不干了。systemd-sysv-generator、systemd-rc-local-generator 连同 rc-local.service,以及藏在 systemctl enable 背后的 systemd-sysv-install 钩子,全部被彻底删除。你的脚本本身一个字都没变,变的是再也没有人去读它们了。
![两栏对照图。左栏标题为「DELETED IN systemd 260」,列出 systemd-sysv-generator、systemd-rc-local-generator、rc-local.service 和 systemd-sysv-install。右栏标题为「WHAT REPLACES WHAT」,给出四组前后对照:start-stop-daemon --background 换成 Type=exec;--make-pidfile 换成完全不用 PID 文件;--chuid acme 换成 User=acme;/etc/rc[2-5].d/S20name 换成 WantedBy=multi-user.target。](/og/migrate-sysv-init-to-systemd-units.png)
这是一份写给真要在生产服务器上动手的人的迁移指南,而不是隔岸观火的评论:怎样用一个几乎没人知道的 journal 字段把整个机器群里被包装过的脚本盘点干净;生成器到底写出了什么样的单元文件,好让你照抄而不是靠猜;一个贴近现实的 init 脚本如何逐字段改写;哪一个 Type= 写错会让单元显示启动成功而守护进程其实已经死了;以及 init 3 之类命令被什么取代了。文中每一个版本号、每一条指令,都是对照 systemd 的 NEWS 文件、以及那些被删除的代码仍然存在的 tag 上的源码逐一核对过的,不是凭印象写的。
服务不是 failed,而是根本不存在
这个故障的症状格外让人犯迷糊,因为它是一种「缺席」而不是一个错误。你的服务不会显示成 failed,它压根就不出现。systemctl status 告诉你 Unit could not be found,而脚本明明还躺在磁盘上,可执行位好好的,从 2015 年起一个字节都没改过。这个行为是正确的:那个脚本从来就不是一个单元。是生成器在每次开机时于 /run/systemd/generator.late 里临时造出一个单元,而现在那个目录空了。[v260]
| 你看到的现象 | 它意味着什么 | 在哪一节讲 |
|---|---|---|
提示 Unit x.service could not be found,而 /etc/init.d/x 明明存在且可执行 | 那个负责临时造出这个单元的生成器已经被移除。没有东西坏掉,只是不再有翻译发生了。 | 生成器替你写了什么 |
| 一个多年来每次开机都会启动的服务干脆不启动了,journal 里什么也没有 | 同一个原因,只是换成从开机流程而非命令行的角度看。没有 failed 单元,因为压根就没有单元。 | 逐字段转换一个脚本 |
/etc/rc.local 仍然有可执行位,却不再执行 | systemd-rc-local-generator 和 rc-local.service 与 SysV 生成器一并被移除。 | 替换 /etc/rc.local |
对老服务执行 systemctl enable 已经没有任何实际效果 | 把 enable/disable 转交给 chkconfig 或 update-rc.d 的那个钩子 systemd-sysv-install 没有了。 | 软件包必须交付什么 |
telinit: command not found,或者 init 3 敲下去毫无反应 | 这是 258 里移除的,比上面那些早两个版本,原因也不同:被丢掉的是运行级别这个概念本身,不只是脚本。 | init 3、telinit 和 runlevel |
| 转换后的单元显示 active,进程却没了——或者显示 failed,守护进程反而在跑 | 对一个会 fork 的守护进程来说 Type= 写错了。这是最常见的一种转换失败。 | Type=forking 与 PID 文件 |
# After the upgrade, the service that has started at boot for eleven years
# is simply not there. Not failed - not there.
systemctl status acme-collector
# Unit acme-collector.service could not be found.
ls -l /etc/init.d/acme-collector
# -rwxr-xr-x 1 root root 1284 Mar 14 2015 /etc/init.d/acme-collector
# ^ the script is still on disk, still executable, still correct.
# The script was never a service. A generator turned it into one on every
# boot, and the generator is gone. Confirm which systemd you are on:
systemctl --version | head -1
# systemd 260 (260.2-1)
# And confirm the generator really is absent rather than just failing:
ls /usr/lib/systemd/system-generators/ | grep -E 'sysv|rc-local'
# (no output on 260 and later; on 259 and earlier you get one or two lines)
# The same disappearance, seen from the other end - nothing was generated:
ls /run/systemd/generator.late/
# (empty, or missing your unit)生成器运行在管理器加载任何单元之前,每次开机、每次 daemon-reload 都会跑一遍,产物写在 tmpfs 里。这就是为什么升级之后磁盘上看不出任何差别,也是为什么根本没有东西可以「修复」:哪里都没有损坏的文件,只是一个翻译步骤不再发生了。下文默认你能登上这台机器,并且最后可以重启它一次。在你自己动手之前,本文任何内容都不会改变系统状态;盘点用的命令全部是只读的。[generator]
到底删了什么,又是什么时候删的
这件事牵扯到几个不同的版本,把它们混为一谈,正是网上大量相关建议出错的根源。服务脚本早在 255 版本就已经被正式宣布废弃,并且从那时起每包装一个脚本都会打出警告。2025 年 9 月的 258 版本做的是另一码事:它移除了 System V 的系统状态接口——/dev/initctl、initctl、runlevel、telinit 命令、init 3 这类状态切换方式、runlevel[0-6].target 单元,以及把运行级别切换记入 utmp/wtmp 的行为。2025 年 12 月的 259 版本把其中一项部分恢复了回来。2026 年 3 月的 260 版本才删掉服务脚本支持本身。[news][v255][v258]
| 版本 | 时间 | 发生了什么 |
|---|---|---|
| 258 | 2025 年 9 月 | 重申 SysV 服务脚本的废弃状态,当时的发布说明仍把移除时间定在 259,后来才推迟到 260。与此同时立即移除:/dev/initctl、initctl、runlevel 和 telinit 命令、init 3 式的状态切换,以及 runlevel[0-6].target 单元;运行级别切换也不再记入 utmp/wtmp。同一版本还去掉了 cgroup v1 支持。 |
| 259 | 2025 年 12 月 | runlevel[0-6].target 恢复,但仅当发行版以 -Dcompat-sysv-interfaces=yes 编译时才有。被移除的那些命令没有回来。Ubuntu 26.04 LTS 带的就是这个版本。 |
| 260 | 2026 年 3 月 | 移除对 System V 服务脚本的支持。被删除的有:systemd-sysv-generator;systemd-rc-local-generator 与 rc-local.service;systemd-sysv-install。编译选项 -Drc-local=、-Dsysvinit-path= 和 -Dsysvrcnd-path= 转为废弃状态。最低内核要求升到 5.10,glibc 升到 2.34,OpenSSL 升到 3.0。 |
| 261 及以后 | 2026 年 | 不再有与 SysV 相关的新变化;兼容层就是单纯地不存在了。那几个已废弃的编译选项预计会在未来某个版本彻底消失。 |
如果你管的是混合版本的机器群,259 这次部分回退就很关键:runlevel[0-6].target 单元回来了,但只是一个发行版需要在编译期主动打开的选项,而那几个命令一个都没回来。所以 systemctl isolate runlevel3.target 可能在你这台机器上能用,换一台就不行,取决于各家发行版打包时是怎么编译的。别把运维习惯建立在这上面。你的服务器目前处在什么位置,取决于它带的是哪个版本的 systemd:[v259][v260]
| 你所处的位置 | systemd 版本 | SysV 脚本与 rc.local |
|---|---|---|
| Debian 12 bookworm | 252 | 两者都正常,journal 里没有逐脚本的废弃警告。 |
| Debian 13 trixie | 257 | 两者都正常,但 journal 已经对每个被包装的脚本发出废弃警告。 |
| Ubuntu 24.04 LTS | 255 | 两者都正常,而且 journal 已经对每个被包装的脚本发出废弃警告。 |
| Ubuntu 26.04 LTS | 259 | 两者都还能用,journal 对每个被包装的脚本发出警告(从 24.04 起就是如此)。Canonical 明确表示这是最后一个带 SysV 兼容的 Ubuntu。 |
| Ubuntu 26.10 | 260 或更高 | 已移除。脚本仍留在磁盘上,但永远不会被读取。 |
| RHEL 9 / RHEL 10 | 252 / 257 | 在各自版本的整个生命周期内都能用。RHEL 9 是静默的;RHEL 10 会逐脚本发出废弃警告,其发布说明里也已经写上了废弃声明。变更将随下一个大版本到来。 |
| 滚动发行版 | 260 或更高 | 已经移除了。用 systemctl --version 确认,别想当然。 |
把日期摆出来,这件事就从「理论上」变成「日程上」了。Canonical 官方发布说明写明:搭载 systemd 259 的 Ubuntu 26.04 LTS 是最后一个提供 System V 兼容的 Ubuntu 版本,该变更将在 26.10 生效,而 26.10 的发布时间定在 2026 年 10 月。如果你跑的是中间版本,或者手上有任何滚动发行版的机器,那么截止期是以周计的,不是以年计的。如果你在 LTS 或企业版发行版上,那么期限就是下一次大版本升级——而那恰恰是你最不希望撞见一堆没人认领的 shell 脚本的时刻。[ubuntu2604][ubuntu2610][rhel10]
在升级替你发现之前,先把脚本全找出来
先做盘点,而且要趁生成器还在跑的时候做,因为生成器自己会把答案告诉你。从 255 版本开始,systemd 每包装一个脚本都会打出一条带稳定 message ID 的结构化警告,外加两个自定义 journal 字段:SYSVSCRIPT= 记录脚本路径,UNIT= 记录它临时造出来的单元名。这就把一次全机器群的审计变成了一条精确查询,而不是拿 ls 加运气拼出来的猜测。[messages][v255][journalctl]
#!/usr/bin/env bash
# Inventory every SysV script this host still depends on. Run it BEFORE the
# upgrade, on every machine, and keep the output.
echo '== 1. scripts systemd is currently wrapping =========================='
# systemd 255 and later log a structured warning for every script they wrap.
# The message ID is stable, so this is an exact list rather than a guess.
journalctl -b -o json --output-fields=SYSVSCRIPT,UNIT \
MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb 2>/dev/null \
| python3 -c 'import sys,json
for l in sys.stdin:
d = json.loads(l)
print("%-28s %s" % (d.get("UNIT","?"), d.get("SYSVSCRIPT","?")))'
echo '== 2. fallback for systemd 254 and older ============================='
# Older systemd wraps silently. Ask the manager which units came from a
# generator instead: generated units live under /run/systemd/generator.late
# and carry a SourcePath= pointing back at the script. This also catches
# scripts that were wrapped before the current boot's journal starts.
systemctl list-units --type=service --all --no-legend --plain \
| awk '{print $1}' \
| while read -r u; do
frag=$(systemctl show -p FragmentPath --value "$u" 2>/dev/null)
case "$frag" in
/run/systemd/generator*)
src=$(systemctl show -p SourcePath --value "$u" 2>/dev/null)
[ -n "$src" ] && printf '%-28s %s\n' "$u" "$src" ;;
esac
done
echo '== 3. scripts on disk with no NATIVE unit behind them ================'
# Note the test: `systemctl cat` succeeds for a generated unit too, so asking
# whether the unit exists tells you nothing while the generator is running.
# Ask where the definition lives instead.
for f in /etc/init.d/*; do
[ -f "$f" ] && [ -x "$f" ] || continue
n=$(basename "$f"); n=${n%.sh}
frag=$(systemctl show -p FragmentPath --value "$n.service" 2>/dev/null)
case "$frag" in
/etc/systemd/system/*|/usr/lib/systemd/system/*|/lib/systemd/system/*) ;;
*) echo "no native unit: $f" ;;
esac
done
echo '== 4. runlevel wiring (this is what set the boot order) ============='
ls -l /etc/rc[1-5].d/S* 2>/dev/null | awk '{print $9, $10, $11}'
echo '== 5. rc.local ======================================================'
ls -l /etc/rc.local 2>/dev/null && \
{ [ -x /etc/rc.local ] && echo 'executable: it runs at boot today'; }盘点要在每一台主机上跑,而不是挑一台「有代表性的」。按我的经验,能活到 2026 年的脚本,从来都不是配置管理仓库里记着的那些——而是某个外包顺手写的一次性脚本、厂商一体机的 agent、比现在这拨人还老的备份包装脚本。这些恰恰是没人想到要去检查的主机,也恰恰是那种停掉一周之后才被发现的服务。
那条查询里有三个细节值得记牢。第一步只看得到本次开机的 journal,所以一台机器如果那条警告已经随日志轮转掉了,光靠它会显得干干净净——这类主机仍然要靠第二步来兜底。systemd 只会包装 init 目录下有可执行位的普通文件,所以一个权限停在 644 的脚本本来就已经是死的,你不该把它救回来。另外,同名的原生单元永远优先于脚本:生成器会明确跳过任何已经存在真实单元的脚本。后面这条规则正是整个迁移可以增量进行的原因——你可以在脚本仍然存在、仍然处于 enabled 状态时就把新单元装上去,脚本会自然而然地不再被采纳。[gensrc]
生成器一直在悄悄替你写的那份单元
在动手写单元之前,先把生成器产出的那一份拿出来读一遍。它不是一个粗略的近似,而是一次确定性的翻译,也是你能拿到的、最接近「你这个脚本开机行为规格说明书」的东西。趁还来得及,用 systemctl cat 把它抓下来——升级之后这份信息就没了,而从 LSB 头反推回去,正是大家引入顺序 bug 的地方。[gensrc][genman]
# Capture the generated unit BEFORE you upgrade. It is the specification for
# the unit you are about to write, produced by the only thing that ever read
# your init script correctly.
systemctl cat acme-collector.service > ~/acme-collector.generated.service
# What comes out looks like this - and every line of it is decided by the
# generator source, not by convention:
# /run/systemd/generator.late/acme-collector.service
# Automatically generated by systemd-sysv-generator
[Unit]
Documentation=man:systemd-sysv-generator(8)
SourcePath=/etc/init.d/acme-collector
Description=LSB: ACME metrics collector
Before=multi-user.target
Before=multi-user.target
Before=multi-user.target
Before=graphical.target
After=remote-fs.target
After=network-online.target
After=time-sync.target
After=postgresql.service
Wants=network-online.target
[Service]
Type=forking
Restart=no
TimeoutSec=5min
IgnoreSIGPIPE=no
KillMode=process
GuessMainPID=no
RemainAfterExit=no
PIDFile=/var/run/acme-collector.pid
SuccessExitStatus=5 6
ExecStart=/etc/init.d/acme-collector start
ExecStop=/etc/init.d/acme-collector stop
ExecReload=/etc/init.d/acme-collector reload
# Yes, Before=multi-user.target really is repeated. The rc2.d, rc3.d and rc4.d
# symlinks each append it and nothing deduplicates the list. Harmless, but it
# tells you the file was machine-written and how.
#
# Note what is NOT there: no [Install] section. The generator wired the unit in
# by dropping symlinks next to it - into multi-user.target.wants for rc2-rc4,
# and into graphical.target.wants for rc5 - which is why `systemctl is-enabled`
# on one of these was never a straight answer.接下来是最让人意外、而且你可以在生成器源码里亲自核实的一点。LSB 头里的 Default-Start: 和 Default-Stop: 从来没有被解析过。生成器只读 Provides:、Required-Start:、Should-Start:、X-Start-Before:、X-Start-After:、两个描述字段,以及 Red Hat 风格的 # pidfile: 和 # description: 注释。运行级别的接线关系完全来自 /etc/rc[1-5].d 里的 S??name 符号链接。如果你十年来一直在认真维护 Default-Start,那你维护的其实是一行注释。[lsb][exitstatus]
| init 脚本里的写法 | 生成器拿它做了什么 | 你的单元里该怎么写 |
|---|---|---|
Provides: name | 如果写的是服务名,生成一个别名符号链接。如果写的是 $facility,则对该 target 加 Before= 加 Wants=——单向的。如果只是重复文件名,忽略。 | [Install] 里的 Alias=,或者什么都不写 |
Required-Start: $network | After=network-online.target 并且 Wants=network-online.target,因为这个 target 不被主动拉起来就是惰性的 | Wants= 加 After=network-online.target |
Required-Start: $remote_fs | After=remote-fs.target | After=remote-fs.target |
Required-Start: $named | After=nss-lookup.target | After=nss-lookup.target |
Required-Start: $portmap | After=rpcbind.target | After=rpcbind.target |
Required-Start: $time | After=time-sync.target | After=time-sync.target |
Required-Start: $local_fs 或 $syslog | 什么也没做。两者都被丢弃:在 systemd 下,任何普通服务运行之前它们就已经满足了。 | 什么都不写 |
Should-Start: foo | After=foo.service——只是排序,从不构成强依赖 | After=foo.service,不要写 Requires= |
X-Start-Before: foo | Before=foo.service | Before=foo.service |
Default-Start: / Default-Stop: | 什么都没做。从未被解析。运行级别的接线来自 /etc/rc[1-5].d 里的 S?? 符号链接。 | [Install] 里写 WantedBy=multi-user.target |
/etc/rc2.d、rc3.d、rc4.d 里的符号链接 | Before=multi-user.target,外加一个指向它的 wants 符号链接 | WantedBy=multi-user.target |
/etc/rc5.d 里的符号链接 | Before=graphical.target,外加一个指向它的 wants 符号链接 | WantedBy=graphical.target |
/etc/rc1.d 里的符号链接 | Before=rescue.target,并在其中放一个 wants 符号链接——生成器对 rc1.d 的处理和 rc2–rc5 完全一样 | 几乎从来不是你想要的,直接省掉 |
# pidfile: /path(Red Hat 风格) | PIDFile=/path,并且 RemainAfterExit=no。完全没有 pidfile 行时,则是 RemainAfterExit=yes | 两者都删掉,改用 Type=exec |
存在 ### BEGIN INIT INFO | SuccessExitStatus=5 6——LSB 里表示「未安装」和「未配置」的退出码 | 只有当你确实还在用这些退出码时才写 |
usage 行里含有 |reload} 之类的字样 | ExecReload=/etc/init.d/x reload。没有 usage 行,就没有 reload 动作。 | ExecReload=/bin/kill -HUP $MAINPID |
对照表里有两条 facility 映射值得回头再看一眼,因为它们是「以前在 SysV 下好好的,现在开始抢跑」这类问题的常见来源。$network 并没有变成 network.target,它变成了 network-online.target,而且生成器同时加了 After= 和 Wants=,因为这个 target 除非有人主动把它拉起来,否则什么都不做。与此相对,$local_fs 和 $syslog 什么也没映射成——它们被直接丢弃了,理由也说得通:在 systemd 下,任何普通服务启动时这两者都已经就绪。把这些决定原样抄进你的单元,别自己临场发挥另一套。[special]
逐字段转换一个真实脚本
下面是一个贴近现实的脚本,不是玩具。它有 LSB 头,有 Red Hat 风格的 PID 文件注释,有一段 start-stop-daemon 调用把进程放到后台并代替守护进程写下 PID 文件,有一个 reload 动作,还有那行 usage 提示——生成器之所以会产出 ExecReload=,唯一的原因就是它。现实中大多数脚本比这个还糟。
#!/bin/sh
### BEGIN INIT INFO
# Provides: acme-collector
# Required-Start: $remote_fs $network $time
# Required-Stop: $remote_fs $network
# Should-Start: postgresql
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: ACME metrics collector
### END INIT INFO
# pidfile: /var/run/acme-collector.pid
DAEMON=/opt/acme/bin/collector
PIDFILE=/var/run/acme-collector.pid
RUNAS=acme
OPTS="--config /etc/acme/collector.conf"
case "$1" in
start)
start-stop-daemon --start --quiet --background --make-pidfile \
--pidfile "$PIDFILE" --chuid "$RUNAS" --exec "$DAEMON" -- $OPTS
;;
stop)
start-stop-daemon --stop --quiet --pidfile "$PIDFILE" --retry 30
rm -f "$PIDFILE"
;;
reload)
kill -HUP "$(cat "$PIDFILE")"
;;
restart)
"$0" stop; sleep 1; "$0" start
;;
*)
echo "Usage: $0 {start|stop|restart|reload}" >&2
exit 2
;;
esac
exit 0下面是取代它的单元。请重点读注释而不是指令本身:真正有意思的决定,是那些关于「哪些东西不要带过来」的判断。fork 去掉。手写 PID 文件去掉。start-stop-daemon 这层包装也去掉,因为它做的所有事情——降权、放后台、跟踪进程——现在都是一条指令。[service][exec]
# /etc/systemd/system/acme-collector.service
#
# Everything above the blank line inside [Service] is the translation of the
# init script. Everything below it is what the wrapper could never give you.
[Unit]
Description=ACME metrics collector
Documentation=https://example.internal/acme/collector
# $network in the old header became network-online.target - and that target
# only does something if a unit actively pulls it in, hence the Wants=.
Wants=network-online.target
After=network-online.target
# "Should-Start: postgresql" was ordering only, never a requirement. Keep it
# that way: After= without Requires= means a database outage does not cascade.
After=postgresql.service
[Service]
Type=exec
User=acme
Group=acme
# The single most important line of the migration: stop the daemon forking.
# Almost every daemon has a flag for this - --foreground, -f, -D, --no-detach,
# --nodaemon. Find it and the PID file problem disappears with it.
ExecStart=/opt/acme/bin/collector --config /etc/acme/collector.conf --foreground
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
# /var/run/acme-collector.pid was in /run all along; let systemd own it.
RuntimeDirectory=acme-collector
StateDirectory=acme-collector
ConfigurationDirectory=acme
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
# Empty means "no capabilities at all". If the daemon binds a port below 1024,
# use CapabilityBoundingSet=CAP_NET_BIND_SERVICE plus the matching
# AmbientCapabilities= instead of dropping this line.
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target| 脚本里的写法 | 单元里的写法 | 说明 |
|---|---|---|
start-stop-daemon --background 或 & | Type=exec 加守护进程自己的前台运行参数 | 这就是整件事的关键。什么都别放后台。 |
--make-pidfile、echo $! > … | 直接删掉 | 进程是 systemd 自己 fork 出来的,它知道 PID。 |
--chuid、su - user -c、runuser | User= 和 Group= | 对每一个后代进程都生效,不只是第一个。 |
mkdir -p /run/x; chown … | RuntimeDirectory=x | 启动前创建,停止后删除,属主自动设对。 |
mkdir -p /var/lib/x | StateDirectory=x | 重启后仍然保留;其他用途请用 CacheDirectory=/LogsDirectory=。 |
ulimit -n 65535 | LimitNOFILE=65535 | 脚本里的 ulimit 作用于那个 shell,有时候根本没落到守护进程身上。 |
export FOO=bar | Environment= 或 EnvironmentFile= | EnvironmentFile=-/etc/default/x 可以让现有配置文件继续生效。 |
nice -n 10、ionice | Nice=10、IOSchedulingClass= | 也可以更进一步用 CPUWeight= 和 IOWeight=。 |
cd /opt/x | WorkingDirectory=/opt/x | |
>> /var/log/x.log 2>&1 | 直接删掉 | 输出进 journal 并带上单元标签,不用再写轮转规则。 |
sleep 5; check_if_up | 让守护进程支持 Type=notify,或者把健康检查拆成独立单元 | start 动作里的 sleep 只是一根引信更长的竞态。 |
restart) stop; sleep 1; start | 直接删掉 | systemctl restart 本来就有,而且会正确等待。 |
status) 分支 | 直接删掉 | systemctl status 和 is-active 报的是真实状态。 |
停止时的 trap / 清理逻辑 | ExecStop=,更好的做法是在守护进程里处理 SIGTERM | TimeoutStopSec= 决定 SIGKILL 之前等多久。 |
下面是通用的对照关系,覆盖你实际会遇到的那些写法。凡是某一行写着「直接删掉」的,都不是简化说法:这个行为由管理器提供,用 shell 再实现一遍,只会得到两个东西争抢同一个进程的局面。[unit]
# Cut over on a system that still has the generator, so you can roll back by
# doing nothing. Order matters here.
# 1. Check the file before systemd ever loads it.
sudo systemd-analyze verify /etc/systemd/system/acme-collector.service
# 2. Install it and reload. The native unit now WINS over the script: the
# generator skips any script that already has a unit of the same name.
sudo systemctl daemon-reload
# 3. Prove which one is live before you touch the running process.
systemctl show -p FragmentPath -p SourcePath --value acme-collector.service
# /etc/systemd/system/acme-collector.service
# <- empty SourcePath = no longer generated. Good.
# 4. Restart through systemd, not through the script.
sudo systemctl restart acme-collector.service
systemctl status acme-collector.service --no-pager
# 5. Enable it explicitly. The generator's implicit wiring is not inherited.
sudo systemctl enable acme-collector.service
systemctl is-enabled acme-collector.service # -> enabled
# 6. Retire the old wiring. Do NOT delete the script yet - move it aside, so
# a rollback is one mv away for the length of the change window.
sudo rm -f /etc/rc[0-6].d/[SK]??acme-collector
sudo mv /etc/init.d/acme-collector /root/retired-init.d-acme-collector
# 7. The only real test: reboot, then read the boot rather than the status.
sudo systemctl reboot
journalctl -b -u acme-collector.service
systemd-analyze blame | head -20按这个顺序切换,回滚的代价就只是一条 mv。其中最重要的是第三步:systemctl show -p FragmentPath -p SourcePath 会毫不含糊地告诉你两份定义里到底哪一份是生效的。SourcePath 非空,就说明你看到的仍然是生成出来的包装单元,而你的新文件要么名字撞了,要么有语法错误,要么放错了目录。[systemctl]
替换 /etc/rc.local,但别把它最坏的毛病带过来
/etc/rc.local 值得单开一节,因为它是大家最爱往后拖的一项,也因为它即将失去的那个兼容单元有着相当古怪的语义——在你把它照搬过去之前,值得先弄明白。真正的 rc-local.service 用的是 Type=forking 配 GuessMainPID=no、RemainAfterExit=yes 和 TimeoutSec=infinity,排序上只跟在 network.target 之后,而且只有文件带可执行位时才会把自己拉起来。上游手册页专门花笔墨警告过:这个排序并不意味着网络已经可用,而且这整套东西存在的意义只是兼容某些特定的 System V 系统,并不是任何人推崇的设计。[rclocalunit][rclocalman]
# /etc/rc.local was never one thing. Read yours first and split it: a mount,
# a sysctl, a firewall rule and a background daemon are four different units,
# and only the last one belongs in a service.
# --- 1. the direct replacement, when it really is one script ---------------
# /etc/systemd/system/local-startup.service
[Unit]
Description=Local startup commands (former /etc/rc.local)
ConditionFileIsExecutable=/usr/local/sbin/local-startup
# The old rc-local.service used After=network.target, which upstream's own
# manual page warns does not mean the network is usable. If your script talks
# to the network, use network-online.target and pull it in.
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/local-startup
# The original had TimeoutSec=infinity. Keeping that means a hung script hangs
# the boot forever with no message. Pick a number you would accept waiting.
TimeoutStartSec=90s
[Install]
WantedBy=multi-user.target
# --- 2. the pieces that should not be a service at all ---------------------
# sysctl: /etc/sysctl.d/90-local.conf
# modules: /etc/modules-load.d/local.conf
# files/dirs: /etc/tmpfiles.d/local.conf
# mounts: /etc/fstab or a .mount unit
# periodic: a .timer, not a sleep loop
# --- 3. install it --------------------------------------------------------
sudo install -m 0755 /etc/rc.local /usr/local/sbin/local-startup
sudo systemctl daemon-reload
sudo systemctl enable --now local-startup.service
systemctl status local-startup.service --no-pager
# On SELinux systems relabel the script, exactly as the old generator's manual
# page told you to do for /etc/rc.local:
command -v restorecon >/dev/null && sudo restorecon -v /usr/local/sbin/local-startup既然动到这里了,顺手改掉两个习惯。别保留 TimeoutSec=infinity:它会把一条卡住的命令变成一次永远结束不了的开机,而且悄无声息,连一个 failed 单元都指不出来。也别把所有东西都塞在一个脚本里。一份 rc.local 通常是四件毫不相干的事被凑在一起,仅仅因为当年只有这一个钩子可用——一条 sysctl、一次挂载、一条防火墙规则,外加一个本来就该做成守护进程的东西。拆成独立单元之后,它们可以各自排序、各自重试、各自禁用,而且其中一个出问题时你能立刻知道是哪一个。[special]
Type=forking、PID 文件,以及跟你对着干的守护进程
这是那种会赔上一个通宵的错误,而且它不会主动报警。如果守护进程会 fork,单元却写成 Type=simple,systemd 跟踪的是父进程,看着它在几毫秒内退出,接下来要么把幸存的子进程当作游离进程杀掉,要么在什么都没跑起来的情况下宣布启动成功。如果守护进程会 fork、单元写的是 Type=forking,但 PID 文件写得太晚,systemd 就跟丢了主进程,报出 MainPID=0:单元看上去是 active,Restart= 永远不会触发,关机时还会留下一堆残留进程。[daemon][service]
# The failure mode nobody warns you about: the unit reports "active" and the
# daemon is dead, or reports "failed" while the daemon is happily running.
# Both come from getting Type= wrong.
# --- diagnosis -------------------------------------------------------------
systemctl show -p Type -p MainPID -p PIDFile -p ControlGroup --value myd.service
# forking
# 0 <- systemd has no main process: it lost the daemon
# /var/run/myd.pid
# /system.slice/myd.service
# If MainPID is 0 while the process exists, systemd is not supervising it.
# Restart=, watchdogs, and clean shutdown are all silently not working.
systemd-cgls -u myd.service # who is actually inside the unit's cgroup
# --- fix 1 (preferred): stop forking --------------------------------------
# Type=exec: systemd considers the unit started once the binary has been
# executed. No PID file, no race, no double-fork, no GuessMainPID guesswork.
# ExecStart=/usr/sbin/myd --foreground
# Type=exec
# --- fix 2 (best, if the daemon supports it): tell systemd when you are up --
# Type=notify with sd_notify(READY=1) from the daemon, or Type=notify-reload
# if it can also signal that a reload finished. These - together with Type=dbus
# for D-Bus services - are the only readiness protocols precise enough that
# After= on a dependent unit means "the port is actually accepting".
# Type=notify
# NotifyAccess=main
# --- fix 3 (last resort): keep forking, but do it correctly ---------------
# PIDFile= must be an absolute path under /run, and the daemon must write it
# BEFORE the parent exits. Anything else is a race you will lose under load.
# Type=forking
# PIDFile=/run/myd/myd.pid
# RuntimeDirectory=myd
# --- do not do this -------------------------------------------------------
# Type=simple with a daemon that forks. systemd will track the parent, see it
# exit immediately, and either kill the children or declare success while the
# service never started. This is the single most common broken conversion.长久的解法不是把 PID 文件写得更聪明,而是干脆别 fork。近二十年写出来的守护进程,几乎每一个都有让它留在前台的开关,上游对新式守护进程的官方建议也是用这个开关。这样 Type=exec 就整类问题一起消掉了;Type=notify(以及 notify-reload;D-Bus 服务则用 Type=dbus)还能更进一步:它是唯一可靠的办法,能让依赖单元上的 After= 真正意味着「套接字确实已经在接受连接」而不是「二进制文件已经被执行了」。其余的都只是一厢情愿的排序。[incompat]
init 3、telinit 和 runlevel 也一起没了
这部分变更的影响超出了服务器本身,直接波及肌肉记忆和运维手册。258 版本移除了 /dev/initctl 设备节点和 initctl、runlevel、telinit 命令,移除了通过 init 3 切换系统状态的支持,移除了 runlevel[0-6].target 单元,并且不再把运行级别切换记录进 utmp 和 wtmp——因为「运行级别」这个概念已经不存在了。259 版本把那些 target 恢复了,但要靠 -Dcompat-sysv-interfaces=yes 这个编译选项;命令并没有恢复。[v258][v259]
| SysV | systemd | 说明 |
|---|---|---|
init 3、telinit 3 | systemctl isolate multi-user.target | 258 里移除的是 telinit 命令本身,以及通过 init 切换系统状态的能力;init 仍然存在——它就是 PID 1。 |
init 5 | systemctl isolate graphical.target | |
init 1、telinit S | systemctl isolate rescue.target | emergency.target 比它更靠底层。 |
init 0 / init 6 | systemctl poweroff / systemctl reboot | |
runlevel | systemctl get-default、systemctl list-units --type=target | 命令已被移除;target 不等于运行级别。 |
/etc/inittab 里的默认运行级别 | systemctl set-default multi-user.target | /etc/inittab 已经很多年没被读过了。 |
chkconfig x on、update-rc.d x defaults | systemctl enable x.service | 单元里必须有 [Install] 段。 |
chkconfig x off、update-rc.d -f x remove | systemctl disable x.service | |
| (没有对应命令) | systemctl mask x.service | 让单元即使作为依赖也无法被启动。 |
chkconfig --list、service --status-all | systemctl list-unit-files --type=service | |
service x start | systemctl start x.service | service 往往还以兼容脚本的形式存在,但别依赖它。 |
# v258 removed /dev/initctl and the initctl, runlevel and telinit commands,
# and with them `init 3`. v259 brought the runlevel[0-6].target ALIASES back,
# but only if the distribution builds with -Dcompat-sysv-interfaces=yes, and
# the commands did not come back at all. Do not build habits on them.
# What runlevel am I in? -> what is the system aiming at?
systemctl get-default # the boot target
systemctl list-units --type=target --state=active
# init 3 / telinit 3 ->
sudo systemctl isolate multi-user.target
# init 5 ->
sudo systemctl isolate graphical.target
# init 1 / telinit 1 / S ->
sudo systemctl isolate rescue.target
# init 0 ->
sudo systemctl poweroff
# init 6 ->
sudo systemctl reboot
# Change the default "runlevel" permanently (this replaces /etc/inittab):
sudo systemctl set-default multi-user.target
# chkconfig foo on / update-rc.d foo defaults ->
sudo systemctl enable foo.service
# chkconfig foo off / update-rc.d -f foo remove ->
sudo systemctl disable foo.service
# ...and the one with no SysV equivalent, for a unit that must never start:
sudo systemctl mask foo.service
# chkconfig --list / service --status-all ->
systemctl list-unit-files --type=service
systemctl list-units --type=service --all上表里有一个替代品在 SysV 中根本没有对应物,单凭它自身的价值也值得学会。systemctl mask 让一个单元彻底无法被启动,任何途径都不行,包括作为别的东西的依赖被拉起来——SysV 时代最接近的做法是把脚本删掉,然后祈祷包管理器别把它装回来。要退役一个你还不打算卸载的服务,或者要确保一个已下线的脚本不会被好心的同事复活,它就是正确的工具。[systemctl]
包装层从来给不了你的那些好处
得说句实在话:这次迁移是被迫做的活儿,做完了也换不来一个新功能。所以该拿的补偿一定要拿走,因为它是实打实的,而且是白得的:被包装的 init 脚本一样都用不上。包装层跑的是你的 shell 脚本,你的 shell 脚本再去跑守护进程;systemd 根本不知道那个进程是什么,无法可靠地重启它,更完全约束不了它。而一个原生单元,只要多写几行就能拿到下面这一整套:[exec][resctl]
- 真正的监控重启机制。
Restart=on-failure配RestartSec=,再加上StartLimitIntervalSec=兜底,崩溃循环会自己停下来,而不是把一个 CPU 核心钉死。而生成出来的包装单元,对它产出的每一个脚本都硬编码了Restart=no。 - 文件系统隔离。
ProtectSystem=strict让整个文件系统除你点名的路径之外全部只读,PrivateTmp=yes给服务一个自己的/tmp,而StateDirectory=、RuntimeDirectory=和ConfigurationDirectory=会自动创建、赋权并清理那些你以前得靠手写mkdir -p来对付的目录。 - 真正管得住的权限收缩。
User=、NoNewPrivileges=yes和显式的CapabilityBoundingSet=取代了su和start-stop-daemon --chuid;与后者不同的是,它们对这个服务派生出的每一个进程都永久生效,包括被攻陷的守护进程试图拉起来的那些。 - 系统调用与命名空间限制。
SystemCallFilter=@system-service、RestrictAddressFamilies=、RestrictNamespaces=和MemoryDenyWriteExecute=yes就是一份四行写完的 seccomp 策略。shell 里再怎么折腾也做不到。 - 按服务计量的资源限制。
MemoryMax=、CPUQuota=、TasksMax=和IOWeight=作用于单元自己的 cgroup,也就是说作用于守护进程以及它派生出的一切——这恰恰是基于 PID 文件的脚本永远兜不住的东西。 - 真的落到某个地方的日志。标准输出和标准错误直接进 journal 并带上单元名,于是
journalctl -u就能用,守护进程完全不需要知道 syslog 是什么,也不会因为没人写轮转规则而让一个日志文件悄悄把分区撑满。
不过别闭着眼睛把这些一股脑全加上。对单元跑一次 systemd-analyze security,把分数当成待办清单而不是成绩单:它会逐条点出你还没设的指令,以及设了能换来什么。小步收紧,重启,然后读 journal——过度加固的典型失败方式是:服务启动得漂漂亮亮,三小时后突然打不开某个文件;这比一个干脆起不来的服务要难受得多。[analyze]
验证迁移结果,而不是祈祷
别靠看 systemctl status 是不是绿的来验证。被包装的脚本当年也是绿的。要验证的是那四件真正发生了变化的事:定义来自一份你自己掌控的文件而不是生成物;没有任何 SourcePath 指回 init 脚本;单元是被显式 enable 的,而不是靠遗留的接线被拉起来;以及 systemd 确实知道主进程的 PID。[analyze]
#!/usr/bin/env bash
# Run after the cut-over and again after the first reboot. Non-zero exit means
# something in the migration is not finished.
set -u
rc=0
units=("$@") # e.g. ./verify.sh acme-collector.service local-startup.service
for u in "${units[@]}"; do
echo "--- $u"
# 1. Is it a real file, not a generated one?
frag=$(systemctl show -p FragmentPath --value "$u")
case "$frag" in
/etc/systemd/system/*|/usr/lib/systemd/system/*|/lib/systemd/system/*) ;;
*) echo " FAIL still generated or missing: '$frag'"; rc=1 ;;
esac
# 2. No SourcePath = no init script behind it.
src=$(systemctl show -p SourcePath --value "$u")
[ -z "$src" ] || { echo " FAIL SourcePath=$src"; rc=1; }
# 3. Syntactically valid, with no warnings.
systemd-analyze verify "$frag" || { echo " FAIL verify"; rc=1; }
# 4. Enabled explicitly, not by leftover generator wiring.
[ "$(systemctl is-enabled "$u")" = enabled ] \
|| { echo " FAIL not enabled"; rc=1; }
# 5. Actually supervised: a Type= that forks and loses its child shows
# MainPID=0 while looking perfectly healthy.
mp=$(systemctl show -p MainPID --value "$u")
ty=$(systemctl show -p Type --value "$u")
[ "$ty" = oneshot ] || [ "$mp" != 0 ] \
|| { echo " FAIL Type=$ty but MainPID=0"; rc=1; }
# 6. Report the sandbox score. Not pass/fail - a number to improve.
systemd-analyze security "$u" | tail -1
done
# 7. Nothing anywhere is still being generated from an init script.
if ls /run/systemd/generator.late/*.service >/dev/null 2>&1; then
for g in /run/systemd/generator.late/*.service; do
grep -q '^SourcePath=/etc/init.d/' "$g" && { echo "STILL WRAPPED: $g"; rc=1; }
done
fi
# 8. Nothing failed at boot for an ordering reason you introduced.
systemctl --failed --no-legend --no-pager
exit $rc然后重启,因为这次迁移会引入的整类 bug 都是顺序 bug,而顺序 bug 在一台各项依赖早就起好的运行中系统上是完全看不出来的。对每个转换后的单元读 journalctl -b -u,再对整次开机读一遍 systemd-analyze blame。一个过去挂在编号 20 的符号链接下启动、现在却起得太早的服务,并不会失败——它会不断重试,或者带着一份空配置启动,或者在网卡拿到地址之前就去 bind。
如果你的软件要装到别人的服务器上
如果你分发的软件要装到别人的机器上,那么这件事在一段时间之前就已经不是可选项了。从 255 起,生成器对每一个被包装的脚本都会打一条直接冲着你来的警告:please update package to include a native systemd unit file。到了 systemd 260,这条警告不见了,因为已经没有什么可警告的了——脚本照装不误,然后没有任何东西去运行它。[gensrc]
现在一个软件包必须交付的,是放在 /usr/lib/systemd/system/ 下、带完整 [Install] 段的单元文件。因为被移除的第三个组件 systemd-sysv-install 正是那个钩子:当目标是脚本而非单元时,它让 systemctl enable 转交给 chkconfig 或 update-rc.d 处理。没有它,启用一个服务就完全取决于 [Install] 描述出来的那些符号链接。如果你还要支持较老的发行版,init 脚本可以继续保留在包里——原生单元和脚本是可以共存的,而只要 systemd 的版本足够新,单元就一定赢。[unit]
推荐的执行顺序
这件事没有「晚点做更省事」的版本。工作量正比于脚本数量,而脚本数量不会自己变少;时间窗口则由别人的发布计划决定。唯一有区别的是你能提前多久收到通知,所以第一个决定是:你属于下面哪一种处境。[ubuntu2604]
| 你的处境 | 还剩多少时间 | 该怎么做 |
|---|---|---|
| 滚动发行版,或者已经装上了 systemd 260 | 没有了——东西已经没了 | 从备份或配置管理里做盘点,然后转换。磁盘上的脚本已经不起作用,但仍然能告诉你当初都有什么。 |
| Ubuntu 中间版本(26.04 → 26.10) | 以周计 | 趁 259 还在跑,现在就盘点并抓取生成单元。在 10 月那次发布之前完成转换。 |
| Ubuntu 26.04 LTS,且打算继续留在 LTS | 到下一个 LTS 为止 | 不算火烧眉毛,但 journal 已经在报警了。见缝插针地转,从没人认领的那些开始。 |
| Debian stable、RHEL 9 或 RHEL 10 | 到下一个大版本为止 | 生成器还在,而且一声不吭。盘点还是照做——成本只是一条命令,而且这份清单能一直有用。 |
| 你是把软件打包给别人用的一方 | 没有了 | 现在就交付带 [Install] 段的单元文件。用户什么时候升级不由你说了算。 |
| 你改不了的一体机或厂商 agent | 跟宿主机一样 | 自己写单元并把厂商的脚本 mask 掉,或者赶在他们下一个版本把你晾在半路之前,去找厂商提这个问题。 |
- 本周之内把整个机器群盘点一遍,用上面那条 journal 查询,并把输出保存下来。主机名和脚本路径两列就够用了,足以估算工作量,也足以揪出那些没人还记得的机器。
- 用
systemctl cat把每个脚本对应的生成单元抓下来,存进一个你会长期保留的目录。这是只读操作,几分钟就能跑完,而升级之后这份信息再也拿不回来了。 - 分成三堆:删掉、换掉、重写。你会发现其中相当一部分服务对应的业务早在几年前就下线了。把它删掉本身就是一次完整的迁移,而且完全不花时间。
- 先转最容易的那些——凡是上游本来就有官方打包的,几乎肯定已经提供了官方单元,装一个当前版本的包,比你自己写一份既快又更正确。
- 趁生成器还在的时候做切换,一次一个服务,并且在脚本仍然存在的系统上做。这样回滚就是删掉一个文件再 reload,而不是在压力之下手忙脚乱地还原备份。
- 逐台重启并读一遍开机日志,然后才算完成。最后把已退役的单元 mask 掉,确保没有东西再把它们带回来。
同一个维护窗口里通常还得顺带把剩下这些做掉:Ubuntu 24.04 升级到 26.04 服务器实战 讲的是那次会把生成器从你脚下抽走的发行版升级;systemd timer 与 cron 的取舍 是同一轮清理的另一半,因为一台还留着 SysV 脚本的机器,几乎必然也有一堆本该做成单元的 crontab;迁移到 Docker Engine 29 会踩到什么 则处理容器运行时,它在同一时期出于相关的原因改掉了自己的默认行为。
常见问题
能不能在 systemd 260 上把 sysv-generator 装回来?
不能,而且那些看上去可能奏效的绕行办法,比老老实实做迁移还糟。生成器是从源码树里删掉的,不是用某个开关关掉的,所以既没有包可装,也没有选项可设——剩下的三个已废弃 meson 选项(-Drc-local=、-Dsysvinit-path=、-Dsysvrcnd-path=)只影响路径,不影响代码是否存在。理论上你可以把 259 构建里的生成器二进制拷进 /usr/lib/systemd/system-generators/,但那等于让一个无人维护的生成器去配一个从未与它一起测试过的管理器,而且下一次 systemd 更新会不声不响地把它覆盖掉。写一份单元文件花的时间,比调试这种问题一次还少。
我手动执行 init 脚本还能用吗?
能用。脚本本身什么都没变,它就是一个普通的 shell 脚本,/etc/init.d/x start 会做它一直以来做的事。消失的是自动翻译成服务这一步,也就意味着:开机不会启动它,systemctl 不认识它,没有任何东西监控或重启这个进程,关机时也不会干净地把它停掉。这个组合比听上去更糟,因为一个手动还能跑的脚本让人感觉不出坏了——于是大家用一条 @reboot 的 crontab 绕过去,问题就这样安静一年。
不逐台登录,怎么知道哪些服务器受影响?
去 journal 里查那条结构化警告。从 systemd 255 起,每个被包装的脚本都会产生一条带 MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb 的日志,以及 SYSVSCRIPT= 和 UNIT= 两个自定义字段。如果你有集中式日志,这就是一条覆盖整个机器群的查询。如果没有,就用你手上任何能批量执行命令的工具跑 journalctl -b MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb。在 systemd 254 及更早的版本上,包装过程是静默的,那就退回到逐个查询单元的 FragmentPath 和 SourcePath,也就是上面那段盘点脚本的做法。
Type=simple、Type=exec、Type=forking 和 Type=notify 有什么区别?
Type=simple 只要 systemd 完成 fork 就认为服务已经启动,此时二进制文件未必已经成功执行。Type=exec 会等到二进制确实被执行之后才算数,于是文件缺失或者 User= 写错会被判定为启动失败,而不是变成一次莫名其妙的瞬间退出——对前台守护进程来说,它是更好的默认值。Type=forking 是给那些自己转入后台的守护进程用的,需要配合 PIDFile=;sysv-generator 用的就是它,因为它没得选。Type=notify 会等守护进程调用 sd_notify(READY=1),四者之中只有它能让 After= 依赖真正意味着服务已就绪。转换时用 Type=exec;守护进程支持的话就用 Type=notify。
/etc/rc.local 是真没了,还是只是被废弃?
在 systemd 260 及以后是真没了。systemd-rc-local-generator 以及它拉起来的 rc-local.service 单元都被移除了。那个文件还会好端端躺在 /etc 里,可执行位也还在,只是永远不会有人调用它。另外注意,这个路径是编译期设置,各发行版并不一致——有些用的是 /etc/rc.d/rc.local——所以在你断定已经全部找齐之前,先确认你这套生成器当初到底配的是哪个路径。
单元显示 active (running),可进程根本不存在,我哪里做错了?
基本可以断定是 Type=forking 配了一个 systemd 在关键时刻读不到的 PID 文件,或者是给一个会 fork 的守护进程写了 Type=simple。执行 systemctl show -p Type -p MainPID -p PIDFile --value yourunit.service:如果守护进程在跑而 MainPID 是 0,说明 systemd 已经跟丢了它,所有监管机制都形同虚设。正确的修法是让守护进程别再 fork——找到它的前台运行参数,改用 Type=exec。如果实在做不到,那就确保 PIDFile= 是 /run 下的绝对路径,并且守护进程在父进程退出之前就把它写好。
写完单元之后,init 脚本还要留着吗?
在切换窗口期内留着,之后删掉。两者并存时原生单元一定赢——生成器会明确跳过任何已经有同名单元的脚本——所以并存是安全的,而且让回滚变得非常轻松。但如果永久留着,你就有了同一个服务的两份定义,其中一份会成为凌晨三点排查问题那个人的陷阱。等这台主机干净地重启过一次之后,把脚本挪到一个「已退役」目录,同时把 /etc/rc*.d 下的符号链接一并清掉。
我用的是 RHEL 或 Debian stable,需要现在担心吗?
不急,但盘点还是要做。RHEL 10 和 Debian 13 带的都是 systemd 257,所以生成器都还在,而且两者都已经在打 255 引入的逐脚本废弃警告——Red Hat 自己的发布说明也写明 System V 服务脚本支持已废弃并将被移除。变更会随各自的下一个大版本到来。现在就动手的理由是:盘点这一步只是一条只读命令,趁生成器还在运行、还在主动告诉你它包装了什么的时候做要容易得多;而另一种可能,是在大版本升级过程中撞见一个没人认领的脚本——那是逆向分析同事写的 shell 最糟糕的时机。
systemd-sysv-install 由什么来替代?
没有替代品,因为已经没有事情留给它做了。它是这样一个钩子:当 systemctl enable、disable 和 is-enabled 的目标是一个脚本而非单元时,把操作转交给发行版自己的工具——Red Hat 系上是 chkconfig,Debian 系上是 update-rc.d。既然脚本支持已被移除,启用一个服务就完全取决于单元 [Install] 段所描述的那些符号链接。如果你的软件包一直依赖这条转交路径,那它现在需要一份真正的单元文件和一个真正的 [Install] 段。
能不能自动转换脚本,而不是一行行手写?
能转一部分,而且最好的转换工具恰恰是即将被删掉的那个:在生成器还在的系统上执行 systemctl cat yourservice.service,你会得到一份机械上完全正确的依赖关系翻译,而依赖关系正是最容易写错的部分。任何工具都替不了你做的,是真正要紧的那部分判断——决定让守护进程不再 fork、决定让 PID 文件消失、决定把 start-stop-daemon --chuid 换成 User=,以及判断哪些沙箱指令对这个特定负载是安全的。把生成出来的单元当作规格说明书,照着它写出真正的那一份。
资源控制层在同一批版本里也变了:systemd 258 直接删除了 cgroup v1,于是每台主机现在都会以统一层级启动,无论有没有人要求。从 cgroup v1 迁移到 cgroup v2讲的是盘点方法、逐个文件的转换表,以及那两处改变的是语义而不是拼写的转换。
参考来源
本文中的每一个版本号、每一条指令和每一项默认值,都是从下列来源直接读出来的,而不是从别人的报道里转述的。凡是相关代码此后已被删除的,链接指向它最后存在的那个 tag,你可以自己去核对。
- systemd — NEWS: the upstream changelog, and the only authoritative statement of what was removed in which release. Everything dated in this article was checked against it
- systemd v260 release notes — "Support for System V service scripts has been removed", with the three components that went with it: systemd-sysv-generator, systemd-rc-local-generator plus rc-local.service, and systemd-sysv-install
- systemd v259 release notes — the release that restored runlevel[0-6].target behind the new -Dcompat-sysv-interfaces=yes build option, and the version shipped by Ubuntu 26.04 LTS
- systemd v258 release notes — the removal of /dev/initctl and of the initctl, runlevel and telinit commands, the removal of the runlevel[0-6].target units, and the removal of cgroup v1
- systemd v255 release notes — where support for System V service scripts was first declared deprecated, and the release whose sysv-generator already logs the structured per-script warning this article uses for the inventory
- systemd-sysv-generator source at v259 — the last tag where the code still exists. It is the definitive answer to what the generator read, what it ignored, and exactly which directives it wrote
- systemd-sysv-generator(8) — the manual page, including the statement that the wrapper units are always ordered after basic.target and that compatibility was never 100%
- systemd-rc-local-generator(8) — the rc.local compatibility generator, its warning that rc-local.service is ordered after network.target and that this does not mean the network works, and the SELinux note about restorecon
- rc-local.service at v259 — the actual unit that ran /etc/rc.local, so you can see what semantics you are replacing rather than guessing them
- systemd exit-status.h — the LSB start-verb exit codes, and the origin of the SuccessExitStatus=5 6 line the generator emitted for scripts with an LSB header
- systemd sd-messages.h — the catalogue of structured log message IDs, including SD_MESSAGE_SYSV_GENERATOR_DEPRECATED, which is what makes the journal-based inventory in this article possible
- systemd — Incompatibilities: the upstream list of the places where SysV behaviour and systemd behaviour genuinely differ, worth reading before you assume a script will behave the same way
- Linux Standard Base — Init Script Actions: the specification that defines the LSB header fields and the exit codes an init script is supposed to return
- systemd.service(5) — Type=, Restart=, PIDFile=, ExecReload=, RemainAfterExit= and the rest of the service options used in the unit files below
- systemd.unit(5) — Wants=, After=, Before=, the Condition family and the [Install] section that replaces update-rc.d and chkconfig
- systemd.exec(5) — User=, RuntimeDirectory=, StateDirectory= and the whole sandboxing vocabulary, none of which a wrapped init script could ever use
- systemd.special(7) — what multi-user.target, graphical.target, network.target and network-online.target actually mean, and why After=network.target does not mean the network is up
- systemd.generator(7) — how generators work and where their output lands, which is why the units you are about to lose live under /run/systemd/generator.late
- systemctl(1) — enable, mask, cat, show, list-unit-files and isolate: the commands that replace chkconfig, update-rc.d, service and telinit
- systemd-analyze(1) — verify, security and blame: the three subcommands that turn this migration from a guess into something you can check
- journalctl(1) — matching on MESSAGE_ID= and on arbitrary structured fields, which is how the inventory command in this article finds every wrapped script
- daemon(7) — the difference between a SysV-style forking daemon and a new-style daemon, and upstream's own recommendation to stop forking
- systemd.resource-control(5) — the per-unit cgroup accounting and limits that come for free once a service is a real unit
- Ubuntu 26.04 LTS release notes — the section stating that 26.04 LTS is the last release with System V compatibility in systemd, that the change takes effect in 26.10, and that the release ships systemd 259
- Ubuntu 26.10 release schedule — the release date that turns "eventually" into a deadline for anyone tracking the interim series
- Red Hat Enterprise Linux 10 release notes — the systemd rebase to 257, the statement that System V service script support is deprecated and will be removed, and the move of default configuration files under /usr/lib/systemd
- Debian trixie systemd package — the version currently in Debian stable, for readers deciding how much runway they actually have
这篇有帮助吗?