TL;DR
- 更新用のヘルパーを起動してから、systemdにサービスの再起動を頼むサービスは、そのヘルパーを失います。実験では、ヘルパーを
setsid(Pythonのstart_new_session=True)で起動しても、最後のログ行が出ませんでした。ヘルパーのcgroupは、再起動しようとしているサービスと同じ/system.slice/demo-app.serviceでした。 setsidが変えるのは、プロセスグループとセッションです。cgroupは変えません。既定のKillMode=control-groupのサービスでは、停止時にsystemdが終了させるのはこのcgroupです。KillMode=processにするとヘルパーは生き残りましたが、ユニットのcgroupに残ったままでした。まだ動いている間は、新しいインスタンスの一部として見えました。systemdのドキュメントはこの設定を「not recommended!」としています。- ヘルパーを
systemd-runで起動すると、独立したユニット(demo-updater.service)に入り、普通に完了しました。サービス自身が単に終了して、Restart=に新しいバージョンを起動させる方法も動きました。 - すべて、Linux上の使い捨ての名前空間の中で、本物のsystemd 257を使って動かしました。
systemd --user、macOSのlaunchd、コンテナ、root以外で動くサービスは試していません。
症状
デーモンの自己更新は、一見まともな形で書けます。新しいバージョンをダウンロードし、小さなヘルパーを起動し、ヘルパーがサービスを再起動して、新しいプロセスが健全かを確認する。健全でなければ、ヘルパーが巻き戻す。最後の手順がヘルパーを置く理由のすべてで、ログで見たい行も「restart finished; verification runs now」です。
失敗はこう見えます。新しいプロセスは問題なく起動しているのに、ヘルパーのログには最初の行(「start」)しかなく、その後がない。エラーも、クラッシュも、途中までの確認もありません。
疑う順に並べると、こうです。
- ヘルパーがクラッシュした。 エラー出力はなく、シェルの手順も単純です。
- 親が終了したときにSIGHUPを受けた。 それを避けるのが
setsidで、コードにはすでに入っていました。 - サービスの再起動時に、何かがシグナルを送った。 ありそうで、次の節でその根拠を探します。
証拠
こういう症状は、原因を取り違えやすいので、できるだけ小さな再現を作りました。サービスマネージャーとして本物のsystemdが要ります。Linuxのマシン上で、使い捨てのPID・マウント・cgroupの名前空間の中に、systemd をPID 1として起動しました(rootで unshare を使いました。この設定は僕のマシン固有なので載せません。使い捨てのsystemd入りVMのほうが楽です)。そして、1ファイルのサービスで、4つの変種を動かしました。
#!/usr/bin/env python3
"""A tiny daemon that can update itself. SIGUSR1 = "update requested".
usage: app.py <how> how = popen | systemd-run | exit
"""
import os, signal, subprocess, sys, time
HOW = sys.argv[1]
LOG = "/run/demo/log"
def log(msg):
with open(LOG, "a") as f: f.write(f"{time.strftime('%H:%M:%S')} [app pid={os.getpid()}] {msg}\n")
def on_usr1(*_):
log(f"update requested, starting the updater via {HOW}")
if HOW == "popen": # the obvious way: detach into a new session so nothing can touch it
subprocess.Popen(["/run/demo/updater.sh"], start_new_session=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
elif HOW == "systemd-run": # hand the updater to the service manager as its own unit
subprocess.run(["systemd-run", "--unit=demo-updater", "--collect", "/run/demo/updater.sh"], check=True)
else: # no helper at all: leave, and let the supervisor (Restart=) start the new version
log("exiting; the supervisor restarts me"); os._exit(0)
signal.signal(signal.SIGUSR1, on_usr1)
signal.signal(signal.SIGTERM, lambda *_: (log("SIGTERM received, exiting"), sys.exit(0)))
log("started")
while True: time.sleep(1)
SIGUSR1 は「更新が要求された」の意味です。ヘルパーはサービスを再起動し、僕が見たい行を書きます。
#!/bin/bash
log() { echo "$(date +%H:%M:%S) [updater pid=$$] $*" >> /run/demo/log; }
log "start; cgroup=$(cut -d: -f3 /proc/self/cgroup)"
systemctl restart demo-app.service
sleep "${UPDATER_SLEEP:-1}"
log "restart finished; the post-restart verification runs now"
ユニットファイルは試行ごとに生成するので、同じサービスを違うオプションで動かせます。
#!/bin/bash
# usage: unit.sh <popen|systemd-run|exit> [extra [Service] lines...]
HOW=$1; shift
cat > /run/systemd/system/demo-app.service <<EOT
[Unit]
Description=demo app
[Service]
ExecStart=/usr/bin/python3 /run/demo/app.py $HOW
$(printf '%s\n' "$@")
EOT
systemctl daemon-reload
試行スクリプトは、リセットして実行し、ログと残ったプロセスを表示します。
#!/bin/bash
# usage: trial.sh <label> <popen|systemd-run|exit> [extra [Service] lines]
label=$1; shift
echo "=== $label"
systemctl stop demo-app.service demo-updater.service 2>/dev/null; : > /run/demo/log
/run/demo/unit.sh "$@"
systemctl start demo-app.service; sleep 1
systemctl kill -s SIGUSR1 --kill-whom=main demo-app.service
sleep 4
cat /run/demo/log
echo "--- processes still around:"; ps -eo pid,cgroup:40,cmd | grep -E 'updater.sh|app.py' | grep -v grep
4つの試行の出力です。
=== A: Popen(start_new_session=True), default KillMode
03:08:37 [app pid=92] started
03:08:38 [app pid=92] update requested, starting the updater via popen
03:08:38 [updater pid=95] start; cgroup=/system.slice/demo-app.service
03:08:38 [app pid=92] SIGTERM received, exiting
03:08:38 [app pid=101] started
--- processes still around:
101 0::/system.slice/demo-app.service /usr/bin/python3 /run/demo/app.py popen
=== B: same, KillMode=process
03:08:42 [app pid=130] started
03:08:43 [app pid=130] update requested, starting the updater via popen
03:08:43 [updater pid=133] start; cgroup=/system.slice/demo-app.service
03:08:43 [app pid=130] SIGTERM received, exiting
03:08:43 [app pid=139] started
03:08:44 [updater pid=133] restart finished; the post-restart verification runs now
=== C: systemd-run --unit=demo-updater
03:08:47 [app pid=170] started
03:08:48 [app pid=170] update requested, starting the updater via systemd-run
03:08:48 [updater pid=175] start; cgroup=/system.slice/demo-updater.service
03:08:48 [app pid=170] SIGTERM received, exiting
03:08:48 [app pid=180] started
03:08:49 [updater pid=175] restart finished; the post-restart verification runs now
=== D: app exits, Restart=always
03:08:52 [app pid=212] started
03:08:53 [app pid=212] update requested, starting the updater via exit
03:08:53 [app pid=212] exiting; the supervisor restarts me
03:08:53 [app pid=217] started
(スクリプトは --- processes still around: の一覧も表示します。試行Aでは新しいアプリのプロセスだけが出て、更新プロセスはいなくなっていました。B、C、Dでも、ヘルパーが4秒の待機の間に完了していたため新しいアプリのプロセスしか出なかったので、それらの一覧は省略しました。これは2回目のセッションの行です。最初のセッションも同じ形でした。)
読み方です。
- Aがバグです。更新プロセスは開始を記録し、ログの行にはcgroupがサービス自身のものだと出ています。旧アプリは
SIGTERMで終了し、新しいアプリが起動しますが、更新プロセスは最後の行を出さず、プロセス一覧にもいません。サービスの再起動は成功したのに、更新プロセスに頼っていた確認や巻き戻しは行われませんでした。 - Bは更新プロセスを生かしました。ただし、これを助けた
KillMode=processは、サービスが生んだものが停止後も生き残りうることも意味します。 - Cは更新プロセスに独自のcgroup(
/system.slice/demo-updater.service)を与え、最後まで動きました。 - Dはヘルパー自体をなくしました。アプリは終了することをログに書き、
Restart=alwaysが新しいインスタンスを起動しました。
Bの代償を見るために、ヘルパーが30秒眠る形でもう一度動かし、ヘルパーが動いている間にサービスの状態をsystemdに聞きました。
● demo-app.service - demo app
Loaded: loaded (/run/systemd/system/demo-app.service; static)
Active: active (running) since Mon 2026-10-05 03:09:11 JST; 3s ago
Main PID: 302 (python3)
CGroup: /system.slice/demo-app.service
├─296 /bin/bash /run/demo/updater.sh
├─302 /usr/bin/python3 /run/demo/app.py popen
└─303 sleep 30
前のインスタンスの更新プロセスが、新しいインスタンスのcgroupの中にいます。このあと systemctl stop してもメインプロセスにしかシグナルは送られず、僕の実行では、サービスを止めたあとも sleep 30 は生き残っていました。ヘルパーが、偶然、次のインスタンスのライフサイクルの一部になっています。
Aで更新プロセスを殺したシグナルは、最初は捕まえていませんでした。そこで updater.sh に1行足して、試行Aをもう一度動かしました。
trap 'log "got SIGTERM"; exit 1' TERM # log()の定義のあとに追加
03:09:06 [app pid=250] update requested, starting the updater via popen
03:09:06 [updater pid=253] start; cgroup=/system.slice/demo-app.service
03:09:06 [app pid=250] SIGTERM received, exiting
03:09:06 [updater pid=253] got SIGTERM
03:09:06 [app pid=261] started
更新プロセスは、メインプロセスと同じ瞬間に、同じ SIGTERM を受け取っています。下のドキュメントとも合います。KillMode=control-group では、ユニットのcgroupにいる全プロセスに、まず SIGTERM が送られます。ここでの更新プロセスはシェルスクリプトなので捕まえられますが、trapがなければ SIGTERM でそのまま終了します。
なぜ: ドキュメントがそう書いている
systemd.kill(5) には、こうあります。「control-group に設定すると、そのユニットのcontrol groupに残るすべてのプロセスが、ユニットの停止時に終了される」。既定値は control-group です。process については、「メインプロセスだけが終了される(not recommended!)」とあり、さらに、これはプロセスが「サービスマネージャーのライフサイクルとリソース管理から逃れ」、「サービスが停止とみなされ、リソースを消費しないと想定されている間も動き続ける」ことを許す、という注意があります(systemd.kill(5))。
だから、この目的に setsid() は適切な道具ではありません。守ってくれるのは、端末のハングアップとプロセスグループ宛のシグナルです。上のドキュメントに従う限り、systemdはサービスに属するものをcontrol groupで決めており、試行Aのログの行は、更新プロセスがまさにそこに残っていたことを示しています。
また、systemd-run は「一時的な .service または .scope ユニットを作って開始し、指定したCOMMANDをその中で実行する」ものです(systemd-run(1))。試行Cが動いたのはそのためで、ヘルパーが別のユニットになり、一方のユニットの再起動はもう一方を止めません。
直し方と、代替案
| 選択肢 | 何が起きるか | 代償 | 実行したか |
|---|---|---|---|
ヘルパーを systemd-run --unit=… --collect で起動 | ヘルパーが独自のユニットとcgroupを持つ | 呼び出す権限が要る(僕はrootで実行)。ユニット名は一意でなければならない | 実行(C) |
サービスが終了し、監督役が再起動する(Restart=) | ヘルパーなし。新しいインスタンスが起動後の確認をする | 確認と巻き戻しを、新しいインスタンスか別の場所に置く必要がある | 実行(D) |
KillMode=process | ヘルパーは生き残る | 次のインスタンスに紛れ込む。ドキュメントは非推奨 | 実行(B) |
| サービスではなく、タイマーかパスユニットで起動する別の更新ユニット | 更新プロセスがサービスのcgroupと無関係になる | ユニットが増える | 未実行 |
macOSの launchd | launchd.plist(5) には、ジョブが死ぬと、AbandonProcessGroup がtrueでない限り、launchdが同じプロセスグループIDの残りのプロセスを終了させるとある | 仕組みも、逃れる条件も違う | 未実行。この環境にmacOSはありません |
launchd については、launchd.plist(5) のページを読みました。そこでの規則はプロセスグループに関するものなので、setsid した子は独自のグループを持ち、systemdとは挙動が違うかもしれません。これは文章からの推測で、動かしてはいません。
僕の選択は、サービスが起動後に自分で検証できるならD、外部の観察者が検証と巻き戻しをする必要があるならCです。巻き戻すかを決めるヘルパーが、自分が止めるかもしれないものの中にいてはいけません。
この直し方が当てはまる場面
| 状況 | 選択 |
|---|---|
| systemd配下の自己更新デーモンで、再起動をまたいで生き残る監視役が要る | 監視役を systemd-run で起動 |
| 新しいバージョンが起動時に自分の健全性を確認でき、壊れていれば非ゼロで終了できる | 終了して、Restart= に任せる(適切な回数制限つき) |
| 更新をパッケージマネージャーや、サービスの外のデプロイツールが行う | サービスを巻き込まない。サービス自身に再起動させない |
ユーザーサービス(systemd --user)、systemdのないコンテナ、macOS | ここでは未検証。そのプラットフォームの同等の規則を確認してください |
バグを呼び戻す習慣
setsid、nohup、disown、ダブルフォークに頼る。 症状: サービスを手で動かすとヘルパーは動くのに、systemd配下では消える。setsidは上(A)で再現しました。他は試していません。これらが変えるのはプロセスや端末の関係であって、cgroupではありません。KillMode=processで「直す」。 症状: 停止後に孤児の子プロセスが残る。次のインスタンスの状態にヘルパーが現れる。再現しました(B)。- 更新のたびに同じ固定のユニット名を使う。 症状: 1回目のヘルパーが残っている間、2回目のヘルパーの起動に失敗する。衝突は試していません。
--collectは終わったユニットをsystemdの記憶から消しますが、名前がまだアクティブなときの挙動は、お使いのsystemdのバージョンで確認してください。 - 死ぬcgroupの外にログを出さない。 症状: 最初の行は見えるのに、最後の行がない。ジャーナルや、ヘルパーが自分で開くファイルなど、生き残る場所に、危険な手順の前に書きます。
- 「サービスが再起動した」を「更新が成功した」とみなす。 症状: 壊れたバージョンが動いているのに、誰も気づかない。Aで失われたのは、まさにこの確認です。生き残る場所に置いてください。
- 更新プロセスを手でしか試さない。 症状: 端末では動くのに、systemd配下では動かない。端末から動かすと、片付けられるユニットのcgroupがありません。実際のサービスマネージャーのもとで、更新を試してください。
4つの試行を動かす
systemdがPID 1で、rootが使えるマシンが要ります。使い捨てのVMが最適です。スクリプトは /run/systemd/system にユニットを書きますが、そこは揮発性です。
sudo mkdir -p /run/demo && sudo cp app.py updater.sh unit.sh trial.sh /run/demo/ && sudo chmod +x /run/demo/*.shsudo /run/demo/trial.sh "A" popenを実行します。成功なら、試行Aと同じく「restart finished」の行がなく、「start」の行に出るcgroupはdemo-app.serviceです。sudo /run/demo/trial.sh "B" popen KillMode=processでは、最後の行が出ます。sudo /run/demo/trial.sh "C" systemd-runでは、cgroupがdemo-updater.serviceで、最後の行が出ます。sudo /run/demo/trial.sh "D" exit Restart=alwaysでは、アプリが自力で再起動します。popen KillMode=processの試行に追加の引数としてEnvironment=UPDATER_SLEEP=30を渡し、最初の数秒のうちにsystemctl status demo-app.serviceを実行すると、紛れ込んだヘルパーが見えます。- 片付けは
sudo systemctl stop demo-app.service demo-updater.service; sudo rm -rf /run/demo /run/systemd/system/demo-app.service; sudo systemctl daemon-reloadです。
使い捨てのsystemdで分かったこと、分からないこと
確認できたこと: 試行A、B、C、D、そしてヘルパーを眠らせた拡張版のBを、上に示したとおりに、Linux 6.12のマシンで、使い捨てのPID・マウント・cgroupの名前空間のPID 1として動くsystemd 257のもと、rootで動かしました。それぞれ1回、拡張版のBは1回です。systemd.kill(5)、systemd-run(1)、launchd.plist(5) からの引用は、書く際に元のページで読みました。
確認できていないこと: SIGTERM を無視するハンドラーを置いた更新プロセスが、あとから来る SIGKILL までどうなるか(trapして終了するところまでしか見ていません)、systemd --user、root以外のユーザーで動くサービス(その場合に systemd-run が要る権限も)、コンテナ、他のディストリビューションやsystemdのバージョン、macOSの launchd で AbandonProcessGroup が setsid した子に効くか、ヘルパーのユニット名の衝突、更新自体が失敗して巻き戻しが走るとき。ログのタイムスタンプは僕のマシンの時計のもので、1秒のsleepは実験用の値です。
同じcgroupなら、運命も同じ
同じcgroupにいるプロセスは、セッションやプロセスグループが何と言おうと、同じサービスの一部です。サービスの再起動をまたいで生きるべきものには、独自のユニットを与えるか、その必要自体をなくします。