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つの別ファイルを探しに行っています。
ファイル名が日本語だったり、サイト名や パスにスペースが入ったりする環境では、この差が実害になります。基本方針は次のとおりです。
- まずリスト形式を使う。 引数の境界を Python 側で確定でき、シェルによる再解釈が起きません。
- どうしてもシェルの機能(パイプ、リダイレクト、ワイルドカード展開)が必要なときだけ
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か所の関数にまとめ、次の約束を持たせると、呼び出し側が楽に正しく書けます。
- 引数はリスト形式で受ける(
shell=Trueは例外扱い)。 - 必ず
timeoutを持つ(呼び出し側が省略できない)。 - 結果は「成功/コマンド失敗(終了コードつき)/時間切れ/コマンド不在」の区別がつく形で返す。
- 失敗時は、終了コードと stderr を記録に残す。
こうしておけば、「失敗しているのに成功に見える」状態を設計のレベルで減らせます。保守ツールでは、失敗を静かに見逃すことが一番高くつくためです。
外部コマンド呼び出しは、書くのは簡単ですが、失敗の仕方が多い領域です。クォート、タイムアウト、終了コードの3点を最初に押さえておくだけで、原因不明の「たまに壊れる」はかなり減ります。