STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES ネットショップのバッチ基盤を振り返る

こんにちはこんにちは。技術基盤グループの Shia です。開発の妨げになるものを排除していく仕事をしています。 入社してからいろんなことをしてきてますが、本日は年末ということで STORES ネットショップの ECS 移行時で作ったバッチ基盤の話とともに当時の技術決定を振り返ってみようと思います。読み手のみなさんにとっては ECS 環境でこういう感じのバッチ基盤もあるんだな〜とか、 Step Functions 便利そうだな〜とか感じてもらえれば幸いです。

STORES Advent Calendar 2024 9日目の記事です。

入る前に: この記事でのバッチは定期的にスケジュールされて実行されるタスクを指しています

仮想サーバ時代

数年前になりますね。 STORES ネットショップは AWS EC2 インスタンス上で動いておりまして、バッチは whenever という gemを用いてバッチを動かしておりました。つまり、 crontab です。簡単にスケジュール通りにタスクを動かせますので大変便利なんですけれども、いくつか問題もありました。

まず、whenever は基本単一サーバで動かす前提になりますので、まずスケールアウトが難しく単一サーバ & スケールアップが選択肢として選ばれがちです。となるとまあバッチが混むときのために混まない時も過剰なキャパシティを保つ必要があります。なお当たり前ですがコンテナ環境で動かすこともできません。 1

2つ目。バッチが失敗してもそれを気づく方法が Sentry から実際おきたエラーベースで拾うか、アプリの実装から Slack 通知を用意して送る必要がありました。これでは OOM みたいなケースは拾えず、バッチが失敗に気付けないこともあるでしょう。なおこれだと失敗したことがわかるだけでどうすべきか、というのは識者しかわからずという感じでした。バッチのリカバリーに関する記事へのリンクとかも出したり、関連チームへ対応を求めるためメンションしたりしたいですね。

3つ目、バッチの並行実行がむずかしいです。個別で全部違うバッチとして定義すればできますが、やっぱネイティブサポートがほしいですよね。

4つ目、バッチの実行履歴がログとしては残ってるかもしれませんが、単なる一覧、としては管理していないため追えませんでした。どのバッチが実行中なのか、どのバッチがいつ実行され、成功したか失敗したか、という情報を調べるためにはバッチサーバに入ってみるしかありませんでした。

このような状況の中、コンテナ環境への移行が始まりまして、新しいバッチ基盤を考える必要が生じました。この設計の時では上記していた問題点を解消できたらいいな、という気持ちで選定をやっていきました。

コンテナ時代の選択肢

これらを踏まえてコンテナ上でどうやってバッチを動かすかなんですが、いろんな選択肢がありました。 サービスの移行先として AWS ECS Fargate を選んでいたため、当時の選択肢だとこのような感じでした。

  • EventBridge + RunTask
  • EventBridge + StepFunction
  • AWS Batch
  • OSS ワークフローエンジン

その上で利用状況も考えていきます。

  • それなりの数(3桁)のバッチがある
  • お互い依存性があるバッチはほぼなく、あったものも基本時間ベース(いつまでは確実に終わるので次のやつをその時間に実行しよう)でも一旦足りていた
  • 長時間動くジョブが複数あり、並列実行への需要がありそう
  • 運用コストはできるだけ減らしたい

以上を頭に入れたうえで各候補をみていきましょう。

EventBridge + ECS Run Task API

ECS Scheduled Tasks と言われるよくみるパターンです。 EventBridge は設定した時刻になったらバッチを動かす目覚ましとしての役割なのでこれに関する話は後述し、一旦 RunTask API のことを話していきましょう。

このパターンは簡単に実装できますし、 ecschedule という便利なやつがありまして、こちら利用すれば運用も比較的にできそうです。 ただ、RunTask API はいろいろな理由で失敗できるものでして、例えば Fargate で動かすものなら ENI アタッチで失敗したり、キャパシティがなくて起動そのものに失敗したりしますが、これらを適切にリトライするなどの処理が欲しくなります。

さらに、並列で複数のタスクを立ち上げることもできますが、これを実際運用することを考えると実行されてるタスク自身が n並列の m番目という知識があれば便利なので、与えてあげたいのですが、その情報は仕組み上では渡しづらいのも辛いところです。

最後に、バッチの実行状況が分かりづらいです。タスクの実行開始・終わりをいい感じに拾って記録し、描画してくれるサービスを自作・運用できるという前提、もしくはそのような状況の可視化が不要という判断ならばシンプルで便利な解決策なのでしょう。

