Python subprocessで本番が月4回止まった話。Windowsタスクスケジューラ地獄を92日0停止にした42行テンプレ

地方都市で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日溶かさず済む。

明日も暖気エンジンの横でバッチを書きます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次