Перейти к содержимому
← Блог

systemd 260 удалил SysV. Что делать?

systemd 260 выкинул sysv-generator, rc-local.service и systemd-sysv-install. У каждого скрипта в /etc/init.d и у каждого /etc/rc.local на ваших серверах теперь есть срок. Разбираем, как их найти и как перевести в юниты по-человечески.

·18 мин чтения
  • 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.
Три компонента, удалённые в одном релизе. Сама миграция несложная — сложно другое: понять, какие скрипты у вас ещё остались и на каких машинах, до того как об этом сообщит автоматическое обновление.

Это разбор миграции, написанный для того, кому её делать на живых серверах, а не любоваться со стороны: как собрать инвентаризацию всех обёрнутых скриптов по всему парку через поле журнала, о существовании которого почти никто не знает; что именно писал генератор, чтобы это скопировать, а не угадывать; построчный перевод реального 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сентябрь 2025Release notes повторяют, что сервисные скрипты System V устарели (это было объявлено ещё в 255), и на тот момент удаление всё ещё планировалось на 259 — позже его перенесли на 260. Отдельно и немедленно удалены: /dev/initctl, команды initctl, runlevel и telinit, смена состояния в стиле init 3 и юниты runlevel[0-6].target; переходы между уровнями исполнения перестают записываться в utmp/wtmp. В том же релизе выброшена поддержка cgroup v1.
259декабрь 2025runlevel[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]

Где вы находитесьsystemdSysV-скрипты и rc.local
Debian 12 bookworm252Работает и то, и другое. Предупреждений по каждому скрипту в журнале нет.
Debian 13 trixie257Работает и то, и другое, но журнал уже предупреждает по каждому обёрнутому скрипту.
Ubuntu 24.04 LTS255Работает и то, и другое, но журнал уже предупреждает по каждому обёрнутому скрипту.
Ubuntu 26.04 LTS259Работает и то, и другое; журнал предупреждает по каждому обёрнутому скрипту — как и во всех выпусках начиная с 24.04. Canonical заявляет, что это последняя Ubuntu с совместимостью с SysV.
Ubuntu 26.10260 и новееУдалено. Скрипты остаются на диске, и их никто не читает.
RHEL 9 / RHEL 10252 / 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Если названа служба — симлинк-алиас. Если назван $facilityBefore= плюс Wants= на эту цель, в одну сторону. Если повторяет имя файла — игнорируется.Alias= в [Install] либо ничего
Required-Start: $networkAfter=network-online.target и Wants=network-online.target, потому что эта цель инертна, пока её не подтянутWants= + After=network-online.target
Required-Start: $remote_fsAfter=remote-fs.targetAfter=remote-fs.target
Required-Start: $namedAfter=nss-lookup.targetAfter=nss-lookup.target
Required-Start: $portmapAfter=rpcbind.targetAfter=rpcbind.target
Required-Start: $timeAfter=time-sync.targetAfter=time-sync.target
Required-Start: $local_fs или $syslogНичего. Оба выбрасывались: под systemd они уже выполнены до старта любой обычной службы.Ничего
Should-Start: fooAfter=foo.service — только порядок, никогда не требованиеAfter=foo.service, без Requires=
X-Start-Before: fooBefore=foo.serviceBefore=foo.service
Default-Start: / Default-Stop:Ровно ничего. Никогда не разбиралось. Привязка к уровням исполнения бралась из симлинков S?? в /etc/rc[1-5].d.WantedBy=multi-user.target в [Install]
Симлинк в /etc/rc2.d, rc3.d, rc4.dBefore=multi-user.target и wants-симлинк внутрь неёWantedBy=multi-user.target
Симлинк в /etc/rc5.dBefore=graphical.target и wants-симлинк внутрь неёWantedBy=graphical.target
Симлинк в /etc/rc1.dBefore=rescue.target плюс wants-симлинк в неё — генератор обрабатывал rc1.d ровно так же, как rc2–rc5Почти никогда не то, что вам нужно; не переносите
# pidfile: /path (стиль Red Hat)PIDFile=/path и RemainAfterExit=no. Если строки pidfile нет вообще — RemainAfterExit=yesУдалить оба. Используйте Type=exec
Присутствует ### BEGIN INIT INFOSuccessExitStatus=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, runuserUser= и Group=Применяется к каждому потомку, а не только к первому.
mkdir -p /run/x; chown …RuntimeDirectory=xСоздаётся до старта, удаляется после остановки, с нужным владельцем.
mkdir -p /var/lib/xStateDirectory=xПереживает перезапуски; для остального есть CacheDirectory=/LogsDirectory=.
ulimit -n 65535LimitNOFILE=65535ulimit в скрипте применялся к шеллу, а до демона иногда не доходил.
export FOO=barEnvironment= или EnvironmentFile=EnvironmentFile=-/etc/default/x сохраняет работу существующих конфигов.
nice -n 10, ioniceNice=10, IOSchedulingClass=Или дальше — CPUWeight= и IOWeight=.
cd /opt/xWorkingDirectory=/opt/x
>> /var/log/x.log 2>&1удалитьВывод уходит в журнал с меткой юнита. Ротацию писать не нужно.
sleep 5; check_if_upType=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]

