Skip to main content

Lambda 4本を JSONata 1ステートに置き換えたら、Step Functions はどれだけ速く・安くなるのか実測した

 こんにちは~ 浮田です。  

先日、外部の勉強会でStep FunctionsのJSONataについて登壇したんですが、質疑応答で「JSONataに置き換えると処理速くなるんですか?」と聞かれて、正直に言うと曖昧に濁して終わりました。

「Lambda分の処理は速くなると思いますけど……」みたいな、自分でも歯切れが悪いなと思う回答をした記憶があります。

感覚としては「速くなる」と思ってたんですが、「どれくらい」を聞かれた瞬間、何も答えられないことに気づいたんですよね。

これ、悔しかったので実際に測ってみました。

 

■  TL;DR

  • Lambda 4本のチェーンを JSONata の Pass ステート1つに置き換えたら、ウォームで7.9倍、コールドで31倍速くなった
  • 100件バッチ(Map)でも 4.6秒→0.83秒(5.6倍)。誤差では済まない差
  • コストの支配項は Lambda料金じゃなくて状態遷移数。4ステート→1ステートで単純に約4倍の課金差
  • 出力の等価性はローカル・TestState・実機の3層でテスト済み。さらに5日後に別日再計測して倍率の再現も確認した
  • 結論:「その Lambda、入力JSONを出力JSONに写しているだけでは?」に Yes ならJSONataに置き換え可能。外部I/Oがあるなら今でもLambdaの仕事

コーヒー注文処理を「Lambda 4本のチェーン」と「JSONata の Pass ステート1つ」で実装し、両者が同一出力を返すことをテストで保証した上で、東京リージョンで実測しました。

指標(p50)

Lambda版(Task×4)

JSONata版(Pass×1)

単発・ウォーム(Standard)

424 ms

54 ms

7.9倍

単発・コールド(Standard)

1,425 ms

46 ms

31倍

100件バッチ(Map・Standard)

4,625 ms

828 ms

5.6倍

単発(Express)

64 ms

1 ms

64倍

状態遷移数/実行(単発)

4

1

4倍

状態遷移数/実行(100件)

401

101

4倍

Lambda 実行料金

発生

0

コールドスタート

あり

なし

しかも Lambda 版は メモリ 1769MB(1vCPU 相当)・外部依存なし・SDK 初期化なしという最速条件で測っています。それでもこの差です。

 

 「データ変換Lambda」問題  

Step Functions のワークフローには、外部 I/O を持たない純粋なデータ変換だけの Lambda が紛れ込みがちです。

フィールドを整形する、金額を集計する、表示用文字列を組み立てる——数行の JavaScript のためだけに、関数を1本デプロイし、コールドスタートを抱え、同時実行数を消費し、invoke のたびに数十〜数百 ms を払う。

2024年11月に Step Functions が JSONata と変数をサポートして、この種の Lambda はステートマシン定義の中に畳み込めるようになりました。

「畳み込めるのは知ってる。で、実際どれくらい違うの?」この記事はその実測です。

 

■  題材:コーヒー注文処理  

題材は AWS 公式サーバーレスデモ Serverlesspresso の世界観を借りました(実装は借りていません。公式アプリは人間の待ち時間が支配的で、変換処理のベンチマークにならないため)。

注文ペイロードはこれ:

題材は AWS 公式サーバーレスデモ Serverlesspresso の世界観を借りました(実装は借りていません。公式アプリは人間の待ち時間が支配的で、変換処理のベンチマークにならないため)。

処理は典型的な「データ変換Lambdaの仕事」4段です。

処理

正規化

drink/size を小文字化、shots 未指定を 1、cancelled 未指定を false に補完

価格計算

キャンセル除外 → 単価算出(サイズ・追加ショット・シロップ)→ 小計

割引・税

クーポン 10% 割引 → 消費税 10%(切り捨て)

チケット整形

バリスタ画面用文字列、JST 表示、待ち時間見積もり

 

■  Before:Lambda 4本  

 価格計算の関数だけ載せます 

価格計算の関数だけ載せます

 外部依存なし・SDK 初期化なし。ステートマシンはこの4本を Task で直列に呼びます。  

外部依存なし・SDK 初期化なし。ステートマシンはこの4本を Task で直列に呼びます。

 

 After:JSONata の Pass ステート1つ    

 同じ処理が Pass ステート 1つになります。

同じ処理が Pass ステート 1つになります。

 Output に載せている JSONata 式の全文(実際の ASL では1行に潰しています):  

Output に載せている JSONata 式の全文(実際の ASL では1行に潰しています)

