hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

バッチの実装方針について考える




お久しぶりです。基盤本部/プラットフォーム部/hacomono・共通基盤グループのまこたすです。

はじめに

バッチという言葉はまとめて処理することを表すと思うのですが、バッチシステムという観点だとCronのような時間で発火する仕組みだったり、非同期で別プロセスのワーカーに依頼してまとめて処理させたりする仕組みだったりと様々あると思います。もちろん弊社のhacomonoというプロダクトにおいてもバッチ処理は存在し、毎日稼働しています。最近これらのバッチ処理においてやや扱いが大変な場面も増えてきました。今回は弊社のバッチシステムの紹介とバッチという仕組みがよく起こす課題と、その対応について考えます。

本記事の想定対象読者

  • アプリケーションコード側でバッチとして動かすシステムに興味関心がある人
  • バッチとして稼働させるためのインフラに興味関心がある人


hacomonoのバッチシステムについて

弊社のhacomonoプロダクトにおいてバッチ処理を行っている仕組みは3つあります。

  • 時間ベースで発火させるECS run-task
  • リクエストおよびイベントベースでQueueを積んで処理させる非同期ワーカー
  • 時間ベースで発火させるAPIベースのJob処理

それぞれについて簡単にご紹介します。

時間ベースで発火させるECS run-task

この仕組みはCronで発火しそこからRubyのRakeタスクを動かしていた仕組みを、インフラのリアーキテクチャの際に、ECS run-taskで動かせるようにしたものです。現在はEventBridge -> StepFunction -> ECS run-taskという形でrakeタスクを動かしています。

graph LR
    A["EventBridge<br>スケジュール発火"] --> B["Step Functions<br>実行制御・エラーハンドリング"]
    B --> C["ECS run-task<br>コンテナ起動"]
    C --> D["Rake タスク実行"]
リクエストおよびイベントベースでQueueを積んで処理させる非同期処理ワーカー

時間ではなく、人の操作ベースで発生する重めの処理を行うワーカーが常駐しています。例えば、メールの一括配信などはこの仕組みで動かしています。

graph LR
    A[Rails<br>アプリケーション] -->|ジョブをエンキュー| B[(Storage<br>Queue)]
    B -->|ジョブを取得| C[ワーカー<br>常駐プロセス]
    C --> D[処理実行]
時間ベースで発火させるAPIベースのJob処理(= job-manager)

ECS run-taskだと1バッチ処理ごとにコンテナを上げる必要があるためコスト面で優しくなかったり、起動したコンテナでRakeタスクを実行させるので大きな処理になりがちでした。このバッチシステムは大きなバッチをAPIベースの小さな処理に分割して、それらを呼び出すことでバッチ処理を実現しています。

graph LR
    A["EventBridge<br>スケジュール発火"] -->|メッセージ送信| B[("SQS<br>キュー")]
    B -->|メッセージ取得| C["Step Functions<br>実行制御・エラーハンドリング"]
    C -->|呼び出し| D["Lambda<br>関数実行"]
    D -->|HTTPSリクエスト| E["Rails API<br>処理実行"]

こちらの詳しい内容は同じチームの有働が以下にまとめていますので、そちらをご参照ください。


以上の歴史的背景からこのようなバッチシステムが存在しております。それぞれ作られた時期/目的は異なっており、Cron x Rakeタスク -> 非同期ワーカー -> ECS run-task -> job-managerという順番でそれぞれ生まれてきました。一時はバッチシステムの統合の検討もありましたが、既存のバッチの移行コストも安くなく優先度判断のもと現状に落ち着いています。
それぞれ話を深掘りたいところではありますが、話が広がりすぎるためここからは 時間ベースで発火させるECS run-task についてフォーカスし、そのシステムが抱える課題について触れます。

バッチシステム/処理の課題

ここでは時間ベースで発火させるECS run-taskが抱えるバッチ処理の課題と一般的なよくある課題について述べます。

  • リカバリーコストが高い: ECS run-taskで動いているバッチ処理は、Cron x Rakeタスクから派生したものであり、成功 or 失敗というステータスしか持たないものがあります。失敗時は影響範囲の調査と都度の対応が必要になり、お客様環境ごとにバッチを起動している弊社では復旧コストが大きくなりがちです。
  • タスク起動自体の失敗: run-taskの場合、AWS側のQuotaに抵触してECSタスク自体が起動しないケースも稀に発生します。
  • OOMによる異常終了: バッチ処理は大量のデータを扱うため、実装に不備があるとメモリを圧迫します。環境ごとにデータ量が異なるため、一部の環境でのみOOMが発生し処理が停止するといったことが起こりえます。
  • ロングトランザクション: バッチの開始から終了までトランザクションを張ってしまうと、ロングトランザクションとなりDBへの高負荷につながります。
  • 運用体制の属人化: 組織改変や人事異動によりバッチ担当チームが不在になると、失敗の検知が漏れるリスクがあります。

