朝いちばんにセッションを開いたら、MCP サーバが 1 台だけ立ち上がりませんでした。
ログには address already in use の一行。ポートを握っていたのは、前夜のセッションで止めたはずの、まさに同じサーバでした。
セッションは確かに閉じています。ホスト側のプロセスも消えています。それでも子は生きている。個人開発で回している構成なので、朝の一台が止まっていることに気づけました。台数と人数が増えれば、この手の取り残しは静かに積み上がります。
手元の ps を眺めながら、「止めた」と「止まった」の間にどれだけの隙間があるのかを、一度きちんと測っておきたくなりました。
停止方法だけを変えて、同じツリーを 84 回作り直す
MCP サーバは多くの場合、ホストから子プロセスとして起動されます。sh -c を挟み、ランチャを挟み、その先に実体がいる。この途中の一段でもシグナルを飲み込めば、末端は取り残されます。
そこで、実運用に近い 3 段のツリーを最小構成で再現しました。末端は TCP ポートを掴んだまま待ち続けるだけのサーバです。ポートを持たせたのは、「生きているかどうか」を主観ではなく次の bind の成否で判定したかったからです。
# srv.py — ポートを掴んで待つだけの末端プロセス
import socket, sys, os, time
port, mode, tag = int(sys.argv[1]), sys.argv[2], sys.argv[3]
if mode == "own_group":
os.setsid() # 自分で新しいセッション/プロセスグループを作る
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
s.listen(8)
open(tag + ".srv.pid", "w").write(str(os.getpid()))
while True:
time.sleep(0.05)
# wrap.py — 子を起動して待つだけのラッパー(シグナルを転送しない)
import subprocess, sys, os
port, mode, tag = sys.argv[1], sys.argv[2], sys.argv[3]
open(tag + ".wrap.pid", "w").write(str(os.getpid()))
subprocess.Popen([sys.executable, "srv.py", port, mode, tag]).wait()
シグナルを転送しないラッパーは、意地悪な仮定ではありません。パッケージランナーやシェルスクリプト経由でサーバを起動している構成では、むしろこちらが既定の振る舞いです。
ホスト側は start_new_session=True でツリーを起動し、LISTEN を確認してから停止方法を適用します。停止後は 1.5 秒を上限に、末端プロセスの生存とポートの再 bind 可否を 10ms ごとに観測しました。
各条件 12 試行、全 7 条件で 84 試行。起動から LISTEN までは中央値 41.5ms で、条件間の差はありませんでした。
PID に SIGTERM を送る — 12 回中 12 回、孤児が残る
最初はいちばん素朴な方法です。ホストが把握している先頭の PID に SIGTERM を送ります。
os.kill(top.pid, signal.SIGTERM)
結果は、孤児率 100%、ポート占有率 100%。12 回すべてで末端サーバが生き残り、12 回すべてで次の bind が失敗しました。
理由は単純です。SIGTERM はプロセス 1 つに届くだけで、子孫には伝播しません。ラッパーは素直に終了し、その瞬間に末端は親を失って PID 1 に引き取られる。ホストから見れば「起動したプロセスは消えた」ので、成功に見えてしまいます。
ここが厄介なところでした。停止処理の戻り値は正常です。ログにも異常は出ません。翌朝の address already in use まで、誰も失敗に気づけません。
プロセスグループ宛なら 0%。ただし setsid で崩れる
次に、プロセスグループ全体へ送ります。start_new_session=True で起動したので、ツリー全体が 1 つのグループに入っているはずです。
os.killpg(top.pid, signal.SIGTERM)
孤児率 0%、ポートの解放までは中央値 10.4ms、p90 10.5ms。12 回すべてで綺麗に片付きました。
ここで終われば、話は「プロセスグループ宛に送りましょう」で済みます。実際、私も一度はそう結論しかけました。
ここに落とし穴がありました。念のため、末端が自分で os.setsid() を呼ぶ条件も測っております。デーモン化するサーバや、シグナルの巻き添えを回避したいランタイムが実際に行う処理です。
孤児率は 100% に戻りました。
setsid() を呼んだ瞬間、そのプロセスは新しいセッションと新しいプロセスグループの長になります。親のグループ ID とは別物になるので、killpg の宛先から外れる。親子関係(ppid)はそのまま残っているのに、シグナルだけが届かなくなるのです。
つまりプロセスグループ宛の送信は、「相手が自分のグループから出ていかない」という、こちらが制御できない前提の上に成り立っていました。MCP サーバは第三者が書いたコードです。その前提を握ることはできません。
猶予つきエスカレーションが隠していたもの
現場でよく採用されるのは、SIGTERM を送り、猶予を置いて、落ちていなければ SIGKILL に上げる二段構えです。これも測りました。先頭 PID に SIGTERM を送り、500ms 待ってから、グループ宛に SIGKILL を送ります。
通常の子に対しては孤児率 0%。ただし、ポートの解放までの中央値は 516.9ms でした。
内訳を見て、手が止まりました。猶予中に終了した試行は 12 回中 0 回。500ms は毎回まるごと消費され、実際に効いていたのは最後の SIGKILL だけです。
猶予期間というのは本来、「行儀よく終わる時間を与える」ための設計です。ところがこの構成では SIGTERM が末端に届いていないので、待っても状況は変わりません。猶予は、シグナルが届いていない事実を 500ms 分だけ覆い隠す装置になっていました。
正しく届いた場合の停止は 10.4ms です。50 倍の時間を払って、届かなかったことを隠していた計算になります。
そして setsid を呼ぶ子に対しては、この二段構えでも孤児率 100% のままでした。SIGKILL もまたグループ宛だからです。強い信号に上げても、宛先が間違っていれば結果は変わりません。
| 停止方法 | 末端の挙動 | 孤児率 | ポート占有 | 解放まで(中央値 / p90) |
| PID 宛 SIGTERM | 通常 | 100% | 100% | 解放されず |
| プロセスグループ宛 SIGTERM | 通常 | 0% | 0% | 10.4ms / 10.5ms |
| プロセスグループ宛 SIGTERM | setsid | 100% | 100% | 解放されず |
| TERM → 500ms 猶予 → グループ KILL | 通常 | 0% | 0% | 516.9ms / 517.5ms |
| TERM → 500ms 猶予 → グループ KILL | setsid | 100% | 100% | 解放されず |
| 子孫スイープ | 通常 | 0% | 0% | 10.8ms / 11.0ms |
| 子孫スイープ | setsid | 0% | 0% | 10.8ms / 11.0ms |
各条件 12 試行。ポート占有は、停止処理の完了直後に同じポートへ再 bind できなかった割合です。
ppid を辿って、宛先を自分で作る
グループ ID が信用できないなら、信用できるものを使います。親子関係です。
setsid() はセッションとプロセスグループを変えますが、ppid は変えません。/proc の各エントリから ppid を集めて木を組み直せば、相手がどこのグループへ移っていようと子孫を列挙できます。
"""子プロセスとして起動した MCP サーバを, 取りこぼしなく停止する."""
import os
import signal
import time
def _read_stat(pid):
"""/proc/<pid>/stat から (ppid, starttime) を返す. 読めなければ None."""
try:
with open(f"/proc/{pid}/stat", "r") as f:
raw = f.read()
except (FileNotFoundError, ProcessLookupError, PermissionError):
return None
# comm には空白や ')' が入りうるので, 最後の ') ' で切る
try:
fields = raw.rsplit(") ", 1)[1].split()
except IndexError:
return None
return int(fields[1]), int(fields[19]) # ppid, starttime
def _is_live(pid, starttime):
"""zombie を除いた生存判定 + PID 再利用ガード."""
st = _read_stat(pid)
if st is None or st[1] != starttime:
return False # 死亡, または同じ PID の別プロセス
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
return len(f.read()) > 0 # zombie は cmdline が空になる
except OSError:
return False
def snapshot_tree(root_pid):
"""root_pid 以下の子孫を (pid, starttime) で収集し, 葉から順に返す."""
children, meta = {}, {}
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
pid = int(entry)
st = _read_stat(pid)
if st is None:
continue
children.setdefault(st[0], []).append(pid)
meta[pid] = st[1]
ordered, stack = [], [root_pid]
while stack:
pid = stack.pop()
if pid in meta:
ordered.append((pid, meta[pid]))
stack.extend(children.get(pid, []))
return list(reversed(ordered)) # 葉から先に止める
def terminate_tree(root_pid, grace=0.5, poll=0.01):
"""子孫を SIGTERM で止め, 猶予内に落ちなかったものだけ SIGKILL する.
返り値: (対象数, SIGKILL に落ちた数)
"""
targets = snapshot_tree(root_pid)
if not targets:
return 0, 0
for pid, _ in targets:
try:
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
deadline = time.monotonic() + grace
while time.monotonic() < deadline:
if not any(_is_live(pid, st) for pid, st in targets):
return len(targets), 0
time.sleep(poll)
killed = 0
for pid, st in targets:
if _is_live(pid, st):
try:
os.kill(pid, signal.SIGKILL)
killed += 1
except (ProcessLookupError, PermissionError):
pass
return len(targets), killed
この方法での結果が、表の最後の 2 行です。通常の子でも setsid を呼ぶ子でも、孤児率 0%、解放まで 10.8ms。SIGKILL へ落ちた対象は 1 つもありませんでした。宛先さえ合っていれば、猶予はほとんど消費されません。
葉から順に止めているのは、親を先に消すと、その時点で残っている子の ppid が 1 に書き換わり、次のスナップショットで辿れなくなるからです。列挙は停止の前に一度だけ行い、以降はその配列を宛先として使い切ります。
starttime の比較は、列挙から kill までのわずかな隙間で PID が再利用された場合の保険です。停止処理そのものが数十ミリ秒で終わる以上、実際に踏む確率は高くありません。それでも、無関係のプロセスを殺し得る経路を残したくはありませんでした。
zombie が、生存判定を狂わせていました
このスイープを最初に測ったとき、中央値は 505.9ms でした。
猶予をまるごと使い切っている。ppid で辿っているのだから宛先は合っているはずなのに、なぜ落ちないのか。私自身、しばらく kill の戻り値や権限ばかりを疑って時間を溶かしました。
原因は停止側ではなく、判定側にありました。生存確認に os.path.exists(f"/proc/{pid}") を使っていたのです。
終了したプロセスは、親が回収するまで zombie として残ります。そして zombie は /proc にエントリを持ち続けます。ラッパー自身も同時に終了しているため、末端を回収する親がいない。ディレクトリだけが猶予の終わりまで残り続けていました。
実際にはサーバは 10ms 台で終わっており、ポートもとうに解放されていました。私が見ていたのは、死んだプロセスの抜け殻です。
判定を cmdline の中身が空でないことに変えたところ、中央値は 10.8ms になりました。zombie は cmdline を空にするので、これで実体のあるプロセスだけを数えられます。
ここで学んだのは、停止処理の正しさは「送り方」と「見え方」の両方で決まるということでした。宛先が合っていても判定が間違っていれば、正しい実装が遅い実装に見えてしまいます。逆に、判定が甘ければ止まっていないものを止まったと報告してしまう。今回の最初の 3 条件で起きていたのは、まさに後者でした。
どれを選ぶか
自分で書いたサーバだけを相手にするなら、プロセスグループ宛の SIGTERM で十分です。10.4ms で片付き、実装も 1 行で済みます。setsid() を呼ばないと保証できるのは、自分のコードだけだからです。
第三者の MCP サーバを 1 台でも起動するなら、ppid を辿る方式に寄せる価値があります。追加コストは列挙の一手間だけで、停止時間はほとんど変わりませんでした。私はこの方式を既定に切り替え、自前のサーバだけで完結する検証用の構成にだけグループ宛を残しております。
本番運用に近い環境ほど、起動しているサーバの素性は把握しづらくなります。「全部が行儀よく振る舞う」という前提を置かずに済む方を選んでおくと、後から一台増えたときに何も考えなくて済みます。
猶予つきエスカレーションを既に組んでいる場合は、猶予中に何件が自発的に終了したかを一度記録してみてください。その数が 0 に張り付いているなら、猶予は安全マージンではなく、届いていないシグナルの隠れ蓑になっています。
そして計測を仕込むなら、ポートのような外形的な指標を 1 つ持たせておくことをお勧めします。プロセスの生死は zombie に惑わされますが、「次の bind が通るか」は嘘をつきません。今回、停止方法ごとの差がはっきり出たのは、この 1 本の指標があったおかげでした。
MCP まわりの起動側については、起動 3 ミリ秒の裏で、最初のターンはツールを 0 本しか見ていませんでしたとサーバーごとに秒数を書く前に測ったこと — MCP の接続と呼び出しは 486 倍違っていましたでも実測を並べております。起動と停止は、同じライフサイクルの両端です。
今朝の一台から始まった調査でしたが、測ってみると自分の思い込みのほうがよほど頑固でした。お読みいただきありがとうございました。