$filter / $map / $sum / $lookup / $exists / 三項分岐 / $fromMillis / 文字列連結——グルー Lambda がやっていた仕事は、JSONata の語彙でひと通り書けます。

 

 この比較はフェアか  

ベンチマークで一番大事なのは「比較が成立していること」なので、先にそこを固めました。

1. 両者は同一の出力を返す。

これは3層で保証しています。

  • ローカル: jsonata npm パッケージで、ASL ファイルから抽出した式と Lambda 4本のチェーンを同一入力で実行し deep-equal 比較(エッジケース含め7テスト)。
  • TestState API: 実機の JSONata エンジンでも同じ出力になることを確認
  • 実機実行: デプロイした A/B に同一ペイロードを投げて出力一致を確認

2. Lambda 側に最速条件を与える。

メモリ 1769MB(1vCPU 相当)、arm64、外部依存ゼロ、SDK 初期化ゼロ、ハンドラは数行の同期処理。

3. 測定条件を揃える。

同一リージョン(ap-northeast-1)・同一時間帯に A/B を交互に実行。ウォームアップ後に計測し、コールドは Lambda の設定を更新して毎回強制的にコールド化。時間はクライアント時計ではなく DescribeExecution のサーバ側 startDate/stopDate から算出。N=50(Map系は20、コールドは5〜10)、p50/p95 で報告。

4. 別日に全計測を再実行して再現を確認。

5日後に同じスタックへ同じ計測を流したところ、すべての p50 が ±7% 以内で一致し、倍率(ウォーム約8倍・コールド約30倍・Map 約5.6倍)と遷移数(4:1、401:101)は完全に再現しました。本文の数字は初回計測のものです

正直、ここまでやるかどうか少し迷ったんですが、「1回測った数字」と「再現した数字」は説得力が全然違うので、やってよかったです。

なお、いくつか注意点も明記しておきます: Express の「1 ms」は API タイムスタンプの分解能(ミリ秒)の下限値であり実質サブミリ秒であること、Map の倍率は並列度(今回は10)に依存すること(遷移数の 4:1 は並列度に依存しません)。

 

 結果  

   単発実行(Standard)  

単発実行(Standard)

ウォームで 424 ms → 54 ms(7.9倍)。1回の Lambda invoke あたり約90 ms のオーバーヘッド(遷移+invoke+ランタイム往復)が4回積み重なった分が、まるごと消えます。

コールドは 1,425 ms → 46 ms(31倍)。JSONata 版にはコールドスタートという概念自体がありません。ちなみに JSONata 版のコールド列(46 ms)はウォームと同じ値です。

イベント履歴を見ると面白いことが分かります。JSONata 版では PassStateEnteredPassStateExited のタイムスタンプが同一(ミリ秒単位で差が出ない)。つまり JSONata の評価自体は 1 ms 未満で、54 ms のほとんどは Standard ワークフローの実行開始/終了のオーバーヘッドです。後述の Express 版で p50 が 1 ms だった実測とも辻褄が合います。


 100件バッチ(Map、並列度10)    

100件バッチ(Map、並列度10)

単発の数十 ms 差は「誤差では?」と言われがちですが、Map で100件処理すると 4.6秒 → 0.83秒 と、誰の目にも明らかな差になります。  


 Express ワークフロー  

Express ワークフロー

 Express だと JSONata 版は p50 で 1 ms。API タイムスタンプの粒度がミリ秒なので、これは計測分解能の下限に張り付いた値、つまり実質サブミリ秒です(Standard 版のイベント履歴で PassStateEnteredPassStateExited が同一タイムスタンプだったことと整合します)。

Lambda 版は 64 ms。ただし Express の課金は 100 ms 単位の切り上げなので、SFN 側の請求はどちらも最低課金の 100 ms でした。差が出るのは Lambda の実行料金と、応答レイテンシそのものです。  


 コスト:遷移数が4倍、そして Lambda 料金  

コスト遷移数が4倍、そして Lambda 料金

Standard の課金単位は状態遷移数です。

単発で 4 対 1、Map 100件で 401 対 101。

実行履歴のイベント数で見ると、C(Lambda版)は 2,206 イベント、D(JSONata版)は 406 イベント。課金対象の遷移数だけでなく、実行履歴そのものも約5倍膨らみます(Standard には実行あたり 25,000 イベントの上限があるので、Lambda チェーン × 大きめの Map はこの上限にも早く近づきます)。

100万注文を単発実行で処理した場合の試算(ap-northeast-1、執筆時点の単価):

Lambda版

