コンテンツへスキップ
← ブログ

systemd 260 が SysV を削除。どうする

systemd 260 で sysv-generator、rc-local.service、systemd-sysv-install が削除された。手元のサーバーに残る /etc/init.d スクリプトと /etc/rc.local は、これで全台が期限付きになった。洗い出し方と、正しい unit ファイルへの変換手順をまとめる。

·読了目安 18 分
  • systemd
  • Linux
  • サーバー運用
  • 移行

およそ 15 年のあいだ、systemd は起動のたびに /etc/init.d のスクリプトを黙ってサービスへ翻訳し続け、その存在を意識させることすらなかった。2026 年 3 月にリリースされたバージョン 260 で、それが止まった。systemd-sysv-generatorsystemd-rc-local-generatorrc-local.service、そして systemctl enable の裏側で動いていた systemd-sysv-install フック。この 3 つがまとめて削除された。スクリプト側は何も変わっていない。変わったのは、もう誰もそれを読まなくなったという一点だけである。

2 枚のパネルを並べた図。左は「DELETED IN systemd 260」と題し、systemd-sysv-generator、systemd-rc-local-generator、rc-local.service、systemd-sysv-install を列挙している。右は「WHAT REPLACES WHAT」と題し、4 組の変換前後を示す。start-stop-daemon --background は Type=exec へ、--make-pidfile は PID ファイルそのものを廃止、--chuid acme は User=acme へ、/etc/rc[2-5].d/S20name は WantedBy=multi-user.target へ。
1 リリースで 3 つのコンポーネントが削除された。移行作業そのものは難しくない。難しいのは、どのマシンにどのスクリプトがまだ残っているかを、無人アップグレードに教えられる前に把握することだ。

本稿は、遠くから眺めるためではなく、実機のサーバーで実際に手を動かす人間のために書いた移行手順である。ほとんど知られていない journal のフィールドを使って全台からラップ済みスクリプトを洗い出す方法、推測ではなく丸ごと写せるように generator が何を生成していたかの実際、現実的な init スクリプトを項目ごとに変換する作業、デーモンが死んでいるのに unit は成功と報告してしまう Type= の間違い、そして init 3 を置き換えたコマンド。ここに出てくるバージョン番号とディレクティブはすべて、記憶ではなく、systemd の NEWS ファイルと、削除済みコードがまだ存在するタグ時点のソースに照らして確認している。

failed ではない。そもそも存在しない

症状はやけに分かりにくい。エラーではなく、不在だからだ。サービスが failed として現れるのではない。そもそも現れない。スクリプトは 2015 年から一文字も変わらず、実行可能属性つきでディスク上に鎮座しているのに、systemctl statusUnit could not be found と答える。これは正しい挙動である。あのスクリプトは最初から unit ではなかった。起動のたびに generator が /run/systemd/generator.late に unit を製造していただけであり、そのディレクトリが今は空なのだ。[v260]

見えている症状その意味解説している箇所
/etc/init.d/x は存在して実行可能なのに Unit x.service could not be found と出るその unit をでっち上げていた generator が削除された。壊れているものは何もない。翻訳が行われなくなっただけである。generator が書いていた内容
何年も毎回起動していたサービスがただ起動せず、journal にも何も出ない原因は同じで、コマンドラインからではなく起動側から見た姿。unit が無いので failed な unit も無い。1 本のスクリプトを変換する
/etc/rc.local は実行可能なままなのに実行されなくなったsystemd-rc-local-generatorrc-local.service は SysV generator と一緒に削除された。/etc/rc.local を置き換える
古いサービスに対する systemctl enable が何の役にも立たなくなったenable/disable を chkconfigupdate-rc.d へ委譲していたフック systemd-sysv-install が消えた。パッケージが同梱すべきもの
telinit: command not found、あるいは init 3 が何もしない2 つ前のリリースである 258 で、別の理由により削除済み。スクリプトだけでなくランレベルという概念そのものが捨てられた。init 3、telinit、runlevel
変換した unit が active と報告するのにプロセスが居ない。あるいはデーモンは動いているのに failed と報告するfork するデーモンに対して Type= が誤っている。変換ミスとして最も多い。Type=forking と PID ファイル
# After the upgrade, the service that has started at boot for eleven years
# is simply not there. Not failed - not there.

systemctl status acme-collector
# Unit acme-collector.service could not be found.

ls -l /etc/init.d/acme-collector
# -rwxr-xr-x 1 root root 1284 Mar 14  2015 /etc/init.d/acme-collector
#            ^ the script is still on disk, still executable, still correct.

# The script was never a service. A generator turned it into one on every
# boot, and the generator is gone. Confirm which systemd you are on:
systemctl --version | head -1
# systemd 260 (260.2-1)

# And confirm the generator really is absent rather than just failing:
ls /usr/lib/systemd/system-generators/ | grep -E 'sysv|rc-local'
# (no output on 260 and later; on 259 and earlier you get one or two lines)

# The same disappearance, seen from the other end - nothing was generated:
ls /run/systemd/generator.late/
# (empty, or missing your unit)

generator はマネージャが unit を読み込む前、起動のたびと daemon-reload のたびに走り、その出力は tmpfs に置かれる。アップグレード後もディスク上の見た目が何も変わらないのはそのためであり、直すべきファイルが一つも存在しないのもそのためだ。壊れたファイルなどどこにもなく、ただ翻訳という工程が行われなくなっただけである。以降はすべて、対象マシンにシェルで入れて、最後に一度だけ再起動できることを前提にしている。本稿の内容は、こちらが決断するまでシステムの状態を変えない。洗い出しのコマンドは読み取りしか行わない。[generator]

