Pythonのsubprocess.runで終了コードとタイムアウトを見分ける:失敗を正常終了と誤認しない実験

プログラミング・Web開発カテゴリを表すパンダのイラスト プログラミング・Web開発

この記事について
この記事は、生成AIを活用した自動生成フローで作成しています。Python 3.13の公式subprocess仕様を確認し、Linux上のPython 3.13.5で行った初回検証に加え、2026年10月28日にPython 3.12.14で固定commitのサンプルを再検証しました。再利用可能なコードと単体テストはDaily-Code-Samplesに分離しています。

検証ステータス:🧪 Linux / Python 3.13.5と3.12.14でCLIの3分岐と単体テスト9件を確認。Windows・macOS・本番業務コマンドは未検証。

外部コマンドをPythonから呼び出したとき、画面にエラーが表示されなければ成功だと思っていないだろうか。実際には、起動できなかった、起動したが異常終了した、時間切れで停止したという三つの状態は別物だ。ここを区別しないと、バッチ処理の後続工程が「前段の処理は完了した」と誤解し、古いデータを送ったり、未完成の結果を採用したりする。

今回は、外部サービスや実ファイルに触れず、Python自身を子プロセスとして起動する。わざと正常終了、異常終了、待機を発生させ、subprocess.run()の戻り値と例外を観察する。最後に、運用スクリプトへ組み込む際の判断基準まで整理する。

まず、3種類の結果を見比べる

前提はPython 3.9以降。追加のパッケージや管理者権限は不要だ。実験に使用したのはLinux上のPython 3.13.5で、WindowsとmacOSではこの掲載手順をまだ実機確認していない。

完成版のrun_demo.pyをGitHubのサンプルから取得し、ファイルのあるフォルダーで次を一つずつ実行する。

python3 run_demo.py --mode ok
python3 run_demo.py --mode fail
python3 run_demo.py --mode timeout --timeout 5

failとtimeoutは意図的にゼロ以外の終了コードを返す。&&で連結すると途中で次の実験が実行されないことがあるので、独立したコマンドとして試す。

正常終了の実測結果は次のとおり。

STATUS=OK
CHILD_RETURN_CODE=0
STDOUT=child: done
STDERR=

異常終了では、子プロセス自身が終了コード7を返し、エラー用の出力先へ文字を出した。

STATUS=NONZERO
CHILD_RETURN_CODE=7
STDOUT=
STDERR=child: failed

時間切れでは、待機中の子プロセスが終了させられた。今回のLinux実測では、待機開始を示す行も取得できた。

STATUS=TIMEOUT
CHILD_RETURN_CODE=N/A
STDOUT=child: waiting
STDERR=

ここで重要なのは、子プロセスの終了コードと、この検査プログラム自身の終了コードは別だということだ。failの子プロセスは7で終了したが、検査プログラムは「異常終了を検出した」という意味で1を返す。timeoutでは子プロセスの通常の終了コードは取得できないため、N/Aと表示し、検査プログラムが124を返す。124と127はこの教材が採用した分類値であり、Python全体で統一された意味ではない。

最小コードで、終了コードと出力先を観察する

最初は、時間切れの処理をいったん外して考える。Python自身を呼び出し、標準出力へ一行表示する最小例は次のようになる。

import subprocess
import sys

result = subprocess.run(
    [sys.executable, "-c", "print('hello')"],
    capture_output=True,
    text=True,
    encoding="utf-8",
    check=False,
    shell=False,
)
print(result.returncode)
print(result.stdout.strip())
print(result.stderr.strip())

このコードで最初に注目するのは、[sys.executable, "-c", ...]という引数のリストだ。sys.executableは現在動いているPythonインタープリターの実行ファイルを指す。OSによってpythonとpython3のどちらが通るかを推測せず、同じPythonを使うための工夫である。公式説明はPython sys.executableを参照してほしい。

