JAWS-UG岡山でStepFunctions運用1年の反省を発表してきました

2026年9月9日

こんにちは!ミライ工事ウェブエンジニアの @t4traw です。

先日、JAWS-UG岡山に参加して、StepFunctionsに関する登壇をしてきました。

こういった登壇は初めてだったのでとても緊張して、途中で発表したいことが飛んだりもしましたが、なんとか無事に終わることができました。その中でいろんなアドバイスをいただきました。本当にありがとうございます!

スライドはこちらです。

発表した内容をもう少しブログの記事としても残しておきたいので、ここに書いておきます。

話の内容としては、StepFunctionsでこれまでやってきた中での課題と、それを解決しようと頑張ったけれど「本当はこうするべきだったよね」といった感じの、反省というか失敗談みたいな形の内容になっています。

StepFunctionsを導入した経緯や全体の構成については、以前書いた記事の方にまとまっているので、よろしければそちらも合わせてどうぞ。

課題1: 思ったより高い

StepFunctionsを導入して1年ちょっと経ったのですが、一番最初に取り組んだのが「思ったより高い」という部分です。

StepFunctions(Standardワークフロー)は、1,000回の状態遷移あたり0.025 USD という料金形態になっています。

始める前は「そんなに高くないよね」と思っていたのですが、実際に運用を開始し、ユーザーにたくさん使われるようになってくると「なんだか思ったよりもかかるな……」というように感じてきました。

ミライ工事の写真台帳出力は、画像1枚につき1関数を動かして、それをMapステートでファンアウトするような構成になっています。つまり 2,000枚の写真を処理する台帳なら、1回の出力でざっくり2,000回ちょっとの状態遷移が発生する ということです。

これが月に10,000回実行されたとすると、

2,000 遷移 × 10,000 回 = 20,000,000 遷移
20,000,000 × 0.000025 USD = 500 USD / 月

StepFunctionsの料金だけで月500ドル計算になります。ウェブサーバーや画像ストレージのコストに比べれば全然厳しくない、というレベルではあるのですが、それでもやっぱりちょっと高いよね、という感覚でした。

これは利用が増えればどんどん増えていく一方なので、今のうちに見直しておきたいな、というのが最初の課題でした。

「Distributed Map + Expressワークフロー」で安くなると思った(勘違い)

いろいろ調べたときに出てきたのが、Distributed MapExpressワークフロー です。

Expressワークフロー

StepFunctionsにはワークフロータイプが2つあります。

StandardExpress
課金状態遷移の回数リクエスト数 + 実行時間(GB-秒)
最大実行時間1年5分
実行履歴StepFunctions上に保存されるCloudWatch Logsに出力

Expressの方はハイパフォーマンス・低コストで、大規模な処理をやりたいときに向いているタイプ。ただし実行時間が5分までという制限のあるタイプです。

Distributed Map

Mapステートにもモードが2つあります。

  • Inline(通常のMapステート): 入力が配列になっているところを順次処理していってくれる。並列数の上限は40。
  • Distributed: 処理する入力を、ちょっと手間なのですがS3にJSONL(JSON Lines)などで置くことで、それを入力にできる。並列数が 最大10,000 まで行ける。

そしてこのDistributed Mapの特徴として、ドキュメントに

各イテレーションは、独自の実行履歴を持つ子ワークフロー実行として実行される

というような説明が書いてあります。

……ここを勘違いしました。

「このMapステート自体が1つのStepFunctionsのワークフローになるんだ」 と思ってしまったんですよね。

画像処理自体は5分以内で全然終わっていたので、「じゃあこのMapステートをExpressワークフローで動かしたら激安になるじゃん!」と思ってやってみたんですが、全然安くなりませんでした。

「なんで……?」と思っていろいろ調べたのですが、答えはドキュメントに書いてある通りで、

  • Mapステート全体が1つの子ワークフローになるのではなく
  • イテレーション(=アイテム)ごとに、独自の実行履歴を持つ子ワークフロー実行が起きる

ということでした。

自分が思っていた形(勘違い)

親ワークフロー

Distributed Map
(これ全体で1つの子ワークフロー)

画像1

画像2

画像...N

実際の形

親ワークフロー

Distributed Map

子ワークフロー実行
(画像1)

子ワークフロー実行
(画像2)

子ワークフロー実行
(画像...N)

なので、アイテムの数だけ子ワークフロー実行が起動することになり、単純に ワークフローを動かす分の料金が純増している という状態になっていました🫠

