AWS Step Functionsでバッチ処理を並列化した話

はじめに

こんにちは、プロダクト開発エンジニアの梶山(@h__kajiyama)です。

私たちのチームでは、「カラリア 香りの定期便」という、サブスクリプション型のECサービスを開発・運用しています。

こちらのサービスは開始から4年が経過しユーザー数は55万人に達しました。サービスの成長に伴いシステム内で最も重要なバッチである、サブスクリプション更新バッチの実行時間が徐々に長くなり、そのままだと安定的なサービス運用に支障をきたす可能性がありました。

そこで私たちのチームは、この課題を解決するため、AWS Step Functionsを利用してバッチ処理を並列化することで実行時間を大幅に短縮しました。今回の記事では、AWS Step Functionsを利用したバッチ処理の並列化について、選定理由から実装方法までを紹介したいと思います。

従来のバッチ処理の構成と課題

「カラリア 香りの定期便」では、ユーザーのサブスクリプション登録日を基準に毎月一度、サブスクリプションの更新処理が行われます。更新処理の中では、決済サービスのAPIへのリクエストとDBのクエリ呼び出しが行われ、それが更新のサブスクリプションの数だけ行われます。

従来の方法では、各サブスクリプションを1件ずつ同期的かつ直列に処理するバッチが実行されていました。従来のバッチ処理の概要を図にしたものが以下です。

こちらのバッチ処理は、EventBridgeを介してECS Taskで定期実行されています。

このバッチ処理において、実行速度に関する以下の問題が顕在化したため、バッチ処理の改善を行うことにしました。

  1. 実行時間の増加によるユーザーへの影響:処理対象が増加しバッチ処理の終了時間が遅くなっていました。このバッチは早朝に行い完了後に配送オペレーションを行うため、処理時間の増加は結果的にユーザーへの配送が遅れることを意味します。これはサービスとして許容できません。
  2. スケーラビリティの問題:今後もサービスを利用していただけるユーザーが増え続けることを考えると、従来のシステムのままではそれに対応するのが困難です。

実装選択肢の検討

実行時間とスケーラビリティの課題だけでなく、他にも検討すべき事があります。バッチの改善をするにあたり、満たすべき要件を整理し、現在の私たちに適した選択をしました。

満たすべき要件

以下6つの観点から要件を整理しました。

  • 実行時間:バッチ処理の実行時間は1時間程度に抑えることを目指します。これにより、配送オペレーションを遅延なく進められ、障害時に再実行が必要になった場合でも配送オペレーションへの影響を小さく抑えられます
  • スケーラブル:バッチ処理の対象数が増え続けるという前提のもとで、最長実行時間を維持する必要があります。
  • バッチの終了検知: バッチの終了が明確で、それを検知できることが重要です。後続の配送業務が、このバッチの終了後に開始される必要があるためです。
  • 異常検知:障害が発生した場合に、その異常を早急に検知する必要があります。これにより、バッチ処理の信頼性を保つことができ、配送遅延を抑えられます。
  • 再試行可能性:一部の処理に異常が発生した場合でも、該当部分の再実行が可能であることが求められます。これにより、エンドユーザーへの影響を抑え、サービス全体の信頼性を保つことができます。
  • テスタブル:テストが通常のアプリケーションと同様に可能であることが必要です。これにより、開発プロセスをスムーズに進行させ、品質を保つことができます。

これらの要件が十分に満たせ、かつ開発工数や保守のコストを抑えられる方法がないか検討しました。

選択肢と要件を満たすための検討事項

要件を満たすための方法としてまずは、直列のままクエリ改善を行うか、並列化するかどうかについて検討しました。

