プログラマのための IT 教科書

バッチとジョブ

この部の 8 / 13 章 ・ 全体で 50 / 76 章 ・ 読了目安 40 分

この章を読むとできるようになること
  • 「もう一度流せば直る」バッチを書ける
  • 対象期間を引数で受け取り、バックフィルできる
  • 起動しなかったことに気づける監視を設計できる

前章のメッセージングは「出来事が起きたら処理する」形でした。 もう1つ、実務で必ず登場するのが決まった時刻にまとめて動かす処理です。

毎日 3:00  前日の売上を集計する
毎時 :00   外部システムから最新データを取り込む
毎月 1日   請求データを作る
毎週 月曜  古いデータを削除する

新人が最初に任される仕事の定番でもあります。 単純に見えますが、壊れ方が独特で、しかも静かに壊れます。

バッチの特徴は「誰も見ていない」こと

オンライン処理: 失敗すると、利用者がすぐ気づく
バッチ処理:     失敗しても、誰も気づかない。数日後に「数字が合わない」で発覚する

だから、バッチ処理の設計は失敗を前提にします。

問い実務での答え
途中で落ちたら?もう一度実行すれば直るようにする
二重に動いたら?同じ結果になるようにする(冪等性)
動かなかったら?気づけるようにする
データが10倍になったら?時間が10倍になっても終わるか、確認する

実行基盤

方法使いどころ
cron(サーバー上)単純だが、そのサーバーが単一障害点になる
Kubernetes CronJobk8s 上で動かす。ログも監視も既存の仕組みに乗る
Cloud Scheduler + Cloud Run Jobsサーバーレス。実行時だけ課金
ワークフローエンジン依存関係のあるジョブ群(A が終わったら B)
# Kubernetes CronJob の要点だけ
apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-aggregate
spec:
  schedule: "0 18 * * *"          # UTC。JST 3:00 に動かしたいなら 18:00 UTC
  timeZone: "Asia/Tokyo"          # 対応バージョンなら、こちらで指定するほうが安全
  concurrencyPolicy: Forbid       # 前回が終わっていなければ起動しない ← 重要
  startingDeadlineSeconds: 300    # 遅延した場合、5分以内なら実行する
  failedJobsHistoryLimit: 7       # 失敗したジョブのログを残す
  jobTemplate:
    spec:
      backoffLimit: 2             # 失敗時のリトライ回数
      activeDeadlineSeconds: 3600 # 1時間で打ち切る ← 無限に走らせない
`concurrencyPolicy` を指定しないと二重に動く

既定では、前回の実行が終わっていなくても次が起動します。

データ量が増えて処理時間が伸びた結果、

3:00 の実行がまだ動いている
4:00 の実行が始まる
5:00 の実行も始まる
→ DB の同じ行を複数プロセスが更新し、集計が二重になる

これは実際によく起きます。Forbid を明示してください。

何度実行しても同じ結果にする

バッチ設計で最も重要な性質です。

-- 悪い: 実行するたびに加算される
UPDATE summary SET total = total + (SELECT SUM(amount) FROM orders WHERE date = '2026-08-07');
 
-- 良い: 何度実行しても同じ結果になる
INSERT INTO summary (date, total)
SELECT '2026-08-07', SUM(amount) FROM orders WHERE date = '2026-08-07'
ON CONFLICT (date) DO UPDATE SET total = EXCLUDED.total;

「もう一度流せば直る」状態になっていれば、障害対応が圧倒的に楽になります。 逆に、加算型の処理は再実行できないため、 失敗するたびに手作業で復旧することになります。

判断基準

自問してください。

「今このバッチをもう一度流したら、何が起きるか?」

「まずいことになる」なら、設計を直す価値があります。 深夜に障害対応をするのは、たいてい自分です。

途中で落ちた時のために

大量データを扱うバッチは、途中で落ちます(OOM、タイムアウト、デプロイ)。

全件を1トランザクションで処理する
  → 落ちたら全部ロールバック。最初からやり直し。しかもロックが長い

チャンクに分けて、進捗を記録する
  → 落ちた続きから再開できる
// 一定件数ごとに区切り、どこまで終わったかを記録する
for {
    rows := fetchNext(ctx, lastID, 1000)
    if len(rows) == 0 { break }
 
    if err := processChunk(ctx, rows); err != nil {
        return err   // ここで落ちても、lastID から再開できる
    }
    lastID = rows[len(rows)-1].ID
    saveCheckpoint(ctx, lastID)
}
1回の SELECT で全件取らない
SELECT * FROM orders;    -- 本番で1000万件返ってくる

プロセスとメモリの OOM と計算量とデータ構造の計算量が、そのままバッチで顕在化します。 必ずページングするか、カーソルでストリーミングしてください。

「開発環境では3秒で終わったのに、本番でメモリ不足で落ちる」の典型です。

時刻の扱い — バグの温床

バッチのバグの半分は、日付の扱いです。

