👨‍👨‍👧‍👧

ジョブ分割を抽象化してバッチ処理をスケーラブルにできないか考える

2023/12/15に公開

Makuake Advent Calendar 2023の15日目の記事です。

普段仕事では基盤系サービスの構築や運用を行っているためスケーラビリティについて考えることが多いのですが、バッチ処理のスケーラビリティについてあまり考えたことが無かったなーということで、色々遊んでみつつ記事にしてみたいと思いました。

注意点&反省点

※ここで紹介しているコードや構成については全くプロダクションレベルではありません(コードはありますが机上のdockerレベルです)

趣味レベルで捉えて頂ければ幸いです

モチベーション

バッチ処理といっても色々あるかと思いますが、今回取り上げるのは「水平方向のスケーラビリティを考慮していないバッチ処理」になります。

例えば「特定の時間になったらDBにクエリを投げて、該当したユーザーに対してメールを送る」などです。

大抵は、初期実装のタイミングではそこまで件数も多くなく、ある程度件数が増えたとしても垂直スケーリングで捌けるようなデータ量ですが、サービスが大きくなるにつれて垂直スケーリングではコスト的にも厳しくなってくるようなケースもあるかと思います。

逆に、AWS Batchのようなバッチ実行基盤を用いてイベント駆動、かつ適切に分割されて分散して処理することが可能な設計になっているバッチは当記事のスコープ外です。

こうしたバッチをスケールアウトできるようにしたい場合、ロジック的に分割が可能であれば下記のような対応で実現可能に思われます。

  1. 引数などで開始と終了を差し込めるように実装を修正する
  2. 件数を見積もりジョブを適切な粒度に分割する処理を実装する
  3. 上記を組み合わせ、バッチ基盤上に構成する(EventBridge, Batch, Lambda, StepFunctionなど)

しかし、同じようにスケールアウトしたいバッチ処理が大量にあった場合、ジョブの分割と実行というディスパッチ部分の処理を再実装する必要が出てくるため、

  • コードのコピペが発生する
  • 実装者によって実装が異なる(シェルで書いたりpython使ったり)
  • バッチ実装者が意識すべき部分が多くなる

といった懸念が生じそうです。

ということで、ジョブ分割と実行という共通部分を抽象化し、共通化されたディスパッチ方法でスケールアウト可能なバッチをお手軽に作れるような仕組みを考えてみることにしました。

実現したい要件

  • データ量の見積もりからジョブを分割する処理については、言語を問わずインターフェースとして抽象化されていること
    • バッチの実装者は、インターフェースに則って分割処理を書く必要がある
  • インターフェースに則っていさえすれば、どんなジョブであってもジョブの開始から分割、分散実行までの処理は同じ構成を流用できること

コマンドラインツールを作ってみた

要件を満たせそうなツールをさっくり作ってみました。

https://github.com/ymtdzzz/sashimi

ツール名はsashimiですが、なんかこう、でかい魚とかを捌いていくイメージでChatGPTが名付けました。特に深い意味はありません。

言語を跨いだ抽象化の実現

ツール自体はGolangで実装したのと、インターフェースの抽象化とつなぎ込みはgo-pluginを利用しています。

go-pluginはRPCの仕組みを使ってプラグインの機構を実現するための仕組みを提供してくれるので、言語に依存せずにインターフェースに従わせることが可能です。

関係性としてはsashimi本体のプラグインとしてバッチの分割処理を実装してあげるようなイメージです。

使い方

バッチ処理本体の実装

ここはどんな言語やFWを使っても問題無いです。先ほどのシンプルな例でいくと「引数などで開始と終了を差し込めるような実装」になっていれば問題無さそうです。

ここでは下記のようなコマンドで実行する形式とします。

$ my_job [start] [end]

インターフェースに則ってジョブ分割処理を実装

ここも、gRPCサーバーとして実装されていればどんな言語でも大丈夫です。下記のようなprotoを満たしさえしてくれればOKです。

message SplitJobResponse {
  repeated string commands = 1;
}

service Job {
  rpc SplitJob(google.protobuf.Empty) returns (SplitJobResponse) {}
}

実行コマンドとしては、gRPCサーバーが上がるような形になっていれば大丈夫です。

# 単体のコマンドとして実装
$ my_job_split [args]
# サブコマンドとして実装
$ my_job split [args]

sashimiを叩く

EventBridgeやcronなど、実際にバッチ処理がトリガーされる部分でsashimiを叩きます。

$ sashimi my_job_split 1234

すると、内部的にmy_job_split 1234が呼び出され、分割されたコマンドのリストがgRPC経由でsashimiに届き、出力されます。

# Output: my_job 1 500,my_job 501 1000,my_job 1001 1234

実際はここから自動でジョブキューに送信したりとかできたら良さそうですが、そこまでは実装していません。

とはいえ、ジョブ分割の処理内容が抽象化されたので別のバッチ処理であっても最初のトリガー部分のコマンドさえ渡せれば、以降の処理(分割後のコマンドを分散して実行させる処理)については共通化できそうです。

まとめ

良さそうなところ

このツールを使用することで、Dispatch部分を共通化することができそうです。

[before]

[after]

この方法によってもたらされるメリットは下記のような感じでしょうか。

  • Dispatch部分が共通化されたことで再利用性が向上する(containerのベースイメージを提供するなど)
  • 全体の統一感
    • ジョブ分割のIF
    • Dispatch部分

微妙なところ

逆に、プロダクションで使っていくには微妙なところも多そうです。

  • 引数で分割できるほどシンプルじゃない
    • 連番なんてあり得ない
    • ジョブ分割処理のIF自体色々ありそうなのでprotoで抽象化し切れなそう
  • 抽象化はできてるけど実装コストはそれなり
    • コマンド分割を強制するためにgRPC使うのは大げさすぎるかも
    • 普通にアウトプット形式の合意取ってシェルとかで書くだけでも良い気はした

さいごに

自分で作っておいてあれですが、発想は良かったかもしれないけどプロダクションで使うには色々微妙なところ多そうだなという感じでした(活用できるところはあるかもしれないけど大分限定されそう)。

結局のところ、バッチ処理それぞれの処理内容をきちんと整理して、適切な粒度に分割して分散して実行する構成をするのがやっぱり大切だと思いました。

残念ながら技術的な記事として有益な情報を提供することはかないませんでしたが、バッチ処理のスケーラビリティについて改めて調べたり考えたりできたのでそこは良かったです。

Discussion