何が、いつ削除されたのか

関係するリリースは 3 つあり、これを混同していることが、この話題の情報の多くが間違っている原因になっている。SysV サービススクリプトが非推奨と宣言されたのは、さかのぼってバージョン 255 である。2025 年 9 月のバージョン 258 がやったのは、System V のシステム状態インターフェイスの削除だった。すなわち /dev/initctlinitctlrunleveltelinit の各コマンド、init 3 形式での状態制御、runlevel[0-6].target の各 unit、そしてランレベル遷移の utmp/wtmp への記録である。バージョン 259 はそのうち一部を差し戻した。バージョン 260 がサービススクリプトそのものを削除した。[news][v255][v258]

リリース時期起きたこと
2582025年9月255 で非推奨となっていた SysV サービススクリプトについて、削除は 259 と改めて予告(のちに 260 へ延期)。それとは別に即時削除されたもの: /dev/initctlinitctlrunleveltelinit の各コマンド、init 3 形式の状態変更、runlevel[0-6].target の各 unit。ランレベル遷移の utmp/wtmp への記録も停止。同じリリースで cgroup v1 サポートも廃止。
2592025年12月runlevel[0-6].target が復活。ただしディストリビューションが -Dcompat-sysv-interfaces=yes を付けてビルドした場合に限る。削除されたコマンドは戻らない。Ubuntu 26.04 LTS が搭載するのはこのバージョン。
2602026年3月System V サービススクリプトのサポートを削除。削除されたもの: systemd-sysv-generatorsystemd-rc-local-generatorrc-local.servicesystemd-sysv-install。ビルドオプション -Drc-local=-Dsysvinit-path=-Dsysvrcnd-path= は非推奨に。カーネル要件が 5.10、glibc が 2.34、OpenSSL が 3.0 へ引き上げ。
261 以降2026年SysV 関連の追加変更は無し。互換レイヤーが単に存在しない状態。非推奨のビルドオプションは将来のリリースで消える見込み。

259 での部分的な差し戻しは、バージョンが混在した環境を運用しているなら無視できない。戻ってきたのは runlevel[0-6].target の unit だけで、しかもディストリビューションがビルド時に有効化した場合のみのオプション扱いであり、コマンド類はまったく戻っていない。つまり systemctl isolate runlevel3.target は、各ディストリビューションがパッケージをどうコンパイルしたか次第で、あるマシンでは通り、隣のマシンでは通らない。これを前提に運用手順を組んではいけない。自分のサーバーがどの位置にいるかは、搭載している systemd で決まる。[v259][v260]

自分の環境systemdSysV スクリプトと rc.local
Debian 12 bookworm252どちらも動く。journal にスクリプト単位の非推奨警告は出ない。
Debian 13 trixie257どちらも動く。ただしラップされたスクリプトごとに journal は既に非推奨を警告している。
Ubuntu 24.04 LTS255どちらも動く。ただしラップされたスクリプトごとに journal は既に非推奨を警告している。
Ubuntu 26.04 LTS259どちらも動く。ラップされたスクリプトごとの journal 警告は 24.04 の時点から出ている。Canonical は、これが SysV 互換を持つ最後の Ubuntu だと明言している。
Ubuntu 26.10260 以降削除済み。スクリプトはディスクに残るが、二度と読まれない。
RHEL 9 / RHEL 10252 / 257そのリリースの寿命の間はどちらも動く。RHEL 9 は無言だが、RHEL 10 はラップされたスクリプトごとに journal が警告を出し、リリースノートにも既に非推奨の記載がある。変更は次のメジャーバージョンで来る。
ローリングリリース系260 以降既に削除済み。思い込みで判断せず systemctl --version で確認すること。

日付を見れば、これが机上の話でないことが分かる。Canonical のリリースノート自身が、systemd 259 を搭載する Ubuntu 26.04 LTS が System V 互換を持つ最後の Ubuntu であり、変更は 26.10 で有効になると明記している。そのリリースは 2026 年 10 月に予定されている。中間リリースを使っているなら、あるいはローリングリリースを追うものが一つでもあるなら、期限は年単位ではなく週単位だ。LTS やエンタープライズ系ディストリビューションなら次のメジャーアップグレードまで猶予があるが、それは所有者不明のシェルスクリプトを発見したい瞬間として最悪のタイミングでもある。[ubuntu2604][ubuntu2610][rhel10]

アップグレードに見つけられる前に、自分で全部洗い出す