□ サーバーのタイムゾーンは何か(UTC か JST か)
□ 「昨日」とは何時から何時までか
□ 月末の翌日は? 1月31日の1ヶ月後は?
□ うるう年・うるう秒
□ 夏時間のある国のデータを扱うか
□ 締め時刻をまたぐデータをどう扱うか
「昨日」を実行時刻から計算しない
// 危険: 実行が遅延して 00:05 に動くと、対象日がずれる
yesterday := time.Now().AddDate(0, 0, -1)

再実行した時にも問題になります。 8月7日分のバッチを8月9日に手動で流したら、8月8日分が集計されます。

// 対象日を引数で受け取る。指定が無い時だけ既定値を使う
func run(ctx context.Context, targetDate time.Time) error { ... }

対象期間は必ず外から渡せるようにしてください。 これができていないと、バックフィル(過去分の再実行)ができません。 そして過去分の再実行は、必ず必要になります。

// 期間の指定は「以上・未満」で統一する(境界の重複と抜けを防ぐ)
start := time.Date(y, m, d, 0, 0, 0, 0, jst)
end   := start.AddDate(0, 0, 1)
// WHERE created_at >= start AND created_at < end

BETWEEN を安易に使わないでください。 23:59:59 までを指定すると、その日の最後の1秒未満のデータが抜けます。

「動かなかった」に気づく

エラー監視だけでは足りない

バッチが起動すらしなかった場合、エラーログは出ません。 何も起きないので、監視も何も検知しません。

□ 成功したことを記録し、「一定時間内に成功が記録されていない」ことを監視する
   (デッドマンスイッチ、ハートビート監視)
□ 処理件数を記録し、0件や異常な件数でアラートを出す
□ 実行時間を記録し、極端に長い・短い時に気づけるようにする

「失敗の監視」ではなく「成功の監視」です。 これは監視とオンコールの可観測性で最も見落とされる観点です。

処理件数の監視は特に効きます。

昨日: 12,483件を処理
今日: 0件を処理        ← エラーは出ていないが、明らかにおかしい

オンライン処理との干渉

バッチは同じ DB を使っています。

□ 深夜でも利用者はいる(海外・夜型のユーザー)
□ 大量の UPDATE がロックを取り、オンラインの処理を止める
□ 全件スキャンがキャッシュを追い出し、その後しばらく全体が遅くなる
□ レプリカに向けられるなら、そちらを使う
対策: チャンクごとにコミットする / 実行間隔を空ける /
      読み取り専用レプリカを使う / 実行時間帯を分散する

運用のための作法

□ 手動で実行できる口を用意する(引数で対象期間を指定できること)
□ ドライラン(実際には書き込まず、何件対象になるかだけ出す)
□ 開始・終了・件数・所要時間を構造化ログに出す(監視とオンコール)
□ 実行結果を記録するテーブルを持つ(いつ・どの期間を・何件処理したか)
□ 失敗時の連絡先と再実行手順を、Runbook に書く(ドキュメントを書く)
ドライランは実装コストに見合う
./batch aggregate --date=2026-08-07 --dry-run
# 対象: 12,483件 / 更新予定: 340件 / 削除予定: 0件

特に削除系のバッチでは必須です。 条件を1文字間違えて全件削除、という事故を実際に防ぎます。

日次集計バッチが3日間失敗していたことに、月次の数字が合わないことで気づきました。再発防止として最も効果的なのは?

実務の落とし穴まとめ

  1. 加算型の更新 — 再実行すると二重になる。UPSERT にする
  2. concurrencyPolicy 未指定 — 処理時間が伸びると多重起動する
  3. 対象期間を実行時刻から計算 — 遅延と再実行でずれる。引数で渡す
  4. BETWEEN で終端を指定 — 境界のデータが抜ける。>= と < を使う
  5. タイムゾーンを意識しない — UTC と JST で日付が1日ずれる
  6. 全件を一度に読む — 本番のデータ量で OOM
  7. 全件を1トランザクション — 落ちたら最初から。ロックも長い
  8. 失敗の監視しかない — 起動しなかった場合に気づけない
  9. 手動実行の口が無い — 障害時に復旧できない
  10. オンライン処理への影響を考えない — 深夜でも利用者はいる
  11. ドライランが無い — 削除系で事故が起きる

まとめ

  • バッチは誰も見ていない。だから失敗を前提に設計する
  • 最重要は**「もう一度流せば直る」こと**。加算ではなく UPSERT
  • 多重起動を止める(concurrencyPolicy: Forbid)
  • 対象期間は引数で受け取る。これが無いとバックフィルができない
  • 日付はタイムゾーン・境界(>= と <)・月末に注意する
  • 大量データはチャンクに分けて、進捗を記録する
  • 成功を監視する。失敗の監視だけでは「動かなかった」に気づけない
  • 手動実行の口とドライランを用意する。障害対応の速度が変わる

公式ドキュメント

迷ったら一次情報に戻ってください。

章末問題

「前日の注文を集計する」バッチを実装します。対象期間の指定として最も安全なのはどれですか。

次の章では、ここまで作ってきたものを実際に動かす場所—— コンテナと Kubernetes に移ります。

読み終わったら記録しておくと、目次で進み具合が分かります。