JSONata版

状態遷移

400万 × $0.025/1000 = $100

100万 × $0.025/1000 = $25

Lambda リクエスト

400万 × $0.20/100万 ≈ $0.8

$0

Lambda 実行時間

≈ $1

$0

合計

≈ $102

$25

意外なことに、支配項は Lambda 料金ではなく遷移数です。グルー Lambda は「Lambda 代がもったいない」のではなく、「ステートを増やすこと自体が高い」のです。

地味に一番の発見でした。「JSONataにするとLambda代が浮く」くらいの認識で測り始めたんですが、実際はLambda代なんてほぼ誤差で、遷移数という別の軸が支配していた。予想を外したベンチマークほど書く価値があるな、と思いました。

 

 じゃあ Lambda は要らないのか  

要ります。この結果は「純粋なデータ変換に Lambda を使うのをやめよう」であって、Lambda 不要論ではありません。

  • 外部 I/O(DynamoDB、API 呼び出しなど)があるなら Lambda(または SDK 統合)の仕事
  • 複雑なビジネスロジック、npm の資産、既存コードの再利用が要るなら Lambda の勝ち
  • JSONata 式もテストは書けます。JSONata はただの言語なので、ASL から式を抽出して jsonata パッケージでローカル実行すれば、AWS アカウントなしで単体テストできます
  • 判断基準はシンプルで、「その Lambda、入力 JSON を出力 JSON に写しているだけでは?」 に Yes なら JSONata に畳めます。


今回の実測が示しているのは、単なる高速化テクニックではなく Step Functions の進化の方向性だと思います。 Lambda が不要になるのではなく、Lambda を「本当に計算が必要な場所」だけに置ける時代になった

「とりあえずグルー Lambda」がデフォルトだった時代から、「まず JSONata で書けないか?」を先に考える時代へ。

デフォルトが静かに逆転しつつある、というのが実測を終えての実感です。


 ハマったところ  

実装中に踏んだ罠を4つ置いておきます。  

1. Assign した変数は同一ステートの Output で使えない。

AssignOutput は並行に評価されるため、「Assign で $subtotal を作って Output で参照」は動きません。Pass 1つに収めたければ、Output 全体を1つの JSONata 式にして、式の内部で変数束縛($x := ...)します。

2. 空配列で $map/$sum が undefined になる。

全アイテムがキャンセルされたケースで、$sum($map([], ...)) の結果が undefined になり、出力からキーごと消えました(SFN では States.QueryEvaluationError の原因にもなります)。$count($x) = 0 ? 0 : $sum(...) のガードが必要です。

3. jsonata npm パッケージの出力は null-prototype オブジェクト。

ローカルの等価性テストで assert.deepStrictEqual が「値は同じなのに」落ちます。JSON ラウンドトリップ(JSON.parse(JSON.stringify(x)))してから比較します。

4. TestState API にはステート単位の QueryLanguage 指定が要る。

ステートマシン全体では "QueryLanguage": "JSONata" をトップレベルに書きますが、TestState に単一ステートを渡すときはそのステート自身に QueryLanguage を付けないと JSONPath として解釈されてエラーになります。

 

 まとめ  

  • 外部 I/O を持たないグルー Lambda 4本を JSONata の Pass 1つに置き換えると、ウォームで7.9倍、コールドで31倍、100件バッチで5.6倍速くなった
  • Standard のコストは遷移数がストを大きく左右する。4ステート→1ステートはそのまま約4倍の料金差になる
  • JSONata 版にはコールドスタートも同時実行数の消費も Lambda 料金もない
  • 等価性はローカル(jsonata パッケージ)・TestState・実機の3層でテストできる。「置き換えて壊れないか」は勘ではなくテストで保証できる。さらに5日後の再計測でも倍率が再現した

一番の収穫は「コストを大きく左右するのは遷移数」という発見でした。Lambda代がもったいないという当初の予想は外れて、実際はステート数そのものがコストを決めていた。感覚で「速くなるはず」と言うのと、数字で「7.9倍です」と言えるのとでは、説得力がまるで違います。

これで、次に同じ質問をされたら数字で答えられます。「7.9倍、コールドなら31倍です」と。

次は、SDK統合でLambdaを削れるパターンも同じやり方で実測してみようと思っています。DynamoDBへの書き込みをLambda経由でやるのと直接SDK統合でやるのと、どれくらい差が出るのか気になっているので。

※ この記事の数値は執筆時点(2026年7月)の Step Functions / Lambda の料金・挙動に基づきます。今後のアップデートで変わる可能性があります。

 

  参考