subprocess で timeout が発生した際に子プロセスをまとめてkillする(Python)

課題

subprocessモジュールを利用して外部のシェルスクリプトを起動することがある。そのシェルスクリプトにおいて子プロセス(バックグラウンドプロセスなど)が動作している最中に、subprocessで設定したtimeoutが発生した。このとき、直接起動したシェルスクリプトのプロセスはkillされるが、その子プロセスは残存しているようだ。timeoutが発生した際には子プロセスも含めてkillしたいがどうすればよいか。

解決策

対策概要

起動するプロセスを別のセッションとして管理し、python の timeout 例外 catch において、プロセスグループ ID に対してkill する。

発生状況

最初に「課題」の状況が発生する様子を示す。以下のようなスクリプトを用意した。シェルスクリプトでは(subprocessで設定したtimeoutの期間より十分長い期間の)sleepのプロセスを複数起動している。つまり、シェルスクリプトで複数のsleepが起動している間に、subprocessでtimeoutする状況を作っている。

@main.py

#!/usr/bin/env python3
import os
import sys
import subprocess

if __name__ == '__main__':
    sub_cmd = './sub.sh'
    ps_cmd  = 'ps -e -o command,pid,ppid,pgid,sess | grep sleep | grep -v grep | awk \'$1=$1{print}\''

    cmd  = sys.argv[0]
    pid  = os.getpid()
    ppid = os.getppid()
    pgid = os.getpgrp()
    sid  = os.getsid(os.getpid())

    print('# this process ##################')
    print('{} {} {} {} {}'.format(cmd, pid, ppid, pgid, sid))

    try:
        print('start the subprocess')
        subprocess.run(sub_cmd, shell=True, timeout=5)
        print('end the subprocess')

    except subprocess.TimeoutExpired:
        print('timeout occured')
        print('# ps output #####################')
        ret = subprocess.run(ps_cmd, shell=True, stdout=subprocess.PIPE)
        print(ret.stdout.decode('utf-8', errors='ignore'), end='')

@sub.sh

#!/bin/sh

sleep 10 &
sleep 20 &
sleep 30

とくに main.py の ps_cmd は、psコマンドを利用して、そのときに残存する sleep のプロセスの情報を取得するためのコマンドラインの文字列である。取得する情報は左から順に、コマンドライン、PID、PPID、PGID、SIDである。これを実行すると以下のようになった。

$ ./main.py 
# this process ##################
./main.py 2113 1611 2113 1611
start the subprocess
timeout occured
# ps output #####################
sleep 10 2116 2115 2113 1611
sleep 20 2117 2115 2113 1611
sleep 30 2118 2115 2113 1611

上記の状況をざっくりと整理すると以下のようになる。

  • python プロセス(main.py)と sleep プロセスは同じプロセスグループ・セッションに所属している。
  • sh プロセス(sub.sh)が kill されたあとにも sleep プロセスは残存している。

対策詳細

次に、本記事の趣旨を確認するため、python スクリプトを以下のように書き換えてみた。変化点は以下だ。

  • subprocess.run 利用せず、subprocess.Popenで外部プロセスを起動し wait で完了を待つ方式としている。Popenで起動する際に start_new_session=True を指定している。
  • 外部プロセスが所属するプロセスグループに対して TERM シグナルを送信している。

@main.py

#!/usr/bin/env python3
import os
import sys
import subprocess
import signal

if __name__ == '__main__':
    sub_cmd = './sub.sh'
    ps_cmd  = 'ps -e -o command,pid,ppid,pgid,sess | grep sleep | grep -v grep | awk \'$1=$1{print}\''

    cmd  = sys.argv[0]
    pid  = os.getpid()
    ppid = os.getppid()
    pgid = os.getpgrp()
    sid  = os.getsid(os.getpid())

    print('# this process ##################')
    print('{} {} {} {} {}'.format(cmd, pid, ppid, pgid, sid))

    try:
        print('start the subprocess')
        p = subprocess.Popen(sub_cmd, start_new_session=True, shell=True)
        p.wait(timeout=5)
        print('end the subprocess')

    except subprocess.TimeoutExpired:
        print('timeout occured')

        print('# ps output #####################')
        ret = subprocess.run(ps_cmd, shell=True, stdout=subprocess.PIPE)
        print(ret.stdout.decode('utf-8', errors='ignore'), end='')

        os.killpg(os.getpgid(p.pid), signal.SIGTERM)
        print('< kill executed >')

        print('# ps output #####################')
        ret = subprocess.run(ps_cmd, shell=True, stdout=subprocess.PIPE)
        print(ret.stdout.decode('utf-8', errors='ignore'), end='')

これを実行すると以下のようになった。

$ ./main.py 
# this process ##################
./main.py 2157 1611 2157 1611
start the subprocess
timeout occured
# ps output #####################
sleep 10 2160 2159 2158 2158
sleep 20 2161 2159 2158 2158
sleep 30 2162 2159 2158 2158
< kill executed >
# ps output #####################

上記の状況をざっくりと整理すると以下のようになる。

  • python プロセス(main.py)と sleep プロセスは異なるプロセスグループ・セッションに所属している。
  • sh プロセス(sub.sh)が kill されたあとにも sleep プロセスは残存しているが、shプロセスのプロセスグループ全体に対して TERM シグナルを送信すると sleep プロセスは終了する(最後の ps の出力が空になっていることからわかる)


これの肝は sh プロセス(sub.sh)以下のすべてのプロセスを、pythonプロセス(main.py)とは異なるプロセスグループ(セッション)として管理していることだ。わざわざ異なるプロセスグループ(セッション)にしなくてもそのまま kill すれば良いのでは?と思った人は気をつけるべきだ。果たしてそのプロセスグループはどの範囲のプロセスが所属しているものだろうか。もしかしたら想像もつかないプロセスまで巻き込んで kill してしまうかもしれない。本記事での python プロセス(main.py)はもちろんのこと。

所感

自身が業務で出会った課題に対する解決策である。まさしく、外部プロセスを起動して timeout が発生したときに子プロセス群が残留して困っていた。もう少し詳細には、あるループ処理の中で subprocess.run を呼んでおり、timeout が発生するとその子プロセスが残留して、次のループに悪影響を及ぼすような状況であった。「参考」に記載した記事を書いてくれた人、ありがサンキュー。

注意

本検証は Rasberry Pi(Rasberry Pi OS)にて行った。

参考

Kill a Python subprocess and its children when a timeout is reached