EventBridge + Step Functions

Step Functions は AWS 提供のワークフローエンジンです。ステートマシンを記述できて、これには各種 AWS と組み合わせることでいろんな操作が簡単にできますよ〜というものでして、つまり Event Bridge から Step Functions を叩いて、 Step Functions ではさらに RunTask API を叩いて ECS タスクを動かす、ということができるということですね。

この設計のよいところは、まずなんといってもコンソールがある点です。まあ今回の用途で使おうと思ったらそこまで都合がいいものではありませんが、少なくとも再実行がしやすい、実行状況がみえる、実行失敗と失敗の理由が調べられる、といった面では評価できました。別のメリットもあります。

1つ目、RunTask API 呼び出しはステートマシンの内部の1つのステートでしかありませんので、実行結果から再実行するか、どうかの分岐なども追加できるんですね。ENI のアタッチに失敗したり、Fargate からキャパシティ不足のエラーがでたりしてもステートマシンの内部ですべてが解決します。

2つ目、まあまあ複雑なワークフローも実現できます。例えば、並列実行は Map ステートで実装し、各 ECS タスクに番号を振ることで自分が何並列の中の何番目ということを伝えられますし、並列処理の前後に処理を追加することもできます。

3つ目、失敗時の通知する機構を足せるというものです。 今は EventBridge のイベントを拾うだけでいろんなところから同じことが実装できるということをわかっていますが、当時はそこまで考えが回ってなかったし、ステートマシンの中でしか取れない情報があると思っていたんですね。そんなものはなかったんですけど...

といった感じですべてが完璧!即採用!と謳ってるように見えますが、もちろんデメリットはあります。 まずステートマシンの定義というものに難がありましてですね、ステートマシンを記述するための言語である Amazon State Language(ASL) が難しいです。もちろんこのようなステートマシンを定義する言語というのが簡単なわけがないのですが、 JSON のサブセットとして頑張ろうとした結果、実行時間で評価される変数の書き味がだいぶ独特だし、ステートを何個か定義すると行数が偉いことになので個人的には扱いづらいなぁという印象でした。入力データ加工の制約もあります。入力データは JSON なのでそれを加工しようと思うと js が書けるかいろんな関数があってほしいのですが、選定直前に JSON.merge 相当の JsonMerge という関数が追加されたくらいだし、これはネストされた中身のマージができなく(いわゆる shallow merge)、それは今も続いている制約になります。 Ref: States.JsonMerge - AWS Step Functions

このように JSON 操作方法が限られるため、ちょっと難しいことをしようと思ったら Lambda 関数を呼ぶか、複数のステートに分けて書くことで頑張って加工することになります。 あとは入力と結果の加工が難しいところでしょうか。これは使ってみながら増えた感想ですが、パスを指定するとなると割と直感と違う挙動がいくつかあったので、ちゃんと理解してないとどういう処理結果になるか予想が難しくて大変でした。

AWS Batch

AWS Batch というやつはどうでしょう。名前からしてそれっぽいやつですが、我々のユースケース的には難がありました。コンソールがあるので実行履歴が残るのは便利でしたが、一度に動かせるコンテナが1つということで sidecar コンテナを動かしたい我々としては、この制約事項をわかった時点でこれを選ぶことはないと却下しました。

OSS ワークフローエンジン

AWS のサービスの事をずっと話しておりましたが、もちろん OSS で公開されてるツールを自前運用する、という選択肢もありました。例えば、前職で使っていた kuroko2 だったり、世の中でよく名前を聞く Airflow とかですね。

これらはちゃっとしたプロダクトでして、ちょっと難しいかもだけど表現力あるし、良く出来てると思います。 ただしデメリットとしてそれを運用しなきゃいけないという点ですね。運用するのか....?

sidekiq

当時、検討したかというとしておらず。なのですが、気になる人がいらっしゃるかなと思って一応理由を書いておきましょう。

コンテナでデプロイされることを考えると ECS の stopTimeout より長時間実行されるジョブの場合、 IteratableJob に変換することになりますが、三桁のバッチを全部変換するんですか?という質問にまず答えないといけないし、一方その見返りも薄かった、という感じですね。

最終決定

