JvLink To Importer

docker + cron で競馬バッチを回すときの落とし穴5選

docker + cron で競馬バッチを回すときの落とし穴5選

レース結果の収集、オッズの取得、週次の再訓練。競馬予測の運用は「開催日のスケジュールに合わせてバッチを回す」ことの積み重ねです。dockerコンテナとcron系スケジューラの組み合わせは定番ですが、実際に運用してみると独特のハマりどころがあります。この記事では、実害が出やすい順に5つの落とし穴と対策を紹介します。


落とし穴1: コンテナの時計は UTC かもしれない

最も古典的で、最も被害が大きい落とし穴です。コンテナに TZ=Asia/Tokyo を設定したつもりでも、ベースイメージに tzdata が入っていないと日時関数は UTC を返します。slim系やalpine系のイメージでは珍しくありません。

症状は「JST 21時に動くはずのオッズ取得が朝6時に動いている」「土曜のジョブが金曜の夜に発火する」といった形で現れます。9時間ズレるだけなので一見動いているように見えるのが厄介で、開催日判定が1日ズレて初めて気づいたりします。

確認はdateコマンドとPythonのdatetimeの両方で行ってください。シェルとアプリケーションで参照する仕組みが違うため、片方だけ正しいことがあります。

# コンテナ内で両方確認する
docker compose exec batch date
docker compose exec batch python -c \
"from datetime import datetime; print(datetime.now(), datetime.now().astimezone())"

2つの出力が9時間ズレていたら、Dockerfileに tzdata の導入を足します。

# Debian系の例
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
ENV TZ=Asia/Tokyo

なお、ホスト側の cron 設定がどのタイムゾーンで解釈されるかも別問題として存在します。「スケジューラの時計」と「ジョブの中の時計」は別物だと考えて、両方を確認するのが安全です。


落とし穴2: ジョブの失敗は黙っていても誰も教えてくれない

手元で叩くスクリプトと違い、cronで動くジョブは失敗してもエラーが目の前に出ません。気づくのは「今週の予測が更新されていない」と思った数日後です。

なので、機能を作り込む前にexit codeを拾って通知する仕組みを最初に作ることを勧めます。DiscordやSlackのWebhook 1本で十分です。

#!/bin/bash
# notify_wrap.sh — ジョブをラップして失敗時に通知する
JOB_NAME="$1"; shift

if "$@"; then
exit 0
else
code=$?
curl -fsS -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"content\": \"[FAIL] ${JOB_NAME} が exit code ${code} で失敗\"}"
exit $code
fi

ただし、これには抜け穴があります。メモリ不足でOOM killerに殺された場合、プロセスは SIGKILL で即死するため、ラッパーの通知処理ごと巻き込まれて何も飛ばないことがあります。SIGKILLはトラップ不能です。

そこで二段構えとしてハートビート監視を入れます。ジョブが正常完了するたびに状態ファイルを touch しておき、別のジョブがそのmtimeを見て「N時間更新がなければ警報」を出す方式です。

#!/bin/bash
# heartbeat_check.sh — 状態ファイルの mtime が古ければ警報(別ジョブとして毎時実行)
HEARTBEAT_FILE="/data/state/last_success"
MAX_AGE_HOURS=26  # 日次ジョブなら 24h + バッファ

if [ ! -f "$HEARTBEAT_FILE" ] || \
[ $(( ($(date +%s) - $(stat -c %Y "$HEARTBEAT_FILE")) / 3600 )) -ge $MAX_AGE_HOURS ]; then
curl -fsS -X POST "$WEBHOOK_URL" -H 'Content-Type: application/json' \
-d '{"content": "[ALERT] 結果収集ジョブの完了記録が26時間以上更新されていません"}'
fi

「失敗を通知する」と「成功が途絶えたことを検知する」は別の保険です。前者はジョブが死に方を選べた場合、後者はそれすらできなかった場合をカバーします。


落とし穴3: 設定を変えたのに効いていない