他にも色々あると思いますが、弊社のバッチシステムはこんな課題が思いつきます。

バッチ処理の実装で意識するべきこと

先述した課題への対応やバッチ処理という特性を鑑みて、バッチを実装するぞ!となった時に考えるべきことを言語化してみたいと思います。

1. 冪等性を持たせること

バッチ処理において冪等性があることが非常に重要です。失敗したバッチが再実行可能であり、それらを組織内で共通認識として持てると、リカバリー対応を圧倒的に容易にできます。
具体的には

  • 人を限定せずリカバリー作業ができる
  • リカバリー作業の自動化を作り込める

これらの恩恵にあやかれます。
この冪等性を実現するためにはアプリケーション側の実装観点としては次のような考慮が必要だと考えています。

  • DBトランザクションを意味のある最小単位(またはある程度の単位)に分割し、再実行しても二重に処理がされないようにする。※1
  • 再実行可能時間を明示的に持つ
    • 1時間の遅れだったら警告ぐらいだけど、1日遅れると危険という処理などを判断するために必要
  • メール送信など取り返しがつかない処理は、※1のコミットでDBに「メールを送るぞ!」というデータを書き込み、メールを送る処理は外側の仕組みにわけるなどOutboxパターンのような工夫が必要
graph LR
    subgraph TX["バッチ処理(トランザクション内)"]
        A[業務データ更新] --> B[Outboxテーブルに<br>メール送信予約を<br>書き込み]
    end
    B -.->|同一DBにコミット| C
    subgraph ワーカー["外側の仕組み(別プロセス)"]
        C[Outboxテーブルを<br>ポーリング] --> D[メール送信実行]
        D --> E[送信済みに更新]
    end

このあたりを満たしていたら、冪等性があるバッチ処理を書けているといえるのではないでしょうか。

2. メモリを使いすぎない実装

バッチが対象にするデータ量が多くても、プログラム内で扱うデータ量を小さくする工夫が必要です。Railsの場合の例になりますが、一度に全件を取得せずに1000件ずつActiveRecordのObjectを作ってループを回すでメモリ使用量を抑えるような対策ができるかと思います。

User.find_each(batch_size: 1000) do |user|
  ...
end

メモリの使いすぎなどを調べるために、APMを活用できると運用時などの調査が楽になるので検討してみるのも良いかもしれません。

3. エラーを握りつぶさない

バッチ処理では、「途中の処理で失敗しても後続は成功させたい」というケースは多いと思います。その際失敗した記録をちゃんと開発者に届けることを意識しないとひっそり失敗しつづけて、気づいた時には大惨事ということも起こりうると思います。通知の手段はログレベルエラーで出してアラートを出すことや、SentryなどのSaaSを利用して直接なげるなど色々あると思いますが、どうやったらエラーの通知が適切なチャネルへ飛ばせるかを意識したほうがいいです。

4. 優先度を明示的にすること

いくら冪等性を担保し、再実行できる仕組みを築き上げたとしても失敗するものは失敗します。最終的な失敗は適切な担当者もしくはチームに通達がいくと思いますが、そのバッチが重要なものかを伝える仕組みも必要です。
通知を受け取った人が、初動として即時対応すべきものか、翌日対応で済むかを一目で認識できるようにする必要があります。ここで合わせて即時対応の場合は復旧方法を添えたRunBookも添えられていると理想的です。

5. DevOpsの実現

インフラチームとプロダクト開発をするチームが分かれていると、実装と検知/通知の仕組みがうまく機能しなくなる可能性があります。ここの橋渡しをどうするか初期の段階から握れていると仕組みがワークします。
アプリチームが能動的に検知/通知できるようにインフラを整備しきってもいいですし、定期的な場を設けることで常に正しい設定なのかを確認できるようにしてもよいし、できるところからやっていくのがいいのかなと思います。


月並みかもしれませんが、こういった基本的なことを押さえることで安定/自立したバッチシステムを構築できそうに感じます。

ECS run-taskの改善

冒頭で話にあがったECS run-taskも整備しなければいけないところは多々ありますが、できる小さな改善から進めております。

  • StepFunctionのリトライ処理を追加し、タスク起動自体の失敗に備える: タスクの起動自体の失敗はアプリ側の冪等に関係なく、再実行可能なためStepFunctionsにリトライの分岐を追加。
  • 重要バッチの失敗の通知: EventBridgeのルールで即時確認が必要なバッチの失敗を検知し、担当チームへ通知/セルフで復旧できる手順を整備。(通知例)