capture_output=Trueを指定すると、子プロセスの標準出力と標準エラー出力を別々に回収できる。stdoutは通常の結果、stderrはエラーや診断メッセージに使われることが多い。ただし、stderrに文字があることだけで失敗と断定してはいけない。進捗をstderrへ出す正常なツールもある。成功・失敗の基本判定にはreturncodeを使い、メッセージは原因調査の材料として読む。

check=Falseでは、ゼロ以外の終了コードでもCompletedProcessが返る。反対にcheck=Trueを指定すると、ゼロ以外の場合にCalledProcessErrorが送出される。どちらが正しいというより、呼出し側が異常終了をどの形で扱いたいかの違いだ。今回は状態を並べて観察したいため、check=Falseを選んでいる。Python subprocess.runの公式仕様でも、これらの引数と戻り値が説明されている。

「失敗」と「時間切れ」は同じではない

たとえば夜間バッチがCSVを生成する外部プログラムを呼ぶとする。子プロセスが終了コード7を返したなら、プログラムは起動し、終了状態を返すところまでは進んだと分かる。一方、タイムアウトなら、指定時間内に完了を確認できなかったことが分かる。ファイルが完成したかどうかは、別途確認しなければならない。

時間切れの扱いを簡略化した例がこちらだ。

import subprocess
import sys

try:
    result = subprocess.run(
        [sys.executable, "-c", "import time; time.sleep(30)"],
        timeout=5,
        capture_output=True,
        text=True,
        check=False,
    )
    print("returncode:", result.returncode)
except subprocess.TimeoutExpired:
    print("TIMEOUT: 完了を確認できませんでした")

timeout=5は「どんな場合でも起動から正確に5秒で戻る」という保証ではない。Pythonの公式資料は、環境によってはプロセスの生成自体を途中で中断できないこと、期限に達すると子プロセスを終了させて待った後に例外を送出することを説明している。厳密な処理時間の上限が必要なら、OS側の監視やプロセス管理も設計対象になる。

また、タイムアウト時のTimeoutExpired.stdoutやstderrには、途中まで取得した出力が含まれることがある。この部分出力はtext=Trueを指定していてもバイト列になり得る。完成版では、bytesならUTF-8でデコードし、Noneなら空文字にする処理を用意している。これにより、タイムアウトしたときだけ表示処理が別の例外で落ちる事故を避けられる。

1か所変える:時間切れを正常終了へ変えてみる

次の実験は、--mode timeout --timeout 5の5を40に変更するだけだ。

python3 run_demo.py --mode timeout --timeout 40

このモードの子プロセスは30秒待ってから終了する。したがって、十分に余裕のある40秒を与えれば、通常はSTATUS=OKになると予想できる。40秒設定での再実行は今回未実施であり、ここは仕様とコードからの予想である。30秒待つため、短いテストだけを行いたい場合は実行しなくてよい。

この比較から、タイムアウトは単なる「失敗」の別名ではなく、与えた待ち時間と処理に要する時間の関係で決まる状態だと分かる。実務では、処理が遅いときに何秒まで待つのか、途中で止めてよい処理なのか、止めた後に何を確認するのかを先に決めておく必要がある。

失敗時の切り分け:起動できない場合も別に扱う

subprocess.run()を呼び出しても、実行ファイルが見つからない場合は子プロセスの終了コードすら得られない。たとえばPATHが通っていない、実行ファイル名が違う、実行権限がないといったケースだ。

完成版のrun_checked()はOSErrorを受け取り、START_FAILEDとして返す。教材では実パスやコマンド全文を表示せず、FileNotFoundErrorなどの例外型だけを表示する。ログに環境情報や秘密が混入することを避けるためだ。原因を調べるときは、実行ユーザー、実行ファイルの存在、権限、作業ディレクトリを別途安全な場所で確認する。

一方、NONZEROであれば子プロセスは起動している。標準エラー出力やプログラム固有の終了コード表を調べる。TIMEOUTなら、処理が停止しているのか、単に遅いのか、待機する外部資源があるのかを確認する。同じ「処理失敗」でも調査の入口が異なる。