docker系のcronスケジューラには、ジョブ定義をコンテナのラベルや設定ファイルで持つものがあります。ここでハマるのが「設定を変えたのに反映されていない」問題です。

設定の種類によって反映条件が違います。

  • イメージの変更 → 再ビルド + コンテナ再作成が必要
  • ラベルの変更 → restart では反映されないことが多く、コンテナの再作成(up -d --force-recreate など)が必要
  • マウントした設定ファイルの変更 → アプリ側がhot-reloadするかどうか次第

特にラベル方式は罠です。docker compose restart はコンテナを作り直さないため、古いラベル=古いスケジュールのまま動き続けます。「スケジュールを21時に変えたのに20時に発火し続ける」という症状になります。

対策はシンプルで、設定変更のたびに「実際に何が読まれているか」を確認することです。docker inspect でラベルを見る、スケジューラのログで次回発火時刻を見る、など「変えたつもり」を信用しない習慣をつけます。


落とし穴4: ログを 1 ジョブ 1 ファイルにする

複数のバッチを動かしていると、ログが混ざった瞬間に調査不能になります。「土曜の結果収集だけが失敗した」とき、全ジョブの出力が1本のストリームに混ざっていると、該当箇所を探すだけで消耗します。

定石はteeで日付つきのジョブ別ファイルに落とすことです。

#!/bin/bash
# run_with_log.sh — 1ジョブ1ファイルでログを残す
JOB_NAME="$1"; shift
LOG_DIR="/data/logs/${JOB_NAME}"
mkdir -p "$LOG_DIR"

"$@" 2>&1 | tee -a "${LOG_DIR}/$(date +%Y%m%d).log"
exit "${PIPESTATUS[0]}"  # tee ではなくジョブ本体の exit code を返す

最後の PIPESTATUS[0] が重要です。パイプの exit code は最後のコマンド(tee)のものになるため、これを忘れると「ジョブが失敗してもラッパーは成功扱い」になり、落とし穴2の通知が一切飛ばなくなります。

古いログは find "$LOG_DIR" -name '*.log' -mtime +60 -delete のような掃除を月次ジョブにしておけば、ディスクも溢れません。


落とし穴5: 「発火しなかった」はログにすら残らない

競馬バッチ特有の事情として、週末だけ動くジョブが多くなります。そして週末ジョブの一番怖い故障モードは「失敗した」ではなく「そもそも発火しなかった」です。スケジューラ自体が止まっていた、設定変更で曜日指定が壊れた、コンテナが再作成されずに古い定義で動いていた(落とし穴3)。

このとき、ジョブは1行もログを吐きません。失敗ログを監視していても検知できないのです。何も起きなかったことは、起きた側からは観測できません

対策は落とし穴2のハートビート監視の応用で、「期待時刻の後に、成果物が存在するかを確認する側の仕組み」を置くことです。

  • 土曜のレース結果収集が18時完了予定なら、19時に「今日の日付のデータがDBに入っているか」を確認するジョブを置く
  • 確認ジョブ自体は毎日動かし、開催日かどうかの判定は確認ジョブの中で行う(確認ジョブまで週末限定にすると、今度は確認ジョブの発火失敗を検知できません)

「やるジョブ」と「やったか確かめるジョブ」を分ける。冗長に見えますが、スケジューラそのものの故障まで検知できるのはこの構造だけです。


まとめ

落とし穴 対策
コンテナの時計が UTC tzdata 導入 + date と Python 両方で確認
失敗が通知されない exit code 通知 + ハートビート監視の二段構え
設定変更が反映されない 反映条件の確認、docker inspect で実際の値を見る
ログが混ざって調査不能 tee で 1 ジョブ 1 ファイル + PIPESTATUS
発火しなかったことに気づけない 期待時刻の後に成果物を確認する別ジョブ

共通するのは「動いていることを自分から証明させる」設計です。バッチは黙って止まるものだと想定して、検知の仕組みを先に作っておくと、週末を安心して迎えられます。

収集対象のデータベース構築には JvLink To Importer が使えます。まずはデータの土台から整えていきましょう。

この記事をシェア

Post