今後運用の手間とコード修正のコストを鑑みて、アプリケーション側のコードの冪等化も進めていけたらと考えています。

最後に

以上弊社のバッチシステムの一部課題の紹介とそれを踏まえて考える良いバッチとはという内容でした。進展が出てきたらまたこちらにて記事にしようと思います。

おまけ

本記事のバッチ改善案をClaude Codeにスキル(SKILL)にしてもらいました。よかったらご活用ください。

---
name: batch-review
description: |
  バッチ処理の実装コードをレビューするスキル。
  冪等性・メモリ効率・エラーハンドリング・優先度・運用性の5観点でチェックし、
  総合評価A-Dと改善提案を出力する。
disable-model-invocation: false
allowed-tools:
  - Read
  - Glob
  - Grep
  - Task
---

# batch-review

バッチ処理の実装をレビューし、品質・運用性の観点から問題点をリストアップするスキル。

## 実行手順

1. 対象のバッチ処理コードを特定する
2. 関連するモデル・ジョブ・設定ファイルも合わせて読み込む
3. 5つの観点でチェックリストを順に評価する
4. 出力フォーマットに従ってレビュー結果をまとめる
5. 推奨アクションを優先度順に提示する

## レビュー観点

対象コードを以下の5つのチェックリストに照らしてレビューする。
各観点ごとに ✅ OK / ⚠️ 要改善 / ❌ 未対応 の3段階で判定し、改善案を提示する。

### 1. 冪等性(Idempotency)

- [ ] 再実行しても二重処理が発生しないか
  - DBトランザクションが意味のある最小単位に分割されているか
  - INSERT時にON CONFLICT / UPSERT等で重複を防いでいるか
- [ ] 再実行可能時間が明示されているか
  - 遅延時間に応じた警告・エラーの閾値が定義されているか
- [ ] 副作用のある処理(メール送信、外部API呼び出し等)がトランザクション外に分離されているか
  - Outboxパターン等で「実行予約」と「実際の実行」が分かれているか

### 2. メモリ効率(Memory Efficiency)

- [ ] 大量データを一括でメモリに載せていないか
  - バッチサイズを指定したチャンク処理になっているか(例: find_each, cursor, LIMIT/OFFSET)
- [ ] 不要なオブジェクトがループ内で蓄積されていないか
  - ループ内で配列に結果を溜め続けていないか
- [ ] N+1クエリが発生していないか

### 3. エラーハンドリング(Error Visibility)

- [ ] エラーを握りつぶしていないか
  - rescue/catch内で何もしていないブロックがないか
- [ ] 失敗した処理の記録が開発者に届く仕組みがあるか
  - ログレベルERRORでの出力、Sentry等への通知
- [ ] 途中失敗しても後続処理を継続する設計の場合、失敗件数・内容のサマリが出力されるか

### 4. 優先度の明示(Priority & Severity)

- [ ] バッチの重要度(severity/priority)が定義されているか
- [ ] 失敗時の通知に優先度情報が含まれているか
  - 即時対応 vs 翌営業日対応が判断できるか
- [ ] RunBook(復旧手順書)へのリンクが通知やコメントに記載されているか

### 5. 運用性(Operability / DevOps)

- [ ] 監視・アラートの設定がされている、またはその設計が考慮されているか
- [ ] 実行ログに開始/終了/処理件数/所要時間が記録されるか
- [ ] ジョブのタイムアウト設定があるか
- [ ] 依存する外部サービスの障害時の振る舞いが考慮されているか

## 出力フォーマット

レビュー結果は以下の形式で出力する:

```
## バッチ処理レビュー結果

### 総合評価: [A / B / C / D]

| 観点 | 判定 | 概要 |
|------|------|------|
| 冪等性 | ✅/⚠️/❌ | 一言サマリ |
| メモリ効率 | ✅/⚠️/❌ | 一言サマリ |
| エラーハンドリング | ✅/⚠️/❌ | 一言サマリ |
| 優先度の明示 | ✅/⚠️/❌ | 一言サマリ |
| 運用性 | ✅/⚠️/❌ | 一言サマリ |

### 詳細

(各観点ごとに、該当コード箇所の引用 + 改善提案を記載)

### 推奨アクション

(優先度順に改善すべき項目をリスト化)
```

## 総合評価の基準

- **A**: 全観点 ✅。本番運用に十分な品質
- **B**: ❌なし、⚠️が1-2個。軽微な改善で本番運用可能
- **C**: ❌が1個、または⚠️が3個以上。改善してから本番投入すべき
- **D**: ❌が2個以上。設計の見直しが必要
```



💁 関連記事