Windowsタスクスケジューラで朝7時にPythonを動かしたかっただけなのに、3日溶けた話

正直に書くと、毎朝7時にPythonスクリプトを1本動かしたいだけで、3日溶かしました。

Macなら launchdcron で5分の話です。Windowsだって、タスクスケジューラに登録するだけ。そう信じていた金曜の夜、私はPowerShellの真っ黒な画面と、何も書かれていない結果欄を見つめていました。

地方の静かな部屋で、コーヒーが3杯目に入っていました。

目次

やりたかったのは本当に単純な話

業務で使うCSVを朝7時にダウンロードして、整形して、Slackに通知する。Pythonで書いた150行のスクリプトを、出勤前に終わらせておきたかっただけです。

スクリプト自体はPowerShellから手で叩けば完璧に動きます。

python C:\work\daily_report.py

戻り値0、出力もきれい、Slack通知もちゃんと飛んでくる。これを朝7時にタスクスケジューラから自動実行する。それだけの話のはずでした。

1日目: そもそも起動しない

最初、私は素直にタスクスケジューラの「基本タスクの作成」で進めました。

プログラム欄に python、引数に C:\work\daily_report.py。実行すると、結果欄に 0x1 という見慣れない数字が返ってきました。

ログをまず見ようとして、ここで2つ目の失敗をします。タスクスケジューラの「履歴」タブが無効化されていました。右側ペインの「すべてのタスク履歴を有効にする」を押してから、もう一度実行。

それでも 0x1。これは「一般的なエラー」という、何も言っていないに等しい返事です。

最終的にわかった原因は、python をフルパスで書いていなかったことでした。タスクスケジューラはユーザーの PATH をそのまま継承しません。コマンドプロンプトでは通っても、スケジューラからは別人として呼ばれます。

プログラム: C:\Users\taka\AppData\Local\Programs\Python\Python312\python.exe
引数: C:\work\daily_report.py
開始(オプション): C:\work

ポイントは3つ目の「開始」です。ここを空欄にしていると、相対パスでファイルを読むスクリプトが全部落ちます。私はここで2時間溶かしました。

2日目: 動いたけど文字化けで死ぬ

python.exe をフルパスにして、開始ディレクトリも入れて、これでようやく結果欄が 0x0 になりました。完了です。やった、と思いました。

ところが、ログファイルを開くと、こうなっていました。

?????? ???? ?? ?????

スクリプト内で print("処理開始: 朝のCSV取得") と書いている部分が、全部クエスチョンマークになっていました。

原因はWindowsコンソールの既定エンコーディングです。手動でPowerShellから叩いた時はUTF-8で動くのに、タスクスケジューラから起動されると cp932 環境で走り、UTF-8の絵文字や記号が化けます。

対策は2行で済みました。スクリプトの先頭に、こう入れます。

import sys
import io

if hasattr(sys.stdout, "buffer"):
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "buffer"):
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")

これでstdoutもstderrもUTF-8で吐かれます。ファイルに logging で書く場合は、明示的に encoding="utf-8" を渡します。

import logging
from pathlib import Path

log_path = Path("C:/work/logs/daily_report.log")
log_path.parent.mkdir(parents=True, exist_ok=True)

logging.basicConfig(
    filename=str(log_path),
    encoding="utf-8",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)

logging.info("処理開始: 朝のCSV取得")

地味ですが、encoding="utf-8" を書き忘れると、Windowsの既定で cp932 になります。日本語コメントを書いた瞬間に、ファイルが壊れます。私はここで90分溶かしました。

3日目: 動いたり動かなかったりする

3日目、文字化けは直りました。タスクスケジューラの結果欄も 0x0。完璧です。

ところが、月曜の朝、Slack通知が来ませんでした。

タスクスケジューラを開くと、ステータスは「正常終了」。でも、ログには何も残っていません。スクリプトが1行も実行されていない。

ここで30分悩んで、ようやく気づきました。タスクスケジューラの設定で、こうしていたのが原因です。

[全般] ユーザーがログオンしているときのみ実行する

これだと、PCがスリープしていたり、私がログオフしていると、タスクが「成功」したことになって何もしないで終わります。これが一番たちが悪い。

対策はチェックを切り替えます。

[全般] ユーザーがログオンしているかどうかにかかわらず実行する
[全般] 最上位の特権で実行する  (必要な場合のみ)
[条件] コンピューターをAC電源で使用している場合のみタスクを開始する  (オフ)
[条件] タスクを実行するためにスリープを解除する  (オン)
[設定] タスクを要求時に実行する  (オン)
[設定] スケジュールされた時刻にタスクを開始できなかった場合、すぐにタスクを実行する  (オン)

「ログオンしているかどうかにかかわらず実行する」を選ぶと、Windowsはユーザーパスワードを要求します。アプリケーションパスワードではなく、Windowsログインのパスワードです。これを入れないと保存できません。

ここで企業環境だと、IT管理者と相談が要る場面が出てきます。個人PCなら自分のパスワードを入れて先へ進みます。

結局たどり着いた最小構成

最終的に、私の朝7時タスクはこの形に落ち着きました。

タスクスケジューラ側の設定はこうです。

名前: daily_report_7am
全般:
  - ユーザーがログオンしているかどうかにかかわらず実行する
  - 構成: Windows 10
トリガー:
  - 毎日 07:00 開始
  - 同期する時間帯: ローカル
操作:
  - プログラム: C:\Users\taka\AppData\Local\Programs\Python\Python312\python.exe
  - 引数: -X utf8 C:\work\daily_report.py
  - 開始: C:\work
条件:
  - AC電源条件: オフ
  - スリープ解除: オン