ここまでいろいろ候補はあげて、我々においてのメリット、デメリットを述べました。結果としては EventBridge + Step Functions に決めまして、理由は以下です。

  • 並列処理が今後必要になるはず、という予想があるので、ある程度の柔軟性があるワークフローが組めたほうが嬉しい
  • 運用コストはできれば減らしたい。現状だと OSS のなにかを運用しなきゃいけない要求仕様はないし、きっとオーバーキル
  • ステートマシンが複雑というデメリットは抽象化レイヤを用意することで隠す

設計に関して

ということで移行しました。ざっくりこんなもんです。

STORES ネットショップ ECS 移行直後のインフラ構成

まあそんなもんだよね、というものですが、書かれてないところで色々ありました。例えば EventBridge、 名前が EventBridge Scheduler になってるのは間違いではありません。一体なにがあったのか。

EventBridge のスケジューリング機能ってこういっちゃあれですが、あまりにもおまけ感が強いものでして(あくまで個人の感想です)、イベントバスという概念でルール(スケジュール)を登録するのですがこれの登録できるルールの数に上限が 300 とかだったんですね。つまり微妙に低い! 2 なお AWS サービスの連携で生やされるルールはデフォルトのバスで生えてくるのでそちらの枠を消費するわけにはいかなく、バスを必ず別途にしていく必要があります。まあこれは環境毎に分けたいみたいな話もあるのでどうせやるから良し!としました。 もう一つはスケジュールの登録に使う時刻指定ですが、 UTC しか選択できなかったという点です。 EC2 時代では普通に JST を利用していたため、移行により不便になるところでした。

しゃ、しゃないか...と悩みながら crontab のスケジュールをパースして JST に変換するスクリプトを書いていたらですね、 EventBridge Scheduler が公開されました。3 これは EventBridge が持っていたスケジューリング & 任意のリソースを呼び出す機能を切り出し強化したものでして、スケジュール設定時にタイムゾーンが設定できたり、1グループ(わかりやすくなりました)で登録できるスケジュールの数の上限も増えました。タイミングすごいですよね。何もしてないのに悩みが解消しちゃった。 最後の問題はスケジュール設定の書き方です。ステートマシンの難しさを抽象化して隠すぞ!ということでなんかしなきゃいけません。そこで選択肢が2つありました。自作するか terraform ってやつを使うか。 当時自分の考えとして terraform をこういう用途で使うことにためらいがありました。そこに明確な理由があったわけではないのですが、強いて言えば明らかに module の利用が強制されるし4、当時、サービス開発チームは terraform への経験がほぼない状態でして、突然これでよろしく!というのもな、と考えてたんですね。 5 では自作するの?と言われるとですね。。。実際ちょっと書いてみたんですがあまり納得がいくものがつくれず、一旦 terraform 頑張るか、となりました。 

module "run_task" {
  source = "./single-task"

  batch_name             = "run_task"
  command                = ["bundle", "exec", "rake", "run_task"]
  schedule_expression    = "cron(0 3 * * ? *)"
  failure_notify_channel = "netshop-team-channel"
  alert_group_name       = "netshop-team"
  is_enabled             = true
  recovery_procedure_url = "some-url"
}

結果としてはこのような書き味になりました。

  • RunTask の実行時に渡す必要がある各種コンテキスト情報(クラスタ、セキュリティグループ、サブネット、タスク定義などなど)を毎度書かせるわけにはいかないのでいい感じに隠す
  • ステートマシンの中身は、単一タスクを動かす前提として組み立てておく

という感じです。まあこんなもんですよね。

その後

ではこれでみんなはっぴ〜になったというと実はそうはなりませんでした。運用していきながらまあまあめんどい問題が2つ見つかりました。

バッチ失敗通知

まず失敗通知です。最初の設計だとステートマシンに埋め込む形になっておりまして、 RunTask API の実行結果をみて通知をするようになっていました。すでに気づいてる方もいらっしゃるかもですが、 RunTask API 実行以外のもので問題がある、もしくはステートマシンのエラー判定ステートが想定しているものと違う構造のレスポンスが返ってくると通知も失敗するという抜け穴がありました。これでは作った甲斐がないですね。

後は地味に困るものとして、Step Functions の実行履歴ページ、実行した ECS タスクの詳細画面のリンクは持ってますが、 AWS の仕様として ECS タスクが終了して 30分後には参照できなくなるんですよね。つまりバッチが失敗して 30分以上経ってから調べようとする場合、実行履歴から実行された ECS タスクの識別子を調べ、ログを参照しに行く必要があり面倒でした。そうなるだろなと思いつつ作っていたんですが、使ってるとやっぱ面倒すぎるということで、なんとかしたいようね、と。

