問題の背景
Goのexec.Commandを使用してプロセスを起動する際、主プロセスは正常に終了するものの、起動した子プロセスの子プロセス(「孫プロセス」)がまだ実行されているという厄介な問題に直面しました。
mcpRunner := fmt.Sprintf("\"%s %s\"", s.Config.Command, strings.Join(s.Config.Args, " "))
cmd := exec.Command("/bin/sh", "-c",
fmt.Sprintf("%s --stdio %s --port %d",
config.COMMAND_SUPERGATEWAY,
mcpRunner,
s.Port))
なぜ/bin/shを使用するのか?
なぜならCOMMAND_SUPERGATEWAYプログラムは内部で/bin/shを介して子プロセスを起動するからです。もしCOMMAND_SUPERGATEWAYコマンドを直接起動すると、環境変数が/bin/shに正しくマウントされず、子プログラムコマンドが見つからない問題が発生します。
実際に実行されるコマンドは以下の通りです:
/bin/sh -c supergateway --stdio "uvx mcp-server-time --local-timezone=America/New_York" --port 10000
ここでuvx mcp-server-time...はsupergateway内部で実行される子コマンドです。
問題の現象
cmd.Process.Kill()を呼び出すと:
supergatewayプロセスは終了する- しかし
uvxプロセスはまだ実行中のまま!
原因分析
Kill()は直接の親プロセスのみを終了させるKill()はcmd.Processに対応するプロセス(ここでは/bin/sh)にSIGKILLを送信しますが、そのプロセスが起動した子プロセス(例えばuvx)は自動的に終了しません。Unixシステムでは、これらの子プロセスはPID 1(
init)にアタッチして実行を続けます。シェルのプロセスグループ管理
/bin/sh -c "command"で起動する場合、commandは**新しいプロセスグループ(PGID)**に属する可能性があり、Kill()はデフォルトで単一のプロセス(PID)のみを対象とします。
解決策:プロセスグループの使用
プロセスツリー全体を完全に終了させるには、プロセスグループ(Process Group)の概念を使用する必要があります:
cmd := exec.Command("/bin/sh", "-c",
"supergateway --stdio 'uvx mcp-server-time --local-timezone=America/New_York' --port 10000")
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // 新しいプロセスグループを設定
}
if err := cmd.Start(); err != nil {
log.Fatal(err)
}
// プロセスグループ全体を終了(子プロセスを含む)
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
log.Printf("プロセスグループの終了に失敗: %v", err)
}
_ = cmd.Wait() // リソースを回収
重要ポイント
Setpgid: trueにより/bin/shと子プロセスが同じプロセスグループに属するようになりますsyscall.Kill(-pid, sig)の負のPIDは、プロセスグループ全体を終了させることを意味します