本当はこうすべきだった: Distributed Map + ItemBatcher

正解というかやるべきは単純に Distributed Map + ItemBatcher で、1関数の処理を増やしてステップ数を減らすという施策でした。

ItemBatcherは、入力の配列をバッチ数によってまとめて分割し、それを入力として作ってくれるDistributed Mapのオプションです。これ自体はDistributed Mapを使っていないと使えないものなので、Distributed Mapにしたこと自体は間違いというよりは良かったな、という形になっています。

{
  "Type": "Map",
  "ItemProcessor": {
    "ProcessorConfig": {
      "Mode": "DISTRIBUTED",
      "ExecutionType": "EXPRESS"
    },
    "StartAt": "ProcessImages",
    "States": { "...": "..." }
  },
  "ItemReader": {
    "Resource": "arn:aws:states:::s3:getObject",
    "ReaderConfig": { "InputType": "JSONL" },
    "Parameters": { "Bucket": "my-bucket", "Key": "items.jsonl" }
  },
  "ItemBatcher": {
    "MaxItemsPerBatch": 10
  }
}

MaxItemsPerBatch を10にすれば、1つの子ワークフローに画像10枚分のアイテムが渡ってきます。つまり 子ワークフローの起動数が1/10になり、StepFunctionsの料金もざっくり1/10くらいになります。

そして、10個ずつまとめて渡されたときの画像処理やダウンロードなどは、Goのgoroutineを使ってさっと並列に実装できるので、その辺も楽だったなという感じです。分散はStepFunctionsに全部やらせるのではなく、粒度の粗いところだけStepFunctionsに任せて、細かいところはランタイム側でやる、という切り分けですね。

課題2: StepFunctionsからStepFunctionsを呼びたい

写真台帳の出力自体は2,000枚に対応できるようになったのですが、ミライ工事にはプロジェクト単位で出力する機能もあります。

具体的には、そのプロジェクトに入っている台帳すべてを出力した後で、まとめて写真をダウンロードする、といった動きです。これをやるときに、先ほどまで作ってきたワークフローを並列で回したい、という話になりました。

LambdaからStepFunctionsを呼ぶとか、SNS/SQSを使うとか、そのあたりかなぁと思ったのですが、WaitForTaskToken というトークンを発行して、それを返してくれるまでステップを待つ、という仕組みがあります。

この機能を使って、StepFunctionsで出力したい台帳のところをMapステートにして、それらのタスクトークンが返ってくるまで待つ、という構成にしました。

これは動いていて問題はなかったんですけど、もっと良い方法がありました。

本当はこうすべきだった: 標準の StepFunctions 呼び出し(startExecution.sync)

これは本当にシンプルに、StepFunctionsにはStepFunctionsを呼ぶ標準機能がある ということです。

{
  "Type": "Task",
  "Resource": "arn:aws:states:::states:startExecution.sync:2",
  "Arguments": {
    "StateMachineArn": "arn:aws:states:ap-northeast-1:xxxx:stateMachine:LedgerWorkflow",
    "Input": { "...": "..." }
  }
}

わざわざ自分でWaitForTaskToken用のトークンを発行するLambdaを作成して、それで呼び出していたのですが、ちゃんと標準のStepFunctions呼び出し機能を使うと、

  • 親のStepFunctionsから子のStepFunctionsのワークフローにさっとアクセスできて、非常に見やすくなる
  • そもそも トークンを発行する必要がない(=トークン発行用Lambdaが丸ごと要らない)

ということになります。

今までだと、実行履歴を見に行ってもトークン発行Lambdaの履歴しか見れないので、そこから自分で子の実行を辿っていかないといけない、という地味に辛い問題がありました。

小さな学び: Workflow Studio をちゃんと見よう

ここまでの2つに共通する反省なのですが。

どうせIaCでコードを書いて、ビルドしてデプロイして、という流れを組むので、ブラウザからアクセスして使えるWorkflow Studioをほとんど触っていませんでした。最初からServerless Frameworkでやっていたので、そもそもHelloWorld以上の事は見ていない&やっていなかったんですよね。

でも後から見てみると、やれること・できることがすごく分かりやすくなっているんだな、と知りました。

  • 標準で呼び出せるAWSサービスの一覧
  • Mapステートのモード(Inline / Distributed)が選択できること
  • ItemBatcherのようなオプションがあること