調査の結果、MySQLへのクエリのチューニングにより10-20%程度の改善は見込めるものの、処理時間中のボトルネックは決済APIへのリクエスト時間であることが判明しました。またユーザー数は日々増加しているため数十%程度の改善ではまた近い将来高速化の対応が必要となります。そのため、スケーラビリティを重視して並列化を選択しました。

  1. クエリ改善(パフォーマンス改善)

    メリット:このアプローチの主な利点は、新しいインフラを導入したり新しいコードを書いたりする必要がないことです。既存のシステム内でクエリを最適化するだけで、一定のパフォーマンス向上が見込めます。

    デメリット:決済APIへの時間がかかる問題は解決できず、大幅な速度向上は期待できません。さらに、このアプローチはスケーラビリティに欠け、処理対象が増え続ける状況に対応することが難しいです。

  2. 並列化

    メリット:複数の処理を同時に実行できるため実行時間を大幅に短縮することができます。また、並列数を操作できることによる高いスケーラビリティを実現できます。

    デメリット:並列で動作するシステムはデバッグの難易度が高く、問題が発生した時にそれのキャッチと再現が困難です。また、同期実行より複雑性が増しタスクの分割、同期など考えるべきことが増えます。

並列化を実現する上で、複数の方法が考えられます。その中でもサービス自体の構成との親和性などを考え以下の二つの選択肢が上がりました。

  1. SQS + Shoryuken (ジョブキュー & ワーカー)

  1. AWS Step Functions

以下は2つの選択肢を要件の6項目で比較した表です。

SQS+Shoryuken Step Functions
実行時間
スケーラブル ◎ ワーカープロセスの数を増やすことでスケーラビリティを確保することが可能 ◎ Mapステートを使用することで並列処理の数を簡単に増やすことが可能
明確な開始・終了・結果 △ 結果集計処理を自前実装する必要がある ◎ Step Functionsの機能で取得できる
異常検知 △ ジョブの監視・通知を実装する必要がある ◎ 検知機能があり、異常をトリガーに様々な動作を実行可能
再試行可能性
テスタブル 単体テストに加え結果集計や監視などのテストが可能 単体テストは可能、ワークフロー全体の自動テストは難しい

私たちは開発リソースが限定されたスタートアップのため、実装コストも技術選定の際に重要となります。

SQSなどのジョブキューを用いる方法の場合、並列化のためのワークフローを実装・管理する必要がありませんが、ジョブの実行状態や結果を集計するための実装が別途必要となります。要件にある通り、私たちは全てのジョブが完了したことを知る必要があるため、その実装は必要です。ただし、SQSを利用した非同期ジョブの実行は、メール配信目的でプロダクト内ですでに用いられているため立ち上げのコストが低いです。

一方、Step Functionsは、ワークフローの記述が必要なため、その点でコストが一定発生しますが、ワークフローの存在によって、全てのジョブが完了したことを比較的簡単に検知できますし、完了後の処理(通知など)の追加も容易です。ただし、並列数に応じてタスクを分割する処理を自前で実装する必要があります。(ジョブキューの場合はワーカー数を増やせばいいだけ)

選択した方法と理由

以下の理由によりAWS Step Functionsを選択しました。

  • 全てのジョブが完了したことを検知したいという要件を実現する上ではStep Functionsのようなワークフローを扱えるサービスのほうがいい
  • 異常系エラー発生時の挙動をワークフロー内で管理できるといった点も、プロダクトにとって重要なバッチ処理の信頼性を低コストで保つ上でいい
  • Step Functions利用時のワークフローの管理コストは、上記メリットと比較すると小さい

実装

この章では、実装方法について紹介します。

以下がバッチ処理の構成を図にしたものです。

Step FunctionsのステートマシンをEventBridgeを使って定期実行しています。ステートマシン内では、ECS TaskとLambdaがステートとして利用されています。

AWS Step Functionsについて

ここでは利用している機能の概要のみを軽く説明します。詳細な説明は公式ドキュメントを参照してください。

