systemd 260 удалил SysV. Что делать?
systemd 260 выкинул sysv-generator, rc-local.service и systemd-sysv-install. У каждого скрипта в /etc/init.d и у каждого /etc/rc.local на ваших серверах теперь есть срок. Разбираем, как их найти и как перевести в юниты по-человечески.
- systemd
- Linux
- Системное администрирование
- Миграция
Пятнадцать лет подряд systemd тихо переводил ваши скрипты из /etc/init.d в службы при каждой загрузке и ни разу не попросил обратить на это внимание. В версии 260, вышедшей в марте 2026 года, он перестал. systemd-sysv-generator, systemd-rc-local-generator вместе с rc-local.service и хук systemd-sysv-install, стоявший за systemctl enable, удалены полностью. В самих скриптах не изменилось ничего. Изменилось то, что их больше никто не читает.
![Схема из двух панелей. Слева панель «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)
Это разбор миграции, написанный для того, кому её делать на живых серверах, а не любоваться со стороны: как собрать инвентаризацию всех обёрнутых скриптов по всему парку через поле журнала, о существовании которого почти никто не знает; что именно писал генератор, чтобы это скопировать, а не угадывать; построчный перевод реального init-скрипта; ошибка в Type=, из-за которой юнит рапортует об успехе, пока демон мёртв; и команды, пришедшие на смену init 3. Каждый номер версии и каждая директива здесь сверены с файлом NEWS systemd и с исходным кодом на тех тегах, где удалённый код ещё существовал, а не по памяти.
Юнит не упал. Его просто нет
Симптом сбивает с толку сильнее обычного, потому что это отсутствие, а не ошибка. Служба не показывается как failed. Она не показывается вообще. systemctl status отвечает, что юнит не найден, тогда как скрипт лежит на диске, исполняемый, не менявшийся с 2015 года. Это корректное поведение: скрипт никогда и не был юнитом. Генератор изготавливал юнит в /run/systemd/generator.late при каждой загрузке, и теперь этот каталог пуст.[v260]
| Что вы видите | Что это значит | Где разбирается |
|---|---|---|
Unit x.service could not be found, при том что /etc/init.d/x существует и исполняемый | Генератор, который раньше изобретал этот юнит, удалён. Ничего не сломано; просто больше нечему транслировать. | Что писал генератор |
| Служба, стартовавшая годами при каждой загрузке, просто не стартует, и в журнале ничего нет | Та же причина, вид со стороны загрузки, а не командной строки. Упавшего юнита нет, потому что юнита нет вообще. | Переводим один скрипт |
/etc/rc.local по-прежнему исполняемый и больше не запускается | systemd-rc-local-generator и rc-local.service удалены вместе с SysV-генератором. | Замена /etc/rc.local |
systemctl enable для старой службы больше не делает ничего полезного | systemd-sysv-install — хук, который перенаправлял enable/disable в chkconfig или update-rc.d, — исчез. | Что обязан везти пакет |
telinit: command not found, или init 3 ничего не делает | Удалено в 258, на две версии раньше и по другой причине: выброшено само понятие уровней исполнения, а не только скрипты. | init 3, telinit и runlevel |
| Переведённый юнит показывает active, но процесса нет — либо показывает failed, пока демон работает | Неверный 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]
Что удалили и когда именно
В деле замешаны несколько разных релизов, и путаница между ними — причина, по которой большая часть советов на эту тему неверна. Сервисные скрипты System V были объявлены устаревшими ещё в версии 255. Версия 258 в сентябре 2025 года сделала другое: она удалила интерфейсы состояния системы System V — /dev/initctl, команды initctl, runlevel и telinit, управление состоянием через init 3, юниты runlevel[0-6].target и запись переходов между уровнями исполнения в utmp/wtmp. Версия 259 частично отыграла назад одно из этих решений. Версия 260 удалила уже сами сервисные скрипты.[news][v255][v258]
| Релиз | Дата | Что произошло |
|---|---|---|
| 258 | сентябрь 2025 | Release notes повторяют, что сервисные скрипты System V устарели (это было объявлено ещё в 255), и на тот момент удаление всё ещё планировалось на 259 — позже его перенесли на 260. Отдельно и немедленно удалены: /dev/initctl, команды initctl, runlevel и telinit, смена состояния в стиле init 3 и юниты runlevel[0-6].target; переходы между уровнями исполнения перестают записываться в utmp/wtmp. В том же релизе выброшена поддержка cgroup v1. |
| 259 | декабрь 2025 | runlevel[0-6].target возвращены, но только если дистрибутив собран с -Dcompat-sysv-interfaces=yes. Удалённые команды не возвращаются. Именно эта версия едет в Ubuntu 26.04 LTS. |
| 260 | март 2026 | Поддержка сервисных скриптов 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 | Работает и то, и другое. Предупреждений по каждому скрипту в журнале нет. |
| Debian 13 trixie | 257 | Работает и то, и другое, но журнал уже предупреждает по каждому обёрнутому скрипту. |
| Ubuntu 24.04 LTS | 255 | Работает и то, и другое, но журнал уже предупреждает по каждому обёрнутому скрипту. |
| Ubuntu 26.04 LTS | 259 | Работает и то, и другое; журнал предупреждает по каждому обёрнутому скрипту — как и во всех выпусках начиная с 24.04. Canonical заявляет, что это последняя Ubuntu с совместимостью с SysV. |
| Ubuntu 26.10 | 260 и новее | Удалено. Скрипты остаются на диске, и их никто не читает. |
| RHEL 9 / RHEL 10 | 252 / 257 | Работает и то, и другое до конца жизненного цикла выпуска. RHEL 9 молчит; RHEL 10 предупреждает по каждому обёрнутому скрипту, и его release notes уже содержат отметку об устаревании. Изменение приедет со следующей мажорной версией. |
| Rolling-дистрибутивы | 260 и новее | Уже удалено. Проверяйте через systemctl --version, а не по предположению. |
Даты переводят разговор из теоретического в конкретный. В собственных release notes Canonical сказано, что Ubuntu 26.04 LTS — которая везёт systemd 259 — это последний выпуск Ubuntu с совместимостью с System V, и что изменение вступает в силу в 26.10. Этот релиз запланирован на октябрь 2026 года. Если вы сидите на промежуточных выпусках или у вас есть что-то на rolling-дистрибутиве, срок измеряется неделями, а не годами. Если вы на LTS или на корпоративном дистрибутиве, у вас есть время до следующего мажорного обновления — то есть ровно до того момента, когда меньше всего хочется обнаруживать бесхозные шелл-скрипты.[ubuntu2604][ubuntu2610][rhel10]
Найдите все скрипты раньше, чем их найдёт обновление
Инвентаризацию делайте первой и делайте, пока генератор ещё работает, потому что ответ вам даст сам генератор. Начиная с версии 255, каждый раз, когда systemd оборачивает скрипт, он пишет структурированное предупреждение со стабильным message ID и двумя собственными полями журнала: 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 года, — это никогда не те, о которых знает репозиторий системы управления конфигурацией. Это разовая поделка подрядчика, агент вендорского устройства (appliance), обёртка вокруг бэкапа, которая старше нынешней команды. Именно эти хосты никому не приходит в голову проверить, и именно эти службы обнаруживают пропавшими через неделю.
Сразу оговорка: шаг 1 читает только журнал текущей загрузки, поэтому хост, у которого предупреждение уже вытеснено ротацией или который не перезагружался после обновления, ответит пустотой — его всё равно придётся проверять шагом 2. Дальше — две детали этого запроса, которые стоит запомнить. systemd оборачивал только исполняемые обычные файлы в каталоге init.d, так что скрипт, оставленный с правами 644, был мёртв уже тогда, и воскрешать его не надо. И нативный юнит всегда побеждал одноимённый скрипт: генератор явно пропускал любой скрипт, для которого уже существовал настоящий юнит. Именно второе правило делает всю миграцию безопасной для пошагового выполнения — вы можете поставить новый юнит, пока скрипт ещё лежит на месте и всё ещё включён, и скрипт просто перестанет участвовать в деле.[gensrc]
Что генератор молча писал за вас
Прежде чем писать хоть один юнит, возьмите тот, что производит генератор, и прочитайте его. Это не грубое приближение, а детерминированный перевод, и это самое близкое к спецификации описание того, каким на самом деле было поведение вашего скрипта при загрузке. Заберите его через systemctl cat, пока есть возможность: после обновления эта информация исчезает, а восстановление её из LSB-заголовка — как раз то место, где люди вносят ошибки в порядок запуска.[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.Теперь то, что удивляет большинство и что можно проверить прямо в исходниках генератора. Default-Start: и Default-Stop: в LSB-заголовке никогда не читались. Генератор разбирает только Provides:, Required-Start:, Should-Start:, X-Start-Before:, X-Start-After:, два поля с описанием и комментарии в стиле Red Hat # pidfile: и # description:. Привязка к уровням исполнения целиком бралась из симлинков S??name в /etc/rc[1-5].d. Если вы десять лет поддерживали в актуальном состоянии Default-Start, вы десять лет поддерживали комментарий.[lsb][exitstatus]
| В init-скрипте | Что с этим делал генератор | Что писать в вашем юните |
|---|---|---|
Provides: name | Если названа служба — симлинк-алиас. Если назван $facility — Before= плюс Wants= на эту цель, в одну сторону. Если повторяет имя файла — игнорируется. | Alias= в [Install] либо ничего |
Required-Start: $network | After=network-online.target и Wants=network-online.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: | Ровно ничего. Никогда не разбиралось. Привязка к уровням исполнения бралась из симлинков S?? в /etc/rc[1-5].d. | WantedBy=multi-user.target в [Install] |
Симлинк в /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 |
Два соответствия из этой таблицы заслуживают отдельного взгляда, потому что именно они обычно стоят за фразой «под SysV работало, а теперь гонки». $network превращался не в network.target, а в network-online.target, причём генератор добавлял и After=, и Wants=, потому что эта цель ничего не делает, пока её кто-нибудь активно не подтянет. А вот $local_fs и $syslog не превращались ни во что — их выбрасывали, и вполне обоснованно: под systemd обе гарантированно выполнены к моменту старта любой обычной службы. Перенесите эти решения в свой юнит и не изобретайте новых.[special]
Переводим один скрипт, поле за полем
Вот реалистичный скрипт — не игрушечный. В нём есть LSB-заголовок, комментарий с PID-файлом в стиле Red Hat, вызов 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А вот то, что приходит ему на смену. Читайте комментарии, а не директивы: интересны здесь решения о том, что не переносить. Уходит форкание. Уходит ручной 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 и собственный foreground-флаг демона | В этом весь смысл. Ничего не уводите в фон. |
--make-pidfile, echo $! > … | удалить | systemd знает 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 в скрипте применялся к шеллу, а до демона иногда не доходил. |
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 | удалить | Вывод уходит в журнал с меткой юнита. Ротацию писать не нужно. |
sleep 5; check_if_up | Type=notify в демоне или health-check отдельным юнитом | sleep в глаголе start — это гонка с удлинённым фитилём. |
restart) stop; sleep 1; start | удалить | systemctl restart существует и ждёт как положено. |
Блок status) | удалить | systemctl status и is-active показывают реальное состояние. |
trap / уборка при остановке | ExecStop=, а лучше — обработка SIGTERM в самом демоне | TimeoutStopSec= ограничивает ожидание до SIGKILL. |
Общая таблица соответствий — для идиом, которые вам действительно попадутся. Там, где в строке написано «удалить», это не упрощение: поведение обеспечивает менеджер, а переизобретение его на шелле — верный способ получить две сущности, дерущиеся за один процесс.[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 и подтягивался только в том случае, если файл был исполняемым. Апстримный man специально оговаривал, что такой порядок не означает работоспособной сети и что вся конструкция существует ради совместимости с конкретными системами 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: он превращает зависшую команду в загрузку, которая никогда не заканчивается, — молча и без единого упавшего юнита, на который можно показать пальцем. И не оставляйте всё в одном скрипте. Обычный rc.local — это четыре несвязанные вещи, сложенные вместе только потому, что крючок был ровно один: sysctl, монтирование, правило файрвола и нечто, что давно должно было стать демоном. Разнесённые по отдельным юнитам, они упорядочиваются, перезапускаются и отключаются независимо, и когда одна из них ломается, вы знаете, какая именно.[special]
Type=forking, PID-файлы и демоны, которые вам мешают
Вот ошибка, которая стоит одной ночи, и она о себе не объявляет. Если демон форкается, а в юните написано Type=simple, systemd отслеживает родителя, видит его выход через миллисекунды и дальше либо убивает выживших детей как беспризорных, либо объявляет старт успешным, хотя не работает ничего. Если демон форкается, в юните стоит Type=forking, но PID-файл появляется с опозданием, systemd теряет главный процесс и показывает MainPID=0: юнит выглядит активным, 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-файл, а отказ от форкания. Практически у любого демона, написанного за последние двадцать лет, есть флаг, чтобы остаться на переднем плане, и апстримные рекомендации для демонов нового образца велят его использовать. Type=exec после этого снимает весь класс проблем, а Type=notify (а также notify-reload и Type=dbus для служб D-Bus) идёт дальше: это единственный надёжный способ добиться того, чтобы After= у зависимого юнита означал «сокет действительно принимает соединения», а не «бинарник был запущен». Всё остальное — упорядочивание с надеждой.[incompat]
init 3, telinit и runlevel тоже исчезли
Это та часть изменений, которая выходит за пределы серверов — в мышечную память и в runbook'и. Версия 258 удалила устройство /dev/initctl и команды initctl, runlevel и telinit, убрала поддержку смены состояния через init 3, удалила юниты runlevel[0-6].target и перестала записывать переходы между уровнями исполнения в utmp и wtmp — потому что понятия уровня исполнения больше не существует. Версия 259 вернула цели за опцией сборки -Dcompat-sysv-interfaces=yes; команды она не вернула.[v258][v259]
| SysV | systemd | Примечание |
|---|---|---|
init 3, telinit 3 | systemctl isolate multi-user.target | В 258 удалён telinit; сам 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 | Команда удалена; цель — это не уровень исполнения. |
Уровень по умолчанию в /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-скрипту не доставалось из неё ничего. Обёртка запускала ваш шелл-скрипт, а ваш шелл-скрипт запускал демона; systemd не имел понятия, что это за процесс, не мог надёжно его перезапустить и не мог ограничить его вообще никак. Нативный юнит получает всё перечисленное добавлением строк:[exec][resctl]
- Настоящий надзор за процессом.
Restart=on-failureвместе сRestartSec=и подстраховкойStartLimitIntervalSec=, чтобы цикл падений останавливался, а не занимал процессорное ядро наглухо. Сгенерированная обёртка жёстко прописывала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-политика, которую вы пишете в четыре строки. На шелле такого не было ни за какие деньги. - Учёт ресурсов по каждой службе.
MemoryMax=,CPUQuota=,TasksMax=иIOWeight=применяются к cgroup юнита, то есть к демону и ко всему, что он форкает, — ровно то, что скрипт на PID-файле не мог удержать никогда. - Логи, которые куда-то доходят. Стандартный вывод и стандартный поток ошибок уходят в журнал с прикреплённым именем юнита, так что
journalctl -uработает независимо от того, знает ли демон про syslog, и без лог-файла, который молча забивает раздел, потому что правило ротации никто не написал.
Только не добавляйте всё это вслепую. Прогоните systemd-analyze security по юниту и относитесь к оценке как к списку задач, а не как к отметке в дневнике: она называет каждую невыставленную директиву и то, что та даёт. Закручивайте гайки маленькими шагами, перезапускайте и читайте журнал — типичный отказ от перезатянутой изоляции выглядит как служба, которая прекрасно стартует, а через три часа не может открыть файл. Разбираться с этим куда неприятнее, чем со службой, которая честно отказывается запускаться сразу.[analyze]
Проверять миграцию, а не надеяться на неё
Не проверяйте миграцию тем, что посмотрели в systemctl status и увидели зелёное. Обёрнутый скрипт тоже показывал зелёное. Проверяйте четыре вещи, которые действительно изменились: что определение — это файл, который контролируете вы, а не сгенерированный; что ни один SourcePath не указывает обратно на init-скрипт; что юнит включён явно, а не остатками старой обвязки; и что 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Потом перезагружайтесь, потому что весь класс ошибок, который вносит эта миграция, — это ошибки порядка запуска, а они не видны на работающей системе, где все зависимости уже подняты. Прочитайте journalctl -b -u по каждому переведённому юниту и systemd-analyze blame по загрузке целиком. Служба, которая раньше стартовала по нумерованному симлинку на позиции 20, а теперь стартует слишком рано, не упадёт — она будет ретраить, или поднимется с пустой конфигурацией, или забиндится раньше, чем у интерфейса появится адрес.
Если вы поставляете софт на чужие серверы
Если вы распространяете софт, который ставят другие люди, для вас это перестало быть необязательным довольно давно. Генератор с версии 255 писал предупреждение по каждому обёрнутому скрипту, адресованное прямо вам: please update package to include a native systemd unit file. На systemd 260 этого предупреждения больше нет, потому что предупреждать не о чем — скрипт устанавливается, и после этого его никто не запускает.[gensrc]
Пакет теперь обязан везти unit-файл в /usr/lib/systemd/system/ с нормальной секцией [Install], потому что третий удалённый компонент, systemd-sysv-install, был тем самым хуком, который позволял systemctl enable делегировать вызов chkconfig или update-rc.d для служб на скриптах. Без него включение службы — это исключительно вопрос симлинков, описанных в [Install]. Оставьте init-скрипт в пакете, если вы всё ещё поддерживаете старые дистрибутивы: нативный юнит и скрипт спокойно сосуществуют, и юнит побеждает везде, где systemd достаточно свежий, чтобы это имело значение.[unit]
В каком порядке это делать
Варианта, в котором отложить дешевле, не существует. Объём работы пропорционален количеству скриптов, а оно не уменьшается само, и окно определяется чужим графиком релизов. Меняется только то, сколько времени вам дали, поэтому первое решение — понять, в какой из этих ситуаций вы находитесь:[ubuntu2604]
| Ситуация | Сколько у вас времени | Что делать |
|---|---|---|
| Rolling-дистрибутив или systemd 260 уже установлен | Нисколько — всё уже удалено | Инвентаризация по бэкапам или системе управления конфигурацией, затем конвертация. Скрипты на диске мертвы, но всё ещё говорят, что у вас было. |
| Промежуточные выпуски Ubuntu (26.04 → 26.10) | Недели | Соберите инвентаризацию и заберите сгенерированные юниты сейчас, пока работает 259. Переведите всё до октябрьского релиза. |
| Ubuntu 26.04 LTS, остаётесь на LTS | До следующей LTS | Пожара нет, но журнал уже предупреждает. Переводите по мере возможности, начиная с бесхозного. |
| Debian stable, RHEL 9 или 10 | До следующей мажорной версии | Генератор на месте; на всём, что новее systemd 255 (Debian 13, RHEL 10), он уже предупреждает по каждому скрипту. Инвентаризацию всё равно сделайте — она стоит одной команды и хорошо хранится. |
| Вы собираете пакеты для других | Нисколько | Кладите в пакет unit-файл с секцией [Install] уже сейчас. Обновления ваших пользователей вам не подконтрольны. |
| Аппаратное или вендорское устройство (appliance), которое нельзя менять | Столько же, сколько у хоста | Напишите юнит сами и замаскируйте вендорский скрипт — или поднимите вопрос с вендором до того, как его следующий релиз оставит вас без службы. |
- Проведите инвентаризацию по всему парку на этой неделе запросом к журналу выше и сохраните вывод. Двух колонок — имя хоста и путь к скрипту — достаточно, чтобы оценить объём работы и поймать машины, о которых никто не помнит.
- Заберите сгенерированный юнит для каждого скрипта через
systemctl catв каталог, который останется у вас. Операция только на чтение, занимает минуты, а после обновления информацию уже не восстановить. - Разложите на три кучи: удалить, заменить, переписать. Удивительно большая доля найденного — служба чего-то, что вывели из эксплуатации несколько лет назад. Её удаление — это полноценная миграция, и она не занимает времени вообще.
- Начните с лёгких — у всего, что уже собрано в апстримный пакет, почти наверняка есть официальный юнит, и поставить актуальный пакет быстрее и правильнее, чем писать юнит самому.
- Переключайтесь, пока генератор ещё существует, по одной службе за раз, на системе, где скрипт всё ещё лежит на месте. Откат тогда — удалить один файл и сделать reload, а не восстанавливать бэкап под давлением.
- Перезагрузитесь и прочитайте загрузку на каждом хосте, прежде чем считать что-либо готовым. Потом замаскируйте списанные юниты, чтобы их ничто не вернуло.
В то же самое окно обслуживания обычно помещается и всё остальное: обновление сервера с Ubuntu 24.04 до 26.04 разбирает обновление дистрибутива, которое и вынесет генератор у вас из-под ног, таймеры systemd против cron — это вторая половина той же уборки, потому что на машине с SysV-скриптами почти всегда есть кронтабы, которым тоже пора стать юнитами, а что ломается при переходе на Docker Engine 29 посвящён контейнерному рантайму, который сменил свои умолчания в тот же период и по смежным причинам.
Частые вопросы
Можно ли просто поставить sysv-generator обратно на systemd 260?
Нет, и обходные пути, которые на вид могли бы сработать, хуже самой миграции. Генератор удалён из дерева исходников, а не спрятан за флагом, поэтому ставить нечего и включать нечем — три оставшиеся устаревшие опции meson (-Drc-local=, -Dsysvinit-path=, -Dsysvrcnd-path=) влияют только на пути, а не на наличие кода. Теоретически можно скопировать бинарник генератора из сборки 259 в /usr/lib/systemd/system-generators/, но вы получите неподдерживаемый генератор рядом с менеджером, с которым его никогда не тестировали, и следующее обновление systemd молча его затрёт. Написать unit-файл быстрее, чем один раз это отладить.
Скрипт из init.d всё ещё работает, если запустить его руками?
Да. В самом скрипте ничего не изменилось; это обычный шелл-скрипт, и /etc/init.d/x start сделает ровно то, что делал всегда. Исчезла автоматическая трансляция в службу, а это значит: он не стартует при загрузке, systemctl о нём не знает, никто не следит за процессом и не перезапускает его, а при выключении машины он не остановится корректно. Сочетание хуже, чем звучит, потому что скрипт, который по-прежнему запускается руками, не ощущается сломанным — и его обходят записью @reboot в кронтабе, после чего проблема замолкает на год.
Как понять, какие из моих серверов затронуты, не заходя на каждый?
Спросите журнал про структурированное предупреждение. Начиная с 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 с PID-файлом, который systemd не смог прочитать в нужный момент, либо Type=simple на демоне, который форкается. Выполните systemctl show -p Type -p MainPID -p PIDFile --value yourunit.service: если MainPID равен 0, а демон при этом работает, systemd его потерял и ни один механизм надзора не действует. Правильное лечение — заставить демона не форкаться: найдите его foreground-флаг и поставьте Type=exec. Если это совсем невозможно, убедитесь, что PIDFile= — абсолютный путь внутри /run и что демон пишет файл до выхода родительского процесса.
Стоит ли оставлять init-скрипт после того, как написан юнит?
Оставьте на время переключения, потом уберите. Пока существуют оба, побеждает нативный юнит — генератор явно пропускал любой скрипт, для которого уже был одноимённый юнит, — так что держать оба безопасно, а откат становится тривиальным. Но если оставить скрипт навсегда, у вас будет два определения одной службы, и одно из них — ловушка для того, кто будет разбираться с этим в три часа ночи. Перенесите его в каталог для списанного, как только хост чисто перезагрузится, и заодно уберите симлинки в /etc/rc*.d.
Нужно ли об этом беспокоиться на RHEL или Debian stable?
Срочно — нет, но инвентаризацию всё равно сделайте. RHEL 10 и Debian 13 везут systemd 257, так что генератор есть у обоих — и оба уже печатают пришедшее в 255 предупреждение об устаревании по каждому обёрнутому скрипту, а в собственных release notes Red Hat вдобавок сказано, что поддержка сервисных скриптов System V устарела и будет удалена. Изменение приедет со следующей мажорной версией каждого из них. Действовать сейчас стоит потому, что шаг инвентаризации — это одна команда только на чтение, делать её несравнимо проще, пока генератор работает и сам рассказывает, что он оборачивает, а альтернатива — обнаружить бесхозный скрипт посреди мажорного обновления, то есть в худшее из возможных времён для реверс-инжиниринга чужого шелла.
Что пришло на смену systemd-sysv-install?
Ничего, потому что делать ему больше нечего. Это был хук, позволявший systemctl enable, disable и is-enabled делегировать вызов собственному инструменту дистрибутива — chkconfig в системах Red Hat, update-rc.d в Debian, — когда названная служба была скриптом, а не юнитом. С удалением поддержки скриптов включение службы целиком сводится к симлинкам, описанным секцией [Install] юнита. Если ваш пакет полагался на это делегирование, ему теперь нужен настоящий unit-файл с настоящей секцией [Install].
Можно ли сконвертировать скрипт автоматически, а не руками?
Частично, и лучший конвертер — тот, который вот-вот удалят: systemctl cat yourservice.service на системе, где генератор ещё есть, выдаёт механически корректный перевод зависимостей, а это как раз та часть, в которой легко ошибиться. Чего не сделает за вас ни один инструмент — самого важного: решить, что демон должен перестать форкаться, что PID-файл должен исчезнуть, что start-stop-daemon --chuid превращается в User=, и какие директивы песочницы безопасны именно для этой нагрузки. Считайте сгенерированный юнит спецификацией и пишите по ней настоящий.
Слой управления ресурсами сдвинулся в тех же релизах: systemd 258 полностью удалил cgroup v1, поэтому теперь любой хост загружается в единой иерархии независимо от чьих-либо пожеланий. переход с cgroup v1 на cgroup v2 — это аудит, пофайловая таблица соответствий и два перевода, которые меняют поведение, а не только название.
Источники
Каждый номер версии, каждая директива и каждое значение по умолчанию в этой статье прочитаны из источников ниже, а не переписаны из чужих публикаций. Там, где код с тех пор удалён, ссылка ведёт на последний тег, где он ещё существовал, — чтобы вы могли проверить сами.
- 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
Было полезно?