まず洗い出しを行う。しかも generator がまだ動いているうちにやる。generator 自身が答えを教えてくれるからだ。バージョン 255 以降、systemd はスクリプトをラップするたびに、安定した message ID を持つ構造化された警告を出す。さらに 2 つの独自 journal フィールド、パスを持つ SYSVSCRIPT= と、systemd が命名した名前を持つ UNIT= が付く。これにより、ls と願望を組み合わせた推測ではなく、全台監査を 1 本の厳密なクエリで済ませられる。[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 年まで生き残るスクリプトは構成管理リポジトリが把握しているものではない。外注が一度きりで入れたもの、ベンダー製アプライアンスのエージェント、今のチームより古いバックアップのラッパー。まさに誰も確認しようと思わないホストであり、止まっていることに 1 週間後に気づくサービスである。

このクエリについて、頭に入れておくべき点が 2 つある。systemd が init ディレクトリでラップしたのは実行可能な通常ファイルだけだった。したがってモード 644 で放置されていたスクリプトはすでに死んでいたのであり、これを蘇生させてはいけない。もう 1 つ、同名のネイティブ unit は常にスクリプトに優先した。generator は、すでに実 unit が存在するスクリプトを明示的にスキップしていたのである。この 2 つ目の規則こそが、この移行を段階的に進めても安全である理由だ。スクリプトを残したまま、有効化したまま新しい unit を設置でき、スクリプトは単に参照されなくなる。なお、手順 1 が見ているのは今回の起動分の journal だけなので、警告が既にローテーションで流れてしまったホストについては手順 2 が必要になる。[gensrc]

generator が黙って書いてくれていた内容

unit を 1 行も書き始める前に、generator が生成しているものを取り出して読むこと。あれは大雑把な近似ではなく決定論的な翻訳であり、そのスクリプトの起動時の挙動が実際どうだったかについて、仕様書に最も近いものである。まだ可能なうちに 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.

ここからが多くの人を驚かせる部分であり、generator のソースで自分で確認できる部分でもある。LSB ヘッダーの Default-Start:Default-Stop:一度も読まれていなかった。generator が解析していたのは Provides:Required-Start:Should-Start:X-Start-Before:X-Start-After:、2 つの description フィールド、そして Red Hat 流の # pidfile:# description: コメントだけである。ランレベルへの紐付けは、まるごと /etc/rc[1-5].d にある S??name シンボリックリンクから来ていた。10 年間 Default-Start を保守してきたのなら、保守していたのはコメントである。[lsb][exitstatus]

init スクリプト内の記述generator の扱いunit にはこう書く
Provides: nameサービス名なら別名のシンボリックリンク。$facility ならその target に対する Before=Wants=(一方向)。ファイル名の繰り返しなら無視。[Install]Alias=、または何も書かない
Required-Start: $networkAfter=network-online.target および Wants=network-online.target。あの 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。順序付けのみで、要求関係には決してならないRequires= を付けずに After=foo.service
X-Start-Before: fooBefore=foo.serviceBefore=foo.service
Default-Start: / Default-Stop:まったく何もしない。一度も解析されていない。ランレベルへの紐付けは /etc/rc[1-5].dS?? シンボリックリンク由来。[Install]WantedBy=multi-user.target
/etc/rc2.drc3.drc4.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 シンボリックリンク望んだ動作であることはまずない。書かないこと
# pidfile: /path(Red Hat 流)PIDFile=/pathRemainAfterExit=no。pidfile 行が無い場合は RemainAfterExit=yesどちらも削除する。代わりに Type=exec を使う
### BEGIN INIT INFO があるSuccessExitStatus=5 6。「未インストール」と「未設定」を表す LSB の終了コードそのコードで終了し続ける場合のみ書く
|reload} などを含む usage 行ExecReload=/etc/init.d/x reload。usage 行が無ければ reload の動詞も無い。ExecReload=/bin/kill -HUP $MAINPID

この表のファシリティ対応のうち 2 つは、二度見する価値がある。「SysV では動いていたのに今は競合する」の典型的な原因だからだ。$networknetwork.target にはならず network-online.target になり、しかも generator は After=Wants= の両方を付けていた。あの target は誰かが能動的に引き込まない限り何もしないからである。一方で $local_fs$syslog は何にも対応せず、単に捨てられていた。systemd では通常のサービスが起動する時点でどちらもすでに保証されている、という妥当な理由による。この判断をそのまま自分の unit に写すこと。新しい流儀を即興で作らないこと。[special]

1 本のスクリプトを、項目ごとに変換する

以下は現実的なスクリプトである。おもちゃではない。LSB ヘッダーがあり、Red Hat 流の PID ファイルコメントがあり、プロセスをバックグラウンド化してデーモンの代わりに PID ファイルを書く start-stop-daemon の呼び出しがあり、reload の動詞があり、そして generator が ExecReload= を出力した唯一の理由である usage 行がある。現場にある実物のスクリプトは、たいていこれより酷い。

#!/bin/sh
### BEGIN INIT INFO
# Provides:          acme-collector
# Required-Start:    $remote_fs $network $time
# Required-Stop:     $remote_fs $network
# Should-Start:      postgresql
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: ACME metrics collector
### END INIT INFO
# pidfile: /var/run/acme-collector.pid

DAEMON=/opt/acme/bin/collector
PIDFILE=/var/run/acme-collector.pid
RUNAS=acme
OPTS="--config /etc/acme/collector.conf"

case "$1" in
  start)
    start-stop-daemon --start --quiet --background --make-pidfile \
        --pidfile "$PIDFILE" --chuid "$RUNAS" --exec "$DAEMON" -- $OPTS
    ;;
  stop)
    start-stop-daemon --stop --quiet --pidfile "$PIDFILE" --retry 30
    rm -f "$PIDFILE"
    ;;
  reload)
    kill -HUP "$(cat "$PIDFILE")"
    ;;
  restart)
    "$0" stop; sleep 1; "$0" start
    ;;
  *)
    echo "Usage: $0 {start|stop|restart|reload}" >&2
    exit 2
    ;;
esac
exit 0