結論としては作り直しました。新しいバッチ通知は EventBridge 経由で失敗と検知し、ステートマシン・実行履歴から通知に必要な情報を抽出するという方向にしました。真っ当になりましたね。 必要な情報には失敗通知に含めたいメンション先、チャンネル名などがありまして、これらはステートマシンのタグとして持たせています。

terraform しんどい

terraform もやっていくうちにしんどくなりました。理由はいくつかありまして

  • 構成上1バッチ1モジュールになるので追加のたびに terraform init が必要(ほとんどの人は CI 上でしか実行しないが、運用する側はたまに手元で動かすので面倒)
  • ワークフローの組み立てとモジュールの組み合わせの悪さ
  • HCL と ASL が混ざると意味がわからなくなる

ワークフローの組み立ての話をもう少し詳しくすると、以前予想していた通り長時間かかるバッチを並列実行したい、という話が降ってきました。となるとまあ単純に Map ステート使えばいいじゃんとなるんですが、立ち上がった ECS タスクが自分が何番目なのか分かる必要があるため、 Map ステートを使うことになり、これの記述方法の都合で単一 ECS タスク実行と複数実行する場合のステートマシンには HCL では吸収が難しい差分ができあがるんですね。こちらみてみましょう。

{
    // ステートマシンの一部
    "Validate All": {
        "Type": "Map",
        "InputPath": "$.detail",
        "ItemProcessor": {
            "ProcessorConfig": {
                "Mode": "INLINE"
            },
            "StartAt": "Validate",
            "States": {
                "Validate": {
                    "Type": "Task",
                    "Resource": "arn:aws:states:::lambda:invoke",
                    "OutputPath": "$.Payload",
                    "Parameters": {
                        "FunctionName": "arn:aws:lambda:us-east-2:123456789012:function:ship-val:$LATEST"
                    },
                    "End": true
                }
            }
        },
        "End": true,
        "ResultPath": "$.detail.shipped",
        "ItemsPath": "$.shipped"
    }
}

AWS 公式ドキュメントの例をもってきております。

https://docs.aws.amazon.com/step-functions/latest/dg/state-map-inline.html

通常のものと Map で同じ処理を書くときの違いは、

  • ItemProcessor の中に実処理を書く
  • 内部がまた別のステートマシンとして定義されている
  • 内部のステートマシンをどう動かすか、に関しては ProcessorConfig で定義する

のようになりまして、さらに ^ だと見えませんが、Map 処理中エラーを fast fail せず、まとめた結果を扱いたいのであれば、結果を返してもらって各並列の処理結果を外でまとめて判定してなにかをするみたいなことになるので、要は単純に並列に実行するだけじゃ済まされないです。 6 そうだ、n並列の中 n番目、という情報を渡したいとなれば、 RunTask API の引数に環境変数あたりを差し込む必要もありますね。

では並列とそうじゃないものを ASL レベルで分岐をいれて両方のケースを全部考慮するステートマシンにするとステートマシンが意味わからない感じになりメンテが難しくなります。7 となると一番安直な解は?並列じゃないケースでも Map を使って 1 並列のように振る舞うか、別モジュールにすることになります。別モジュールにする場合はどうなるか。メンテすべきモジュールが2つになるし、例えばバッチ A を並列実行に変えたいとなると別モジュールに差し替えが必要となるので消して作り直すか頑張ってステートをむーぶするか、になります。並列度 1 の時にステートマシンが直感的じゃないので運用者が苦労すればいいかということで、結果としては別モジュールにする形にしました。

ただですね、その次は並列処理前後に処理を入れたいという需要が出ました。つまり

A -> B * 4 -> C

みたいに前処理としてバッチA を実行して、それを元に4並列で別のバッチB を実行して、最終的な結果を集計するバッチ Cを実行するワークフローが組みたいんですね。どうしましょう。 何言ってるんですか。もう一つモジュールを増やすんですよ。ということでモジュールをまた1つ増やし、心の中で何かが切れました。はい、自作の始まりです。

