バッチとジョブ
この部の 8 / 13 章 ・ 全体で 50 / 76 章 ・ 読了目安 40 分
- 「もう一度流せば直る」バッチを書ける
- 対象期間を引数で受け取り、バックフィルできる
- 起動しなかったことに気づける監視を設計できる
前章のメッセージングは「出来事が起きたら処理する」形でした。 もう1つ、実務で必ず登場するのが決まった時刻にまとめて動かす処理です。
毎日 3:00 前日の売上を集計する
毎時 :00 外部システムから最新データを取り込む
毎月 1日 請求データを作る
毎週 月曜 古いデータを削除する
新人が最初に任される仕事の定番でもあります。 単純に見えますが、壊れ方が独特で、しかも静かに壊れます。
バッチの特徴は「誰も見ていない」こと
オンライン処理: 失敗すると、利用者がすぐ気づく
バッチ処理: 失敗しても、誰も気づかない。数日後に「数字が合わない」で発覚する
だから、バッチ処理の設計は失敗を前提にします。
| 問い | 実務での答え |
|---|---|
| 途中で落ちたら? | もう一度実行すれば直るようにする |
| 二重に動いたら? | 同じ結果になるようにする(冪等性) |
| 動かなかったら? | 気づけるようにする |
| データが10倍になったら? | 時間が10倍になっても終わるか、確認する |
実行基盤
| 方法 | 使いどころ |
|---|---|
cron(サーバー上) | 単純だが、そのサーバーが単一障害点になる |
| Kubernetes CronJob | k8s 上で動かす。ログも監視も既存の仕組みに乗る |
| 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時間で打ち切る ← 無限に走らせない既定では、前回の実行が終わっていなくても次が起動します。
データ量が増えて処理時間が伸びた結果、
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)
}時刻の扱い — バグの温床
バッチのバグの半分は、日付の扱いです。
□ サーバーのタイムゾーンは何か(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 < endBETWEEN を安易に使わないでください。
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日間失敗していたことに、月次の数字が合わないことで気づきました。再発防止として最も効果的なのは?
実務の落とし穴まとめ
- 加算型の更新 — 再実行すると二重になる。UPSERT にする
concurrencyPolicy未指定 — 処理時間が伸びると多重起動する- 対象期間を実行時刻から計算 — 遅延と再実行でずれる。引数で渡す
BETWEENで終端を指定 — 境界のデータが抜ける。>=と<を使う- タイムゾーンを意識しない — UTC と JST で日付が1日ずれる
- 全件を一度に読む — 本番のデータ量で OOM
- 全件を1トランザクション — 落ちたら最初から。ロックも長い
- 失敗の監視しかない — 起動しなかった場合に気づけない
- 手動実行の口が無い — 障害時に復旧できない
- オンライン処理への影響を考えない — 深夜でも利用者はいる
- ドライランが無い — 削除系で事故が起きる
まとめ
- バッチは誰も見ていない。だから失敗を前提に設計する
- 最重要は**「もう一度流せば直る」こと**。加算ではなく UPSERT
- 多重起動を止める(
concurrencyPolicy: Forbid) - 対象期間は引数で受け取る。これが無いとバックフィルができない
- 日付はタイムゾーン・境界(
>=と<)・月末に注意する - 大量データはチャンクに分けて、進捗を記録する
- 成功を監視する。失敗の監視だけでは「動かなかった」に気づけない
- 手動実行の口とドライランを用意する。障害対応の速度が変わる
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| Kubernetes CronJob(日本語) | https://kubernetes.io/ja/docs/concepts/workloads/controllers/cron-jobs/ |
| Cloud Scheduler(日本語) | https://cloud.google.com/scheduler/docs?hl=ja |
| crontab(5) man page | https://man7.org/linux/man-pages/man5/crontab.5.html |
| Cloud Run Jobs(日本語) | https://cloud.google.com/run/docs/create-jobs?hl=ja |
章末問題
「前日の注文を集計する」バッチを実装します。対象期間の指定として最も安全なのはどれですか。
次の章では、ここまで作ってきたものを実際に動かす場所—— コンテナと Kubernetes に移ります。