地方都市で30代エンジニアの taka です。一人運用の業務自動化を5年。冬は出社前に車の暖気をしながらスマホでタスクスケジューラの履歴を確認するのが日課です。
今日の主役は地味だけど死ぬほど使う Python の `subprocess`。`subprocess.run` で外部CLI(`ffmpeg`、`git`、`gh`、Pandoc)を叩くだけのコードが本番で30日に4回止まりました。
この記事は **API説明ではなく、Windows タスクスケジューラ本番運用で Python 外部コマンドを一人で回している運用ログ**です。4つの落とし穴と42行テンプレ、スマホ通知込みチェックリスト。`subprocess timeout`・`Windows subprocess 文字化け`・`CalledProcessError stderr`・`Python subprocess タスクスケジューラ` で詰まった人向け。
一番痛かった朝:6:55に止まった配布ジョブの被害額
ある月曜朝6:55、タスクスケジューラ起動のジョブが exit code 0 なのに出力ゼロ。毎週月曜7:00に関係者11人へ配布する前段。
気づいたのは7:23。コーヒー片手にスマホで履歴を開くと「結果: 0x0」、出力フォルダは前日のまま空。血の気が引いた。
原因特定2時間、再配布30分、謝罪チャット11通、社内連絡1通、合計2時間50分が消滅。「資料まだですか?」「朝イチで使いたかった」と4人から連投され、9時の会議が15分繰り下げに。地方の小さい会社で代替担当はおらず、朝の15分遅延がそのまま現場に刺さる。これが30日に4回。
「絶対止めない」と決めて `subprocess.run` を4回直しました。順に。
構成:1本で4個のCLIを連結
Windows 11 のタスクスケジューラから毎朝6:55起動で、`git pull` → Pandoc で docx 化 → ffmpeg で冒頭2秒カット → `gh release upload` で配布、の4工程を1本の Python(287 行、`subprocess.run` 6箇所)で連結。最初のバージョンは全部こう。
import subprocess
subprocess.run("git pull", shell=True)
subprocess.run("pandoc input.md -o output.docx", shell=True)
subprocess.run("ffmpeg -i in.mp4 -ss 2 out.mp4", shell=True)
subprocess.run("gh release upload v1.0 ./dist/*", shell=True)
ローカル手動実行は完璧。本番で5日連続成功し、6日目に最初の事故。
落とし穴1:Windows subprocess 文字化け(shell=True と日本語パス)
朝6:55のジョブが6:55:02で終了、exit code 0、出力なし。`capture_output=True` を付け再実行するとこう出た。
pandoc: 鬩穂ク崎ア?npu.md: openBinaryFile: does not exist
パスに日本語(`C:\作業\tomori\`)が入っていて、`shell=True` 経由で cp932 と UTF-8 の境界で文字化け、Pandoc が「ファイルない」と言っていた。
直したのはこの数行。
subprocess.run(
["pandoc", str(input_path), "-o", str(output_path)],
shell=False,
check=True,
)
`shell=False` にして引数をリストで渡す。これだけで Windows でも Mac でも文字化けは消えました。所要時間:原因特定2時間、修正5分。
教訓:**`shell=True` は1行楽でもデバッグ100倍重い**。Windows 日本語パスなら最初から捨てる。
落とし穴2:subprocess timeout が無いとタスクスケジューラが永遠に「実行中」
落とし穴1を直して3日後、ジョブだけがずっと「実行中」の地獄。タスクマネージャを見ると Python プロセスが3時間生きて CPU 0%・メモリ 42MB で完全停止。
原因は ffmpeg。`-ss 2` の位置がおかしく確認プロンプトを標準入力に出していたが、タスクスケジューラ経由 Python には標準入力がなく、ffmpeg と私は永遠に待ち続けていました。
最初は `timeout` がなかったのです。こう直しました。
try:
subprocess.run(
["ffmpeg", "-y", "-i", str(src), "-ss", "2", str(dst)],
check=True,
timeout=180,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
logger.error("ffmpeg timeout after 180s, killing process")
raise
ポイントは3つ。
– `timeout=180` で最大3分でタイムアウト
– `stdin=subprocess.DEVNULL` でプロンプトを聞かれないよう入力を塞ぐ
– `ffmpeg -y` で「上書きしますか?」を最初から潰す
これでフリーズは2ヶ月ゼロ。教訓:**`subprocess timeout` と `stdin=DEVNULL`** が Windows タスクスケジューラ Python の命綱。「永遠に実行中」は翌朝まで気づかないので、最悪3分で死ぬ状態を作る。
落とし穴3:標準出力が4096バイト溜まってデッドロック
一番気持ち悪いやつ。Pandoc で大きい docx を作る時、特定サイズで途中で固まる。`timeout=600` で6分後に殺されるが毎朝6分待ち。1週間で7回連続失敗、原因究明に半日。
真犯人は `Popen` で `stdout=subprocess.PIPE` だけ指定し `communicate()` を呼ばず別ループで待っていたこと。私の環境では4KB前後でブロックを再現。読み取り側がいないと子プロセスの `write` が止まる。修正は `subprocess.run(…, capture_output=True)` で run に読み切らせるか、`Popen` なら `communicate()`。私は run に統一。
result = subprocess.run(
["pandoc", str(input_path), "-o", str(output_path)],
check=True,
timeout=300,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.stdout:
logger.info("pandoc stdout: %s", result.stdout[:2000])
if result.stderr:
logger.warning("pandoc stderr: %s", result.stderr[:2000])
`capture_output=True` で run 側に読み切らせる。`encoding=”utf-8″` と `errors=”replace”` も同時に入れないと cp932 バイナリ混入で UnicodeDecodeError 死。別の日に1回踏み、通算3回目の停止。
教訓:**標準出力は読み切る。読まないなら DEVNULL、読むなら capture_output**。都会の大規模SREなら監視で拾う話だろうけど、地方の一人運用は朝7時の自分が最後の監視者です。
落とし穴4:CalledProcessError stderr が無いと朝の自分が詰む
最後の停止は5月のある朝、`gh release upload` が失敗。exit code 1 で `check=True` の例外で死ぬのは正しい。問題はログがこう。
except Exception as exc:
logger.error("upload failed: %s", exc)
これだと「Command ‘[‘gh’, ‘release’, ‘upload’, …]’ returned non-zero exit status 1.」しか出ない。gh の言い分は `stderr` 側。朝7時に「nonzero exit 1」だけ眺めるのは地味に絶望。最終形はこれ。
try:
result = subprocess.run(
cmd,
check=True,
timeout=300,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except subprocess.CalledProcessError as exc:
logger.error("CLI failed: cmd=%s exit=%s", exc.cmd, exc.returncode)
logger.error("stdout: %s", (exc.stdout or "")[:2000])
logger.error("stderr: %s", (exc.stderr or "")[:2000])
raise
except subprocess.TimeoutExpired as exc:
logger.error("CLI timeout: cmd=%s after %ss", exc.cmd, exc.timeout)
raise
翌朝ログに `stderr: HTTP 422: validation failed (already exists)` と出ていて、「同じバージョンで2回上げてた」と5秒で原因が分かりました。
教訓:**`CalledProcessError` を捕まえたら `stdout` と `stderr` を別行で残す**。朝7時の自分のためにログを書く、これは一人運用の基本動作です。
最終形:42行テンプレ(92日停止ゼロ前提)
4回の停止を経て、社内7本の自動化が全部このラッパーに統一されています。Windows タスクスケジューラ本番運用+停止ゼロ+スマホ通知の一人運用前提。
from __future__ import annotations
import logging
import os
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
def run_cli(
cmd: list[str],
*,
cwd: Path | None = None,
timeout: int = 300,
env_extra: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""外部CLIを叩く安全なラッパー。subprocess.run の4大落とし穴を全部潰した版。"""
env = os.environ.copy()
if env_extra:
env.update(env_extra)
logger.info("run_cli: %s (cwd=%s timeout=%ss)", " ".join(cmd), cwd, timeout)
try:
result = subprocess.run(
cmd,
cwd=cwd,
shell=False,
check=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
except subprocess.CalledProcessError as exc:
logger.error(
"CLI failed: cmd=%s exit=%s\nstdout=%s\nstderr=%s",
exc.cmd, exc.returncode,
(exc.stdout or "")[:2000],
(exc.stderr or "")[:2000],
)
raise
except subprocess.TimeoutExpired as exc:
logger.error("CLI timeout: cmd=%s after %ss", exc.cmd, exc.timeout)
raise
if result.stdout:
logger.info("stdout: %s", result.stdout[:2000])
if result.stderr:
logger.warning("stderr: %s", result.stderr[:2000])
return result
たった42行。これで Pandoc・ffmpeg・git・gh・rclone・PDFtk・ImageMagick の7本を回しています。統一後、本番停止ゼロが92日続いています。
タスクスケジューラ特有の3点(Qiita/Zennにあまり無い部分)
対話実行と決定的に違う3点を書きます。
– **作業ディレクトリ問題**: ジョブ「開始」欄が空だと既定で `C:\Windows\System32` 起動。相対パスが全滅。`subprocess.run(…, cwd=…)` か「開始」欄絶対パス必須。
– **PATH差分問題**: 実行ユーザーの PATH は対話ログインと違うことがあり、`gh` や `ffmpeg` がローカルで見えて本番で見えない事故が起きる。外部CLIは絶対パス。
– **非対話stdin問題**: Python に標準入力が無く、CLIが何か聞いた瞬間固まる。`stdin=subprocess.DEVNULL` を全箇所。
今の全ジョブで踏む10項目のチェックリスト:
– `shell=True` を一切使わない(リスト渡し統一)
– 全 `subprocess.run` に `timeout`
– 全 `subprocess.run` に `stdin=subprocess.DEVNULL`
– `subprocess.run(…, cwd=…)` を必ず明示
– 外部CLIは絶対パス
– `capture_output=True` + `text=True` + `encoding=”utf-8″` + `errors=”replace”` セット
– `ffmpeg -y`、`git` の確認プロンプトを潰す
– `CalledProcessError` は `cmd / returncode / stdout / stderr` を別行
– `TimeoutExpired` は `cmd / timeout` を別行
– 失敗時はスマホへ Discord webhook 通知
10項目を埋めた朝、「履歴チェック→祈る」が「緑チェック確認」に変わり、毎朝17秒で92日続いています。
知りたかった3行
– `shell=True` は使わない。リストで渡す
– `timeout` と `stdin=DEVNULL` は必ずつける
– 例外は握り潰さず `stdout` と `stderr` を別行でログに残す
これだけで「30日4回止まる自動化」が「92日0回」に。ラッパーを `tools/_safe.py` に置けば3日溶かさず済む。
明日も暖気エンジンの横でバッチを書きます。

コメント