自作ツールは次のことを考えて作りました。

  • ステートマシン記述を楽に・抽象化しやすくしたい
    • JSON よりもう少し表現力がほしいのでJsonnet を採用する
  • できるだけ magic を取り入れない
    • ecspresso みたいな感じで api のインタフェースをほぼそのまま設定ファイルに落とす
  • state mv みたいな消耗を避けるため状態を持たない
    • tfstate の管理が不要になって便利。代わりに削除の判断をできなくなるので削除フラグを提供する

なお CI で実行プラン計算・適用などを実行したいので何かしら CLI を作ることになりますが、開発言語としては Rust を採用しました。採用意図としては一応 Ruby 以外のプロダクトから使うことも想定はしていたのでできればバイナリを提供したかったというのがありまして、となると Go or Rust の二択となりました。そこであまり使わないパラメータも sdk が提供する型をみてある程度コンパイルレベルで動くことを保証できてコンパイラが親切 & ちょうど興味もあったという理由で最終的に Rust を選んだという流れです。実際 serde によるシリアライズと未対応パラメータへのチェックなどは大変便利でした。8 逆に雑にモックできないのでテストはものすごく大変でしたが、それはまた別の話。

名前は fubura です。なんと brew からインストールできて便利です。

まあそれはさておき、導入もしました。結果としてはこんな感じの設定の書き方になりました。

d.batch(
  name='run_task',
  definition=[
    d.runRailsTask(task_name='run_task_a'),
    d.runRailsTask(task_name='run_task_b', fork_size=4),
    d.runRailsTask(task_name='run_task_c'),
  ],
  schedule='cron(0 3 * * ? *)',
  scheduleEnabled=true,
  failureNotifyChannel='netshop-team-channel',
  alertGroupName="netshop-team",
  recoveryProcedureUrl="some-url",
)

terraform の設定と大きく変わらないことがわかります。違いは1つのバッチに対して中のワークフローをある程度柔軟に定義できるようになっている、ということですね。 この定義はせっかくなので複雑なワークフローの例としてあげていた、前処理、並列処理、後処理を別々の ECS タスクで順次実行するステートマシンにしています。[^8] 余談ですが、自作が終わって terraform で external data source 使えば外部コマンドを呼び出せるので terraform で完結できたと気づいてしまいまして。つまり jsonnet コマンドを呼び出して評価結果だけを terraform で扱えばよかったのです。これがわかってたら terraform で我慢したかもな、と思いつつ外部コマンド呼び出すの嫌だし、地味に tfstate ないほうが便利だし、 terraform と aws provider の更新が頻繁すぎて結局自作したかもな、なども考えたり。

感想

結果、現在のネットショップバッチ基盤はこうなっています。

STORES ネットショップバッチインフラ 2024 ver

最後に今までの意思決定を見直して終わりたいと思います。

  • Event Bridge Scheduler + Step Functions
    • ちょうど並列処理の需要も発生した & そこまで難しいワークフローを組みたいというニーズは出てないので完全に狙い通り
    • たまに Fargate が荒ぶることがあるのでリトライ機構もあってよかった
  • バッチ失敗通知
    • アイデアは良かったが、初期実装は雑すぎた。反省
  • terraform による設定管理
    • 自分の知識が足りないばかりに、難しい設定を書いていた。外部コマンド呼び出しで実装してたら未来は変わったかもしれない

今のところ、当時の知識不足による判断ミスはしょうがないものとして、そこまで外してる意思決定はしてないかな、という感触を得ています。さて、今後もっと成長した後で振り返るとどう思うのでしょう。ドキドキですね。

みなさんのバッチ基盤運用の足しになったら幸いです。良いお年を〜


  1. まあ理論的には最大デプロイタイムより長い間隔で設定された crontab で単一コンテナ(ないしはタスク定義)で動かすのであればいけるかもしれませんね
  2. https://github.com/heyinc/retail-sre/issues/2751#issuecomment-1268175523
  3. https://aws.amazon.com/about-aws/whats-new/2022/11/amazon-eventbridge-launches-new-scheduler/
  4. TMI: module は本当に最後の砦と考えている人で、あまり使いたくない派です。
  5. 今考えるとまあ自作してもそのハードル変わらないのであまり意味ない考えでした。
  6. いろんな後処理を運用する人に丸投げすれば話は少し変わりますが、前提としてバッチの失敗の対応をする人は Step Functions の ASL に詳しくない、前提で話を進めています。
  7. 具体的にはまず、登場するステートが多すぎて状態遷移図が読めなくなるでしょう。。。
  8. Rust のメリットっぽく読めるかもですが、これは Go でも似たような体験を得られたと思います。