コンテンツへスキップ

Python から外部コマンドを呼ぶときの落とし穴 — クォート・タイムアウト・終了コード

Python のツールから ssh や wp(WP-CLI)、rsync のような外部コマンドを呼ぶ場面は多くあります。subprocess.run() を使えば数行で書けますが、「動いた」ことと「失敗したときに正しく気づける」ことは別の話です。

この記事では、外部コマンド呼び出しで繰り返し出会う3つの落とし穴 — 引数のクォート、タイムアウト、終了コード — を、Python 3.12 で実際に動かした結果を交えて整理します。Python 標準ライブラリの一般的な使い方の解説で、特定の製品に依存した話ではありません。また、セキュリティ上の具体的な手順には踏み込まず、設計の考え方にとどめます。

補足: 終了コード(exit status / return code) は、コマンドが終わるときに OS へ返す整数です。慣例として 0 が成功、それ以外が失敗を表します。

落とし穴1: 文字列で渡すと、空白で分割される

外部コマンドの呼び方には2通りあります。

  • リスト形式: subprocess.run(["ls", "my file.txt"]) — 引数を1つずつ分けて渡す。
  • 文字列+shell=True: subprocess.run("ls my file.txt", shell=True) — 1本の文字列をシェル(/bin/sh)に解釈させる。

空白を含むファイル名 my file.txt を渡したときの違いを、実際に試しました。

import subprocess
subprocess.run("ls my file.txt", shell=True)   # シェルが空白で分割する
subprocess.run(["ls", "my file.txt"])           # 1つの引数としてそのまま渡る

実行すると、前者のエラーは ls: my: No such file or directory と ls: file.txt: No such file or directory の2行、後者は ls: my file.txt: No such file or directory の1行でした。つまり shell=True の文字列版では、my と file.txt という2つの別ファイルを探しに行っています。

ファイル名が日本語だったり、サイト名や パスにスペースが入ったりする環境では、この差が実害になります。基本方針は次のとおりです。

  1. まずリスト形式を使う。 引数の境界を Python 側で確定でき、シェルによる再解釈が起きません。
  2. どうしてもシェルの機能(パイプ、リダイレクト、ワイルドカード展開)が必要なときだけ shell=True を使い、動的な値は shlex.quote() で包む。
import shlex
shlex.quote("it's a file.txt")   # => 'it'"'"'s a file.txt'

shlex.quote() は、シングルクォートを含む文字列でも安全に1語として扱える形に変換してくれます。

補足すると、ssh host "wp plugin list" のようにリモートへコマンドを送る場合は、クォートが二段になります。手元のシェル(または Python のリスト)で1回、リモート側のシェルでもう1回解釈されるためです。リモートに渡す文字列の中の動的な値にも、リモート側のシェル向けの shlex.quote() が必要になります。「手元で動いたのにリモートで空白やクォートが崩れる」という症状は、たいていこの二段目の見落としです。

落とし穴2: タイムアウトを付けないと、永遠に待つ

外部コマンドは、ネットワークの断絶やサーバー側の応答待ちで、いつまでも終わらないことがあります。subprocess.run() は、timeout を指定しなければ無期限に待ちます。

try:
    subprocess.run(["sleep", "5"], timeout=1)
except subprocess.TimeoutExpired as e:
    print("timeout:", e.cmd)

この例では約1.0秒で TimeoutExpired 例外が発生し、e.cmd に ['sleep', '5'] が入っていました。run() は時間切れの際に子プロセスへ kill を送り、終了を待ってから例外を上げます。

押さえておきたい点が3つあります。

  • 例外は呼び出し側で受ける。 受けなければツール全体が落ちます。「このサイトは時間切れだった」と記録して次へ進めるように、try/except を用意します。
  • shell=True では、kill される対象はシェル自身。 シェルが起動した孫プロセスまで確実に止まるとは限りません(Python 公式ドキュメントにも注意書きがあります)。この点も、リスト形式で直接コマンドを起動する方が扱いやすい理由です。
  • タイムアウト値は「通常の何倍か」で決める。 短すぎると正常な処理を切り、長すぎると異常の検知が遅れます。待ち方の設計は、別記事の「指数バックオフとリトライ設計」の考え方とつながります。

落とし穴3: 終了コードを見ないと、失敗が成功に見える

subprocess.run() は、コマンドが失敗しても既定では例外を投げません。CompletedProcess オブジェクトを返すだけです。終了コードの確認を忘れると、失敗したコマンドの空の出力を「結果なし」として受け取ってしまいます。

r = subprocess.run("echo out; echo err >&2; exit 3",
                   shell=True, capture_output=True, text=True)
# r.returncode == 3 / r.stdout == 'out\n' / r.stderr == 'err\n'

対処は2通りあります。

  • check=True を付ける: 失敗時に CalledProcessError が上がり、e.returncode で値を取れる。
  • r.returncode を自分で見て分岐する。

後者が必要になるのは、終了コード1が必ずしも「エラー」ではないコマンドがあるためです。たとえば grep は、一致なしのとき終了コード1を返します(実際に grep x /dev/null で1を確認)。「見つからなかった」と「実行に失敗した」を区別したいなら、コマンドごとの終了コードの意味を調べて分岐させます。

終了コードには、他にも癖があります。

状況 実測した結果
コマンドが存在しない(リスト形式) FileNotFoundError 例外
コマンドが存在しない(shell=True) 例外ではなく、終了コード 127(command not found)
シグナルで強制終了された returncode は 負の値(kill -9 で -9)
false の出力を true にパイプでつないで shell=True で実行 終了コード 0(最後のコマンドの結果になる)

最後のパイプの例は特に見落としやすく、途中のコマンドが失敗しても全体は成功に見えます。パイプ越しに結果を受け取るときは、各段の成否を別々に確認できる形(段ごとに run() を分ける、など)を検討してください。

stdout と stderr は別物として扱う

capture_output=True を付けると、標準出力は r.stdout、標準エラーは r.stderr に分かれて入ります。先の例でも 'out\n' と 'err\n' が別々に取れていました。

  • 機械が読む結果(JSON など)は stdout に出ることが多く、警告やログは stderr に出ることが多い。
  • 両方を混ぜて解析すると、警告の1行が JSON パースを壊すことがある。
  • 出力末尾には改行が付くので、比較の前に strip() する。
  • text=True を付けないと bytes が返る。日本語環境では文字コードの指定(encoding=)も明示しておく方が安全です(別記事の「UTF-8 と cp932」参照)。

設計としてのまとめ

外部コマンドを呼ぶ処理を1か所の関数にまとめ、次の約束を持たせると、呼び出し側が楽に正しく書けます。

  1. 引数はリスト形式で受ける(shell=True は例外扱い)。
  2. 必ず timeout を持つ(呼び出し側が省略できない)。
  3. 結果は「成功/コマンド失敗(終了コードつき)/時間切れ/コマンド不在」の区別がつく形で返す。
  4. 失敗時は、終了コードと stderr を記録に残す。

こうしておけば、「失敗しているのに成功に見える」状態を設計のレベルで減らせます。保守ツールでは、失敗を静かに見逃すことが一番高くつくためです。

外部コマンド呼び出しは、書くのは簡単ですが、失敗の仕方が多い領域です。クォート、タイムアウト、終了コードの3点を最初に押さえておくだけで、原因不明の「たまに壊れる」はかなり減ります。