
こんにちは、株式会社アドグローブ ソリューション事業部 エンジニアの宮崎です。
今回は、バッチ処理を任意のタイミングで実行するREST API設計について考えてみました。
はじめに
本稿では、「画面操作をトリガーにして、バッチ処理を実行したい」という、業務システムで頻出する要件に対するAPI設計について整理します。
たとえば、管理画面の「実行」ボタン、手動再集計、緊急時のリカバリ処理などです。
レガシー寄りなシステムでは、CGI経由で直接バッチ起動、HTTPリクエストで同期実行、非同期っぽい顔をしているが、実は同期であるといった構成をよく見かけます。
しかし、現代的なWeb APIとして考えると、タイムアウトしない、再実行に強い、状態が追跡できる設計にしておきたいところです。
本稿では、RESTの原則を大きく外さず、非同期実行を前提にし、「バッチ実行」をAPIとして自然に扱う構成を一例として整理します。
よくあるアンチパターン集
本題に入る前に、やりがちだけど事故りやすい設計を先に挙げます。
❌ アンチパターン1:同期実行API
POST /batches/run → 処理完了まで待つ → タイムアウト
バッチ処理をHTTPリクエストの中で完了まで実行する構成です。
処理が長引けばタイムアウトし、途中で切れた場合に「成功したのか失敗したのか」が分かりません。
その結果、再実行による二重処理が起きやすくなります。
❌ アンチパターン2:実行結果を管理しない
POST /batches/123 → 200 OK
実行要求を投げるだけで終わる構成です。
処理が始まったのか、まだ動いているのか、失敗したのかをAPIで確認できません。
成否はログを見るしかなく、再実行すべきかも判断できない設計になります。
❌ アンチパターン3:状態と操作が混ざっている
DELETE /tasks/123 (キャンセル) PUT /tasks/123 (再実行)
HTTPメソッドに業務操作を押し込む構成です。
削除なのか停止なのか、更新なのか再実行なのかが曖昧になり、APIの意味を仕様で覚える必要が出てきます。
結果として、設計意図がURLやメソッドから読み取れなくなります。
❌ アンチパターン4:未完了なのに結果を返す
GET /tasks/123 → まだ実行中だけど result を返す
処理が終わっていないのに結果らしきものを返す設計です。
それが最終結果なのか途中経過なのかが分からず、利用側が状態管理を肩代わりすることになります。
結果は「完了してから返す」という線引きがないと、責務が曖昧になります。
REST APIと「カスタムメソッド」
REST APIとは
一般的にREST APIと呼ばれる設計では、HTTPメソッド(GET / POST / PUT / DELETE)、リソース指向のURL、ステータスコードによる成否表現を共通ルールとして使うインターフェースです。
これにより、振る舞いを推測しやすくなり、仕様書を読まなくてもだいたい分かるという利点があり、結果としてクライアント実装が楽になります。
CRUDに当てはまらない操作も存在する
とはいえ、実務ではどうしてもCRUDにきれいに当てはまらない操作が出てきます。
たとえば、仮想マシンを「再起動」する、論理削除したユーザーを「復活」させる、トランザクションを「ロールバック」するといった操作です。
これらは、取得(GET)でもなく、更新(PUT)とも微妙に違い、削除(DELETE)とも意味がズレる動作系の操作です。
ここを無理やりCRUDに押し込むと、意味の分かりにくいAPIになり、クライアント側が推測できなくなり、設計意図がURLから消えます。
カスタムメソッドとは
こうしたケースのために用意されているのが、カスタムメソッド(Custom Methods / Actions)という考え方です。
これは、複数のAPI設計ガイドラインで言及されています。
Google Cloud API Design Guide では、次のように説明されています。
Standard HTTP methods may not be sufficient to express some operations.
In such cases, APIs may define custom methods.
また、Microsoft Azure REST API Guidelines でも、同様の考え方が示されています。
The REST specification is used to model the state of a resource, and is primarily intended to handle CRUD (Create, Read, Update, Delete) operations.
However, many services require the ability to perform an action on a resource, e.g. getting the thumbnail of an image or rebooting a VM.
つまり、基本はリソース指向で設計し、それでも表現できない操作がある場合のみ「特別な操作」として明示する、という位置づけになります。
具体的には以下のように、リソースURLの末尾をコロンで区切り、独自の操作名を付与した形式となります。
/resources/detail:customMethod または /resources:customMethod
CRUDに当てはまらない操作をカスタムメソッドで表現する
たとえば、先ほどの例をカスタムメソッドで表すと、次のようになります。
- 仮想マシンの再起動
POST /virtual-machines/{id}:reboot - 論理削除ユーザーの復活
POST /users/{id}:undelete - トランザクションのロールバック
POST /transactions/{id}:rollback
これらは、/virtual-machines/{id} や /users/{id} というリソースに対して、reboot や undelete という特別な操作を明示的に表しています。
この形式にすることで、「何を対象に」「何をするのか」がURLから読み取れるようになります。
ただし、何でも動詞にする、業務ロジックをURLに埋め込むとRESTらしさが失われます。
使いどころは慎重に選ぶ必要があります。
本稿で扱う「バッチ実行」も、CRUDでは表現しにくい操作のひとつです。
そのため、このカスタムメソッドの考え方を採用します。
非同期APIの基本パターン
バッチ処理は、基本的に時間がかかります。
そのため、HTTP接続を張りっぱなしにして完了まで待たせる設計は避けたいところです。
このようなケースに対しては、リクエストを受け付け、処理はバックグラウンドで行い、別APIで状態を取得するという設計パターンが知られています。
この構成は、HTTP仕様における 202 Accepted の意味(「処理は受理したが、まだ完了していない」)とも整合しており、Google Cloud や Microsoft Azure のAPI設計ガイドラインでもLong-Running Operation(長時間処理)パターンとして紹介されています。
具体的には、
- 実行要求を受け付けるAPIが
202 Acceptedを返す - 状態確認用のリソース(task / operation)を返す
- クライアントはそのリソースをポーリングして結果を取得する
という形になります。
以下に典型的な例を示します。
① 実行指示
POST /something
→ 202 Accepted
→ { "task_id": "xxxx-yyyy" }
意味としては、「受け付けました」「このIDで状態を見てください」というメッセージです。
Location ヘッダを活用し、状態確認用のURLを設定すると、より親切な設計になると思います。
② 実行状況確認
GET /tasks/xxxx-yyyy
→ 200 OK
→ { "status": "completed", "result": {...} }
これで、running、completed、error といった状態を取得できます。
UIとの相性も良く、リトライ設計もしやすい構成です。
Retry-After ヘッダを活用することで、クライアントにどの程度の間隔で状態確認を行えばよいか伝えることができるため、負荷分散の観点でも有効だと思います。
バッチ処理を任意実行するAPI設計
前置きが長くなりましたが、やっと本題です。
タイトル回収を進めていきましょう。
1. 実行指示API
指定されたバッチ定義を実行します。
実行時引数はリクエストボディで指定し、実行は非同期です。
エンドポイント
POST /batches/{id}:execute
レスポンス
202 Accepted
{
"task_id": "xxxx-yyyy-zzzz"
}
この task_id を使って、後続の状態確認を行います。
2. 実行状況確認API
実行指示APIによって走り始めたバッチの実行状況を取得します。
エンドポイント
GET /tasks/{id}
レスポンス例
200 OK
{
"status": "completed",
"result": {...}
}
status は次の列挙体で現在の task の実行状況を表現します。
runningcompletederrorcanceled
result は、件数、サマリ、生成物IDなど、「APIで返せるサイズの結果」を想定します。
巨大データそのものを返すのは避け、ダウンロード用のURLや生成物IDを返す形にするのが現実的です。
エラーモデル
task の失敗とAPI自体の失敗は区別します。
- HTTPステータス: 通信レベルの成否
- レスポンスボディ: task の論理状態
と役割を分けます。
task 失敗時(処理エラー)
200 OK
{
"status": "error",
"error": {
"code": "BATCH_FAILED",
"message": "Batch execution failed",
"details": {
"failed_step": "aggregation",
"retryable": false
}
}
}
これは、
- API通信は成功
- task の実行結果が失敗
であることを示します。
APIレベルのエラー(存在しないIDなど)
404 Not Found
{
"error": {
"code": "TASK_NOT_FOUND",
"message": "Task not found"
}
}
設計上のポイント
- HTTPステータスと業務エラーを混ぜない
- エラーコードは機械可読にする
- message は人間向け説明
- details は拡張用フィールド
という構成にすると、
- フロントエンドで分岐しやすい
- ログ解析しやすい
- 将来の拡張に耐えやすい
APIになると思います。
3. キャンセル指示API
実行中 task に対してキャンセルを要求します。
実際の停止処理は非同期で行われるため、「キャンセルできた」ことを保証しません。
エンドポイント
POST /tasks/{id}:cancel
レスポンス
202 Accepted
状態の確認
キャンセルが完了したかどうかは、実行状況確認APIで取得します。
GET /tasks/{id}
→ 200 OK
→ { "status": "canceled" }
となれば、キャンセル完了です。
制約
completed / error 状態ではキャンセル不可とし、その場合は 409 Conflict や 405 Method Not Allowed を返します。
task のステータス遷移
task の状態遷移は、概念的には次のようになります。
└→ error → [end]
└→ canceled → [end]
execute で running に遷移し、正常終了で completed、失敗で error、中断で canceled になります。
実業務では running の前に pending 等の実際に動き始まるまでのステータスがあることも多いと思いますが、今回は割愛しています。
FAQ
Q. これ、どう見てもRESTじゃなくない?
はい、純粋なRESTではありません。
ただし、「バッチ」というリソースと「実行」という特別な操作を分離して表現しているため、/batches/{id} に直接副作用を持たせるよりも意図は明確になります。
CRUDに無理やり当てはめるより、カスタムメソッドとして明示した方が安全という判断です。
Q. GET /tasks/{id} で結果まで返すの、やりすぎじゃない?
返す結果のサイズによりますが、軽量なデータであれば問題ありません。
task は「結果を持つリソース」と見なせるため、completed 状態で result を返すのは自然であり、ジョブ管理APIでもよく見られる構成です。
ただし、結果のJSONが大きくなる場合や、責務を明確に分離したい場合は、状態確認とは別に GET /tasks/{id}/result というサブリソースを作る設計も非常に有効です。
これによりポーリング時の通信を常に軽量に保つことができます。
いずれにせよ、巨大なファイルそのものを直接返すのは避け、ダウンロードURLや生成物IDを返す形にとどめるのが現実的です。
Q. なんで DELETE /tasks/{id} にしないの?
DELETE はリソース削除の意味が強く、実行中プロセスの停止や中断処理まで含めると意味が曖昧になります。
そのため、POST /tasks/{id}:cancel とした方が、「止める操作」であることが明確になります。
Q. cancel 叩いたんだから、もう止まったってことでよくない?
よくありません。
キャンセルは「停止要求」を出すだけで、その場で確実に止まるとは限りません。
実際に止まったかどうかは、実行状況確認APIで canceled になったことを見て判断します。
「止めて」と「止まった」を分けて扱わないと、止まったつもり事故が起きます。
Q. ポーリング(笑)WebSocket とか Webhook 使った方がイケてない?
用途次第では有効です。
WebSocket はリアルタイム通知ができる一方で、接続管理が重くなります。
Webhook は完了通知ができますが、受信側の管理やリトライ設計が必要になります。
そのため、基本は POST → task_id → GETで状態確認 とし、必要に応じて拡張する構成が、実装コストと汎用性のバランスが良いと考えます。
Q. で、今どこまで終わってるか、どうやったらわかるの?
進捗が分かると便利なのは事実です。
ただし、進捗率を業務ロジックに使う設計はおすすめしません。
割合を厳密に定義するのは難しく、意味がぶれやすいためです。
返す場合は「どの段階か」を示す情報や、UI向けの目安値にとどめ、状態(running / completed / error)を主、進捗は補助として扱うのが無難です。
Q. 状態とか持ち始めたら、もうステートレスじゃなくない?
いいえ、RESTのステートレス原則と矛盾しません。
GET /tasks/{id} が返しているのは、通信の途中経過ではなく task というリソースの状態です。
禁止されているのは「前のリクエストの続き」を覚えることです。
task をID指定で取得しているだけなので、各リクエストは独立して完結しています。
Q. 失敗したときのために、POST /tasks/{id}:retry みたいな再実行APIは作らないの?
今回の設計では作らない方針としています。
失敗した場合は、もう一度 POST /batches/{id}:execute を叩いて「新しい task を作って実行」すれば済むからです。
リトライ専用のAPIを作ると、task の状態遷移が複雑になり(error から running に戻る等)、クライアント側の状態管理も難しくなります。
失敗した task はそのまま履歴として残し、通常の実行APIを再度呼ぶ方が、設計も運用もずっとシンプルになります。
Q. で、その task っていつ消すの?ずっと残すの?
用途次第です。
監査や再取得が必要なら一定期間は残し、不要になったら定期削除します。
重要なのは、「自然消滅」ではなくライフサイクルを設計することです。
Q. 同じバッチを同時に2回叩かれたら、どうなるの?
設計次第です。
許可するなら並列実行できるようにし、困るなら多重起動を検知して弾きます。
いずれにせよ、想定外に2回走る状態だけは避けるべきです。
Q. 今更だけど task って何?job じゃないの?
どちらでも設計できます。
ただし本稿では、「何をするか(バッチ定義)」と「実際に走った実行単位(インスタンス)」を分けるため、後者を task と呼んでいます。
job だと定義と実行が混ざりやすいため、状態管理する対象として task の方が意味が明確になると考えています。
まとめ
本稿では、バッチ処理をAPIで実行する構成を軸に、非同期APIの基本パターン、カスタムメソッドの使いどころといったAPIの設計について整理しました。
ポイントは、実行は非同期にする、taskという中間リソースを導入する、状態確認と結果取得を分離する、URLから挙動が想像できるようにすることです。
「バッチをHTTPで叩く」という一見レガシーな要件も、整理すれば十分モダンなAPIになります。
設計に唯一の正解はありませんが、実運用を見据えた選択肢の一つとして参考になれば幸いです。
参考文献
- RFC 7231 – Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content
https://datatracker.ietf.org/doc/html/rfc7231#section-6.3.3 - Google Cloud API Design Guide – Custom Methods
https://google.aip.dev/136 - Google Cloud API Design Guide – Long-Running Operations
https://google.aip.dev/151 - Microsoft Azure REST API Guidelines – Performing an Action
https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md#performing-an-action - Microsoft Azure REST API Guidelines – Long-Running Operations & Jobs
https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md#long-running-operations--jobs
付録:OpenAPIサンプル
openapi: 3.0.3 info: title: Batch Execution API version: 1.0.0 paths: /batches/{id}:execute: post: summary: Execute batch job asynchronously parameters: - name: id in: path required: true schema: type: string requestBody: required: false content: application/json: schema: type: object properties: args: type: object description: Execution arguments for batch responses: "202": description: Accepted headers: Location: schema: type: string description: URL for task status polling content: application/json: schema: $ref: "#/components/schemas/TaskAcceptedResponse" "404": description: Batch not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /tasks/{id}: get: summary: Get task status and result parameters: - name: id in: path required: true schema: type: string responses: "200": description: Task status headers: Retry-After: schema: type: integer description: Suggested polling interval in seconds content: application/json: schema: $ref: "#/components/schemas/TaskStatusResponse" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /tasks/{id}:cancel: post: summary: Cancel running task parameters: - name: id in: path required: true schema: type: string responses: "202": description: Cancel requested content: application/json: schema: $ref: "#/components/schemas/TaskStatusResponse" "409": description: Task cannot be canceled content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" components: schemas: TaskAcceptedResponse: type: object properties: task_id: type: string TaskStatusResponse: type: object properties: status: type: string enum: [running, completed, error, canceled] result: type: object nullable: true error: $ref: "#/components/schemas/ErrorDetail" ErrorResponse: type: object properties: error: $ref: "#/components/schemas/ErrorDetail" ErrorDetail: type: object properties: code: type: string example: BATCH_FAILED message: type: string example: Batch execution failed details: type: object additionalProperties: true