SysVsystemdПримечание
init 3, telinit 3systemctl isolate multi-user.targetВ 258 удалён telinit; сам init никуда не делся — это PID 1, — но управлять состоянием системы через него больше нельзя.
init 5systemctl isolate graphical.target
init 1, telinit Ssystemctl isolate rescue.targetemergency.target опускает ещё ниже.
init 0 / init 6systemctl poweroff / systemctl reboot
runlevelsystemctl get-default, systemctl list-units --type=targetКоманда удалена; цель — это не уровень исполнения.
Уровень по умолчанию в /etc/inittabsystemctl set-default multi-user.target/etc/inittab не читается уже много лет.
chkconfig x on, update-rc.d x defaultssystemctl enable x.serviceТребует секции [Install] в юните.
chkconfig x off, update-rc.d -f x removesystemctl disable x.service
(аналога нет)systemctl mask x.serviceДелает юнит незапускаемым даже как зависимость.
chkconfig --list, service --status-allsystemctl list-unit-files --type=service
service x startsystemctl start x.serviceservice часто ещё существует как прослойка; не полагайтесь на неё.
# 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), которое нельзя менятьСтолько же, сколько у хостаНапишите юнит сами и замаскируйте вендорский скрипт — или поднимите вопрос с вендором до того, как его следующий релиз оставит вас без службы.
  1. Проведите инвентаризацию по всему парку на этой неделе запросом к журналу выше и сохраните вывод. Двух колонок — имя хоста и путь к скрипту — достаточно, чтобы оценить объём работы и поймать машины, о которых никто не помнит.
  2. Заберите сгенерированный юнит для каждого скрипта через systemctl cat в каталог, который останется у вас. Операция только на чтение, занимает минуты, а после обновления информацию уже не восстановить.
  3. Разложите на три кучи: удалить, заменить, переписать. Удивительно большая доля найденного — служба чего-то, что вывели из эксплуатации несколько лет назад. Её удаление — это полноценная миграция, и она не занимает времени вообще.
  4. Начните с лёгких — у всего, что уже собрано в апстримный пакет, почти наверняка есть официальный юнит, и поставить актуальный пакет быстрее и правильнее, чем писать юнит самому.
  5. Переключайтесь, пока генератор ещё существует, по одной службе за раз, на системе, где скрипт всё ещё лежит на месте. Откат тогда — удалить один файл и сделать reload, а не восстанавливать бэкап под давлением.
  6. Перезагрузитесь и прочитайте загрузку на каждом хосте, прежде чем считать что-либо готовым. Потом замаскируйте списанные юниты, чтобы их ничто не вернуло.

В то же самое окно обслуживания обычно помещается и всё остальное: обновление сервера с 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 — это аудит, пофайловая таблица соответствий и два перевода, которые меняют поведение, а не только название.

Источники

Каждый номер версии, каждая директива и каждое значение по умолчанию в этой статье прочитаны из источников ниже, а не переписаны из чужих публикаций. Там, где код с тех пор удалён, ссылка ведёт на последний тег, где он ещё существовал, — чтобы вы могли проверить сами.

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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%
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. systemd.service(5) — Type=, Restart=, PIDFile=, ExecReload=, RemainAfterExit= and the rest of the service options used in the unit files below
  15. systemd.unit(5) — Wants=, After=, Before=, the Condition family and the [Install] section that replaces update-rc.d and chkconfig
  16. systemd.exec(5) — User=, RuntimeDirectory=, StateDirectory= and the whole sandboxing vocabulary, none of which a wrapped init script could ever use
  17. 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
  18. 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
  19. systemctl(1) — enable, mask, cat, show, list-unit-files and isolate: the commands that replace chkconfig, update-rc.d, service and telinit
  20. systemd-analyze(1) — verify, security and blame: the three subcommands that turn this migration from a guess into something you can check
  21. journalctl(1) — matching on MESSAGE_ID= and on arbitrary structured fields, which is how the inventory command in this article finds every wrapped script
  22. daemon(7) — the difference between a SysV-style forking daemon and a new-style daemon, and upstream's own recommendation to stop forking
  23. systemd.resource-control(5) — the per-unit cgroup accounting and limits that come for free once a service is a real unit
  24. 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
  25. Ubuntu 26.10 release schedule — the release date that turns "eventually" into a deadline for anyone tracking the interim series
  26. 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
  27. Debian trixie systemd package — the version currently in Debian stable, for readers deciding how much runway they actually have

Было полезно?