そういうことを踏まえて「もうちょっとこういう方法があるよね」と調べられていたら、もっと早くこの答えにたどり着けたか、もしくは最初からできていたのかなぁ、という反省があります。

コードで書く前提であっても、「何ができるか」のカタログとしてWorkflow Studioを一度眺めてみる のは価値があると思います。

課題3: 開発環境を作るのが難しい(未解決)

最後のスライドは、まだ解決していない話です。

ミライ工事のStepFunctionsから呼ばれるLambdaはGoを採用しています。GoだとLambdaのマネージドランタイムが実質ないので、provided.al2 / provided.al2023 のカスタムランタイムを使うことになります。

それを使うのであれば、どうせビルドしたバイナリを置いて叩くだけなので、それならdistrolessでほんとうに小さいコンテナを作ってECRにプッシュして使う方が、開発環境と実行環境のコンテナが同じになる仕組みになるよね、ということでコンテナイメージを採用しています。

ここまではいいのですが、正直、開発環境を作るのはめちゃくちゃ難しいです。

Lambda 1本でやっていたところは、

  • Lambdaエミュレーター(RIE)
  • S3のエミュレーター

を使って、単純に実行して目視で確認し、コードの変更分を修正したらairでホットリロードする、みたいなことができたので非常に開発しやすかったんです。

参考: Go言語でのLambda+API Gatewayの開発環境をRIE(AWS Lambda Runtime Interface Emulator)を使って構築する

ですが、これがStepFunctionsになってしまうと、適切な選択が分からない、というか良い方法が分かっていない状態です。

なので今はもう、開発用のAWS上にデプロイできるようにして、デプロイしてから確認する、という形になっています。

これに対しては、まだ「本当はこうすべきだった」が見つかっていません、という形で発表を締めました。

登壇後に教えてもらったこと: TestState API

そうしたら、スライドを見てくださった方が声をかけてくださって、TestState APIのことを教えてくれて「試してみましたか?」みたいなお話をさせていただきました。

StepFunctionsをローカルで動かしてテストできる StepFunctions Local は知っていたのですが、2024年にサポートが終了していて、「もう公式で使えるものはないのかなぁ」という形でちょっと諦めてしまっていたんですよね。

ですが、その後に TestState API というものがあることを知りませんでした。

TestStateは、ステートマシン全体をデプロイして流さなくても、1つのステート単体を指定した入力で実行して結果を確認できる APIです。

aws stepfunctions test-state \
  --definition file://state.json \
  --role-arn arn:aws:iam::xxxx:role/StepFunctionsTestRole \
  --input '{"bucket":"my-bucket","key":"items.jsonl"}' \
  --inspection-level DEBUG

--inspection-levelDEBUGTRACE にすると、入力に対してInput/OutputのProcessing(ArgumentsOutput、JSONPath時代の InputPath / Parameters / ResultSelector など)がどう適用されたかまで見られるので、「JSONの変形がうまくいかない」系のハマりどころにかなり効きます。Workflow Studio上からも同じことができます。

ステートマシン全体の結合テストが解決したわけではないのですが、「デプロイしないと何も確認できない」という状態からは一歩進めたので、これはかなり大きい収穫でした。

おわりに

まとめると、今回の反省は以下の3つでした。

  1. 料金: Distributed Map × Expressで安くなると思ったら勘違いで純増した。正解は Distributed Map + ItemBatcher でバッチ化すること。
  2. StepFunctions呼び出し: 自前のWaitForTaskToken + Lambdaでやっていたが、標準の states:startExecution.sync を使えばシンプルで追跡もしやすい。
  3. 開発環境: まだ答えは出ていないが、TestState API という手札が増えた。

そしてこの3つに共通する反省は、そのサービスが標準で何を提供してくれているのかを、ちゃんと調べる ということに尽きるなと思っています。IaCで書いているとブラウザ画面の設定・編集を見なくなりがちですが、Workflow Studioを一度眺めるだけで防げた遠回りが結構ありました。

初めての登壇でしたが、発表したことで知らなかったことを教えてもらえるという体験ができて、本当にありがたかったです。JAWS-UG岡山のみなさん、ありがとうございました!

これからもっと面白い経験を積んで、また登壇できる機会があったら挑戦してみたいと思います。

Tatsuro Moriyama

Tatsuro Moriyama

音楽と珈琲とサウナと釣りが好きです🤟 Ruby, Go, Typescriptでいろいろ書いてます😃 WEB開発、WEBデザイン、自動化などをよくやっています🚀