そして、これがその置き換えである。ディレクティブそのものより、コメントのほうを読んでほしい。興味深いのは、何を引き継がないかの判断だからだ。fork は捨てる。手書きの PID ファイルも捨てる。start-stop-daemon のラッパーも捨てる。それがやっていたこと、すなわち権限の降格、バックグラウンド化、プロセスの追跡は、今やすべてディレクティブだからである。[service][exec]

# /etc/systemd/system/acme-collector.service
#
# Everything above the blank line inside [Service] is the translation of the
# init script. Everything below it is what the wrapper could never give you.

[Unit]
Description=ACME metrics collector
Documentation=https://example.internal/acme/collector
# $network in the old header became network-online.target - and that target
# only does something if a unit actively pulls it in, hence the Wants=.
Wants=network-online.target
After=network-online.target
# "Should-Start: postgresql" was ordering only, never a requirement. Keep it
# that way: After= without Requires= means a database outage does not cascade.
After=postgresql.service

[Service]
Type=exec
User=acme
Group=acme
# The single most important line of the migration: stop the daemon forking.
# Almost every daemon has a flag for this - --foreground, -f, -D, --no-detach,
# --nodaemon. Find it and the PID file problem disappears with it.
ExecStart=/opt/acme/bin/collector --config /etc/acme/collector.conf --foreground
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s

# /var/run/acme-collector.pid was in /run all along; let systemd own it.
RuntimeDirectory=acme-collector
StateDirectory=acme-collector
ConfigurationDirectory=acme

NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
# Empty means "no capabilities at all". If the daemon binds a port below 1024,
# use CapabilityBoundingSet=CAP_NET_BIND_SERVICE plus the matching
# AmbientCapabilities= instead of dropping this line.
CapabilityBoundingSet=

[Install]
WantedBy=multi-user.target
スクリプト内unit では備考
start-stop-daemon --background&Type=exec とデーモン自身のフォアグラウンド用フラグここが要点。何もバックグラウンド化しないこと。
--make-pidfileecho $! > …削除するsystemd はそのプロセスを fork した当人なので PID を知っている。
--chuidsu - user -crunuserUser=Group=最初のプロセスだけでなく、すべての子孫に適用される。
mkdir -p /run/x; chown …RuntimeDirectory=x起動前に正しい所有者で作成され、停止後に削除される。
mkdir -p /var/lib/xStateDirectory=x再起動をまたいで残る。用途に応じて CacheDirectory=LogsDirectory= を使う。
ulimit -n 65535LimitNOFILE=65535スクリプト内の ulimit はシェルに効いていただけで、デーモンに効かないこともあった。
export FOO=barEnvironment= または EnvironmentFile=EnvironmentFile=-/etc/default/x なら既存の設定ファイルをそのまま活かせる。
nice -n 10ioniceNice=10IOSchedulingClass=さらに踏み込むなら CPUWeight=IOWeight=
cd /opt/xWorkingDirectory=/opt/x
>> /var/log/x.log 2>&1削除する出力は unit 名付きで journal へ行く。ローテーション設定を書く必要もない。
sleep 5; check_if_upデーモン側の Type=notify、または別 unit でのヘルスチェックstart の動詞に置いた sleep は、導火線の長い競合状態にすぎない。
restart) stop; sleep 1; start削除するsystemctl restart が存在し、きちんと待つ。
status) のブロック削除するsystemctl statusis-active が実際の状態を報告する。
停止時の trap や後始末ExecStop=、より良いのはデーモン側での SIGTERM 処理TimeoutStopSec= が SIGKILL までの待ち時間を制限する。