設定:
  - 失敗したら3分後に再試行を3回まで
  - タスクが3時間を超えたら停止
  - すぐ実行: スケジュール時刻にPCが落ちていたら起動後に補完実行

ポイントは引数の -X utf8 です。Python 3.7以降で使えるUTF-8モードで、内部のファイル読み書きやprintのデフォルトをUTF-8に統一できます。スクリプト側にエンコーディングのおまじないを書く必要が少し減ります。

そして実行されるPythonスクリプトの先頭は、こんな感じにしています。

# daily_report.py
from __future__ import annotations

import io
import os
import sys
import logging
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

JST = ZoneInfo("Asia/Tokyo")
BASE = Path(r"C:\work")
LOG_PATH = BASE / "logs" / "daily_report.log"


def setup_logging() -> logging.Logger:
    LOG_PATH.parent.mkdir(parents=True, exist_ok=True)

    if hasattr(sys.stdout, "buffer"):
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    if hasattr(sys.stderr, "buffer"):
        sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")

    logger = logging.getLogger("daily_report")
    logger.setLevel(logging.INFO)
    logger.handlers.clear()

    fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
    fh = logging.FileHandler(LOG_PATH, encoding="utf-8")
    fh.setFormatter(fmt)
    sh = logging.StreamHandler()
    sh.setFormatter(fmt)
    logger.addHandler(fh)
    logger.addHandler(sh)
    return logger


def main() -> int:
    logger = setup_logging()
    started_at = datetime.now(JST)
    logger.info("daily_report 起動: %s", started_at.isoformat(timespec="seconds"))

    try:
        # ハマりポイント: 相対パスは絶対に書かない。BASEからの絶対パスで統一する
        target = BASE / "input" / "today.csv"
        if not target.exists():
            logger.warning("入力ファイルが無い: %s", target)
            return 0  # 失敗ではなく「やることが無い」で正常終了

        # ここで実際の処理を呼ぶ
        logger.info("CSV取得開始")
        # ... 本体 ...
        logger.info("Slack通知済み")
        return 0

    except Exception:
        logger.exception("daily_report 失敗")
        return 1


if __name__ == "__main__":
    sys.exit(main())

この骨格にしてから、朝7時の通知は1回も落ちていません。

個人的に効いた4つの確認手順

3日溶かして得た教訓は、地味な4つです。

1つ目は、タスクスケジューラに登録する前に、必ず別の標準ユーザー権限で cmd /c "python C:\work\daily_report.py" をフルパスで叩いてみることです。手動で動いても、タスクスケジューラの実行ユーザーから動くかどうかは別の話です。

2つ目は、Pythonをフルパスで指定することです。python だけで書くと、PATHの解決に依存して罠だらけになります。仮想環境を使うなら、venvの中の python.exe を直接指定します。

3つ目は、ログをファイルに必ず吐くことです。タスクスケジューラの結果欄は 0x00x1 しか教えてくれません。本当の失敗理由は、自分でファイルに書かないと分かりません。

4つ目は、失敗を恐れずに 0 を返すことです。「ファイルが無い」「Slackが落ちている」みたいな「外因の正常」は、戻り値を1にしないほうが落ち着きます。タスクスケジューラの履歴が真っ赤になると、本当に直すべき事故が埋もれます。

やってよかったコマンド一発登録

GUIでポチポチやるのも疲れたので、最終的にPowerShellワンライナーで登録するようにしました。

$Action = New-ScheduledTaskAction `
  -Execute "C:\Users\taka\AppData\Local\Programs\Python\Python312\python.exe" `
  -Argument "-X utf8 C:\work\daily_report.py" `
  -WorkingDirectory "C:\work"

$Trigger = New-ScheduledTaskTrigger -Daily -At 7:00am

$Settings = New-ScheduledTaskSettingsSet `
  -StartWhenAvailable `
  -DontStopIfGoingOnBatteries `
  -AllowStartIfOnBatteries `
  -WakeToRun

Register-ScheduledTask `
  -TaskName "daily_report_7am" `
  -Action $Action `
  -Trigger $Trigger `
  -Settings $Settings `
  -User "$env:USERDOMAIN\$env:USERNAME" `
  -RunLevel Highest

これで、PCを買い替えても10秒で再現できます。GUIで設定した内容をスクショに残すより、コードで残したほうが楽です。3年後の自分が泣かなくて済みます。

実際にやってみた感想として、Windowsタスクスケジューラは決して悪い道具ではありません。ただ、隠れた前提が多すぎるだけです。私の環境では、上の構成で半年運用していて、停止は計画再起動の翌朝1回だけでした。

私が3日で学んだ唯一の教訓

最初はエラーが出るたびに、新しい設定項目を探していました。

でも、振り返ってみると、私が踏んだ罠は全部「実行ユーザーが違う」「文字コードが違う」「PCが起きていない」の3パターンに集約されます。

タスクスケジューラの設定画面には、本当にたくさんのチェックボックスがあります。全部を理解しようとすると、また3日溶かします。

最初に押さえるのは、フルパス、UTF-8、ログオン状態。この3つだけです。残りは動いてから1個ずつ理解すればいいと、私は思います。

まとめ

Windowsタスクスケジューラは、Macのcronより一段難しい道具です。

でも、フルパスでPythonを指定し、UTF-8で出力を統一し、ログオン状態に依存しない設定にすれば、毎朝7時に確実に動きます。私の場合、3日溶けましたが、それ以降は1回も止まっていません。

業務自動化を始める時、一番ハードルが高いのは「コードを書くこと」ではなく「毎日確実に動かすこと」です。

このしょぼいPowerShellスクリプト1つで、自分の生活が10分早く始まる。そういう小さな自動化を積み上げていくのが、地方で1人でコードを書く週末の楽しみになりました。

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

この記事を書いた人

コメント

コメントする

目次