(ドキュメント : https://aws.amazon.com/jp/step-functions/ )

ステートマシン:定義したワークフローをステートマシンと呼びます。標準ステートマシンと最大5分間しか実行できない大容量のイベント処理ワークロードに適した高速ステートマシンの2種類があります。ここでは標準ステートマシンが適しているため標準ステートマシンを利用します。

ステート:ステートマシンを構成するノードです。単体で処理ができるほか、AWSの様々なサービスを呼び出せます。今回はECSとLambdaを呼び出し実行しています。

Mapステート:入力されたデータセットの各項目に対して一連のワークフローステップを並列に実行します。並列化はこのステートを利用して行なっています。

リトライとキャッチ:ステートが失敗した時、リトライとキャッチを設定できます。これもワークフローに定義でき、キャッチした後に特定のステートを実行することができます。

ステートマシンの実装

バッチ処理は、主に3つのステップで構成されています。ステップ1では対象を分割し、ステップ2で分割された各対象に対して処理を行い、最終的にステップ3で全処理結果を合算して通知します。以下に各ステップの詳細を説明します。

ステップ1: 分割

ステップ1では、指定された並列数に応じて対象(更新対象のサブスクリプション)を分割し、IDの始端と終端を表す配列の配列が返されます。

以下の例は3並列を設定したときの返り値です。この形で返した場合、ステップ2の並列処理のうち、一つ目のステートがidが1 ~ 100のレコードに対して、二つ目が101 ~ 200を、三つ目が201 ~ 300を更新します。

[[1, 100], [101, 200], [201, 300]]

ステップ2: 更新処理

ステップ2では、Step FunctionsのMapステートを利用します。Mapステートは、ステップ1で返された配列で指定されたIDの範囲に対して更新処理をするバッチを並列で実行し、それぞれの結果を配列にまとめて返します。

ステップ3: 集計

ステップ3では、ステップ2で得られた全ての更新バッチの結果を引数として集計バッチを実行します。この集計バッチにより、全ての更新バッチの結果が合算され、その結果が通知されます。

並列実行数の調整

Mapステートは、入力の配列の要素数に応じて処理バッチを並列実行します。対象の分割はステップ1で行われ、その分割数はステップ1の入力で設定します。この分割数を変更することで、並列実行数を柔軟に調整することが可能です。例えば、定期実行時のステートマシンの引数を変更することで並列実行数を上げることができます。

エラー通知

エラーハンドリングのために、各ステートには失敗時のフローが設定されています。

ステートが失敗した場合、設定されたLambda関数が呼び出され、適切なエラー通知が行われます。これにより、問題が発生した場合でも迅速に対応することが可能です。

結果とまとめ

速度比較

以下の表は、並列数とそれに伴う実行時間の比較を示しています。

並列数 更新対象のサブスクリプション数比で補正した実行時間
1 216min
2 114min
4 55min

この表から、並列数が増えるにつれて、実行時間が減少していることが分かります。並列数が2から4に増えたときに、実行時間が半分以下になっているのは不思議ですが、これは決済APIのレスポンスタイムや、ECSタスク起動のオーバーヘッドなどの影響を受けたためだと考えられます。

並列数を上げることによる注意点

現在は並列数4の実行で十分に短い実行時間が確保できていますが、今後も並列数は上がり続けていきます。この過程で注意すべきなのは、決済系APIのレートリミットです。並列数の増加は、短時間に多くのリクエストが送信される状況を生み出します。そのため、時間あたりのリクエスト数を監視し、それに応じて適切に並列数を調整することが重要です。

今後の課題

現在のところ、システムは順調に動作していますが、さらなる最適化のための潜在的な課題が浮かび上がってきました。今後必要に応じて改善していく予定です。

  • ジョブ間の実行時間の偏り:ジョブ間の処理時間が異なると、並列数に応じた効果が得られない問題が生じます。サブスクリプション更新バッチでは仕様上、更新対象のサブスクリプションであっても決済APIが呼び出されない場合があるため、そのデータの存在によって実行時間に偏りが生じます。
  • 並列数の調整:並列数を手動で調整するのは容易ですが、管理が手間となる可能性があります。実行時間を監視し、適切に並列数を調整するために自動化を検討しています。ただし、その際にはAPIのレートリミットを考慮する必要があります。

終わりに

プロダクトにとって重要なバッチ処理を、AWS Step Functionsにより並列化を行いました。

これにより、従来よりスケーラビリティが高くなり、ユーザー数の増加に応じて対応できるようになりました。

今後の展望として、より効率的なバッチ処理を実現するために、自動的に適切な並列処理数を決定したり、処理対象を一様に分割するなどの対応があります。

High Link の開発チームではアプリケーション開発に加え、サービスの規模拡大するにつれて必要となるパフォーマンス改善などに関心のある、プロダクトと技術どちらも磨いていきたいというエンジニアを募集しております。

興味がある方はカジュアルにお話をするだけでも歓迎です。詳細は下記のリンクからどうぞ!

herp.careers