実際に出くわす書き方について、一般化した対応表を挙げる。「削除する」と書いてある行は単純化ではない。その挙動はマネージャ側が提供しており、シェルで再実装することが、同じプロセスを 2 者が奪い合う事態への近道になる。[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 1 回で済む。最も重要なのは 3 番目の手順だ。systemctl show -p FragmentPath -p SourcePath は、2 つの定義のどちらが生きているかを一義的に教えてくれる。SourcePath が空でなければ、まだ生成されたラッパーを見ているということであり、新しいファイルは名前が衝突しているか、構文エラーがあるか、置き場所が違う。[systemctl]

/etc/rc.local を、悪い癖ごと引き継がずに置き換える

/etc/rc.local には独立した節を割く。後回しにされがちであること、そして失われようとしている互換 unit が本当に奇妙なセマンティクスを持っており、それを引き写す前に知っておく価値があることが理由だ。本物の rc-local.serviceType=forkingGuessMainPID=noRemainAfterExit=yesTimeoutSec=infinity を組み合わせ、順序は network.target の後ろだけ、そしてファイルが実行可能なときだけ自分を引き込んでいた。上流のマニュアルページ自身が、この順序はネットワークが使える状態を意味しないこと、そしてこの仕組み全体が誰かが推奨する設計ではなく特定の System V システムとの互換のために存在していることを、わざわざ警告していた。[rclocalunit][rclocalman]

# /etc/rc.local was never one thing. Read yours first and split it: a mount,
# a sysctl, a firewall rule and a background daemon are four different units,
# and only the last one belongs in a service.

# --- 1. the direct replacement, when it really is one script ---------------
# /etc/systemd/system/local-startup.service
[Unit]
Description=Local startup commands (former /etc/rc.local)
ConditionFileIsExecutable=/usr/local/sbin/local-startup
# The old rc-local.service used After=network.target, which upstream's own
# manual page warns does not mean the network is usable. If your script talks
# to the network, use network-online.target and pull it in.
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/local-startup
# The original had TimeoutSec=infinity. Keeping that means a hung script hangs
# the boot forever with no message. Pick a number you would accept waiting.
TimeoutStartSec=90s

[Install]
WantedBy=multi-user.target

# --- 2. the pieces that should not be a service at all ---------------------
# sysctl:      /etc/sysctl.d/90-local.conf
# modules:     /etc/modules-load.d/local.conf
# files/dirs:  /etc/tmpfiles.d/local.conf
# mounts:      /etc/fstab or a .mount unit
# periodic:    a .timer, not a sleep loop

# --- 3. install it --------------------------------------------------------
sudo install -m 0755 /etc/rc.local /usr/local/sbin/local-startup
sudo systemctl daemon-reload
sudo systemctl enable --now local-startup.service
systemctl status local-startup.service --no-pager
# On SELinux systems relabel the script, exactly as the old generator's manual
# page told you to do for /etc/rc.local:
command -v restorecon >/dev/null && sudo restorecon -v /usr/local/sbin/local-startup

ついでに断ち切っておくべき癖が 2 つある。TimeoutSec=infinity は引き継がないこと。ハングしたコマンドを、指し示すべき failed unit すらないまま、永遠に終わらない起動へ静かに変えてしまう。そして全部を 1 つのスクリプトに置いたままにしないこと。rc.local はたいてい、フックが 1 つしかなかったという理由だけで同居している無関係な 4 つの何かである。sysctl、マウント、ファイアウォールのルール、そして本来デーモンであるべきだったもの。別々の unit にすれば、順序付けも再試行も無効化も個別にでき、どれか 1 つが壊れたときにどれが壊れたのかが分かる。[special]

Type=forking、PID ファイル、そして手こずるデーモン

これが一晩を溶かす間違いであり、しかも自分から名乗り出てこない。デーモンが fork するのに unit が Type=simple だと、systemd は親プロセスを追跡し、それがミリ秒単位で終了するのを見届け、生き残った子プロセスを迷子として kill するか、何も動いていないのに起動成功と宣言するかのどちらかになる。デーモンが fork し unit が Type=forking でも、PID ファイルの到着が遅ければ systemd はメインプロセスを見失い、MainPID=0 と報告する。unit は active に見え、Restart= は決して発火せず、シャットダウン時にプロセスが取り残される。[daemon][service]

# The failure mode nobody warns you about: the unit reports "active" and the
# daemon is dead, or reports "failed" while the daemon is happily running.
# Both come from getting Type= wrong.

# --- diagnosis -------------------------------------------------------------
systemctl show -p Type -p MainPID -p PIDFile -p ControlGroup --value myd.service
# forking
# 0                <- systemd has no main process: it lost the daemon
# /var/run/myd.pid
# /system.slice/myd.service

# If MainPID is 0 while the process exists, systemd is not supervising it.
# Restart=, watchdogs, and clean shutdown are all silently not working.
systemd-cgls -u myd.service      # who is actually inside the unit's cgroup

# --- fix 1 (preferred): stop forking --------------------------------------
# Type=exec: systemd considers the unit started once the binary has been
# executed. No PID file, no race, no double-fork, no GuessMainPID guesswork.
#   ExecStart=/usr/sbin/myd --foreground
#   Type=exec

# --- fix 2 (best, if the daemon supports it): tell systemd when you are up --
# Type=notify with sd_notify(READY=1) from the daemon, or Type=notify-reload
# if it can also signal that a reload finished. These - together with Type=dbus
# for D-Bus services - are the only readiness protocols precise enough that
# After= on a dependent unit means "the port is actually accepting".
#   Type=notify
#   NotifyAccess=main

# --- fix 3 (last resort): keep forking, but do it correctly ---------------
# PIDFile= must be an absolute path under /run, and the daemon must write it
# BEFORE the parent exits. Anything else is a race you will lose under load.
#   Type=forking
#   PIDFile=/run/myd/myd.pid
#   RuntimeDirectory=myd

# --- do not do this -------------------------------------------------------
# Type=simple with a daemon that forks. systemd will track the parent, see it
# exit immediately, and either kill the children or declare success while the
# service never started. This is the single most common broken conversion.

恒久的な解決策は、より賢い PID ファイルではなく、fork をやめることだ。この 20 年に書かれたデーモンのほぼすべてにフォアグラウンドに留まるフラグがあり、新しい流儀のデーモンに対する上流の指針もそれを使えというものである。Type=exec にすればこの種の問題は丸ごと消え、Type=notify はさらに先へ行く。依存する unit の After= を「バイナリが実行された」ではなく「ソケットが本当に接続を受け付けている」の意味にできる信頼できる方法は、Type=notify(および notify-reload、D-Bus サービスなら Type=dbus)だけである。それ以外はすべて、希望的観測による順序付けにすぎない。[incompat]

init 3、telinit、runlevel も消えている

この変更のうち、サーバーを越えて体に染みついた手順や運用手順書にまで届くのがこの部分だ。バージョン 258 は /dev/initctl のデバイスノードと initctlrunleveltelinit の各コマンドを削除し、init 3 形式での状態変更のサポートを削除し、runlevel[0-6].target の unit を削除し、ランレベル遷移を utmpwtmp に記録するのをやめた。ランレベルという概念そのものがもう存在しないからである。バージョン 259 はビルドオプション -Dcompat-sysv-interfaces=yes の背後で target だけを復活させた。コマンドは復活していない。[v258][v259]

SysVsystemd備考
init 3telinit 3systemctl isolate multi-user.targettelinit は 258 で削除。init は PID 1 そのものなので今も存在するが、258 で削除されたのはそれ経由の状態制御である。
init 5systemctl isolate graphical.target
init 1telinit Ssystemctl isolate rescue.targetemergency.target はさらに下の階層。
init 0 / init 6systemctl poweroff / systemctl reboot
runlevelsystemctl get-defaultsystemctl list-units --type=targetコマンドは削除された。target はランレベルではない。
/etc/inittab のデフォルトランレベルsystemctl set-default multi-user.target/etc/inittab は何年も前から読まれていない。
chkconfig x onupdate-rc.d x defaultssystemctl enable x.serviceunit に [Install] セクションが必要。
chkconfig x offupdate-rc.d -f x removesystemctl disable x.service
(対応するものは無い)systemctl mask x.service依存関係としても起動できない状態にする。
chkconfig --listservice --status-allsystemctl list-unit-files --type=service
service x startsystemctl start x.serviceservice は互換 shim として残っていることが多いが、頼りにしないこと。
# 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

この置き換えのうち 1 つは SysV に対応物がまったくなく、それ自体を学ぶ価値がある。systemctl mask は unit を起動不能にする。他の何かの依存関係として引かれた場合も含め、何によっても起動できなくなる。SysV で最も近かったのは、スクリプトを消してパッケージマネージャが戻さないことを祈るという方法だった。アンインストールに踏み切れないサービスを退役させるのに正しい道具であり、退役済みスクリプトを善意の同僚に復活させられないようにするためにも正しい道具である。[systemctl]

ラッパーでは得られなかったもの

正直に言えば、この移行は押し付けられた作業であり、終わったところで新機能が手に入るわけではない。だからこそ、代償として得られるものは受け取っておくべきだ。それは実在するし、追加費用もない。ラップされた init スクリプトはそのどれ一つ使えなかった。ラッパーはシェルスクリプトを実行し、シェルスクリプトがデーモンを実行する。systemd はそのプロセスが何なのかを知らず、確実に再起動することもできず、制約をかけることなど一切できなかった。ネイティブな unit なら、行を足すだけで以下がすべて手に入る。[exec][resctl]

  • 本物の監視ループ。Restart=on-failureRestartSec=、さらに StartLimitIntervalSec= を併用すれば、クラッシュループが CPU コアを張り付かせる前に停止する。生成されていたラッパーは、あらゆるスクリプトに対して Restart=no をハードコードしていた。
  • ファイルシステムの隔離。ProtectSystem=strict は明示したもの以外のファイルシステム全体を読み取り専用にし、PrivateTmp=yes はサービス専用の /tmp を与える。StateDirectory=RuntimeDirectory=ConfigurationDirectory= は、スクリプトが手作業で mkdir -p していたディレクトリを、作成し、所有者を設定し、後片付けまで行う。
  • 実効性のある権限削減。User=NoNewPrivileges=yes、明示的な CapabilityBoundingSet=sustart-stop-daemon --chuid を置き換える。しかもそれらと違い、サービスが生成するすべてのプロセスに、永続的に適用される。侵害されたデーモンが起動しようとするプロセスも含めてだ。
  • システムコールと名前空間の制限。SystemCallFilter=@system-serviceRestrictAddressFamilies=RestrictNamespaces=MemoryDenyWriteExecute=yes は、4 行で書ける seccomp ポリシーである。シェルには、いくら払っても同等物は存在しなかった。
  • サービス単位のリソース会計。MemoryMax=CPUQuota=TasksMax=IOWeight= は unit の cgroup に適用される。つまりデーモンと、それが fork したものすべてに効く。PID ファイル方式のスクリプトが決して封じ込められなかった、まさにその部分である。
  • どこかに届くログ。標準出力と標準エラーは unit 名付きで journal に流れる。デーモンが syslog を知らなくても journalctl -u が使えるし、ローテーション設定を誰も書かなかったせいでログファイルが静かにパーティションを埋めることもない。

ただし、これらを何も考えずに全部足してはいけない。unit に対して systemd-analyze security を実行し、そのスコアを成績ではなく TODO リストとして扱うこと。未設定のディレクティブと、それを設定すると何が得られるかを 1 つずつ挙げてくれる。小刻みに締め、再起動し、journal を読む。過剰な hardening の失敗の仕方は、完璧に起動したあと 3 時間後にファイルを開けなくなるサービスであり、これは起動を拒否するサービスより、後始末がはるかに厄介な一日になる。[analyze]

祈るのではなく、移行を検証する

systemctl status を眺めて緑を確認する、という検証をしてはいけない。ラップされたスクリプトも緑を出していた。実際に変わった 4 点を検証すること。定義が生成物ではなく自分が管理するファイルであること、SourcePath が init スクリプトを指し返していないこと、unit が残存する紐付けではなく明示的に有効化されていること、そして systemd がメインプロセスの ID を把握していること。[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

その上で再起動する。この移行が持ち込むバグはまるごと順序依存のバグであり、順序依存のバグは、依存関係がすべて起動済みの稼働中システムでは見えないからだ。変換した unit ごとに journalctl -b -u を読み、起動全体については systemd-analyze blame を読む。番号付きシンボリックリンクの 20 番で起動していたサービスが今度は早すぎるタイミングで起動しても、失敗はしない。再試行するか、設定が空のまま起動するか、インターフェイスにアドレスが付く前に bind する。

他人のサーバーで動くソフトを配布しているなら

他人がインストールするソフトウェアを配布しているなら、これはとうに任意ではなくなっている。generator は 255 以降、ラップしたスクリプトごとに、まさにあなた宛ての警告を出し続けていた。please update package to include a native systemd unit file というものだ。systemd 260 ではその警告はもう出ない。警告すべき対象が残っていないからである。スクリプトはインストールされ、そして何もそれを実行しない。[gensrc]

パッケージが今すぐ同梱すべきものは、まともな [Install] セクションを備えた /usr/lib/systemd/system/ の unit ファイルである。というのも、削除された 3 つ目のコンポーネント systemd-sysv-install は、対象がスクリプトベースのサービスだったときに systemctl enablechkconfigupdate-rc.d へ委譲させるフックだったからだ。それが無い以上、サービスの有効化は純粋に [Install] が記述するシンボリックリンクの問題になる。古いディストリビューションをまだサポートするなら init スクリプトはパッケージに残してよい。ネイティブ unit とスクリプトは共存でき、systemd が十分に新しい環境では常に unit が勝つ。[unit]

着手する順番

後でやったほうが安く済む筋書きは存在しない。作業量はスクリプトの本数に比例し、その本数は勝手に減らない。そして期限は他人のリリーススケジュールが決める。変わるのは猶予の長さだけなので、最初の判断は自分がどの状況にいるかを決めることだ。[ubuntu2604]

状況残された時間やること
ローリングリリース、または既に systemd 260 が入っている無し。すでに消えているバックアップか構成管理から洗い出して変換する。ディスク上のスクリプトは不活性だが、何が存在したかは教えてくれる。
Ubuntu の中間リリース(26.04 → 26.10)数週間259 が動いているうちに、洗い出しと生成された unit の保存を今すぐ行う。10 月のリリース前に変換する。
Ubuntu 26.04 LTS、LTS を使い続ける次の LTS まで緊急ではないが、journal は既に警告している。所有者不明のものから順に、機会を見て変換する。
Debian stable、RHEL 9 または 10次のメジャーバージョンまでgenerator は存在して動いている。ただし無言なのは RHEL 9 だけで、Debian 13 と RHEL 10 の journal は既に警告している。いずれにせよ洗い出しはやる。コスト 1 コマンドで、成果は長持ちする。
他者向けにソフトウェアをパッケージしている無し[Install] セクション付きの unit ファイルを今すぐ同梱する。利用者のアップグレード時期は自分の裁量外だ。
手を入れられないアプライアンスやベンダー製エージェントホストと同じunit を自分で書いてベンダーのスクリプトを mask する。あるいは、次のリリースで足止めされる前にベンダーへ提起する。
  1. 今週のうちに全台を洗い出す。上の journal クエリを使い、出力を保存する。ホスト名とスクリプトのパスという 2 列があれば、作業規模の見積もりにも、誰も覚えていないマシンの捕捉にも足りる。
  2. スクリプトごとに生成された unit を保存する。systemctl cat で、自分が管理し続けるディレクトリへ書き出す。読み取り専用の操作で数分しかかからず、アップグレード後にこの情報は復元できない。
  3. 削除・置換・書き直しの 3 つに仕分ける。見つかったもののうち、何年も前に退役した何かのためのサービスが驚くほどの割合を占める。それを削除するのは完全な移行であり、時間もかからない。
  4. 簡単なものから変換する。すでに上流でパッケージ化されているものには、ほぼ確実に公式の unit がある。現行パッケージを入れるほうが、自分で書くより速く、しかも正しい。
  5. generator がまだ存在するうちに切り替える。スクリプトが残っているシステム上で、1 サービスずつ行う。ロールバックはファイルを 1 つ消して再読み込みするだけになり、追い詰められた状態でバックアップを戻す作業にはならない。
  6. 再起動して起動ログを読む。ホストごとに、完了を宣言する前に行う。そのあとで、退役させた unit を mask し、何にも復活させられないようにする。

同じメンテナンス枠に、たいていは残りの作業も入ってくる。Ubuntu 24.04 から 26.04 へのサーバーアップグレード は generator を足元から取り上げるディストリビューションのアップグレードそのものを扱っており、systemd タイマーと cron の比較 は同じ片付けのもう半分だ。SysV スクリプトが残っているマシンには、unit にすべき crontab がほぼ必ずあるからである。そして Docker Engine 29 へ移行すると壊れるもの は、同じ時期に関連する理由でデフォルトを変えたコンテナランタイムの話である。

よくある質問

systemd 260 に sysv-generator を入れ直すことはできないのか?

できない。しかも一見うまくいきそうな回避策は、移行そのものより厄介だ。generator はフラグの裏で無効化されたのではなくソースツリーから削除されたので、インストールできるパッケージも、設定できるオプションも存在しない。残っている 3 つの非推奨 meson オプション(-Drc-local=-Dsysvinit-path=-Dsysvrcnd-path=)が影響するのはパスだけで、コードの有無ではない。原理的には 259 のビルドから generator のバイナリを /usr/lib/systemd/system-generators/ にコピーすることもできるが、それは一度もテストされていないマネージャに対して保守されていない generator を動かすことであり、次の systemd 更新で黙って上書きされる。それを一度デバッグするより、unit ファイルを書くほうが速い。

init スクリプトは、手で実行すればまだ動くのか?

動く。スクリプト側は何も変わっていない。ただのシェルスクリプトであり、/etc/init.d/x start は従来どおりの動作をする。消えたのはサービスへの自動翻訳のほうだ。つまり、起動時に立ち上がらず、systemctl はその存在を知らず、プロセスを監視も再起動もせず、マシンを停止してもクリーンに停止されない。この組み合わせは聞こえより悪い。手で実行できてしまうスクリプトは壊れているように感じられないので、crontab の @reboot で回避され、問題が 1 年沈黙するからである。

全台にログインせずに、影響を受けるサーバーを特定するには?

journal に対して構造化された警告を検索する。systemd 255 以降、ラップされたスクリプトはすべて MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb と、SYSVSCRIPT= および UNIT= という 2 つの独自フィールドを持つログを出す。journal を中央に集約しているなら、全台に対するクエリ 1 本で済む。集約していないなら、多数のホストでコマンドを実行する仕組みを通して journalctl -b MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb を流す。systemd 254 以前ではラップは無言で行われるため、上の洗い出しスクリプトのように、各 unit に FragmentPathSourcePath を問い合わせる方法に切り替える。

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) を呼ぶまで待つ。4 つのうち、After= の依存関係が「本当に準備完了」を意味する唯一の設定である。基本は Type=exec へ変換し、デーモンが対応しているなら Type=notify を使う。

/etc/rc.local は本当に無くなったのか、それとも非推奨なだけか?

systemd 260 以降では無くなっている。systemd-rc-local-generator と、それが引き込んでいた rc-local.service unit は両方とも削除された。ファイル自体は /etc に実行可能なまま残るが、何もそれを呼ばない。なお、このパスはコンパイル時の設定でディストリビューションごとに異なっていた点にも注意したい。/etc/rc.d/rc.local を使うものもあったので、全部見つけたと決めつける前に、自分の環境の generator が実際にどのパスで設定されていたかを確認すること。

unit は active (running) なのにプロセスが存在しない。何を間違えたのか?

ほぼ確実に、Type=forking で systemd が適切なタイミングに PID ファイルを読めなかったか、fork するデーモンに Type=simple を付けたかのどちらかだ。systemctl show -p Type -p MainPID -p PIDFile --value yourunit.service を実行する。デーモンが動いているのに MainPID が 0 なら、systemd は追跡を見失っており、監視機能は一つも効いていない。直し方はデーモンに fork をやめさせることだ。フォアグラウンド用のフラグを探して Type=exec にする。どうしても無理なら、PIDFile=/run 配下の絶対パスにし、親プロセスが終了する前にデーモンがそれを書くことを確認する。

unit を書いたあと、init スクリプトは残すべきか?

切り替え期間中は残し、そのあと削除する。両方が存在する間はネイティブ unit が勝つ。generator は同名の unit が既にあるスクリプトを明示的にスキップしていたからだ。したがって併存は安全で、ロールバックも簡単になる。しかし恒久的に置いたままにすると、同じサービスの定義が 2 つある状態になり、その片方は、深夜 3 時にこれをデバッグする人間に対する罠になる。ホストがクリーンに再起動できたら退役用ディレクトリへ移し、同時に /etc/rc*.d のシンボリックリンクも削除すること。

RHEL や Debian stable でも心配する必要があるか?

急ぐ必要はないが、洗い出しだけはやっておく。RHEL 10 と Debian 13 はどちらも systemd 257 を搭載しており、generator は残っているが、255 で入ったスクリプト単位の非推奨警告はどちらでも既に出ている。さらに Red Hat のリリースノート自身が、System V サービススクリプトのサポートは非推奨であり将来削除されると既に記載している。変更はそれぞれの次期メジャーバージョンで来る。今動くべき理由は、洗い出しが読み取り専用のコマンド 1 本で済むこと、generator が動いていて何をラップしているか教えてくれる今のほうが圧倒的に楽であること、そして代わりの選択肢が、メジャーアップグレードの最中に所有者不明のスクリプトを発見することだからだ。同僚のシェルをリバースエンジニアリングする時期として、それは最悪である。

systemd-sysv-install の代わりは何か?

存在しない。代わりにやるべき仕事がもう残っていないからだ。あれは、指定されたサービスが unit ではなくスクリプトだったときに、systemctl enabledisableis-enabled をディストリビューション固有のツール、すなわち Red Hat 系なら chkconfig、Debian 系なら update-rc.d へ委譲させるフックだった。スクリプトのサポートが消えた以上、サービスの有効化は完全に、unit の [Install] セクションが記述するシンボリックリンクの問題になる。この委譲に依存していたパッケージには、実体のある [Install] セクションを備えた実体のある unit ファイルが必要になる。

スクリプトを手作業ではなく自動で変換できるか?

部分的にはできる。しかも最良の変換ツールは、まさに削除されようとしているものだ。generator がまだ残っているシステムで systemctl cat yourservice.service を実行すれば、依存関係について機械的に正しい翻訳が得られる。そこは間違えやすい部分でもある。どんなツールも代われないのは、重要なほうの判断だ。デーモンに fork をやめさせること、PID ファイルを消すこと、start-stop-daemon --chuidUser= にすること、そしてこのワークロードにとってどのサンドボックス関連ディレクティブが安全かを決めること。生成された unit は仕様書として扱い、本番の unit はそれを元に自分で書くこと。

リソース制御の層も同じリリース群で動いた。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

Was this useful?