状態最初に確認すること安易に行わないこと
OK出力データの妥当性・件数終了コード0だけで業務成功と断定
NONZERO子の終了コードとstderr無条件の自動再実行
TIMEOUT待機対象、所要時間、途中成果物途中ファイルを完成品として扱う
START_FAILED実行ファイル、権限、PATH例外全文や秘密値の公開

仕事で使うなら、どこまで安全を確保するか

今回の教材ではshell=Falseと引数リストを使用する。シェルに一つの文字列を解釈させずに実行することで、引用符や特殊文字の扱いを単純にできる。ただし、shell=Falseだけで任意の外部コマンドが安全になるわけではない。外部入力から実行ファイル名や引数を無制限に選べる設計では、別の危険が残る。実務では許可したコマンドと引数だけを使い、必要最小限の権限で実行する。

さらにcapture_output=Trueは結果をメモリに保持する。大量のログを吐く処理には向かず、出力先ファイルや逐次読み取りを検討した方がよい。ログにはトークン、個人情報、環境変数などが出力される可能性があるため、収集する項目を絞ることも必要だ。

子プロセスが別の孫プロセスを起動する場合、subprocess.run()のタイムアウトだけでプロセス群すべての終了が保証されるわけではない。重要なバッチではプロセスツリーの終了管理、排他制御、再実行時の冪等性、途中ファイルの検査を別の設計項目として扱う。

また、終了コード0は「そのプログラムが正常終了と判断した」ことを示すにすぎない。出力CSVが正しい列数か、想定件数が揃っているか、後続システムが受理したかは別の検査が必要になる。自動化で大切なのは、プログラムが動いた証拠と、業務上の結果が正しい証拠を分けることだ。

検証結果と残る範囲

Daily-Code-Samplesの完成版は、正常終了、非ゼロ終了、タイムアウト、起動失敗、部分出力のバイト列、引数リストとshell=False、不正なタイムアウト値などを対象に単体テスト9件を用意した。Linux / Python 3.13.5で9件成功し、py_compileも成功した。CLIの正常・非ゼロ・5秒タイムアウトも個別に実行し、STATUS=OK、NONZERO、TIMEOUTを確認した。

2026年10月28日の再検証では、同じ固定commitをLinux / Python 3.12.14で読み戻し、py_compile、単体テスト9件、CLIの正常終了・終了コード7・1秒タイムアウトを実行した。結果はそれぞれSTATUS=OK/呼出し側終了コード1/STATUS=TIMEOUTかつ呼出し側終了コード124となり、本文の分類と一致した。1秒は再検証時間を短くするために変更した条件であり、本文例の5秒および未実施の40秒実験とは区別する。

ただし、起動失敗と単体テスト内のタイムアウトはモックによる確認であり、Windows・macOSの実機テストや本番プログラムとの接続試験は行っていない。これらの範囲を混同して「すべての環境で検証済み」とはしない。

まとめ

subprocess.run()を運用スクリプトで使うなら、returncodeだけでなく、TimeoutExpiredと起動時のOSErrorを別々に扱うと障害調査が進めやすくなる。stdoutとstderrは原因調査に役立つが、それだけで成功・失敗を決めない。子プロセスの終了コードと、呼出し側が返す終了コードも区別する。

まずダミーの3分岐を観察し、待ち時間を変えたときの挙動を理解してから、実務の入力検証・成果物確認・ログ保護・再実行設計へ進めるのがよい。

公式情報・一次情報

GitHubサンプル

文書情報

記事タイトル
Pythonのsubprocess.runで終了コードとタイムアウトを見分ける:失敗を正常終了と誤認しない実験
作成日
更新日
Source URL
https://papanda925.com/?p=18261

ライセンス: 本記事のうち、当サイトが権利を有する本文・自作図表は、特記なき限り CC BY 4.0 で利用できます。生成AIを活用して作成・編集した内容を含みます。コードについて、別途ライセンス表示またはリンク先GitHubリポジトリのライセンスがある場合は、その条件を優先します。引用・第三者資料・画像・商標等は本ライセンスの対象外です。 利用ポリシー

タイトルとURLをコピーしました